@otto-code/brain 0.8.1 → 0.8.3

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.
@@ -1,10 +1,29 @@
1
1
  import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
2
2
  import { forModel, getCalibration, put } from "../config/profiles.js";
3
3
  import { deleteModelFiles, diskUsage, planDelete, totalModelBytes } from "../models/manage.js";
4
+ import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
4
5
  import * as vram from "../vram.js";
5
6
  import { errorMessage, readJsonBody, sendError, sendJson } from "./http-util.js";
6
7
  const MAX_PATCH_BYTES = 256 * 1024;
8
+ const MAX_DISPLAY_NAME = 200;
7
9
  const DEFAULT_LOG_LINES = 200;
10
+ /**
11
+ * The management API's own version, additive to the capability flags.
12
+ *
13
+ * Capabilities answer "can this brain do X"; this answers "which generation of
14
+ * the API is this" for the rare change that no single flag describes. A daemon
15
+ * reads both and never requires an exact package-version match.
16
+ */
17
+ export const HOST_API_VERSION = 2;
18
+ /**
19
+ * How often the SSE stream writes a comment line when nothing has changed.
20
+ *
21
+ * This is transport keepalive, not a status event: a proxy or a NAT table with
22
+ * an idle timeout would otherwise silently drop a stream from a brain that is
23
+ * simply sitting still, and the daemon would report an unreachable brain that is
24
+ * fine. Comments are ignored by every SSE parser, so no reader sees them.
25
+ */
26
+ const SSE_KEEPALIVE_MS = 20000;
8
27
  function stateOf(supervisor, model) {
9
28
  if (!supervisor.model || supervisor.model.id !== model.id)
10
29
  return "not-loaded";
@@ -101,6 +120,13 @@ export function createHostApi(deps) {
101
120
  load: true,
102
121
  resources: true,
103
122
  inventory: true,
123
+ rename: true,
124
+ reset: true,
125
+ // Read live rather than captured: the publisher is inert until the router
126
+ // installs its snapshot source, and advertising a stream we cannot serve
127
+ // would make a daemon stop polling and see nothing.
128
+ events: Boolean(deps.statusEvents?.ready),
129
+ liveInference: Boolean(deps.statusEvents?.ready),
104
130
  writable: deps.getAllowWrite(),
105
131
  });
106
132
  /** Refuse a write unless the owner opted into remote configuration. */
@@ -180,6 +206,59 @@ export function createHostApi(deps) {
180
206
  })();
181
207
  });
182
208
  };
