@animalabs/connectome-host 0.3.8 → 0.3.10

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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Health surfaces — the operator-facing view of what /healthz and the
3
+ * ops-alert stream already tell machines (fleet hub, connectome-doctor).
4
+ *
5
+ * Two pieces:
6
+ * - OpsAlertStrip: persistent banner rows under the header, one per active
7
+ * alert (compression quarantine, refusal streaks, inference-exhausted…).
8
+ * Fed live from `ops:alert` traces and reconciled against /healthz polls
9
+ * so a page opened mid-incident still shows the alarm.
10
+ * - HealthPanel: sidebar tab with per-agent runtime state — status, failure
11
+ * streaks, refusal stats, runtime settings (budget / tail / pace /
12
+ * convergence), compression quarantine, and process-level counters.
13
+ *
14
+ * Data access mirrors the Context panel: same-origin fetch of /healthz with
15
+ * the session cookie; observers need the 'health' scope (403 renders as a
16
+ * scope hint, not an error).
17
+ */
18
+
19
+ import { For, Show } from 'solid-js';
20
+
21
+ /** One active operator alert, keyed `${agent}:${kind}`. `count` increments on
22
+ * every re-fire of the same key so a repeating klaxon reads as one row. */
23
+ export interface OpsAlert {
24
+ key: string;
25
+ kind: string;
26
+ agent: string;
27
+ message: string;
28
+ /** Epoch millis of the latest firing. */
29
+ at: number;
30
+ count: number;
31
+ }
32
+
33
+ /** Shape of GET /healthz — framework healthSnapshot() plus the host's
34
+ * compressionQuarantine / runtimeSettings extensions. All fields optional
35
+ * and defensively read: health rendering must survive version skew. */
36
+ export interface HealthSnapshot {
37
+ at?: string;
38
+ uptimeSec?: number;
39
+ gate?: Record<string, unknown> | null;
40
+ pendingRequests?: number;
41
+ activeStreams?: string[];
42
+ agents?: Array<{
43
+ name: string;
44
+ status?: string;
45
+ consecutiveInferenceFailures?: number;
46
+ lastInference?: {
47
+ startedAt?: number;
48
+ completedAt?: number;
49
+ failedAt?: number;
50
+ lastError?: string;
51
+ } | null;
52
+ refusalStats?: {
53
+ total?: number;
54
+ byCategory?: Record<string, number>;
55
+ lastAt?: number;
56
+ lastCategory?: string;
57
+ } | null;
58
+ }>;
59
+ compressionQuarantine?: Record<string, { count?: number; keys?: string[] }>;
60
+ runtimeSettings?: Record<string, {
61
+ contextBudgetTokens?: number;
62
+ tailTokens?: number;
63
+ transitionPaceTokens?: number;
64
+ sameRoundThinkTextPolicy?: string;
65
+ sameRoundThinkTextPolicySource?: string;
66
+ transition?: string;
67
+ transitionReason?: string;
68
+ }>;
69
+ }
70
+
71
+ const fmtTokens = (n: number): string => {
72
+ if (n < 1000) return String(n);
73
+ if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0) + 'k';
74
+ return (n / 1_000_000).toFixed(2) + 'M';
75
+ };
76
+
77
+ const fmtAgo = (ts: number): string => {
78
+ const sec = Math.floor((Date.now() - ts) / 1000);
79
+ if (sec < 60) return `${sec}s ago`;
80
+ const min = Math.floor(sec / 60);
81
+ if (min < 60) return `${min}m ago`;
82
+ const h = Math.floor(min / 60);
83
+ return h < 24 ? `${h}h ${min % 60}m ago` : `${Math.floor(h / 24)}d ago`;
84
+ };
85
+
86
+ const fmtUptime = (sec: number): string => {
87
+ if (sec < 3600) return `${Math.floor(sec / 60)}m`;
88
+ if (sec < 86_400) return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
89
+ return `${Math.floor(sec / 86_400)}d ${Math.floor((sec % 86_400) / 3600)}h`;
90
+ };
91
+
92
+ /** Severity → row tone. Quarantine and hard-down are outage-class (rose);
93
+ * everything else on this channel is at least warning-class (amber). */
94
+ const alertTone = (kind: string): { row: string; dot: string } =>
95
+ kind === 'compression-quarantine' || kind === 'inference-exhausted'
96
+ ? { row: 'bg-rose-950/60 border-rose-900 text-rose-200', dot: 'bg-rose-500 animate-pulse' }
97
+ : { row: 'bg-amber-950/60 border-amber-900 text-amber-200', dot: 'bg-amber-500' };
98
+
99
+ export function OpsAlertStrip(props: {
100
+ alerts: OpsAlert[];
101
+ onDismiss(key: string): void;
102
+ }) {
103
+ return (
104
+ <For each={props.alerts}>{(a) => {
105
+ const tone = alertTone(a.kind);
106
+ return (
107
+ <div class={`border-b px-4 py-1.5 text-xs flex items-center gap-2 ${tone.row}`}>
108
+ <span class={`w-2 h-2 rounded-full shrink-0 ${tone.dot}`} />
109
+ <span class="font-mono font-semibold shrink-0">{a.agent}</span>
110
+ <span class="font-mono text-[10px] uppercase tracking-wider opacity-70 shrink-0">{a.kind}</span>
111
+ <span class="truncate" title={a.message}>{a.message}</span>
112
+ <span class="ml-auto shrink-0 opacity-60 font-mono text-[10px]">
113
+ {a.count > 1 ? `×${a.count} · ` : ''}{fmtAgo(a.at)}
114
+ </span>
115
+ <button
116
+ type="button"
117
+ class="shrink-0 px-1 opacity-60 hover:opacity-100"
118
+ title="Dismiss (reappears if the alert re-fires)"
119
+ onClick={() => props.onDismiss(a.key)}
120
+ >
121
+
122
+ </button>
123
+ </div>
124
+ );
125
+ }}</For>
126
+ );
127
+ }
128
+
129
+ export function HealthPanel(props: {
130
+ health: HealthSnapshot | null;
131
+ /** Non-null when the last /healthz fetch failed; '403' means scope-denied. */
132
+ error: string | null;
133
+ onRefresh(): void;
134
+ }) {
135
+ const agents = () => props.health?.agents ?? [];
136
+ const quarantine = (name: string) => props.health?.compressionQuarantine?.[name];
137
+ const settings = (name: string) => props.health?.runtimeSettings?.[name];
138
+
139
+ const statusTone = (status?: string): string => {
140
+ switch (status) {
141
+ case 'idle': return 'text-emerald-400';
142
+ case 'inferring':
143
+ case 'streaming': return 'text-cyan-300';
144
+ case 'waiting_for_tools':
145
+ case 'ready': return 'text-amber-300';
146
+ default: return 'text-neutral-400';
147
+ }
148
+ };
149
+
150
+ return (
151
+ <div class="p-2 text-[11px] font-mono text-neutral-300 space-y-3 overflow-y-auto h-full">
152
+ <div class="flex items-center justify-between">
153
+ <span class="text-neutral-400">Health</span>
154
+ <button
155
+ type="button"
156
+ class="px-2 py-0.5 text-neutral-400 hover:text-neutral-100 border border-neutral-700 rounded"
157
+ onClick={() => props.onRefresh()}
158
+ title="Refresh /healthz now"
159
+ >
160
+ refresh
161
+ </button>
162
+ </div>
163
+
164
+ <Show when={props.error}>
165
+ <div class="text-rose-400">
166
+ {props.error === '403'
167
+ ? "healthz denied — your observer grant lacks the 'health' scope."
168
+ : `healthz: ${props.error}`}
169
+ </div>
170
+ </Show>
171
+
172
+ <Show when={props.health}>
173
+ <div class="flex gap-4 text-neutral-500">
174
+ <Show when={props.health!.uptimeSec !== undefined}>
175
+ <span>up {fmtUptime(props.health!.uptimeSec!)}</span>
176
+ </Show>
177
+ <Show when={props.health!.pendingRequests !== undefined}>
178
+ <span>{props.health!.pendingRequests} queued</span>
179
+ </Show>
180
+ <Show when={props.health!.activeStreams !== undefined}>
181
+ <span>{props.health!.activeStreams!.length} streaming</span>
182
+ </Show>
183
+ </div>
184
+
185
+ <For each={agents()}>{(a) => (
186
+ <section class="border border-neutral-800 rounded px-2.5 py-2 space-y-1.5">
187
+ <div class="flex items-center gap-2">
188
+ <span class="text-neutral-100">{a.name}</span>
189
+ <span class={statusTone(a.status)}>{a.status ?? '?'}</span>
190
+ <Show when={(a.consecutiveInferenceFailures ?? 0) > 0}>
191
+ <span class="ml-auto text-rose-400">
192
+ {a.consecutiveInferenceFailures} consecutive failure{a.consecutiveInferenceFailures === 1 ? '' : 's'}
193
+ </span>
194
+ </Show>
195
+ </div>
196
+
197
+ <Show when={a.lastInference?.lastError}>
198
+ <div class="text-rose-300/80 truncate" title={a.lastInference!.lastError}>
199
+ last error: {a.lastInference!.lastError}
200
+ </div>
201
+ </Show>
202
+ <Show when={a.lastInference?.completedAt || a.lastInference?.failedAt}>
203
+ <div class="text-neutral-500">
204
+ last inference:{' '}
205
+ {a.lastInference?.completedAt
206
+ ? `ok ${fmtAgo(a.lastInference.completedAt)}`
207
+ : `failed ${fmtAgo(a.lastInference!.failedAt!)}`}
208
+ </div>
209
+ </Show>
210
+
211
+ <Show when={quarantine(a.name) && (quarantine(a.name)!.count ?? 0) > 0}>
212
+ <div class="text-rose-300 bg-rose-950/30 border border-rose-900/50 rounded px-2 py-1">
213
+ ⚠ {quarantine(a.name)!.count} chunk(s) in compression quarantine — raw spans
214
+ accumulate until the window can't fit. Inspect, then branch, pin, or clear.
215
+ <Show when={(quarantine(a.name)!.keys?.length ?? 0) > 0}>
216
+ <div class="text-rose-400/70 truncate" title={quarantine(a.name)!.keys!.join(', ')}>
217
+ {quarantine(a.name)!.keys!.join(', ')}
218
+ </div>
219
+ </Show>
220
+ </div>
221
+ </Show>
222
+
223
+ <Show when={a.refusalStats && (a.refusalStats.total ?? 0) > 0}>
224
+ <div class="text-amber-300/90">
225
+ refusals: {a.refusalStats!.total}
226
+ <Show when={a.refusalStats!.lastCategory}>
227
+ <span class="text-amber-400/60"> · last {a.refusalStats!.lastCategory}</span>
228
+ </Show>
229
+ <Show when={a.refusalStats!.lastAt}>
230
+ <span class="text-neutral-500"> · {fmtAgo(a.refusalStats!.lastAt!)}</span>
231
+ </Show>
232
+ </div>
233
+ </Show>
234
+
235
+ <Show when={settings(a.name)}>
236
+ {(s) => (
237
+ <div class="border-t border-neutral-900 pt-1.5 space-y-0.5 text-neutral-400">
238
+ <div class="text-[10px] uppercase tracking-wider text-neutral-600">runtime settings</div>
239
+ <div>
240
+ budget <span class="text-neutral-200">{fmtTokens(s().contextBudgetTokens ?? 0)}</span>
241
+ <Show when={s().tailTokens !== undefined}>
242
+ <span> · tail <span class="text-neutral-200">{fmtTokens(s().tailTokens!)}</span></span>
243
+ </Show>
244
+ <Show when={s().transitionPaceTokens !== undefined}>
245
+ <span> · pace <span class="text-neutral-200">{fmtTokens(s().transitionPaceTokens!)}</span></span>
246
+ </Show>
247
+ </div>
248
+ <Show when={s().sameRoundThinkTextPolicy}>
249
+ <div>
250
+ think-text <span class="text-neutral-200">{s().sameRoundThinkTextPolicy}</span>
251
+ <span class="text-neutral-600"> ({s().sameRoundThinkTextPolicySource})</span>
252
+ </div>
253
+ </Show>
254
+ <Show when={s().transition && s().transition !== 'stable'}>
255
+ <div class={s().transition === 'blocked' ? 'text-rose-300' : 'text-amber-300'}>
256
+ budget {s().transition}
257
+ <Show when={s().transitionReason}>
258
+ <span class="opacity-70"> — {s().transitionReason}</span>
259
+ </Show>
260
+ </div>
261
+ </Show>
262
+ </div>
263
+ )}
264
+ </Show>
265
+ </section>
266
+ )}</For>
267
+ </Show>
268
+
269
+ <Show when={!props.health && !props.error}>
270
+ <div class="text-neutral-500">loading…</div>
271
+ </Show>
272
+ </div>
273
+ );
274
+ }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Shows active lessons sorted by confidence; deprecated entries collapse to a
5
5
  * dim line with the reason. Manual refresh button — lessons mutate slowly
