@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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- package/ui/steward.css +2008 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node body behind {@link LogTailer}: it follows `llama-server`'s combined
|
|
3
|
+
* stdout/stderr redirect and turns every appended line into a {@link LogLine}.
|
|
4
|
+
*
|
|
5
|
+
* The source is deliberately the router's stdout redirect (what launchd's
|
|
6
|
+
* `StandardOutPath`/`StandardErrorPath` capture), not `--log-file`: llama.cpp
|
|
7
|
+
* propagates `--log-file` into every child model server, so that file gets each
|
|
8
|
+
* child line twice — once written by the child, once echoed by the router — and
|
|
9
|
+
* interleaves concurrent writers. The router's stdout has a single writer and
|
|
10
|
+
* carries the children's output in order, `[port]`-prefixed.
|
|
11
|
+
*
|
|
12
|
+
* It **polls `fs.stat`** rather than watching. `fs.watch` misses appends on
|
|
13
|
+
* Windows and has its own locking quirks there; polling a local file every few
|
|
14
|
+
* hundred ms is portable, predictable, and cheap next to what the dashboard
|
|
15
|
+
* already does per snapshot.
|
|
16
|
+
*
|
|
17
|
+
* The hazards it handles, in the order they actually happen on this platform:
|
|
18
|
+
*
|
|
19
|
+
* - **The file is unlinked under us.** macOS runs `com.apple.tmp_cleaner`
|
|
20
|
+
* daily at 00:00 and deletes any `/tmp` file whose atime, mtime and ctime all
|
|
21
|
+
* exceed three days — so a router stopped for a long weekend loses its log.
|
|
22
|
+
* That is reported as a distinct `missing` state, never as an error, and it
|
|
23
|
+
* is self-healing: the tailer keeps polling and picks the path back up the
|
|
24
|
+
* moment something recreates it.
|
|
25
|
+
* - **The service restarts.** launchd APPENDS across restarts (16 boots in one
|
|
26
|
+
* observed file), so a restart is not a rotation: the offset stands, the
|
|
27
|
+
* sequence numbers keep climbing, and the console simply gets a fresh banner.
|
|
28
|
+
* Resetting `seq` here would make the client's replay/restart detection
|
|
29
|
+
* re-adopt the whole buffer.
|
|
30
|
+
* - **Truncation and replacement.** A shrinking file (`copytruncate`) restarts
|
|
31
|
+
* at offset 0; a changed inode (delete-and-recreate, logrotate create-new)
|
|
32
|
+
* reopens. Neither resets `seq`.
|
|
33
|
+
* - **Partial trailing lines.** A read can land mid-line, so an unterminated
|
|
34
|
+
* tail is buffered and only emitted once its newline arrives — and, per the
|
|
35
|
+
* bounded-splitter lesson from `host-collector.ts`, a newline-less flood is
|
|
36
|
+
* discarded rather than accumulated, so memory stays bounded whatever is
|
|
37
|
+
* written.
|
|
38
|
+
*
|
|
39
|
+
* Nothing here throws. A path that does not exist, cannot be read, or vanishes
|
|
40
|
+
* mid-run is a state the console renders honestly, not a crash.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { closeSync, fstatSync, openSync, readSync, type Stats, statSync } from "node:fs";
|
|
44
|
+
import { StringDecoder } from "node:string_decoder";
|
|
45
|
+
import type { LogTailer } from "../core/llama-source.js";
|
|
46
|
+
import { type ParsedLogLine, parseLogLine } from "../core/log-parse.js";
|
|
47
|
+
import type { LogAttachment, Unsubscribe } from "../core/source.js";
|
|
48
|
+
import type { LogLine, LogSourceState, LogStreamStatus } from "../core/types.js";
|
|
49
|
+
import { createLineSplitter } from "./host-collector.js";
|
|
50
|
+
|
|
51
|
+
/** The environment variable that points Steward at a log file explicitly. */
|
|
52
|
+
export const LOG_FILE_ENV = "STEWARD_LOG_FILE";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where a launchd-managed router's combined redirect lands on this platform by
|
|
56
|
+
* convention. Only ever adopted when the file actually exists — naming a path
|
|
57
|
+
* nobody configured, in a console that then reports it as missing, would send an
|
|
58
|
+
* operator hunting a file that was never theirs.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_LOG_PATH = "/tmp/llama-router.log";
|
|
61
|
+
|
|
62
|
+
/** How often the file is re-`stat`ed. Fast enough to feel live, cheap enough to ignore. */
|
|
63
|
+
const DEFAULT_POLL_INTERVAL_MS = 400;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How much of an existing file is read as backlog on the first poll (and after a
|
|
67
|
+
* reopen). 256 KB is ~3,000 real lines — far more than the 200 the client
|
|
68
|
+
* replays — while a log that has grown for months is not read whole.
|
|
69
|
+
*/
|
|
70
|
+
const DEFAULT_BACKLOG_BYTES = 256 * 1024;
|
|
71
|
+
|
|
72
|
+
/** Lines kept for {@link LogTailer.recent}; the client's own buffer is no larger. */
|
|
73
|
+
const DEFAULT_MAX_LINES = 500;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Longest single line assembled before it is discarded. Same cap and the same
|
|
77
|
+
* reasoning as the host collector's: a producer that writes without newlines
|
|
78
|
+
* must not be able to grow this process's memory.
|
|
79
|
+
*/
|
|
80
|
+
const MAX_LINE_LENGTH = 64 * 1024;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bytes read per poll. A tail that falls a long way behind (a burst, a long
|
|
84
|
+
* pause in the event loop) catches up over several polls instead of blocking one
|
|
85
|
+
* of them on a multi-megabyte read.
|
|
86
|
+
*/
|
|
87
|
+
const MAX_READ_PER_POLL = 1024 * 1024;
|
|
88
|
+
|
|
89
|
+
export interface FileTailerOptions {
|
|
90
|
+
/** The log file to follow. It need not exist yet. */
|
|
91
|
+
path: string;
|
|
92
|
+
/** Milliseconds between `stat` polls. `0` leaves the tailer unscheduled (tests drive `poll`). */
|
|
93
|
+
pollIntervalMs?: number;
|
|
94
|
+
/** Bytes of existing file read as backlog on the first poll. */
|
|
95
|
+
backlogBytes?: number;
|
|
96
|
+
/** Lines retained for {@link LogTailer.recent}. */
|
|
97
|
+
maxLines?: number;
|
|
98
|
+
/** Arrival clock stamped on each line. Injected in tests; defaults to `Date.now`. */
|
|
99
|
+
now?: () => number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A file tailer plus the poll its timer drives, so tests need no timers. */
|
|
103
|
+
export interface FileTailer extends LogTailer {
|
|
104
|
+
/** Runs one `stat`-and-read cycle. Never throws. */
|
|
105
|
+
poll(): void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Node's `stat` error shape, narrowed enough to read the code off. */
|
|
109
|
+
function errorCode(error: unknown): string {
|
|
110
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
111
|
+
const code = (error as { code?: unknown }).code;
|
|
112
|
+
if (typeof code === "string") return code;
|
|
113
|
+
}
|
|
114
|
+
return "unknown error";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Follows `path`, emitting one {@link LogLine} per appended line.
|
|
119
|
+
*
|
|
120
|
+
* The backlog and the live tail come from ONE offset: every time the tailer
|
|
121
|
+
* starts following a file — first sight, a replacement, a truncation — it
|
|
122
|
+
* anchors at the end minus a backlog window, and every later poll continues from
|
|
123
|
+
* where the last one stopped.
|
|
124
|
+
*
|
|
125
|
+
* Use {@link LogTailer.attach} to open a console: it hands back the backlog and
|
|
126
|
+
* registers the listener in one step, which is the only way to get each line
|
|
127
|
+
* exactly once. Calling {@link LogTailer.recent} and then
|
|
128
|
+
* {@link LogTailer.subscribe} separately is correct only if nothing can poll
|
|
129
|
+
* between them, and a line that arrives in that window is in neither result.
|
|
130
|
+
*/
|
|
131
|
+
export function createFileTailer(options: FileTailerOptions): FileTailer {
|
|
132
|
+
const path = options.path;
|
|
133
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
134
|
+
const backlogBytes = options.backlogBytes ?? DEFAULT_BACKLOG_BYTES;
|
|
135
|
+
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
136
|
+
const now = options.now ?? Date.now;
|
|
137
|
+
|
|
138
|
+
const lines: LogLine[] = [];
|
|
139
|
+
const listeners = new Set<(line: LogLine) => void>();
|
|
140
|
+
/** Port→model id, refreshed from `/models` and reinforced by spawn lines. */
|
|
141
|
+
let ports: Map<number, string> = new Map();
|
|
142
|
+
|
|
143
|
+
/** Monotonic for the tailer's whole life — never reset, by any of the hazards. */
|
|
144
|
+
let seq = 0;
|
|
145
|
+
/** Byte offset already consumed from the current file. */
|
|
146
|
+
let position = 0;
|
|
147
|
+
/** Identity of the file we are following, so a replacement is detectable. */
|
|
148
|
+
let inode: number | null = null;
|
|
149
|
+
let device: number | null = null;
|
|
150
|
+
/** False until the first poll has anchored the offset. */
|
|
151
|
+
let anchored = false;
|
|
152
|
+
/** Set when a read starts mid-file, so the leading partial line is discarded. */
|
|
153
|
+
let dropPartial = false;
|
|
154
|
+
let source: LogSourceState = "ok";
|
|
155
|
+
let detail: string | null = null;
|
|
156
|
+
let closed = false;
|
|
157
|
+
|
|
158
|
+
/** The line before the one being parsed — the args run's only state. */
|
|
159
|
+
let previous: ParsedLogLine | null = null;
|
|
160
|
+
let decoder = new StringDecoder("utf8");
|
|
161
|
+
let splitter = createLineSplitter(MAX_LINE_LENGTH, handleLine);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Attributes and emits one complete line.
|
|
165
|
+
*
|
|
166
|
+
* A child line belongs to whichever model holds its port; a router line
|
|
167
|
+
* belongs to the model it names, if it names one. Everything else is
|
|
168
|
+
* router-wide and stays `null` — roughly a quarter of a real log is genuinely
|
|
169
|
+
* about no single model, and inventing an attribution for it would be worse
|
|
170
|
+
* than the em dash the console renders.
|
|
171
|
+
*/
|
|
172
|
+
function handleLine(raw: string): void {
|
|
173
|
+
if (raw.trim() === "") return;
|
|
174
|
+
const parsed = parseLogLine(raw, previous);
|
|
175
|
+
previous = parsed;
|
|
176
|
+
|
|
177
|
+
// A spawn seen live maps its port before the next `/models` poll does.
|
|
178
|
+
if (parsed.modelName !== null && parsed.namedPort !== null) {
|
|
179
|
+
ports.set(parsed.namedPort, parsed.modelName);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fromPort = parsed.port === null ? null : (ports.get(parsed.port) ?? null);
|
|
183
|
+
const modelId = parsed.origin === "child" ? (fromPort ?? parsed.modelName) : parsed.modelName;
|
|
184
|
+
|
|
185
|
+
seq += 1;
|
|
186
|
+
const line: LogLine = {
|
|
187
|
+
seq,
|
|
188
|
+
// The only timestamp in the file is a per-process elapsed counter that is
|
|
189
|
+
// not sortable across processes, so this is arrival time — which is what
|
|
190
|
+
// the console labels it as.
|
|
191
|
+
ts: now(),
|
|
192
|
+
level: parsed.level,
|
|
193
|
+
modelId,
|
|
194
|
+
message: parsed.message,
|
|
195
|
+
kind: parsed.kind,
|
|
196
|
+
origin: parsed.origin,
|
|
197
|
+
family: parsed.family,
|
|
198
|
+
// Every enrichment below is optional on the wire and absent when the
|
|
199
|
+
// parser did not find it, so a future llama.cpp that renames a payload
|
|
200
|
+
// costs one empty badge and never a dropped row.
|
|
201
|
+
...(parsed.port === null ? {} : { port: parsed.port }),
|
|
202
|
+
...(parsed.frame === null ? {} : { frame: parsed.frame }),
|
|
203
|
+
...(parsed.contextLost ? { contextLost: true } : {}),
|
|
204
|
+
...(parsed.cacheHit === null ? {} : { cacheHit: parsed.cacheHit }),
|
|
205
|
+
};
|
|
206
|
+
lines.push(line);
|
|
207
|
+
if (lines.length > maxLines) lines.splice(0, lines.length - maxLines);
|
|
208
|
+
for (const listener of listeners) listener(line);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Drops every byte of half-read state. Called whenever the file identity changes. */
|
|
212
|
+
function resetStream(): void {
|
|
213
|
+
decoder = new StringDecoder("utf8");
|
|
214
|
+
splitter = createLineSplitter(MAX_LINE_LENGTH, handleLine);
|
|
215
|
+
previous = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Where to start reading a file we have not been following: its tail. */
|
|
219
|
+
function anchor(size: number): void {
|
|
220
|
+
position = Math.max(0, size - backlogBytes);
|
|
221
|
+
dropPartial = position > 0;
|
|
222
|
+
resetStream();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Reads at most one chunk of new bytes and feeds them to the splitter. */
|
|
226
|
+
function drain(size: number): void {
|
|
227
|
+
if (position >= size) return;
|
|
228
|
+
const length = Math.min(size - position, MAX_READ_PER_POLL);
|
|
229
|
+
|
|
230
|
+
let fd: number;
|
|
231
|
+
try {
|
|
232
|
+
fd = openSync(path, "r");
|
|
233
|
+
} catch (error) {
|
|
234
|
+
markUnavailable(error);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
// The file can be replaced between the stat above and this open; reading
|
|
239
|
+
// a different inode at our offset would emit garbage. Check identity
|
|
240
|
+
// against the handle we actually hold and let the next poll re-anchor.
|
|
241
|
+
const open = fstatSync(fd);
|
|
242
|
+
if (open.ino !== inode || open.dev !== device) return;
|
|
243
|
+
|
|
244
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
245
|
+
const read = readSync(fd, buffer, 0, length, position);
|
|
246
|
+
if (read <= 0) return;
|
|
247
|
+
position += read;
|
|
248
|
+
|
|
249
|
+
let text = decoder.write(buffer.subarray(0, read));
|
|
250
|
+
if (dropPartial) {
|
|
251
|
+
// Anchoring mid-file lands in the middle of a line; that fragment is not
|
|
252
|
+
// a record and is never emitted as one.
|
|
253
|
+
const newline = text.indexOf("\n");
|
|
254
|
+
text = newline === -1 ? "" : text.slice(newline + 1);
|
|
255
|
+
if (newline !== -1) dropPartial = false;
|
|
256
|
+
}
|
|
257
|
+
splitter.push(text);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
markUnavailable(error);
|
|
260
|
+
} finally {
|
|
261
|
+
closeSync(fd);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The path is there but unreadable — a permission or I/O problem, not an absence. */
|
|
266
|
+
function markUnavailable(error: unknown): void {
|
|
267
|
+
source = "unavailable";
|
|
268
|
+
detail = `${path} could not be read (${errorCode(error)})`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function poll(): void {
|
|
272
|
+
if (closed) return;
|
|
273
|
+
try {
|
|
274
|
+
let stats: Stats;
|
|
275
|
+
try {
|
|
276
|
+
stats = statSync(path);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (errorCode(error) === "ENOENT") {
|
|
279
|
+
// The likely macOS case: `tmp_cleaner` unlinked it. Hold the offset and
|
|
280
|
+
// the sequence, keep polling, and say so — this heals itself.
|
|
281
|
+
source = "missing";
|
|
282
|
+
detail = `${path} does not exist`;
|
|
283
|
+
} else {
|
|
284
|
+
markUnavailable(error);
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const replaced = anchored && (stats.ino !== inode || stats.dev !== device);
|
|
290
|
+
if (!anchored) {
|
|
291
|
+
// First sight: start at the tail, so a log that has grown for months
|
|
292
|
+
// costs one backlog window rather than a whole-file read.
|
|
293
|
+
inode = stats.ino;
|
|
294
|
+
device = stats.dev;
|
|
295
|
+
anchor(stats.size);
|
|
296
|
+
anchored = true;
|
|
297
|
+
} else if (replaced) {
|
|
298
|
+
// Unlinked and recreated (the `tmp_cleaner` case, once the router writes
|
|
299
|
+
// again), or rotated create-new: a different file, followed from its
|
|
300
|
+
// start — but through the same backlog window as a first sight, so
|
|
301
|
+
// rotating into a pre-populated file cannot flood every connected
|
|
302
|
+
// console with a whole log.
|
|
303
|
+
//
|
|
304
|
+
// Recovery from `missing` lands here, because a recreated file always
|
|
305
|
+
// has a new inode — while a transient stat failure on the SAME file does
|
|
306
|
+
// not, and so cannot make us re-read and re-emit what we already sent.
|
|
307
|
+
inode = stats.ino;
|
|
308
|
+
device = stats.dev;
|
|
309
|
+
anchor(stats.size);
|
|
310
|
+
} else if (stats.size < position) {
|
|
311
|
+
// Truncated in place (`copytruncate`): same file, fresh content, and the
|
|
312
|
+
// same cap again — a truncate followed by a large write before the next
|
|
313
|
+
// poll is otherwise the same flood by another route.
|
|
314
|
+
anchor(stats.size);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
source = "ok";
|
|
318
|
+
detail = null;
|
|
319
|
+
drain(stats.size);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// Belt and braces: a tail that throws would take the dashboard's poll
|
|
322
|
+
// loop with it.
|
|
323
|
+
markUnavailable(error);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
poll();
|
|
328
|
+
|
|
329
|
+
const timer = pollIntervalMs > 0 ? setInterval(poll, pollIntervalMs) : null;
|
|
330
|
+
// The dashboard's HTTP server keeps the process alive; a log tail should never
|
|
331
|
+
// be the reason it cannot exit.
|
|
332
|
+
timer?.unref();
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
recent(limit: number): LogLine[] {
|
|
336
|
+
if (limit <= 0) return [];
|
|
337
|
+
return lines.slice(-limit);
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
subscribe(listener: (line: LogLine) => void): Unsubscribe {
|
|
341
|
+
listeners.add(listener);
|
|
342
|
+
return () => {
|
|
343
|
+
listeners.delete(listener);
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
attach(listener: (line: LogLine) => void, limit: number): LogAttachment {
|
|
348
|
+
// The backlog is taken and the listener registered with no suspension
|
|
349
|
+
// point between them, so a poll cannot land in the gap and drop a line
|
|
350
|
+
// out of both halves.
|
|
351
|
+
const backlog = limit <= 0 ? [] : lines.slice(-limit);
|
|
352
|
+
listeners.add(listener);
|
|
353
|
+
return {
|
|
354
|
+
backlog,
|
|
355
|
+
unsubscribe: () => {
|
|
356
|
+
listeners.delete(listener);
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
setPorts(next: ReadonlyMap<number, string>): void {
|
|
362
|
+
// Merged, not replaced, and HTTP wins every conflict: a child that spawned
|
|
363
|
+
// since the last poll is known here from its spawn line and not yet from
|
|
364
|
+
// `/models`, and an empty map (a failed read, or nothing loaded) must not
|
|
365
|
+
// blank out attribution for every child line until the next good poll.
|
|
366
|
+
ports = new Map([...ports, ...next]);
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
status(): LogStreamStatus {
|
|
370
|
+
return { source, path, detail };
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
poll,
|
|
374
|
+
|
|
375
|
+
close(): void {
|
|
376
|
+
closed = true;
|
|
377
|
+
if (timer !== null) clearInterval(timer);
|
|
378
|
+
listeners.clear();
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The `log` block of `steward.json`, as this module needs it. */
|
|
384
|
+
export interface LogPathConfig {
|
|
385
|
+
path: string;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface ResolveLogPathOptions {
|
|
389
|
+
/** The validated `log` block from `steward.json`, or `null` when it has none. */
|
|
390
|
+
config?: LogPathConfig | null;
|
|
391
|
+
/** Environment to read {@link LOG_FILE_ENV} from. Defaults to `process.env`. */
|
|
392
|
+
env?: Record<string, string | undefined>;
|
|
393
|
+
/** Existence test for the convention default. Injected in tests. */
|
|
394
|
+
exists?: (path: string) => boolean;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** True when `path` names a readable regular file. */
|
|
398
|
+
function isFile(path: string): boolean {
|
|
399
|
+
try {
|
|
400
|
+
return statSync(path).isFile();
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The log file to follow, or `null` for an honest "no log source".
|
|
408
|
+
*
|
|
409
|
+
* Precedence: `STEWARD_LOG_FILE`, then `steward.json`'s `log.path`, then the
|
|
410
|
+
* platform convention. The first two are taken at their word even when the file
|
|
411
|
+
* is absent — the operator (or the skill) named that path, and a named path that
|
|
412
|
+
* is currently missing is a state worth showing, and one that heals itself. The
|
|
413
|
+
* convention default is only adopted when the file is really there, so a machine
|
|
414
|
+
* that simply has not been wired up says "no log source" instead of blaming a
|
|
415
|
+
* `/tmp` file nobody chose.
|
|
416
|
+
*
|
|
417
|
+
* Service-manager introspection deliberately does not happen here: recording
|
|
418
|
+
* where this machine's router writes its log is the `/steward_initialize` skill's
|
|
419
|
+
* job, and its answer arrives as `log.path`.
|
|
420
|
+
*/
|
|
421
|
+
export function resolveLogPath(options: ResolveLogPathOptions = {}): string | null {
|
|
422
|
+
const env = options.env ?? process.env;
|
|
423
|
+
const exists = options.exists ?? isFile;
|
|
424
|
+
|
|
425
|
+
const override = env[LOG_FILE_ENV];
|
|
426
|
+
if (override !== undefined && override.trim() !== "") return override.trim();
|
|
427
|
+
|
|
428
|
+
const configured = options.config?.path;
|
|
429
|
+
if (configured !== undefined && configured.trim() !== "") return configured.trim();
|
|
430
|
+
|
|
431
|
+
return exists(DEFAULT_LOG_PATH) ? DEFAULT_LOG_PATH : null;
|
|
432
|
+
}
|