@jmcombs/pi-steward 0.0.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
package/core/source.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The data-source seam.
3
+ *
4
+ * Every fact the dashboard shows enters through a `StewardDataSource`, and
5
+ * every action it takes leaves through one. The server owns the instance; the
6
+ * browser never talks to `llama-server` directly. Swapping the mock source for
7
+ * a live one is therefore a one-line change in the server and touches nothing
8
+ * else.
9
+ *
10
+ * Keep this module free of Node and DOM APIs — see `./types.ts`.
11
+ */
12
+
13
+ import type { LogLine, LogStreamStatus, ModelAction, ServiceAction, Snapshot } from "./types.js";
14
+
15
+ /** Unsubscribes a log listener. Safe to call more than once. */
16
+ export type Unsubscribe = () => void;
17
+
18
+ /** A backlog and a live subscription, opened together. See {@link StewardDataSource.attachLogs}. */
19
+ export interface LogAttachment {
20
+ /** The lines already buffered when the listener was registered, oldest first. */
21
+ backlog: LogLine[];
22
+ unsubscribe: Unsubscribe;
23
+ }
24
+
25
+ export interface StewardDataSource {
26
+ /** Identifies the source in diagnostics, e.g. `mock` or `llama.cpp`. */
27
+ readonly name: string;
28
+
29
+ /** One complete read of current state. Called on every metrics poll. */
30
+ snapshot(): Promise<Snapshot>;
31
+
32
+ /**
33
+ * The most recent lines the source has buffered, oldest first, so a client
34
+ * that connects mid-run does not start with an empty console.
35
+ *
36
+ * Pairing this with {@link subscribeLogs} to open a stream is only safe if
37
+ * nothing can run between the two calls — a line that arrives in that window
38
+ * is in neither result. Prefer {@link attachLogs}.
39
+ */
40
+ recentLogs(limit: number): LogLine[];
41
+
42
+ /**
43
+ * Streams every subsequent line to `listener`. The source is responsible for
44
+ * its own ring buffer; listeners receive lines as they arrive.
45
+ */
46
+ subscribeLogs(listener: (line: LogLine) => void): Unsubscribe;
47
+
48
+ /**
49
+ * Opens a console: the backlog and the live subscription in ONE step, so no
50
+ * line can fall between them. This is the method a stream should use;
51
+ * {@link recentLogs} and {@link subscribeLogs} remain for callers that want
52
+ * only one half.
53
+ *
54
+ * Optional so a source that predates it still satisfies the seam — a caller
55
+ * that does not find it must fall back to the two calls and keep them in the
56
+ * same tick.
57
+ */
58
+ attachLogs?(listener: (line: LogLine) => void, limit: number): LogAttachment;
59
+
60
+ /**
61
+ * The health of the log source behind {@link subscribeLogs}, for a console
62
+ * that has to tell "nothing is happening" apart from "nothing is connected"
63
+ * and from "the file we watch was deleted". Optional: a source whose lines are
64
+ * simulated has no file to report on, and a caller that does not find this
65
+ * method should assume the stream is simply live.
66
+ */
67
+ logStatus?(): LogStreamStatus;
68
+
69
+ /**
70
+ * Starts, stops, or restarts the service. Resolves once the source believes
71
+ * the transition finished — callers re-poll {@link snapshot} rather than
72
+ * assuming it succeeded.
73
+ */
74
+ setService(action: ServiceAction): Promise<void>;
75
+
76
+ /**
77
+ * Loads or unloads one model. Expected to be slow for large models; callers
78
+ * show a pending state until it resolves.
79
+ */
80
+ setModel(modelId: string, action: ModelAction): Promise<void>;
81
+
82
+ /** Releases timers, sockets, and file handles. */
83
+ close(): void;
84
+ }
package/core/state.ts ADDED
@@ -0,0 +1,609 @@
1
+ /**
2
+ * The dashboard's own state — everything the operator changes that the server
3
+ * does not know about, plus the log ring buffer the stream feeds.
4
+ *
5
+ * The reducer is pure and total: the browser holds one `UiState`, dispatches
6
+ * actions at it, and re-renders from the result. Keep this module free of Node
7
+ * and DOM APIs — see `./types.ts`.
8
+ */
9
+
10
+ import type { TemperaturePreference, TemperatureUnit } from "./temperature.js";
11
+ import type {
12
+ LogFamily,
13
+ LogLevel,
14
+ LogLine,
15
+ LogSourceState,
16
+ ModelAction,
17
+ ModelStatus,
18
+ ServiceAction,
19
+ } from "./types.js";
20
+
21
+ /**
22
+ * How many SIGNAL lines the browser keeps — every line whose kind is not
23
+ * `proxy`. Older ones fall off the front.
24
+ *
25
+ * Signal and poll traffic get separate budgets because they arrive at wildly
26
+ * different rates: an idle router emits no signal at all and ~1.25 proxied
27
+ * requests per second per loaded model, most of them Steward's own status
28
+ * polling. Under one shared budget that metronome evicts the boot banner — the
29
+ * thing the operator opened the console to read — within minutes, and hiding
30
+ * proxy lines in the VIEW does nothing about it, because the eviction already
31
+ * happened in the buffer.
32
+ */
33
+ export const LOG_BUFFER_LIMIT = 500;
34
+
35
+ /**
36
+ * How many `proxy` lines the browser keeps. Deliberately much smaller than
37
+ * {@link LOG_BUFFER_LIMIT}: this class is a recency window ("what has been
38
+ * asked of the router lately"), not a history, and it is the only class that
39
+ * can arrive faster than an operator can read.
40
+ */
41
+ export const POLL_BUFFER_LIMIT = 200;
42
+
43
+ /** The level filter, where `all` means "do not filter". */
44
+ export type LevelFilter = LogLevel | "all";
45
+
46
+ /** The record-type filter, where `any` means "do not filter". */
47
+ export type FamilyFilter = LogFamily | "any";
48
+
49
+ /**
50
+ * The request being traced: every line one llama-server task wrote, in file
51
+ * order.
52
+ *
53
+ * Keyed on `(port, task)` and never on `task` alone. Task ids are a per-process
54
+ * counter starting at 0, so task `0` appears under eight different ports in a
55
+ * single measured corpus — a trace keyed on the id would mix two models' lines
56
+ * together and call it one request.
57
+ */
58
+ export interface TraceRef {
59
+ port: number;
60
+ task: number;
61
+ /**
62
+ * `seq` of the row that opened it. Retained so a later phase can narrow to
63
+ * one occurrence when an OS reuses a port, without a state-shape change.
64
+ */
65
+ anchorSeq: number;
66
+ }
67
+
68
+ /**
69
+ * The SSE connection's own state, which is not the same question as whether a
70
+ * log source exists: a live stream can be carrying nothing, and a dead stream
71
+ * can leave a full buffer on screen.
72
+ */
73
+ export type LogStreamState = "connecting" | "live" | "reconnecting";
74
+
75
+ /**
76
+ * `system` follows the OS `prefers-color-scheme` and is the default; `light` and
77
+ * `dark` pin the palette. The one control cycles system → light → dark → system.
78
+ */
79
+ export type Theme = "light" | "dark" | "system";
80
+
81
+ /** A service action that did not take, with the reason the server reported. */
82
+ export interface ServiceFailure {
83
+ action: ServiceAction;
84
+ /** The command's own words (`launchctl: permission denied`), or `null`. */
85
+ detail: string | null;
86
+ }
87
+
88
+ export interface UiState {
89
+ /** Live buffer, oldest first, capped at {@link LOG_BUFFER_LIMIT}. */
90
+ log: LogLine[];
91
+ /** Buffer snapshot taken when the operator paused, or `null` when live. */
92
+ frozen: LogLine[] | null;
93
+ paused: boolean;
94
+ /**
95
+ * True once the buffer has evicted a SIGNAL line, so the console can say that
96
+ * older lines are gone rather than letting the window look complete. Proxy
97
+ * evictions do not set it: that class is a recency window by design, and a
98
+ * permanent banner about it would say nothing.
99
+ */
100
+ bufferDropped: boolean;
101
+ /** Model id the console is scoped to, or `null` for all models. */
102
+ filterModel: string | null;
103
+ filterLevel: LevelFilter;
104
+ /**
105
+ * Which record type the console is scoped to, or `any`. A second filter axis
106
+ * beside the level chips, single-select for exactly the same reason they are:
107
+ * a chip's count means one thing, and pressing it yields that many rows.
108
+ */
109
+ filterFamily: FamilyFilter;
110
+ /**
111
+ * Case-insensitive substring, matched against the line's text — the frame
112
+ * included, so a task id that is visible on the row is findable in the box.
113
+ */
114
+ query: string;
115
+ /**
116
+ * Whether proxied-request lines are shown. Default `false`: they are 86.9% of
117
+ * a real log and most of them are Steward polling itself. Never a hard drop —
118
+ * on a router serving external clients they are the only inbound-traffic
119
+ * evidence in the file — and the toolbar always counts out loud what the
120
+ * toggle is holding back.
121
+ *
122
+ * Deliberately NOT persisted: an operator who left it on would come back to a
123
+ * console filling at 1.25 lines/second with no memory of why.
124
+ */
125
+ showProxy: boolean;
126
+ /**
127
+ * The args folds the operator has opened, keyed by the `seq` of the run's
128
+ * first line. Absent means collapsed; a run that falls out of the buffer takes
129
+ * its entry's meaning with it and the stale key simply never matches again.
130
+ */
131
+ expandedArgs: Record<number, true>;
132
+ /**
133
+ * The request being traced, or `null`. A trace ignores every filter and says
134
+ * so out loud, so this is not a filter — it REPLACES the filter stack while
135
+ * it is set, and every `filter/*` action clears it.
136
+ */
137
+ trace: TraceRef | null;
138
+ /** The log stream's connection state, fed by the `EventSource` lifecycle. */
139
+ logStream: LogStreamState;
140
+ /**
141
+ * Whether the server has a log source at all, and how it is failing when it
142
+ * does not. `ok` until the stream says otherwise, so a console that has not
143
+ * heard yet does not accuse anything.
144
+ */
145
+ logSource: LogSourceState;
146
+ /** The path the server is watching, so the console can name the file it misses. */
147
+ logSourcePath: string | null;
148
+ /**
149
+ * The server's own reason the source is not `ok` (`… could not be read
150
+ * (EACCES)`), or `null`. Rendered verbatim rather than paraphrased: it is the
151
+ * difference between an operator fixing a permission and hunting a bug that
152
+ * does not exist.
153
+ */
154
+ logSourceDetail: string | null;
155
+ theme: Theme;
156
+ /**
157
+ * The unit temperatures are LABELLED in — already resolved, never `auto`.
158
+ *
159
+ * Resolved, because `core/` cannot detect it: the operator's region is a fact
160
+ * about their browser, and the server that builds the same snapshot has no
161
+ * view of it. So the browser bootstrap reads its stored preference, resolves
162
+ * `auto` against the detected locale, and hands the answer in — the same
163
+ * division as `theme`, whose stored mode is `system` and whose applied value
164
+ * is a palette.
165
+ *
166
+ * It reaches exactly one function — `formatTemperature` in `./format.ts`.
167
+ * Thresholds, bar scales and comparisons stay Celsius.
168
+ */
169
+ temperatureUnit: TemperatureUnit;
170
+ /**
171
+ * The operator's stored MODE — `auto` included — which is the thing the HOST
172
+ * block's control cycles and labels.
173
+ *
174
+ * It rides alongside {@link temperatureUnit} rather than replacing it, for
175
+ * the same reason {@link theme} rides alongside the palette it resolves to:
176
+ * `auto` is not a unit, and the formatter needs a unit. The control labels
177
+ * itself from this; `formatTemperature` reads the other.
178
+ */
179
+ temperaturePreference: TemperaturePreference;
180
+ /** Set for a beat after Copy, so the button can acknowledge. */
181
+ copied: boolean;
182
+ /** Service action awaiting its POST, or `null`. */
183
+ pendingService: ServiceAction | null;
184
+ /**
185
+ * The disruptive action whose confirm strip is open, or `null`. Stop and
186
+ * restart unload models and drop in-flight requests, so they are never one
187
+ * click away — the strip names the consequence first.
188
+ */
189
+ confirmService: ServiceAction | null;
190
+ /**
191
+ * The last service action that failed, or `null`. Kept structured (not a
192
+ * sentence) because `core/select.ts` is the one place a displayed string is
193
+ * derived. Cleared when the next action starts.
194
+ */
195
+ serviceFailure: ServiceFailure | null;
196
+ /** Model actions awaiting their POST, keyed by model id. */
197
+ pendingModels: Record<string, ModelAction>;
198
+ /**
199
+ * The key of the drift notice the operator dismissed, or `null`.
200
+ *
201
+ * Deliberately in memory and deliberately keyed: it is forgotten on reload,
202
+ * and a mismatch that CHANGES gets a new key and reappears. Dismissal buys
203
+ * quiet for this session, never a dashboard that looks compliant while it is
204
+ * not — which is the one thing this notice exists to prevent.
205
+ */
206
+ dismissedDrift: string | null;
207
+ }
208
+
209
+ export type UiAction =
210
+ | { type: "logs/append"; lines: readonly LogLine[] }
211
+ | { type: "filter/model"; modelId: string | null }
212
+ | { type: "filter/model-toggle"; modelId: string }
213
+ | { type: "filter/level"; level: LevelFilter }
214
+ | { type: "filter/family"; family: FamilyFilter }
215
+ | { type: "filter/query"; query: string }
216
+ | { type: "filter/proxy-toggle" }
217
+ | { type: "logs/pause-toggle" }
218
+ | { type: "logs/trace"; trace: TraceRef | null }
219
+ | { type: "logs/fold-toggle"; seq: number }
220
+ | { type: "logs/stream-status"; status: LogStreamState }
221
+ | {
222
+ type: "logs/source-status";
223
+ source: LogSourceState;
224
+ path: string | null;
225
+ detail: string | null;
226
+ }
227
+ | { type: "theme/toggle" }
228
+ // Both halves of one press, dispatched together: the HOST block's control
229
+ // hands its new PREFERENCE to `resolveTemperatureUnit` (which is where `auto`
230
+ // stops being `auto`) and dispatches the preference it stores alongside the
231
+ // unit it resolved to. Splitting them across two actions would leave a repaint
232
+ // in between where the label and the gauges disagree.
233
+ | { type: "temperature/unit"; preference: TemperaturePreference; unit: TemperatureUnit }
234
+ | { type: "copy/flag"; copied: boolean }
235
+ | { type: "service/pending"; action: ServiceAction | null }
236
+ | { type: "service/confirm"; action: ServiceAction | null }
237
+ | { type: "service/failure"; failure: ServiceFailure | null }
238
+ | { type: "drift/dismiss"; key: string | null }
239
+ | { type: "model/pending"; modelId: string; action: ModelAction | null }
240
+ | { type: "models/observed"; models: readonly { id: string; status: ModelStatus }[] };
241
+
242
+ /**
243
+ * The starting state.
244
+ *
245
+ * `temperatureUnit` defaults to Celsius rather than being required: it is what
246
+ * every non-browser caller (the tests, and anything that builds a view model
247
+ * outside a page) should see, and it is what the browser falls back to when it
248
+ * cannot read a region off its locale. `temperaturePreference` defaults to
249
+ * `auto` for the same reason — that is what an operator who has never pressed
250
+ * the control has chosen.
251
+ */
252
+ export function initialUiState(
253
+ theme: Theme,
254
+ temperatureUnit: TemperatureUnit = "celsius",
255
+ temperaturePreference: TemperaturePreference = "auto",
256
+ ): UiState {
257
+ return {
258
+ log: [],
259
+ frozen: null,
260
+ paused: false,
261
+ bufferDropped: false,
262
+ filterModel: null,
263
+ filterLevel: "all",
264
+ filterFamily: "any",
265
+ query: "",
266
+ showProxy: false,
267
+ expandedArgs: {},
268
+ trace: null,
269
+ logStream: "connecting",
270
+ logSource: "ok",
271
+ logSourcePath: null,
272
+ logSourceDetail: null,
273
+ theme,
274
+ temperatureUnit,
275
+ temperaturePreference,
276
+ copied: false,
277
+ pendingService: null,
278
+ confirmService: null,
279
+ serviceFailure: null,
280
+ pendingModels: {},
281
+ dismissedDrift: null,
282
+ };
283
+ }
284
+
285
+ /** A capped buffer, plus whether capping it cost the operator any signal. */
286
+ interface CappedBuffer {
287
+ lines: LogLine[];
288
+ /** True when at least one line that was NOT proxy traffic had to be evicted. */
289
+ droppedSignal: boolean;
290
+ /**
291
+ * True when the batch REPLACED the buffer rather than extending it — a source
292
+ * that started over. It means precisely "nothing held is older than this
293
+ * buffer", so whatever the previous source dropped is no longer a fact about
294
+ * what is on screen.
295
+ */
296
+ restarted: boolean;
297
+ }
298
+
299
+ /**
300
+ * Trims the buffer to its two budgets: {@link LOG_BUFFER_LIMIT} signal lines
301
+ * and {@link POLL_BUFFER_LIMIT} proxy lines.
302
+ *
303
+ * The walk goes newest-first so each budget is spent on the most recent lines
304
+ * of its class, and the survivors are re-reversed — so the result is still ONE
305
+ * array, still ascending by `seq`, which is what `appendLines`' replay/restart
306
+ * detection reads. The same array object comes back when nothing was dropped,
307
+ * so an append that changes nothing stays a no-op.
308
+ */
309
+ function cap(lines: LogLine[]): CappedBuffer {
310
+ let signal = 0;
311
+ let poll = 0;
312
+ let dropped = false;
313
+ let droppedSignal = false;
314
+ const kept: LogLine[] = [];
315
+
316
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
317
+ const line = lines[i];
318
+ if (line === undefined) continue;
319
+ if (line.kind === "proxy") {
320
+ if (poll < POLL_BUFFER_LIMIT) {
321
+ poll += 1;
322
+ kept.push(line);
323
+ } else {
324
+ dropped = true;
325
+ }
326
+ } else if (signal < LOG_BUFFER_LIMIT) {
327
+ signal += 1;
328
+ kept.push(line);
329
+ } else {
330
+ dropped = true;
331
+ droppedSignal = true;
332
+ }
333
+ }
334
+
335
+ if (!dropped) return { lines, droppedSignal: false, restarted: false };
336
+ kept.reverse();
337
+ return { lines: kept, droppedSignal, restarted: false };
338
+ }
339
+
340
+ /** {@link cap}, for a batch that replaces the buffer instead of extending it. */
341
+ function adopt(lines: LogLine[]): CappedBuffer {
342
+ return { ...cap(lines), restarted: true };
343
+ }
344
+
345
+ /**
346
+ * Two deliveries of one line, as opposed to two lines sharing a number.
347
+ *
348
+ * **The task id is load-bearing here.** With the pipe frame relocated out of
349
+ * the message, two different requests' `print_timing: eval time = …` lines can
350
+ * compare equal on every other field — same stamp resolution, same level, same
351
+ * model, byte-identical message. {@link appendLines} reads this to tell a
352
+ * stream replay from a source that restarted its numbering, so without the task
353
+ * id a restarted server's whole backlog is mistaken for a replay and discarded,
354
+ * and the console sits there holding the dead source's lines forever.
355
+ */
356
+ function sameLine(a: LogLine, b: LogLine): boolean {
357
+ return (
358
+ a.ts === b.ts &&
359
+ a.level === b.level &&
360
+ a.modelId === b.modelId &&
361
+ a.frame?.task === b.frame?.task &&
362
+ a.message === b.message
363
+ );
364
+ }
365
+
366
+ /**
367
+ * The stream replays its backlog on every connect, so a reconnect re-delivers
368
+ * lines already on screen — and because the browser coalesces stream events per
369
+ * frame, that replay arrives as several batches which can be wholly or partly
370
+ * older than the buffer. Sequence numbers are monotonic per source, so
371
+ * "strictly newer than what we hold" is the de-duplication rule.
372
+ *
373
+ * A source that restarts begins numbering again, and that rule alone would then
374
+ * discard everything it sends forever. The sequence number cannot tell a replay
375
+ * from a restart when the two ranges overlap, but the content can: a restarted
376
+ * source writes different lines under the numbers the buffer already holds. So
377
+ * an overlap that disagrees is a new source and its batch is adopted whole,
378
+ * while an overlap that agrees is a replay and only the genuinely new lines are
379
+ * kept.
380
+ *
381
+ * A source that is REPLACED — the server picked up a log file it was not
382
+ * reading before, or lost the one it was — needs none of that guesswork and
383
+ * must not be left to it: two logs' numbers do not overlap in any dependable
384
+ * way, and the likely case (a fresh tailer whose backlog window has already
385
+ * carried its counter past ours) reads as ordinary progress and would append,
386
+ * concatenating two different logs under one buffer with nothing on screen to
387
+ * say so. {@link LogLine.gen} states the boundary instead of leaving it to be
388
+ * inferred, and it is checked first.
389
+ */
390
+ function appendLines(log: LogLine[], incoming: readonly LogLine[]): CappedBuffer {
391
+ const newest = incoming.at(-1);
392
+ if (newest === undefined) return { lines: log, droppedSignal: false, restarted: false };
393
+ const last = log.at(-1);
394
+ const oldest = log[0];
395
+ if (last === undefined || oldest === undefined) return adopt(incoming.slice());
396
+
397
+ if (newest.gen !== last.gen) {
398
+ // A frame's worth of lines can straddle the swap, so only the part that
399
+ // came from the new source is adopted — the few older lines in front of it
400
+ // belong to a buffer that is being replaced anyway.
401
+ let start = incoming.length - 1;
402
+ while (start > 0 && incoming[start - 1]?.gen === newest.gen) start -= 1;
403
+ return adopt(incoming.slice(start));
404
+ }
405
+
406
+ if (incoming.some((line) => line.seq <= last.seq)) {
407
+ // A batch that ends below everything held cannot be a replay: the source
408
+ // would have had to go backwards to produce it.
409
+ if (newest.seq < oldest.seq) return adopt(incoming.slice());
410
+
411
+ const held = new Map(log.map((line) => [line.seq, line]));
412
+ for (const line of incoming) {
413
+ const previous = held.get(line.seq);
414
+ if (previous !== undefined && !sameLine(previous, line)) return adopt(incoming.slice());
415
+ }
416
+ }
417
+
418
+ const fresh = incoming.filter((line) => line.seq > last.seq);
419
+ return fresh.length === 0
420
+ ? { lines: log, droppedSignal: false, restarted: false }
421
+ : cap(log.concat(fresh));
422
+ }
423
+
424
+ /**
425
+ * Returns the same object when an action is a no-op, so callers can skip a
426
+ * repaint on the many ticks that change nothing.
427
+ *
428
+ * **Every `filter/*` action also closes an open trace**, and it happens here
429
+ * rather than in the handlers — one rule, in one place, that a new control
430
+ * cannot forget. It is why no chip, pill or toggle is ever disabled while a
431
+ * trace is open: pressing WARN during a trace exits the trace and applies the
432
+ * filter, which is the answer an operator expects and the one they get.
433
+ */
434
+ export function reduce(state: UiState, action: UiAction): UiState {
435
+ const next = apply(state, action);
436
+ if (next.trace === null || !action.type.startsWith("filter/")) return next;
437
+ return { ...next, trace: null };
438
+ }
439
+
440
+ function apply(state: UiState, action: UiAction): UiState {
441
+ switch (action.type) {
442
+ case "logs/append": {
443
+ const { lines: log, droppedSignal, restarted } = appendLines(state.log, action.lines);
444
+ // Once true it stays true — the operator has lost lines and the console
445
+ // keeps saying so — EXCEPT across a restart, which replaces the buffer
446
+ // whole. Carrying the flag over would leave a permanent "older lines
447
+ // dropped" banner on a console holding every line the new source ever
448
+ // wrote, which is the opposite of the truth it was added to tell.
449
+ const bufferDropped = restarted ? droppedSignal : state.bufferDropped || droppedSignal;
450
+ if (log === state.log && bufferDropped === state.bufferDropped) return state;
451
+ return { ...state, log, bufferDropped };
452
+ }
453
+ case "filter/model": {
454
+ if (state.filterModel === action.modelId) return state;
455
+ return { ...state, filterModel: action.modelId };
456
+ }
457
+ case "filter/model-toggle": {
458
+ const next = state.filterModel === action.modelId ? null : action.modelId;
459
+ return { ...state, filterModel: next };
460
+ }
461
+ case "filter/level": {
462
+ if (state.filterLevel === action.level) return state;
463
+ return { ...state, filterLevel: action.level };
464
+ }
465
+ case "filter/family": {
466
+ if (state.filterFamily === action.family) return state;
467
+ return { ...state, filterFamily: action.family };
468
+ }
469
+ case "filter/query": {
470
+ if (state.query === action.query) return state;
471
+ return { ...state, query: action.query };
472
+ }
473
+ case "logs/trace": {
474
+ const current = state.trace;
475
+ const next = action.trace;
476
+ if (current === null && next === null) return state;
477
+ if (
478
+ current !== null &&
479
+ next !== null &&
480
+ current.port === next.port &&
481
+ current.task === next.task &&
482
+ current.anchorSeq === next.anchorSeq
483
+ ) {
484
+ return state;
485
+ }
486
+ return { ...state, trace: next };
487
+ }
488
+ case "filter/proxy-toggle": {
489
+ return { ...state, showProxy: !state.showProxy };
490
+ }
491
+ case "logs/pause-toggle": {
492
+ // Pausing freezes what is on screen; the live buffer keeps filling behind
493
+ // it so Resume drops the operator back into the present, not the past.
494
+ if (state.paused) return { ...state, paused: false, frozen: null };
495
+ return { ...state, paused: true, frozen: state.log.slice() };
496
+ }
497
+ case "logs/fold-toggle": {
498
+ const expandedArgs = { ...state.expandedArgs };
499
+ if (expandedArgs[action.seq] === true) delete expandedArgs[action.seq];
500
+ else expandedArgs[action.seq] = true;
501
+ return { ...state, expandedArgs };
502
+ }
503
+ case "logs/stream-status": {
504
+ if (state.logStream === action.status) return state;
505
+ return { ...state, logStream: action.status };
506
+ }
507
+ case "logs/source-status": {
508
+ if (
509
+ state.logSource === action.source &&
510
+ state.logSourcePath === action.path &&
511
+ state.logSourceDetail === action.detail
512
+ ) {
513
+ return state;
514
+ }
515
+ return {
516
+ ...state,
517
+ logSource: action.source,
518
+ logSourcePath: action.path,
519
+ logSourceDetail: action.detail,
520
+ };
521
+ }
522
+ case "theme/toggle": {
523
+ const next: Theme =
524
+ state.theme === "system" ? "light" : state.theme === "light" ? "dark" : "system";
525
+ return { ...state, theme: next };
526
+ }
527
+ case "temperature/unit": {
528
+ // Both halves have to match for this to be a no-op: `auto` and `celsius`
529
+ // resolve to the same unit in most of the world, and treating that press
530
+ // as nothing would leave the control labelled with the mode the operator
531
+ // just left.
532
+ if (
533
+ state.temperatureUnit === action.unit &&
534
+ state.temperaturePreference === action.preference
535
+ ) {
536
+ return state;
537
+ }
538
+ return { ...state, temperatureUnit: action.unit, temperaturePreference: action.preference };
539
+ }
540
+ case "copy/flag": {
541
+ if (state.copied === action.copied) return state;
542
+ return { ...state, copied: action.copied };
543
+ }
544
+ case "service/pending": {
545
+ if (state.pendingService === action.action) return state;
546
+ return { ...state, pendingService: action.action };
547
+ }
548
+ case "service/confirm": {
549
+ if (state.confirmService === action.action) return state;
550
+ return { ...state, confirmService: action.action };
551
+ }
552
+ case "service/failure": {
553
+ if (state.serviceFailure === null && action.failure === null) return state;
554
+ return { ...state, serviceFailure: action.failure };
555
+ }
556
+ case "drift/dismiss": {
557
+ if (state.dismissedDrift === action.key) return state;
558
+ return { ...state, dismissedDrift: action.key };
559
+ }
560
+ case "model/pending": {
561
+ const pendingModels = { ...state.pendingModels };
562
+ if (action.action === null) {
563
+ if (!(action.modelId in pendingModels)) return state;
564
+ delete pendingModels[action.modelId];
565
+ } else {
566
+ if (pendingModels[action.modelId] === action.action) return state;
567
+ pendingModels[action.modelId] = action.action;
568
+ }
569
+ return { ...state, pendingModels };
570
+ }
571
+ case "models/observed": {
572
+ // The POST that started a load/unload returns while the model is still in
573
+ // flight, so the optimistic pending flag has to persist until a *later*
574
+ // snapshot shows the transition finished. A load is done once the model is
575
+ // loaded (active or resident); an unload once it is unloaded (or gone from
576
+ // the list). Until then the flag stays and the button keeps spinning.
577
+ const status = new Map(action.models.map((model) => [model.id, model.status]));
578
+ let pendingModels: Record<string, ModelAction> | null = null;
579
+ for (const [id, pending] of Object.entries(state.pendingModels)) {
580
+ const current = status.get(id);
581
+ // A load finishes once the model is loaded (active or resident); a load
582
+ // that the router accepted but then failed reverts to unloaded, which
583
+ // resolves the flag too — otherwise the button would spin forever. The
584
+ // card still reads `loading`/`downloading` straight off the status, so a
585
+ // flag cleared a poll early (the model briefly still unloaded right after
586
+ // the POST) re-shows as loading on its own. An unload finishes when the
587
+ // model is unloaded or gone.
588
+ const done =
589
+ pending === "load"
590
+ ? current === "active" ||
591
+ current === "resident" ||
592
+ current === "unloaded" ||
593
+ current === undefined
594
+ : current === "unloaded" || current === undefined;
595
+ if (!done) continue;
596
+ if (pendingModels === null) pendingModels = { ...state.pendingModels };
597
+ delete pendingModels[id];
598
+ }
599
+ return pendingModels === null ? state : { ...state, pendingModels };
600
+ }
601
+ default:
602
+ return state;
603
+ }
604
+ }
605
+
606
+ /** The buffer the console renders from: frozen while paused, live otherwise. */
607
+ export function visibleBuffer(state: UiState): readonly LogLine[] {
608
+ return state.paused && state.frozen !== null ? state.frozen : state.log;
609
+ }