209
+ const handleRename = (req, res, model) => {
210
+ readJsonBody(req, MAX_DISPLAY_NAME + 64, (result) => {
211
+ if (!result.ok) {
212
+ sendError(res, 400, result.error);
213
+ return;
214
+ }
215
+ const body = result.body;
216
+ const displayName = body.displayName;
217
+ if (typeof displayName !== "string" || displayName.trim().length === 0) {
218
+ sendError(res, 400, "displayName must be a non-empty string");
219
+ return;
220
+ }
221
+ if (displayName.length > MAX_DISPLAY_NAME) {
222
+ sendError(res, 400, `displayName must be at most ${MAX_DISPLAY_NAME} characters`);
223
+ return;
224
+ }
225
+ if (/[^\x20-\x7E]/.test(displayName)) {
226
+ sendError(res, 400, "displayName must not contain control characters or non-ASCII");
227
+ return;
228
+ }
229
+ // /v1/models keys its `id` on displayName (router.ts) and both the
230
+ // completion path and defaultModel/switchTo resolve a model by
231
+ // `displayName === name || id === name` - a collision here would make
232
+ // one of the two models unreachable by name with no error anywhere.
233
+ const conflict = deps
234
+ .getCatalog()
235
+ .find((m) => m.id !== model.id && (m.displayName === displayName || m.id === displayName));
236
+ if (conflict) {
237
+ sendError(res, 409, `another model is already named "${displayName}"`);
238
+ return;
239
+ }
240
+ updateDisplayName(model.id, displayName);
241
+ // The catalog is kept in memory between requests. Refresh it here so
242
+ // the next inventory request, and all model lookups, see the persisted
243
+ // name immediately instead of reverting to the scan-derived name until
244
+ // the brain is restarted. Reset already follows this pattern below.
245
+ const catalog = deps.rescan();
246
+ const updated = resolveModel(catalog, model.id);
247
+ sendJson(res, { displayName: updated ? updated.displayName : displayName });
248
+ });
249
+ };
250
+ const handleReset = (req, res, model) => {
251
+ readJsonBody(req, 4096, (result) => {
252
+ if (!result.ok) {
253
+ sendError(res, 400, result.error);
254
+ return;
255
+ }
256
+ deleteDisplayName(model.id);
257
+ const catalog = deps.rescan();
258
+ const updated = resolveModel(catalog, model.id);
259
+ sendJson(res, { displayName: updated ? updated.displayName : model.displayName });
260
+ });
261
+ };
183
262
  const handleBudget = (res, model, params) => {
184
263
  void (async () => {
185
264
  try {
@@ -282,6 +361,56 @@ export function createHostApi(deps) {
282
361
  command: deps.supervisor.command,
283
362
  });
284
363
  };
364
+ /**
365
+ * Stream complete status snapshots as SSE.
366
+ *
367
+ * Authentication is the listener's, not this route's: `withAuth` in serve.ts
368
+ * gates every `/__host/*` path with the same token and TLS policy, so an
369
+ * unauthenticated caller never reaches this function.
370
+ *
371
+ * The stream is unidirectional and outlives the request, which is exactly why
372
+ * SSE rather than a socket the brain would have to dial back to a daemon: a
373
+ * remote brain has no idea where its daemon is, and the daemon already knows
374
+ * how to reach the brain over an authenticated HTTP(S) endpoint.
375
+ */
376
+ const handleEvents = (req, res, publisher) => {
377
+ res.writeHead(200, {
378
+ "content-type": "text/event-stream",
379
+ "cache-control": "no-cache, no-transform",
380
+ connection: "keep-alive",
381
+ // Tells nginx-shaped intermediaries not to buffer, which would defeat the
382
+ // whole point by holding each snapshot until the response ended.
383
+ "x-accel-buffering": "no",
384
+ });
385
+ res.flushHeaders?.();
386
+ const write = (snapshot) => {
387
+ if (res.writableEnded || res.destroyed)
388
+ return;
389
+ res.write(`event: status\ndata: ${JSON.stringify(snapshot)}\n\n`);
390
+ };
391
+ let unsubscribe = () => { };
392
+ const keepalive = setInterval(() => {
393
+ if (res.writableEnded || res.destroyed)
394
+ return;
395
+ res.write(": keepalive\n\n");
396
+ }, SSE_KEEPALIVE_MS);
397
+ keepalive.unref?.();
398
+ const teardown = () => {
399
+ clearInterval(keepalive);
400
+ unsubscribe();
401
+ };
402
+ // The publisher ends the response on host shutdown: an open SSE response is
403
+ // an open connection, and `server.close()` waits for those.
404
+ unsubscribe = publisher.subscribe(write, () => {
405
+ clearInterval(keepalive);
406
+ if (!res.writableEnded && !res.destroyed)
407
+ res.end();
408
+ });
409
+ // Both ends matter: `close` on the request covers a client that walked away,
410
+ // and `close` on the response covers the service shutting the socket down.
411
+ req.on("close", teardown);
412
+ res.on("close", teardown);
413
+ };
285
414
  function handleHostApi(req, res) {
286
415
  const raw = req.url || "";
287
416
  if (!raw.startsWith("/__host/"))
@@ -294,6 +423,15 @@ export function createHostApi(deps) {
294
423
  sendJson(res, capabilities());
295
424
  return true;
296
425
  }
426
+ if (route === "/__host/events" && method === "GET") {
427
+ const publisher = deps.statusEvents;
428
+ if (!publisher?.ready) {
429
+ sendError(res, 404, "this brain does not serve a status event stream");
430
+ return true;
431
+ }
432
+ handleEvents(req, res, publisher);
433
+ return true;
434
+ }
297
435
  if (route === "/__host/logs" && method === "GET") {
298
436
  handleLogs(res, params);
299
437
  return true;
@@ -326,6 +464,8 @@ export function createHostApi(deps) {
326
464
  "/__host/model/budget",
327
465
  "/__host/model/load",
328
466
  "/__host/model/fields",
467
+ "/__host/model/rename",
468
+ "/__host/model/rename/reset",
329
469
  ]);
330
470
  if (!modelRoutes.has(route))
331
471
  return false;
@@ -359,6 +499,18 @@ export function createHostApi(deps) {
359
499
  handleLoad(res, model);
360
500
  return true;
361
501
  }
502
+ if (route === "/__host/model/rename" && method === "POST") {
503
+ if (!guardWrite(res))
504
+ return true;
505
+ handleRename(req, res, model);
506
+ return true;
507
+ }
508
+ if (route === "/__host/model/rename/reset" && method === "POST") {
509
+ if (!guardWrite(res))
510
+ return true;
511
+ handleReset(req, res, model);
512
+ return true;
513
+ }
362
514
  if (route === "/__host/model" && method === "DELETE") {
363
515
  if (!guardWrite(res))
364
516
  return true;
@@ -3,7 +3,8 @@ import type { Supervisor } from "./supervisor.js";
3
3
  import { type RankedModel } from "../ops/results.js";
4
4
  import type { GpuInfo, Model } from "../types.js";
5
5
  import type { Profile } from "../config/schema.js";
6
- import type { HostApi } from "./host-api.js";
6
+ import { type HostApi } from "./host-api.js";
7
+ import type { BrainStatusPublisher } from "./status-events.js";
7
8
  type Verdict = "ok" | "reasoning-only" | "truncated" | "failed";
8
9
  /** A logger sink; only `warn` is used by the router. */
9
10
  export interface Logger {
@@ -70,6 +71,8 @@ interface DescribeOptions {
70
71
  /** An LM Studio-style model description, an OpenAI model object enriched. */
71
72
  export interface ModelEntry {
72
73
  id: string;
74
+ /** Brain's editable human-facing name; `id` remains the stable model key. */
75
+ name: string;
73
76
  object: "model";
74
77
  created: number;
75
78
  owned_by: string;
@@ -80,6 +83,10 @@ export interface ModelEntry {
80
83
  quantization: string | null;
81
84
  state: ModelState;
82
85
  max_context_length: number | null;
86
+ /** Whether the model exposes a chat-template reasoning channel. */
87
+ reasoning: boolean;
88
+ /** Optional per-model values accepted by the OpenAI-compatible endpoint. */
89
+ reasoning_efforts?: string[];
83
90
  loaded_context_length?: number;
84
91
  }
85
92
  /**
@@ -156,13 +163,21 @@ export interface RouterOptions {
156
163
  */
157
164
  hostApi?: HostApi | null;
158
165
  /**
159
- * Live system telemetry (CPU, RAM, GPU, slots), folded into `/__host/status`
166
+ * Live system telemetry (CPU, RAM and GPU), folded into `/__host/status`
160
167
  * ONLY when the caller asks with `?resources=1`. The daemon's liveness probe
161
- * polls status frequently and must not pay an `nvidia-smi` spawn for it; the
168
+ * polls status frequently and must not pay an `nvidia-smi` spawn for it. Slot
169
+ * activity is already part of the cheap status; the
162
170
  * Brain page's Overview tab opts in.
163
171
  */
164
172
  getResources?: (() => Promise<unknown>) | null;
173
+ /**
174
+ * The live status source served at `GET /__host/events`. The router installs
175
+ * its snapshot builder here and notifies it whenever something authoritative
176
+ * moves, so the same assembly answers both the pull and the push and the two
177
+ * can never disagree. Absent means this brain does not advertise events.
178
+ */
179
+ statusEvents?: BrainStatusPublisher | null;
165
180
  }
166
- export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
181
+ export declare function createRouter({ supervisor, telemetry, logger, getCatalog, loadModel, loadRanking, queryGpuInfo, version, getConfig, getEvals, getLockModel, getDefaultModel, applyConfigPatch, getAllowConfigWrite, hostApi, getResources, statusEvents, }: RouterOptions): (req: http.IncomingMessage, res: http.ServerResponse) => void;
167
182
  export {};
168
183
  //# sourceMappingURL=router.d.ts.map
@@ -5,6 +5,7 @@ import { slots as sampleSlots } from "../sysmon.js";
5
5
  import { makeVramFitPredicate, selectCodingModel } from "./model-selector.js";
6
6
  import { query as queryGpu } from "../gpu.js";
7
7
  import { rankModels } from "../ops/results.js";
8
+ import { HOST_API_VERSION } from "./host-api.js";
8
9
  import { errorBody, errorMessage, HOP_BY_HOP, readJsonBody, sendError, sendJson, } from "./http-util.js";
9
10
  /**
10
11
  * Fronts the supervised llama-server on a stable port.
@@ -144,8 +145,10 @@ export function describeModel(model, options = {}) {
144
145
  const { state = "not-loaded", profile = null, createdAt = null } = options;
145
146
  const md = model.metadata || {};
146
147
  const entry = {
147
- // Standard OpenAI fields - id is the friendly name, never the file path.
148
- id: model.displayName,
148
+ // Keep the stable model key separate from Brain's editable display name.
149
+ // OpenAI-compatible clients send `id`; Otto uses `name` for presentation.
150
+ id: model.id,
151
+ name: model.displayName,
149
152
  object: "model",
150
153
  created: Math.floor((createdAt ? createdAt.getTime() : Date.now()) / 1000),
151
154
  owned_by: model.publisher || "local",
@@ -157,7 +160,13 @@ export function describeModel(model, options = {}) {
157
160
  quantization: model.quant || null,
158
161
  state,
159
162
  max_context_length: md.contextLength ?? null,
163
+ reasoning: Boolean(md.reasoning ?? model.thinking),
160
164
  };
165
+ const reasoningEfforts = md["reasoning_efforts"];
166
+ if (Array.isArray(reasoningEfforts) &&
167
+ reasoningEfforts.every((value) => typeof value === "string")) {
168
+ entry.reasoning_efforts = reasoningEfforts;
169
+ }
161
170
  if (state === "loaded" && profile && profile.contextSize) {
162
171
  // llama-server splits -c across --parallel slots, so the window a single
163
172
  // request actually gets is the total divided by the concurrency.
@@ -298,6 +307,25 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
298
307
  }
299
308
  res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
300
309
  const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
310
+ const upstreamResponseFailed = (error) => {
311
+ if (settled)
312
+ return;
313
+ const message = `llama-server response ended unexpectedly: ${error.message}`;
314
+ telemetry.record({
315
+ at: new Date().toISOString(),
316
+ path: req.url,
317
+ verdict: "failed",
318
+ error: message,
319
+ });
320
+ logger?.warn?.(message);
321
+ // Headers may already be on the wire for an SSE response. Destroying
322
+ // it is the only honest result, but `done()` still releases the queue.
323
+ if (!res.writableEnded && !res.destroyed)
324
+ res.destroy(error);
325
+ done();
326
+ };
327
+ upstreamRes.once("aborted", () => upstreamResponseFailed(new Error("upstream response aborted")));
328
+ upstreamRes.once("error", upstreamResponseFailed);
301
329
  if (isStream) {
302
330
  let sawContent = false;
303
331
  let sawReasoning = false;
@@ -368,6 +396,10 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
368
396
  sendError(res, 502, `Upstream llama-server error: ${error.message}`);
369
397
  done();
370
398
  });
399
+ // This is the first authoritative inference-stage signal: the request is
400
+ // being dispatched to llama-server and is waiting for prompt processing or
401
+ // its first output delta.
402
+ reasoning?.begin(streamId);
371
403
  upstream.end(body);
372
404
  });
373
405
  }
@@ -458,10 +490,15 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
458
490
  // (rare), so the router caches the ranking and re-reads it at most once per
459
491
  // window - the cheap time-based trigger.
460
492
  const RANKING_TTL_MS = 60000;
461
- export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, }) {
493
+ export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, }) {
462
494
  const agent = new http.Agent({ keepAlive: true, maxSockets: 32 });
463
495
  const scheduler = loadModel
464
- ? new Scheduler({ supervisor, loadModel, logger: (m) => logger?.warn?.(m) })
496
+ ? new Scheduler({
497
+ supervisor,
498
+ loadModel,
499
+ logger: (m) => logger?.warn?.(m),
500
+ onChange: statusEvents ? () => statusEvents.notify() : null,
501
+ })
465
502
  : null;
466
503
  // A (re)start means whatever produced the current warning no longer applies -
467
504
  // either a different model is now resident, or the same one just picked up an
@@ -547,51 +584,79 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
547
584
  resolved: lock ? null : resolveModel(name),
548
585
  });
549
586
  };
587
+ /**
588
+ * The cheap host status: everything `/__host/status` answers except the
589
+ * opt-in `resources` block.
590
+ *
591
+ * One assembly feeds both the pull (`/__host/status`) and the push
592
+ * (`/__host/events`). Keeping them as one function is the point: a field that
593
+ * only the polled answer carried would be a field the rail silently lost the
594
+ * moment a daemon stopped polling.
595
+ */
596
+ const buildCheapStatus = async () => {
597
+ const schedulerStats = scheduler ? scheduler.stats() : null;
598
+ // Slots come from a loopback GET on the resident llama-server. That is
599
+ // cheap enough to pay on every sample - unlike GPU sampling, which spawns
600
+ // `nvidia-smi` and stays opt-in. Skipped entirely unless a model is
601
+ // resident, since there is nothing listening otherwise.
602
+ const slots = supervisor.state === "ready"
603
+ ? await sampleSlots({ host: supervisor.host, port: supervisor.internalPort }).catch(() => null)
604
+ : null;
605
+ return {
606
+ version,
607
+ // Additive, and separate from `version`: the package version says which
608
+ // build this is, this says which generation of the management contract it
609
+ // speaks. A daemon reads this and `capabilities` rather than pinning a
610
+ // package version.
611
+ apiVersion: HOST_API_VERSION,
612
+ ...supervisor.status(),
613
+ telemetry: { ...telemetry.totals, warning: telemetry.warning },
614
+ scheduler: schedulerStats,
615
+ recent: telemetry.records.slice(-10),
616
+ logLineCount: supervisor.logLines.length,
617
+ // Carried inline rather than fetched from /__host/capabilities: the
618
+ // daemon reads status constantly, and a separately cached copy would go
619
+ // stale the moment the owner toggles allowRemoteConfig.
620
+ capabilities: hostApi ? hostApi.capabilities() : null,
621
+ // The three signals the Brain rail's icon is derived from. All are cheap
622
+ // enough for the liveness path: `activity` is one stat of a file that is
623
+ // usually absent, `reasoning` is in-process state, and `queued` is
624
+ // already computed above.
625
+ activity: readActivity(),
626
+ reasoning: reasoningTracker.active,
627
+ // Exact aggregate request stages from the proxy lifecycle. Unlike slot
628
+ // phase sampling, this distinguishes silent prompt processing, reasoning
629
+ // deltas and user-visible content even when several requests overlap.
630
+ inference: reasoningTracker.snapshot,
631
+ queued: schedulerStats ? schedulerStats.queued : 0,
632
+ slots,
633
+ };
634
+ };
635
+ // Publish rather than be polled. The publisher decides what counts as a
636
+ // change (see status-events.ts); everything here just says "look again".
637
+ if (statusEvents) {
638
+ statusEvents.setSource(buildCheapStatus);
639
+ supervisor.on("state", () => statusEvents.notify());
640
+ supervisor.on("crashed", () => statusEvents.notify());
641
+ reasoningTracker.onChange(() => statusEvents.notify());
642
+ }
550
643
  return function handler(req, res) {
551
644
  // Host-management read surface (`/__host/*`): the single API both the TUI and
552
645
  // Otto's GUI consume, so the two never drift. Status is live; config and
553
646
  // evals are point-in-time reads the daemon proxies to its settings UI.
554
647
  const path = (req.url || "").split("?")[0];
555
648
  if (path === "/__host/status") {
556
- const schedulerStats = scheduler ? scheduler.stats() : null;
557
- const base = {
558
- version,
559
- ...supervisor.status(),
560
- telemetry: { ...telemetry.totals, warning: telemetry.warning },
561
- scheduler: schedulerStats,
562
- recent: telemetry.records.slice(-10),
563
- logLineCount: supervisor.logLines.length,
564
- // Carried inline rather than fetched from /__host/capabilities: the
565
- // daemon polls status constantly, and a separately cached copy would go
566
- // stale the moment the owner toggles allowRemoteConfig.
567
- capabilities: hostApi ? hostApi.capabilities() : null,
568
- // The three signals the Brain rail's icon is derived from. All are cheap
569
- // enough for the liveness poll: `activity` is one stat of a file that is
570
- // usually absent, `reasoning` is in-process state, and `queued` is
571
- // already computed above. Slot phases are the one that costs a round
572
- // trip, and are fetched below.
573
- activity: readActivity(),
574
- reasoning: reasoningTracker.active,
575
- queued: schedulerStats ? schedulerStats.queued : 0,
576
- };
577
- // Slots come from a loopback GET on the resident llama-server. That is
578
- // cheap enough to pay on every poll - unlike the GPU sampling below, which
579
- // spawns `nvidia-smi` and stays opt-in. Skipped entirely unless a model is
580
- // resident, since there is nothing listening otherwise.
581
- const slotsPromise = supervisor.state === "ready"
582
- ? sampleSlots({ host: supervisor.host, port: supervisor.internalPort }).catch(() => null)
583
- : Promise.resolve(null);
584
- // Resources cost an `nvidia-smi` spawn, so they are opt-in: the daemon's
585
- // liveness probe polls this route far more often than any UI does, and
586
- // must not pay for a panel it is not rendering.
649
+ // Resources cost an `nvidia-smi` spawn, so they are opt-in: the daemon
650
+ // reads this route far more often than any UI does, and must not pay for
651
+ // a panel it is not rendering.
587
652
  const wantsResources = /[?&]resources=1(&|$)/.test(req.url || "");
588
653
  if (!wantsResources || !getResources) {
589
- void slotsPromise.then((slots) => sendJson(res, { ...base, slots }));
654
+ void buildCheapStatus().then((base) => sendJson(res, base));
590
655
  return;
591
656
  }
592
- Promise.all([slotsPromise, getResources().catch(() => null)])
593
- .then(([slots, resources]) => sendJson(res, { ...base, slots, resources }))
594
- .catch(() => sendJson(res, { ...base, slots: null, resources: null }));
657
+ Promise.all([buildCheapStatus(), getResources().catch(() => null)])
658
+ .then(([base, resources]) => sendJson(res, { ...base, resources }))
659
+ .catch((error) => sendError(res, 500, `could not build the host status: ${errorMessage(error)}`));
595
660
  return;
596
661
  }
597
662
  // Config write: apply an editable patch (model/lock live, the rest persisted).
@@ -0,0 +1,8 @@
1
+ export interface BrainRunLog {
2
+ path: string;
3
+ write(line: string): void;
4
+ }
5
+ /** Start a fresh Brain log and prune only expired Brain run logs. */
6
+ export declare function createBrainRunLog(env?: NodeJS.ProcessEnv): BrainRunLog;
7
+ export declare function pruneBrainRunLogs(logsDir: string, now?: number): void;
8
+ //# sourceMappingURL=run-log.d.ts.map
@@ -0,0 +1,39 @@
1
+ /** Durable, per-service-run diagnostics for Otto Brain. */
2
+ import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveBrainPaths } from "../config/paths.js";
5
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
6
+ const RUN_LOG_SUFFIX = "-brain.log";
7
+ /** Start a fresh Brain log and prune only expired Brain run logs. */
8
+ export function createBrainRunLog(env = process.env) {
9
+ const { logsDir } = resolveBrainPaths(env);
10
+ const startedAt = new Date();
11
+ const stamp = startedAt
12
+ .toISOString()
13
+ .replace(/[-:]/g, "")
14
+ .replace(/\.\d{3}Z$/, "Z");
15
+ const filePath = path.join(logsDir, `${stamp}-${process.pid}${RUN_LOG_SUFFIX}`);
16
+ try {
17
+ mkdirSync(logsDir, { recursive: true });
18
+ pruneBrainRunLogs(logsDir, startedAt.getTime());
19
+ }
20
+ catch { }
21
+ const write = (line) => {
22
+ try {
23
+ appendFileSync(filePath, `${new Date().toISOString()} ${line}\n`, "utf8");
24
+ }
25
+ catch { }
26
+ };
27
+ write(`Brain service started (pid ${process.pid})`);
28
+ return { path: filePath, write };
29
+ }
30
+ export function pruneBrainRunLogs(logsDir, now = Date.now()) {
31
+ for (const entry of readdirSync(logsDir, { withFileTypes: true })) {
32
+ if (!entry.isFile() || !entry.name.endsWith(RUN_LOG_SUFFIX))
33
+ continue;
34
+ const filePath = path.join(logsDir, entry.name);
35
+ if (now - statSync(filePath).mtimeMs > RETENTION_MS)
36
+ rmSync(filePath, { force: true });
37
+ }
38
+ }
39
+ //# sourceMappingURL=run-log.js.map
@@ -33,6 +33,13 @@ export interface SchedulerOptions {
33
33
  supervisor: SchedulerSupervisor;
34
34
  loadModel: (model: Model) => Promise<void>;
35
35
  logger?: ((message: string) => void) | null;
36
+ /**
37
+ * Called whenever the queue depth or the turn changes - i.e. whenever
38
+ * `stats()` would answer differently. The status event stream publishes from
39
+ * this instead of sampling, so "queued behind a model switch" reaches the UI
40
+ * the moment it becomes true rather than up to a poll later.
41
+ */
42
+ onChange?: (() => void) | null;
36
43
  }
37
44
  /** A queued completion request bound to a resolved catalog model. */
38
45
  export interface QueuedJob {
@@ -55,7 +62,8 @@ export declare class Scheduler {
55
62
  queue: QueuedJob[];
56
63
  lastTurnId: string | null;
57
64
  pumping: boolean;
58
- constructor({ supervisor, loadModel, logger }: SchedulerOptions);
65
+ onChange: (() => void) | null;
66
+ constructor({ supervisor, loadModel, logger, onChange }: SchedulerOptions);
59
67
  /** Id of the model that is actually loaded and ready, or null. */
60
68
  get loadedId(): string | null;
61
69
  /** How many requests may run at once against the resident model. */
@@ -3,10 +3,10 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
3
3
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
4
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
5
  };
6
- var _Scheduler_instances, _Scheduler_take, _Scheduler_serveTurn;
6
+ var _Scheduler_instances, _Scheduler_announce, _Scheduler_take, _Scheduler_serveTurn;
7
7
  const MAX_CONCURRENCY = 16;
8
8
  export class Scheduler {
9
- constructor({ supervisor, loadModel, logger = null }) {
9
+ constructor({ supervisor, loadModel, logger = null, onChange = null }) {
10
10
  _Scheduler_instances.add(this);
11
11
  this.supervisor = supervisor;
12
12
  this.loadModel = loadModel; // async (model) => resolves once it is ready
@@ -14,6 +14,7 @@ export class Scheduler {
14
14
  this.queue = []; // { modelId, model, run, resolve, reject }
15
15
  this.lastTurnId = null;
16
16
  this.pumping = false;
17
+ this.onChange = onChange;
17
18
  }
18
19
  /** Id of the model that is actually loaded and ready, or null. */
19
20
  get loadedId() {
@@ -34,6 +35,7 @@ export class Scheduler {
34
35
  submit(model, run) {
35
36
  return new Promise((resolve, reject) => {
36
37
  this.queue.push({ modelId: model.id, model, run, resolve, reject });
38
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
37
39
  queueMicrotask(() => this.pump());
38
40
  });
39
41
  }
@@ -61,6 +63,7 @@ export class Scheduler {
61
63
  }
62
64
  }
63
65
  this.lastTurnId = turnId;
66
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
64
67
  await __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_serveTurn).call(this, turnId);
65
68
  }
66
69
  }
@@ -78,12 +81,21 @@ export class Scheduler {
78
81
  return { queued: this.queue.length, waiting, lastTurn: this.lastTurnId };
79
82
  }
80
83
  }
81
- _Scheduler_instances = new WeakSet(), _Scheduler_take = function _Scheduler_take(pred) {
84
+ _Scheduler_instances = new WeakSet(), _Scheduler_announce = function _Scheduler_announce() {
85
+ try {
86
+ this.onChange?.();
87
+ }
88
+ catch {
89
+ // Status reporting is not allowed to fail a queued completion.
90
+ }
91
+ }, _Scheduler_take = function _Scheduler_take(pred) {
82
92
  const kept = [];
83
93
  const taken = [];
84
94
  for (const job of this.queue)
85
95
  (pred(job) ? taken : kept).push(job);
86
96
  this.queue = kept;
97
+ if (taken.length > 0)
98
+ __classPrivateFieldGet(this, _Scheduler_instances, "m", _Scheduler_announce).call(this);
87
99
  return taken;
88
100
  }, _Scheduler_serveTurn =
89
101
  /** Serve one model's snapshot with bounded concurrency. */
@@ -37,7 +37,8 @@ export interface ServiceHandle {
37
37
  supervisor: Supervisor;
38
38
  host: string;
39
39
  port: number;
40
- model: Model;
40
+ /** The model loaded during startup, if one was available. */
41
+ model: Model | null;
41
42
  /** Whether the listener terminates TLS (config.tls.mode !== "off"). */
42
43
  secure: boolean;
43
44
  /** The address to show a user: the MagicDNS/cert hostname when TLS is on, else the bind host. */