@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,283 @@
|
|
|
1
|
+
// The bootstrap ordering this surface needs because it taps live BEFORE it reads history.
|
|
2
|
+
//
|
|
3
|
+
// THE RACE IS THIS PAGE'S, NOT A HYPOTHETICAL. `app.js` opens the SSE feed and only then calls
|
|
4
|
+
// `refresh()`, so `onMessage` is already appending live traffic while the backfill fetch is still in
|
|
5
|
+
// flight. For ordinary chat that is harmless: entries carry `ts`, the feed is a window, and a
|
|
6
|
+
// duplicate is caught by message id. For an event frame it is not harmless, because a frame's
|
|
7
|
+
// position in its stream is `seq`, and `seq` is the only thing that can tell a reader that a frame
|
|
8
|
+
// is MISSING. Message-id dedupe cannot: two ids are equal or they are not, which says nothing about
|
|
9
|
+
// what belongs between them.
|
|
10
|
+
//
|
|
11
|
+
// SO THE CONSUMER HAS TO IMPOSE ITS OWN PHASE BOUNDARY. The transport arms the watermark, goes live,
|
|
12
|
+
// and only then surfaces retained history, so a live frame at `seq` 1003 can legitimately be the
|
|
13
|
+
// FIRST frame this page ever sees, ahead of the retained 1000-1002. Two rules follow, and both are
|
|
14
|
+
// the opposite of what a first-arrival reading would do:
|
|
15
|
+
//
|
|
16
|
+
// · The baseline is the MINIMUM of the settled history batch, not the first frame observed.
|
|
17
|
+
// Baselining on arrival makes the entire backfill look like it ran backwards.
|
|
18
|
+
// · Gap checking is not armed until the boundary passes. Armed earlier, the same backfill reads as
|
|
19
|
+
// a hole between 1003 and 1000.
|
|
20
|
+
//
|
|
21
|
+
// WHAT A BASELINE ABOVE THE FIRST SEQUENCE MEANS, AND WHAT IT DOES NOT. The chat stream caps per
|
|
22
|
+
// subject, so a reader that arrives late finds the earliest retained frame at some `seq` N > 1. That
|
|
23
|
+
// is ordinary retention and NOT a fault: the chain is marked prefix-incomplete and applied forward.
|
|
24
|
+
// A discontinuity AFTER the baseline is the fault, and the two must never be reported as one thing,
|
|
25
|
+
// because the first is what always happens and the second is what must never be ignored.
|
|
26
|
+
//
|
|
27
|
+
// DETECTION IS NOT RECOVERY, AND A DETECTED GAP STILL DRAWS THE FRAME. A gap note is surfaced and
|
|
28
|
+
// the frame is emitted anyway. Holding it back until the missing predecessor turns up would hold it
|
|
29
|
+
// forever when the predecessor is genuinely gone, which converts a visible gap into a silent loss:
|
|
30
|
+
// the exact trade this lane exists to refuse. `next` also advances past the hole, so one lost frame
|
|
31
|
+
// reports once instead of reporting on every frame that follows it.
|
|
32
|
+
//
|
|
33
|
+
// FIRST SEQUENCE. The emitter publishes its first frame at `seq` 1 (`firstSeq` is the WAL frontier
|
|
34
|
+
// plus one, from a zero frontier), while the frame validator admits any non-negative safe integer.
|
|
35
|
+
// "Complete from origin" therefore keys on `<= 1` and not on `=== 1`, so a `seq` 0 frame, which is
|
|
36
|
+
// structurally valid and which no emitter produces, cannot be reported as an evicted prefix.
|
|
37
|
+
//
|
|
38
|
+
// NOT ORDERED, DELIBERATELY: anything that is not a frame carrying a usable `seq`. Chat, presence,
|
|
39
|
+
// DMs and a malformed frame all pass straight through in arrival order. A machine that held a frame
|
|
40
|
+
// it could not sequence would either hold it forever or invent a gap around it, and delaying chat
|
|
41
|
+
// behind a frame's backfill would make this file a latency bug for the traffic it does not own.
|
|
42
|
+
(() => {
|
|
43
|
+
const KIND = "ag-ui.frame";
|
|
44
|
+
/** The first `seq` any emitter publishes. A baseline at or below this is complete from origin. */
|
|
45
|
+
const FIRST_SEQ = 1;
|
|
46
|
+
|
|
47
|
+
const isSeq = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
48
|
+
|
|
49
|
+
/** The frame part an entry carries, or `undefined`. A ROUTING question, so it never throws.
|
|
50
|
+
*
|
|
51
|
+
* Accepts a feed entry (`{mode, channel, msg}`, the all-activity shape) or a bare message (the
|
|
52
|
+
* selected-channel shape), because both merge sites run this race and a machine that only
|
|
53
|
+
* understood one of them would leave the other unordered. */
|
|
54
|
+
const frameOf = (entry) => {
|
|
55
|
+
try {
|
|
56
|
+
const msg = entry && typeof entry === "object" ? entry.msg || entry : undefined;
|
|
57
|
+
const parts = msg && msg.parts;
|
|
58
|
+
if (!Array.isArray(parts)) return undefined;
|
|
59
|
+
for (const p of parts)
|
|
60
|
+
if (p && typeof p === "object" && p.kind === KIND && isSeq(p.seq)) return p;
|
|
61
|
+
return undefined;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The chain a frame belongs to: `(from.id, epoch, threadId)`.
|
|
68
|
+
*
|
|
69
|
+
* EPOCH IS IN THE KEY BECAUSE `runId` CANNOT DISCRIMINATE. Two writers pick run ids and sequences
|
|
70
|
+
* independently, so their `(runId, seq)` pairs collide by construction; `epoch` is what makes one
|
|
71
|
+
* writer's stream its own. A JSON array is the delimiter rather than a joining character, because
|
|
72
|
+
* a thread id is a producer-supplied string and any separator chosen here could appear inside it,
|
|
73
|
+
* which would fuse two chains into one and hide a gap in the merge. */
|
|
74
|
+
const chainKey = (entry, frame) => {
|
|
75
|
+
const msg = entry && typeof entry === "object" ? entry.msg || entry : undefined;
|
|
76
|
+
const from = msg && msg.from;
|
|
77
|
+
return JSON.stringify([
|
|
78
|
+
(from && typeof from.id === "string" ? from.id : null),
|
|
79
|
+
typeof frame.epoch === "string" ? frame.epoch : null,
|
|
80
|
+
typeof frame.threadId === "string" ? frame.threadId : null,
|
|
81
|
+
]);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** A two-phase shape-B bootstrap. One instance per bootstrap: a reconnect re-runs both phases. */
|
|
85
|
+
const create = () => {
|
|
86
|
+
/** key -> {baseline, next, prefixIncomplete, faulted, seen:Set<seq>} */
|
|
87
|
+
const chains = new Map();
|
|
88
|
+
/** Live frames arriving before the boundary, in arrival order. */
|
|
89
|
+
const pending = [];
|
|
90
|
+
let settled = false;
|
|
91
|
+
|
|
92
|
+
const chainOf = (key) => {
|
|
93
|
+
let c = chains.get(key);
|
|
94
|
+
if (!c) {
|
|
95
|
+
c = { baseline: undefined, next: undefined, prefixIncomplete: false, faulted: false, released: false, seen: new Set() };
|
|
96
|
+
chains.set(key, c);
|
|
97
|
+
}
|
|
98
|
+
return c;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Admit one frame against an armed chain. Returns the notes it produced.
|
|
102
|
+
*
|
|
103
|
+
* `straddle` marks the ONE frame per chain whose predecessor lies on the other side of the
|
|
104
|
+
* bootstrap seam, and it changes what a hole MEANS rather than merely how it is worded. See
|
|
105
|
+
* {@link STRADDLE} below. */
|
|
106
|
+
const admit = (c, key, seq, straddle) => {
|
|
107
|
+
const notes = [];
|
|
108
|
+
if (c.seen.has(seq)) return { emit: false, notes };
|
|
109
|
+
c.seen.add(seq);
|
|
110
|
+
if (seq > c.next) {
|
|
111
|
+
// Both ends are named either way, so a reader sees WHAT is missing rather than only that
|
|
112
|
+
// something is.
|
|
113
|
+
const hole = { key, expected: c.next, got: seq, missing: seq - c.next };
|
|
114
|
+
if (straddle) {
|
|
115
|
+
// STRADDLE: THE ONE HOLE THIS PAGE CANNOT ATTRIBUTE, and calling it a fault was a measured
|
|
116
|
+
// false positive on healthy traffic.
|
|
117
|
+
//
|
|
118
|
+
// The two halves of the bootstrap are two independent reads with no shared cut: a live
|
|
119
|
+
// subscription, and a history request. Nothing makes the point the read was served equal
|
|
120
|
+
// the point the tap began delivering, and on this page the fetch is issued before the tap
|
|
121
|
+
// is even open. So a frame published inside that window is in NEITHER half, and the first
|
|
122
|
+
// buffered frame of a chain can therefore sit above the retained range's top by more than
|
|
123
|
+
// one with nothing lost by the broker at all. Reported as a fault, it latched `faulted`
|
|
124
|
+
// forever on a stream that was fine, and the frame that filled the hole a moment later did
|
|
125
|
+
// not clear it.
|
|
126
|
+
//
|
|
127
|
+
// A fault that fires on healthy traffic is worse than no fault at all, because it teaches
|
|
128
|
+
// the reader to ignore the one signal that matters. So this is reported as its own kind,
|
|
129
|
+
// named as unconfirmed on the surface, and it does NOT set `faulted`. What it is not is
|
|
130
|
+
// silence: the page says it could not establish the join, rather than saying nothing was
|
|
131
|
+
// missing.
|
|
132
|
+
//
|
|
133
|
+
// ONLY THE FIRST RELEASED FRAME OF A CHAIN GETS THIS TREATMENT. Every later buffered frame
|
|
134
|
+
// arrived through the SAME tap as the one before it, and a subscription delivers a subject
|
|
135
|
+
// in order, so a number missing BETWEEN two buffered frames was never delivered while the
|
|
136
|
+
// page was listening. That is a real loss and takes the hard path below, exactly like a
|
|
137
|
+
// post-boundary hole.
|
|
138
|
+
notes.push({ type: "boundary-hole", ...hole });
|
|
139
|
+
} else {
|
|
140
|
+
notes.push({ type: "gap", ...hole });
|
|
141
|
+
c.faulted = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (seq >= c.next) c.next = seq + 1;
|
|
145
|
+
return { emit: true, notes };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
/** A live arrival. Before the boundary a frame is HELD; everything else always passes. */
|
|
150
|
+
live(entry) {
|
|
151
|
+
const f = frameOf(entry);
|
|
152
|
+
if (!f) return { emit: [entry], held: false, notes: [] };
|
|
153
|
+
if (!settled) {
|
|
154
|
+
pending.push(entry);
|
|
155
|
+
return { emit: [], held: true, notes: [] };
|
|
156
|
+
}
|
|
157
|
+
const key = chainKey(entry, f);
|
|
158
|
+
const c = chainOf(key);
|
|
159
|
+
if (c.next === undefined) {
|
|
160
|
+
// First frame for a chain that the settled history did not mention: this arrival is the
|
|
161
|
+
// baseline, which is sound now only because the boundary has passed.
|
|
162
|
+
c.baseline = f.seq;
|
|
163
|
+
c.next = f.seq;
|
|
164
|
+
c.prefixIncomplete = f.seq > FIRST_SEQ;
|
|
165
|
+
}
|
|
166
|
+
const r = admit(c, key, f.seq);
|
|
167
|
+
return { emit: r.emit ? [entry] : [], held: false, notes: r.notes };
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/** THE PHASE BOUNDARY: the history batch has settled.
|
|
171
|
+
*
|
|
172
|
+
* Returns the merged feed to render, oldest-first: the batch in the order the server sorted
|
|
173
|
+
* it, then every held live frame that the batch did not already carry, released in `seq`
|
|
174
|
+
* order per chain. The batch is the baseline source, so a live frame that ran ahead of it
|
|
175
|
+
* lands after its own retained predecessors instead of before them. */
|
|
176
|
+
backfill(batch) {
|
|
177
|
+
const notes = [];
|
|
178
|
+
const rows = Array.isArray(batch) ? batch.slice() : [];
|
|
179
|
+
|
|
180
|
+
// Phase one: the batch establishes each chain's baseline. Taken as a MINIMUM over the batch
|
|
181
|
+
// rather than from its first row, because the server sorts the union by `ts` across channels
|
|
182
|
+
// and a frame's `ts` is not its `seq`.
|
|
183
|
+
for (const row of rows) {
|
|
184
|
+
const f = frameOf(row);
|
|
185
|
+
if (!f) continue;
|
|
186
|
+
const c = chainOf(chainKey(row, f));
|
|
187
|
+
if (c.baseline === undefined || f.seq < c.baseline) c.baseline = f.seq;
|
|
188
|
+
c.seen.add(f.seq);
|
|
189
|
+
}
|
|
190
|
+
for (const [key, c] of chains) {
|
|
191
|
+
if (c.baseline === undefined) continue;
|
|
192
|
+
let highest = c.baseline;
|
|
193
|
+
for (const s of c.seen) if (s > highest) highest = s;
|
|
194
|
+
c.next = highest + 1;
|
|
195
|
+
c.prefixIncomplete = c.baseline > FIRST_SEQ;
|
|
196
|
+
if (c.prefixIncomplete) notes.push({ type: "prefix-incomplete", key, baseline: c.baseline });
|
|
197
|
+
// THE RETAINED RANGE IS AUDITED, NOT JUST ITS ENDS. Recording the minimum, the maximum and
|
|
198
|
+
// the set is enough to place the baseline and to dedupe, and it is NOT enough to notice that
|
|
199
|
+
// the middle is missing: a batch of 1, 2, 5 has a baseline of 1 and a frontier of 6, and
|
|
200
|
+
// every later frame follows contiguously, so the chain reads healthy forever while two
|
|
201
|
+
// frames are gone. A discontinuity that exists only inside retained history is still a
|
|
202
|
+
// discontinuity after the baseline, and it is the one kind no live arrival will ever
|
|
203
|
+
// reveal, because nothing after it is out of order.
|
|
204
|
+
//
|
|
205
|
+
// Reported as runs rather than per missing number, so losing a thousand frames is one note
|
|
206
|
+
// naming both ends instead of a thousand notes burying it. The walk stops at `highest`,
|
|
207
|
+
// which is in the set by construction, so a run always terminates on a present frame.
|
|
208
|
+
let run = 0;
|
|
209
|
+
for (let s = c.baseline + 1; s <= highest; s++) {
|
|
210
|
+
if (!c.seen.has(s)) {
|
|
211
|
+
run++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (run > 0) {
|
|
215
|
+
notes.push({ type: "gap", key, expected: s - run, got: s, missing: run });
|
|
216
|
+
c.faulted = true;
|
|
217
|
+
run = 0;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Phase two: release what the tap delivered while the fetch was in flight. Sorted by `seq`
|
|
223
|
+
// within a chain and stable across chains, so a chain's frames are contiguous with the
|
|
224
|
+
// retained range they follow.
|
|
225
|
+
const heldFrames = pending
|
|
226
|
+
.map((entry, i) => ({ entry, frame: frameOf(entry), i }))
|
|
227
|
+
.filter((h) => h.frame !== undefined);
|
|
228
|
+
heldFrames.sort((a, b) => (a.frame.seq - b.frame.seq) || (a.i - b.i));
|
|
229
|
+
|
|
230
|
+
for (const h of heldFrames) {
|
|
231
|
+
const key = chainKey(h.entry, h.frame);
|
|
232
|
+
const c = chainOf(key);
|
|
233
|
+
if (c.next === undefined) {
|
|
234
|
+
// No retained frame for this chain, so the earliest BUFFERED frame is the baseline. This
|
|
235
|
+
// is the empty-history arm, and it is why the buffer is sorted before it is walked.
|
|
236
|
+
c.baseline = h.frame.seq;
|
|
237
|
+
c.next = h.frame.seq;
|
|
238
|
+
c.prefixIncomplete = h.frame.seq > FIRST_SEQ;
|
|
239
|
+
if (c.prefixIncomplete)
|
|
240
|
+
notes.push({ type: "prefix-incomplete", key, baseline: c.baseline });
|
|
241
|
+
}
|
|
242
|
+
// The seam is crossed once per chain: this frame's predecessor is retained history (or
|
|
243
|
+
// nothing), every later one's predecessor came through the same tap it did.
|
|
244
|
+
const straddle = !c.released;
|
|
245
|
+
c.released = true;
|
|
246
|
+
const r = admit(c, key, h.frame.seq, straddle);
|
|
247
|
+
for (const n of r.notes) notes.push(n);
|
|
248
|
+
if (r.emit) rows.push(h.entry);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
pending.length = 0;
|
|
252
|
+
settled = true;
|
|
253
|
+
return { emit: rows, notes };
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
/** Whether the boundary has passed. Gap checking is armed only after it has. */
|
|
257
|
+
get settled() {
|
|
258
|
+
return settled;
|
|
259
|
+
},
|
|
260
|
+
/** Held-but-unreleased count. Zero after the boundary, by construction. */
|
|
261
|
+
get pendingCount() {
|
|
262
|
+
return pending.length;
|
|
263
|
+
},
|
|
264
|
+
/** A chain's observed state, for a surface that wants to mark it and for the suite. */
|
|
265
|
+
state(key) {
|
|
266
|
+
const c = chains.get(key);
|
|
267
|
+
return c === undefined
|
|
268
|
+
? undefined
|
|
269
|
+
: {
|
|
270
|
+
baseline: c.baseline,
|
|
271
|
+
next: c.next,
|
|
272
|
+
prefixIncomplete: c.prefixIncomplete,
|
|
273
|
+
faulted: c.faulted,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
get chainKeys() {
|
|
277
|
+
return [...chains.keys()];
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
window.COTAL_EVENT_ORDER = { KIND, FIRST_SEQ, frameOf, chainKey, create };
|
|
283
|
+
})();
|
package/dist/web/graph.html
CHANGED
|
@@ -214,6 +214,8 @@
|
|
|
214
214
|
<div class="hint" id="hint">click a node for detail · scroll to zoom · drag to pan</div>
|
|
215
215
|
|
|
216
216
|
<script src="/harness.js"></script>
|
|
217
|
+
<script src="/parts.js"></script>
|
|
218
|
+
<script src="/agui-frame.js"></script>
|
|
217
219
|
<script src="/graph.js"></script>
|
|
218
220
|
</body>
|
|
219
221
|
</html>
|
package/dist/web/graph.js
CHANGED
|
@@ -62,13 +62,18 @@
|
|
|
62
62
|
const particles = [];
|
|
63
63
|
const blooms = [];
|
|
64
64
|
const recent = [];
|
|
65
|
-
|
|
65
|
+
// `available` is "there is a feed and it said something"; `unreadable` is "the last attempt to read
|
|
66
|
+
// it failed". They are independent, and `unreadable` is declared here rather than sprung into
|
|
67
|
+
// existence on first failure so the shape of the state is readable in one place.
|
|
68
|
+
const feed = { asOf: undefined, available: false, unreadable: false }; // membership-feed freshness
|
|
66
69
|
const cam = { x: 0, y: 0, scale: 1, ready: false, user: false };
|
|
67
70
|
const filter = { chat: true, unicast: true, anycast: true, window: 30, paused: false, hideOffline: true, hideEmpty: true };
|
|
68
71
|
let W = 0, H = 0, DPR = 1, hover = null, sel = null, lastT = 0, alpha = 1;
|
|
69
72
|
|
|
70
73
|
// ── utils ──
|
|
71
|
-
|
|
74
|
+
// Shared with app.js via parts.js (loaded before this file). It names a part kind it cannot
|
|
75
|
+
// draw instead of rendering it as the empty string, which read as "nothing arrived".
|
|
76
|
+
const partsText = (m) => window.COTAL_PARTS.partsToText(m.parts);
|
|
72
77
|
const ease = (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
|
73
78
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
74
79
|
const shortId = (x) => (/^[A-Z2-7]{32,}$/.test(x) ? x.slice(0, 6) + "…" : x);
|
|
@@ -195,8 +200,12 @@
|
|
|
195
200
|
function heatFanOut(channel, from) {
|
|
196
201
|
for (const e of edges.values()) if (e.chan === channel && e.a !== from) e.heat = 1;
|
|
197
202
|
}
|
|
198
|
-
function onMessage({ mode, senderId, msg }) {
|
|
203
|
+
function onMessage({ mode, senderId, channel, msg }) {
|
|
199
204
|
if (!msg) return;
|
|
205
|
+
// Trust decided once (see app.js): the subject-derived channel replaces the payload claim,
|
|
206
|
+
// UNCONDITIONALLY. A guarded overwrite fails open on `inst`/`svc`, where there is no
|
|
207
|
+
// authoritative channel and the publisher's forged one would survive.
|
|
208
|
+
msg.channel = channel;
|
|
200
209
|
const from = ensureAgent(senderId ? { id: senderId, name: msg.from?.name, role: msg.from?.role } : msg.from);
|
|
201
210
|
if (from) { from.ts = now(); from.present = true; } // a live sender is a live presence (roster event may lag)
|
|
202
211
|
// Visual gate: pause chip, mode filter, AND tab visibility. State (heat/recent/roster) always applies.
|
|
@@ -256,8 +265,13 @@
|
|
|
256
265
|
}
|
|
257
266
|
|
|
258
267
|
// ── membership (authoritative spokes) ──
|
|
268
|
+
// The server said it could not read membership. Kept separate from `available` rather than folded
|
|
269
|
+
// into it: they are different facts and the pill has to say which one.
|
|
270
|
+
function membershipUnreadable() { feed.unreadable = true; setFeed(); }
|
|
271
|
+
|
|
259
272
|
function applyMembership(snap) {
|
|
260
273
|
if (!snap) return;
|
|
274
|
+
feed.unreadable = false; // a snapshot arrived, whatever it contains
|
|
261
275
|
feed.asOf = snap.asOf;
|
|
262
276
|
feed.available = snap.asOf !== undefined || (Array.isArray(snap.members) && snap.members.length > 0);
|
|
263
277
|
setFeed();
|
|
@@ -328,13 +342,24 @@
|
|
|
328
342
|
// default) collapses a HUB with no VISIBLE member under the current filters — it reads the cached `h.empty`
|
|
329
343
|
// (kept current by recomputeHubEmpty). So when `hide offline` is ON this
|
|
330
344
|
// means "no ONLINE member"; when it's OFF a channel that still shows offline members keeps its hub — the two
|
|
331
|
-
// toggles don't fight (review-critic R2). Gated on
|
|
332
|
-
// are no membership edges, so every hub would read empty — we can't tell a
|
|
333
|
-
// one, so we don't hide. Reading the cached flag keeps isHidden(hub)
|
|
345
|
+
// toggles don't fight (review-critic R2). Gated on the feed being AUTHORITATIVE: without the
|
|
346
|
+
// authoritative feed there are no membership edges, so every hub would read empty — we can't tell a
|
|
347
|
+
// quiet channel from an unknown one, so we don't hide. Reading the cached flag keeps isHidden(hub)
|
|
348
|
+
// O(1), not an all-edges scan per call.
|
|
334
349
|
// Hiding suppresses node/spoke rendering, hit-testing, camera framing, and physics participation; the node
|
|
335
350
|
// stays in the model, so toggling reveals it instantly.
|
|
351
|
+
//
|
|
352
|
+
// `feedAuthoritative()` rather than `feed.available` alone. Those came apart the moment the feed
|
|
353
|
+
// could report that it could not be READ: the last snapshot is still the last thing we KNEW, but it
|
|
354
|
+
// is no longer what IS, and `hide empty` asserts a hub has no member NOW. That is the gate's own
|
|
355
|
+
// stated reason applied to the case where we hold stale edges rather than none — otherwise the pill
|
|
356
|
+
// says "unreadable" while the page keeps acting on the reading it just disowned.
|
|
357
|
+
//
|
|
358
|
+
// The snapshot itself is deliberately NOT discarded: `asOf` and the spokes remain the honest record
|
|
359
|
+
// of the last successful read, which is true and worth showing. What stops is treating it as current.
|
|
360
|
+
const feedAuthoritative = () => feed.available && !feed.unreadable;
|
|
336
361
|
const isHidden = (n) => n.kind === "hub"
|
|
337
|
-
? filter.hideEmpty &&
|
|
362
|
+
? filter.hideEmpty && feedAuthoritative() && n.empty
|
|
338
363
|
: filter.hideOffline && isOffline(n);
|
|
339
364
|
// Per-CHANNEL visibility of a membership spoke: hidden when `hide offline` is on AND this edge is offline
|
|
340
365
|
// for its channel (durable-only, or the agent is offline) — so "hide offline" holds per channel, not just
|
|
@@ -522,7 +547,12 @@
|
|
|
522
547
|
const el = $("feed"); if (!el) return;
|
|
523
548
|
el.hidden = false;
|
|
524
549
|
let cls, text;
|
|
525
|
-
|
|
550
|
+
// UNREADABLE IS ITS OWN STATE, and it must be checked before `available`. "traffic-only" is a
|
|
551
|
+
// CLAIM ABOUT THE MESH — that no membership feed is being published — and it was being shown for
|
|
552
|
+
// a failed read, which is a claim about US. The operator's own viewer reported traffic-only
|
|
553
|
+
// against a mesh that had a feed, and nothing on the page could have revealed the difference.
|
|
554
|
+
if (feed.unreadable) { cls = "off"; text = "membership: unreadable"; }
|
|
555
|
+
else if (!feed.available) { cls = "off"; text = "membership: traffic-only"; }
|
|
526
556
|
else { const age = feed.asOf ? now() - feed.asOf : Infinity; if (age < FEED_STALE_MS) { cls = ""; text = "membership: live"; } else { cls = "stale"; text = "membership: stale"; } }
|
|
527
557
|
el.className = "pill" + (cls ? " " + cls : "");
|
|
528
558
|
el.querySelector(".t").textContent = text;
|
|
@@ -657,14 +687,24 @@
|
|
|
657
687
|
async function load() {
|
|
658
688
|
const [meta, roster, chans, membership, activity, dmHist] = await Promise.all([
|
|
659
689
|
fetch("/api/meta").then((r) => r.json()), fetch("/api/roster").then((r) => r.json()), fetch("/api/channels").then((r) => r.json()),
|
|
660
|
-
|
|
690
|
+
// `.catch(() => ({members: []}))` here turned a failed fetch into an empty snapshot, which the
|
|
691
|
+
// pill then reported as "traffic-only" — the client half of the same defect the server had.
|
|
692
|
+
// 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 })),
|
|
661
697
|
fetch("/api/activity?limit=400").then((r) => r.json()).catch(() => []), fetch("/api/dms?limit=400").then((r) => r.json()).catch(() => []),
|
|
662
698
|
]);
|
|
663
699
|
$("space").textContent = "· " + meta.space;
|
|
664
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; }
|
|
665
701
|
updateRoster(roster);
|
|
666
|
-
|
|
667
|
-
|
|
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);
|
|
707
|
+
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()); }
|
|
668
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()); }
|
|
669
709
|
// Seed the `recent` buffer from the activity backfill so the channel detail's "recently active" tags +
|
|
670
710
|
// the "recent" section aren't empty until the first live SSE message arrives (norman).
|
|
@@ -678,7 +718,7 @@
|
|
|
678
718
|
alpha = 1; for (let i = 0; i < 200; i++) physics(); // pre-warm to a settled layout
|
|
679
719
|
const f = fitTarget(); cam.x = f.x; cam.y = f.y; cam.scale = f.scale;
|
|
680
720
|
}
|
|
681
|
-
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("message", (e) => onMessage(JSON.parse(e.data))); }
|
|
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))); }
|
|
682
722
|
|
|
683
723
|
resize();
|
|
684
724
|
setInterval(setFeed, 5000); // age "live" → "stale" even without new events
|
package/dist/web/index.html
CHANGED
|
@@ -229,6 +229,14 @@
|
|
|
229
229
|
.chip.danger { color: var(--red); }
|
|
230
230
|
.chip.danger:hover { background: #2a1717; border-color: var(--red); }
|
|
231
231
|
|
|
232
|
+
/* Ordering notice: what the bootstrap found out about the event stream it merged. Shape and
|
|
233
|
+
text carry the meaning; `.fault` only adds emphasis, so colour is never the whole signal. */
|
|
234
|
+
.order-notice {
|
|
235
|
+
flex: none; padding: 8px 20px; font-size: 12px; color: var(--fg);
|
|
236
|
+
background: var(--tile); border-bottom: 1px solid var(--line);
|
|
237
|
+
}
|
|
238
|
+
.order-notice.fault { border-left: 3px solid var(--amber); }
|
|
239
|
+
|
|
232
240
|
.feed { overflow-y: auto; min-height: 0; flex: 1; display: flex; flex-direction: column;
|
|
233
241
|
gap: 2px; padding: 8px; }
|
|
234
242
|
.sys { text-align: center; padding: 5px 12px; font-size: 11px; color: var(--faint); }
|
|
@@ -495,6 +503,9 @@
|
|
|
495
503
|
<script src="/vendor/marked.umd.js"></script>
|
|
496
504
|
<script src="/vendor/purify.min.js"></script>
|
|
497
505
|
<script src="/harness.js"></script>
|
|
506
|
+
<script src="/parts.js"></script>
|
|
507
|
+
<script src="/agui-frame.js"></script>
|
|
508
|
+
<script src="/event-order.js"></script>
|
|
498
509
|
<script src="/md.js"></script>
|
|
499
510
|
<script src="/app.js"></script>
|
|
500
511
|
</body>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Rendering message parts as the flat text the dashboard displays.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. `app.js` and `graph.js` each carried their own copy of
|
|
4
|
+
// (msg.parts || []).map((p) => (p.kind === "text" ? p.text : JSON.stringify(p.data))).join(" ")
|
|
5
|
+
// and that expression DELETES any part it cannot draw. `JSON.stringify(undefined)` returns the
|
|
6
|
+
// VALUE `undefined` — not the string "undefined" — and `Array.prototype.join` coerces that to the
|
|
7
|
+
// empty string. So a part with no `data` field — `ag-ui.frame` among them, but NOT every extension
|
|
8
|
+
// kind, since a data-bearing one rendered its JSON perfectly well — vanished: a stray separator
|
|
9
|
+
// between its neighbours, or an empty string when it was the only part. In every case the KIND went
|
|
10
|
+
// unnamed, so a reader could not tell which renderer was missing. Those two are what this file
|
|
11
|
+
// fixes, and they are the claims to hold it to.
|
|
12
|
+
//
|
|
13
|
+
// THE CONSEQUENCE DIFFERS BY SURFACE, AND AN EARLIER VERSION OF THIS COMMENT GOT IT WRONG. It said
|
|
14
|
+
// both surfaces told the operator that nothing arrived. That is true of ONE of them:
|
|
15
|
+
// - `app.js` renders the body through `bodyBlock`, i.e. `MD.render(text || "")`, so an empty
|
|
16
|
+
// rendering really does produce a blank body — absence reported as calm.
|
|
17
|
+
// - `graph.js`'s detail row renders `esc(m.text).slice(0, 160) || "—"`, so an empty rendering
|
|
18
|
+
// became a VISIBLE DASH inside a row still carrying mode, sender and channel/target. The graph
|
|
19
|
+
// showed a message with an unreadable body. It never claimed nothing arrived.
|
|
20
|
+
// Overstating a defect is the same class of error as understating one: it describes a property
|
|
21
|
+
// nothing recomputes, and it makes the fix look like it closes more than it does.
|
|
22
|
+
//
|
|
23
|
+
// A literal "undefined" in a message body would have been reported the day it shipped; a blank one
|
|
24
|
+
// is not, which is part of why this survived. But note the graph placeholder cuts the other way —
|
|
25
|
+
// it was the more visible of the two and still went unfixed, so visibility alone was not enough.
|
|
26
|
+
//
|
|
27
|
+
// ONE renderer for both pages, not two copies, for the reason core's own `partsToText` gives: the
|
|
28
|
+
// duplicated form broke identically everywhere when a new part kind appeared, because each copy had
|
|
29
|
+
// to be found and fixed separately.
|
|
30
|
+
//
|
|
31
|
+
// AND ON THIS SURFACE NOTHING ELSE WOULD HAVE CAUGHT IT. `implementations/web/tsconfig.json` sets
|
|
32
|
+
// `"exclude": ["src/web"]`, so every file in this directory is plain JS that `tsc` never reads. The
|
|
33
|
+
// two copies of the broken expression were the same mistake, in the same words, in two files, and
|
|
34
|
+
// **no compiler in this repo could see either one**. That is not an argument about style; it is why
|
|
35
|
+
// the checks on this directory have to be executable cells that run the shipped file, because they
|
|
36
|
+
// are the only enforcement this surface has. `index.html` and `graph.html` both load this file BEFORE their
|
|
37
|
+
// page script, and `web.ts` serves it from the PAGE allow-list — a file missing from that map is a
|
|
38
|
+
// 404 no matter what the HTML says.
|
|
39
|
+
//
|
|
40
|
+
// IT WAS AHEAD OF CORE, AND CORE HAS SINCE CAUGHT UP. This mirrors the contract of core's
|
|
41
|
+
// `partsToText`. When this file was written, core's copy still had the vanishing
|
|
42
|
+
// `JSON.stringify(p.data)` fallback and printed no marker, so the browser ran ahead on purpose;
|
|
43
|
+
// core now carries the marker and a per-kind renderer seam of its own. The two remain SEPARATE
|
|
44
|
+
// implementations because this surface cannot import core at all, and they are held together by
|
|
45
|
+
// `bin/smoke/agui-render-parity.smoke.ts` rather than by anyone's intention. One difference is
|
|
46
|
+
// deliberate and survives: an extension kind carrying `data` renders its JSON here (see below) and
|
|
47
|
+
// goes straight to the marker in core.
|
|
48
|
+
//
|
|
49
|
+
// SCOPE: this makes an undrawable part SAY SO, preserves everything that was already visible, and
|
|
50
|
+
// CONSULTS a per-kind renderer registry so a surface can be taught to draw a kind without this file
|
|
51
|
+
// learning what the kind is. It does not itself know how to render an AG-UI frame; `agui-frame.js`
|
|
52
|
+
// does, and it registers. An earlier version of this line read "and nothing wider", which was FALSE
|
|
53
|
+
// while the extension branch discarded data: the scope sentence was wider than the code in one
|
|
54
|
+
// direction and narrower in the other. It then read "does not teach either page to RENDER an AG-UI
|
|
55
|
+
// frame", which went false in the other direction the moment the lookup landed, because the page
|
|
56
|
+
// can now draw one via a file this one has never heard of. A scope sentence is only worth having if
|
|
57
|
+
// it is re-read every time the code under it moves.
|
|
58
|
+
//
|
|
59
|
+
// Neither page republishes this text — `graph.js` only issues GETs and `app.js`'s single POST is
|
|
60
|
+
// `/api/channel/delete`, which carries a channel name — so the marker is safe to place in the body
|
|
61
|
+
// itself. If either page ever gains a republish path, the marker must move beside the text rather
|
|
62
|
+
// than inside it, or it will be replayed to the mesh as though an agent had typed it.
|
|
63
|
+
window.COTAL_PARTS = (() => {
|
|
64
|
+
function partText(p) {
|
|
65
|
+
if (p.kind === "text") return p.text;
|
|
66
|
+
// The digest is verbose and not optional: it is the only handle a reader can act on to fetch
|
|
67
|
+
// the bytes. Name and size come from the publisher, so they are shown as its claims.
|
|
68
|
+
if (p.kind === "artifact") return `[artifact ${p.name} (${p.mediaType}, ${p.size} bytes) ${p.digest}]`;
|
|
69
|
+
if (p.kind === "data") {
|
|
70
|
+
const encoded = JSON.stringify(p.data);
|
|
71
|
+
// A `data` part carrying no data hits the same vanishing act as an unknown kind, so it needs
|
|
72
|
+
// its own marker. Named separately from the kind marker below because "a data part with
|
|
73
|
+
// nothing in it" and "a kind this build cannot draw" are different facts, and a reader who
|
|
74
|
+
// sees one must not conclude the other.
|
|
75
|
+
return encoded === undefined ? "[empty data part]" : encoded;
|
|
76
|
+
}
|
|
77
|
+
// An extension kind. A surface may have been TAUGHT to draw one, by registering a function
|
|
78
|
+
// under its kind in `window.COTAL_PART_RENDERERS` (see `agui-frame.js`). Consulted here, first,
|
|
79
|
+
// because a renderer that exists is strictly more specific than either fallback below.
|
|
80
|
+
//
|
|
81
|
+
// READ AT CALL TIME, not when this closure was built, so registration may happen in any script
|
|
82
|
+
// order. That is the whole reason this is a lookup and not an import: this file stays ignorant
|
|
83
|
+
// of every kind anyone teaches it, which is what keeps the dispatcher a dispatcher.
|
|
84
|
+
//
|
|
85
|
+
// A THROWING RENDERER MUST NOT TAKE THE PAGE DOWN, and must not silently become a blank body
|
|
86
|
+
// either, which is precisely the failure this file exists to remove; re-introducing it through
|
|
87
|
+
// the extension seam would be the same bug with a new door. It degrades to a NAMED marker
|
|
88
|
+
// carrying the error, and the data fallback below is not reached: a renderer that registered
|
|
89
|
+
// and then failed is a different fact from no renderer at all, and a reader who saw raw JSON
|
|
90
|
+
// would conclude the second.
|
|
91
|
+
// OWN PROPERTIES ONLY. `p.kind` comes off the wire, and a plain object inherits `toString`,
|
|
92
|
+
// `valueOf` and `constructor` from `Object.prototype` — every one of them a function. A bare
|
|
93
|
+
// `renderers[p.kind]` therefore RESOLVES for a part whose kind is `"toString"`, passes the
|
|
94
|
+
// `typeof === "function"` test, and gets called unbound: the body renders `[object Window]`,
|
|
95
|
+
// which names no kind and reports no failure. Core's dispatcher is keyed by a `Map` and never
|
|
96
|
+
// had this door; a plain object on `window` does, so it is closed explicitly here.
|
|
97
|
+
const renderers = window.COTAL_PART_RENDERERS;
|
|
98
|
+
const render =
|
|
99
|
+
renderers && Object.prototype.hasOwnProperty.call(renderers, p.kind) ? renderers[p.kind] : undefined;
|
|
100
|
+
if (typeof render === "function") {
|
|
101
|
+
let out;
|
|
102
|
+
try {
|
|
103
|
+
out = render(p);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return `[renderer for part kind ${JSON.stringify(p.kind)} failed: ${err && err.message ? err.message : String(err)}]`;
|
|
106
|
+
}
|
|
107
|
+
// A renderer returning a non-string would put `undefined` or `[object Object]` into the body
|
|
108
|
+
// through the same coercion described at the top of this file. Its contract is a string.
|
|
109
|
+
if (typeof out === "string") return out;
|
|
110
|
+
return `[renderer for part kind ${JSON.stringify(p.kind)} returned ${typeof out}, expected a string]`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// No renderer for this kind. Not drawable here, and not silently droppable either.
|
|
114
|
+
//
|
|
115
|
+
// BUT DATA-BEARING EXTENSIONS MUST KEEP THEIR DATA. The first version of this file sent every
|
|
116
|
+
// non-core kind straight to the marker, which THREW AWAY content the old expression had been
|
|
117
|
+
// showing: `{kind:"com.acme.snapshot", data:{x:1}}` rendered `{"x":1}` before and the marker
|
|
118
|
+
// after. That is a regression wearing a fix's clothes — it removes information while claiming
|
|
119
|
+
// to add it, and it made this file's own "scope is nothing wider" claim false.
|
|
120
|
+
//
|
|
121
|
+
// THIS IS THE DELIBERATE DIFFERENCE FROM CORE ANNOUNCED AT THE TOP OF THIS FILE, NOT AGREEMENT
|
|
122
|
+
// WITH IT. Core's `partsToText` renders a data-bearing extension kind as the marker alone and
|
|
123
|
+
// keeps nothing (driven, not read: a part `{kind:"com.acme.snapshot", data:{x:1}}` through core
|
|
124
|
+
// returns only the unrenderable marker). The difference is justified by the SURFACE, not by
|
|
125
|
+
// core: this file replaces an expression that was already showing that JSON, and core's
|
|
126
|
+
// equivalent never did, so keeping it is a regression here and would be an addition there.
|
|
127
|
+
//
|
|
128
|
+
// So: name the kind AND keep the data. That is strictly more than either did alone — the old
|
|
129
|
+
// expression showed the data but never said what it was, and the marker alone said what it was
|
|
130
|
+
// while discarding it.
|
|
131
|
+
const encoded = JSON.stringify(p.data);
|
|
132
|
+
if (encoded !== undefined) return `[${p.kind}] ${encoded}`;
|
|
133
|
+
// No data to keep. This is the case the file exists for: name the kind, so a reader knows which
|
|
134
|
+
// renderer is missing instead of seeing a message that looks like it was sent blank.
|
|
135
|
+
return `[unrenderable part kind ${JSON.stringify(p.kind)} — no renderer for it on this surface]`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A message's parts as one flat string, space-joined. */
|
|
139
|
+
function partsToText(parts) {
|
|
140
|
+
return (parts || []).map(partText).join(" ");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { partsToText };
|
|
144
|
+
})();
|