6
- * (knowledge-miner pipeline is the typical writer), so we don't auto-poll.
6
+ * (the agent's LessonsModule tools are the typical writer), so we don't
7
+ * auto-poll.
7
8
  */
8
9
 
9
10
  import { For, Show } from 'solid-js';
@@ -1,40 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { validateRecipe } from '../src/recipe.js';
3
-
4
- function recipe(primarySummaryFallback?: Record<string, unknown>) {
5
- return {
6
- name: 'primary-summary-fallback-test',
7
- agent: {
8
- systemPrompt: 'sys',
9
- refusalHandling: primarySummaryFallback
10
- ? { primarySummaryFallback }
11
- : undefined,
12
- },
13
- };
14
- }
15
-
16
- describe('recipe primary summary fallback validation', () => {
17
- test('leaves the fallback disabled by default when omitted', () => {
18
- const parsed = validateRecipe(recipe());
19
- expect(parsed.agent.refusalHandling?.primarySummaryFallback).toBeUndefined();
20
- });
21
-
22
- test('preserves valid fallback settings', () => {
23
- const parsed = validateRecipe(recipe({
24
- enabled: true,
25
- maxNewSummaries: 4,
26
- requestBudgetTokens: 216_000,
27
- }));
28
- expect(parsed.agent.refusalHandling?.primarySummaryFallback).toEqual({
29
- enabled: true,
30
- maxNewSummaries: 4,
31
- requestBudgetTokens: 216_000,
32
- });
33
- });
34
-
35
- test('rejects malformed fallback settings', () => {
36
- expect(() => validateRecipe(recipe({ enabled: 'yes' }))).toThrow(/primarySummaryFallback\.enabled/);
37
- expect(() => validateRecipe(recipe({ maxNewSummaries: -1 }))).toThrow(/maxNewSummaries/);
38
- expect(() => validateRecipe(recipe({ requestBudgetTokens: 0 }))).toThrow(/requestBudgetTokens/);
39
- });
40
- });