@otto-code/brain 0.7.5
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/bin/otto-brain +2 -0
- package/dist/bench/context-corpus.d.ts +58 -0
- package/dist/bench/context-corpus.js +210 -0
- package/dist/bench/corpus.d.ts +31 -0
- package/dist/bench/corpus.js +379 -0
- package/dist/bench/curated-repos.d.ts +35 -0
- package/dist/bench/curated-repos.js +41 -0
- package/dist/bench/health.d.ts +50 -0
- package/dist/bench/health.js +112 -0
- package/dist/bench/index.d.ts +103 -0
- package/dist/bench/index.js +209 -0
- package/dist/bench/mine.d.ts +32 -0
- package/dist/bench/mine.js +101 -0
- package/dist/bench/repo-task.d.ts +93 -0
- package/dist/bench/repo-task.js +380 -0
- package/dist/bench/repo.d.ts +125 -0
- package/dist/bench/repo.js +195 -0
- package/dist/bench/rescore.d.ts +41 -0
- package/dist/bench/rescore.js +127 -0
- package/dist/bench/tasks.d.ts +124 -0
- package/dist/bench/tasks.js +1249 -0
- package/dist/bench/verify.d.ts +142 -0
- package/dist/bench/verify.js +350 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +72 -0
- package/dist/commands/bench.d.ts +20 -0
- package/dist/commands/bench.js +222 -0
- package/dist/commands/calibrate.d.ts +19 -0
- package/dist/commands/calibrate.js +75 -0
- package/dist/commands/catalog.d.ts +32 -0
- package/dist/commands/catalog.js +48 -0
- package/dist/commands/config.d.ts +13 -0
- package/dist/commands/config.js +90 -0
- package/dist/commands/lifecycle.d.ts +29 -0
- package/dist/commands/lifecycle.js +194 -0
- package/dist/commands/pull.d.ts +22 -0
- package/dist/commands/pull.js +135 -0
- package/dist/commands/report.d.ts +10 -0
- package/dist/commands/report.js +34 -0
- package/dist/commands/rescore.d.ts +11 -0
- package/dist/commands/rescore.js +48 -0
- package/dist/commands/runtime.d.ts +21 -0
- package/dist/commands/runtime.js +62 -0
- package/dist/commands/scan.d.ts +24 -0
- package/dist/commands/scan.js +55 -0
- package/dist/commands/search.d.ts +31 -0
- package/dist/commands/search.js +135 -0
- package/dist/commands/share.d.ts +26 -0
- package/dist/commands/share.js +148 -0
- package/dist/commands/sweep.d.ts +21 -0
- package/dist/commands/sweep.js +69 -0
- package/dist/commands/ui.d.ts +9 -0
- package/dist/commands/ui.js +28 -0
- package/dist/config/env.d.ts +10 -0
- package/dist/config/env.js +48 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.js +9 -0
- package/dist/config/otto-home.d.ts +2 -0
- package/dist/config/otto-home.js +24 -0
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +33 -0
- package/dist/config/private-files.d.ts +3 -0
- package/dist/config/private-files.js +32 -0
- package/dist/config/profiles.d.ts +34 -0
- package/dist/config/profiles.js +116 -0
- package/dist/config/schema.d.ts +1035 -0
- package/dist/config/schema.js +191 -0
- package/dist/config/store.d.ts +11 -0
- package/dist/config/store.js +73 -0
- package/dist/gguf.d.ts +61 -0
- package/dist/gguf.js +231 -0
- package/dist/gpu.d.ts +12 -0
- package/dist/gpu.js +49 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +5 -0
- package/dist/models/dirs.d.ts +9 -0
- package/dist/models/dirs.js +21 -0
- package/dist/models/download.d.ts +31 -0
- package/dist/models/download.js +105 -0
- package/dist/models/enrich.d.ts +36 -0
- package/dist/models/enrich.js +68 -0
- package/dist/models/hf.d.ts +62 -0
- package/dist/models/hf.js +114 -0
- package/dist/models/index.d.ts +15 -0
- package/dist/models/index.js +49 -0
- package/dist/models/manage.d.ts +27 -0
- package/dist/models/manage.js +112 -0
- package/dist/models/pick.d.ts +3 -0
- package/dist/models/pick.js +36 -0
- package/dist/models/scan.d.ts +13 -0
- package/dist/models/scan.js +185 -0
- package/dist/ops/archive.d.ts +34 -0
- package/dist/ops/archive.js +105 -0
- package/dist/ops/calibrate.d.ts +49 -0
- package/dist/ops/calibrate.js +74 -0
- package/dist/ops/report.d.ts +26 -0
- package/dist/ops/report.js +532 -0
- package/dist/ops/results.d.ts +207 -0
- package/dist/ops/results.js +250 -0
- package/dist/ops/sweep.d.ts +77 -0
- package/dist/ops/sweep.js +126 -0
- package/dist/output/index.d.ts +5 -0
- package/dist/output/index.js +5 -0
- package/dist/output/render.d.ts +4 -0
- package/dist/output/render.js +96 -0
- package/dist/output/types.d.ts +52 -0
- package/dist/output/types.js +9 -0
- package/dist/output/with-output.d.ts +3 -0
- package/dist/output/with-output.js +42 -0
- package/dist/run.d.ts +5 -0
- package/dist/run.js +19 -0
- package/dist/runtime/args.d.ts +21 -0
- package/dist/runtime/args.js +71 -0
- package/dist/runtime/index.d.ts +13 -0
- package/dist/runtime/index.js +41 -0
- package/dist/runtime/lmstudio.d.ts +10 -0
- package/dist/runtime/lmstudio.js +88 -0
- package/dist/runtime/managed.d.ts +26 -0
- package/dist/runtime/managed.js +148 -0
- package/dist/service/model-selector.d.ts +36 -0
- package/dist/service/model-selector.js +123 -0
- package/dist/service/pid-lock.d.ts +18 -0
- package/dist/service/pid-lock.js +50 -0
- package/dist/service/router.d.ts +144 -0
- package/dist/service/router.js +746 -0
- package/dist/service/scheduler.d.ts +74 -0
- package/dist/service/scheduler.js +110 -0
- package/dist/service/serve.d.ts +41 -0
- package/dist/service/serve.js +278 -0
- package/dist/service/supervisor.d.ts +67 -0
- package/dist/service/supervisor.js +222 -0
- package/dist/service/tailscale.d.ts +19 -0
- package/dist/service/tailscale.js +62 -0
- package/dist/service/tls.d.ts +77 -0
- package/dist/service/tls.js +192 -0
- package/dist/sysmon.d.ts +50 -0
- package/dist/sysmon.js +103 -0
- package/dist/tui/app.d.ts +208 -0
- package/dist/tui/app.js +1801 -0
- package/dist/tui/screen.d.ts +72 -0
- package/dist/tui/screen.js +176 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +7 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +8 -0
- package/dist/vram.d.ts +67 -0
- package/dist/vram.js +132 -0
- package/package.json +58 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
2
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
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
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
5
|
+
};
|
|
6
|
+
var _Supervisor_instances, _Supervisor_setState, _Supervisor_log, _Supervisor_health;
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { EventEmitter } from "node:events";
|
|
10
|
+
import { buildArgs, buildEnv, formatCommand } from "../runtime/index.js";
|
|
11
|
+
import { usedBytes } from "../gpu.js";
|
|
12
|
+
const LOG_LINES_KEPT = 300;
|
|
13
|
+
/**
|
|
14
|
+
* Default loopback port for the private llama-server child. Deliberately clear
|
|
15
|
+
* of Otto's space: 8081 (the old default) is the Expo/Metro dev port, so a brain
|
|
16
|
+
* started from the dev checkout collided with the running app - the server bound
|
|
17
|
+
* a port the app also wanted, and benchmark requests hit Metro (or a dead
|
|
18
|
+
* socket) instead of the model. This range sits above Otto's daemon ports
|
|
19
|
+
* (6788/6868) and app port (8081/19000) and below the Windows ephemeral range.
|
|
20
|
+
* Calibrate and sweep run their own supervisors at +1/+2 so they never collide
|
|
21
|
+
* with a main service already holding the base port.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_INTERNAL_PORT = 20800;
|
|
24
|
+
/**
|
|
25
|
+
* Owns the llama-server child process.
|
|
26
|
+
*
|
|
27
|
+
* The server always listens on a private port; `router.js` fronts it on a
|
|
28
|
+
* stable one so switching models never asks a client to reconnect elsewhere.
|
|
29
|
+
*/
|
|
30
|
+
export class Supervisor extends EventEmitter {
|
|
31
|
+
constructor({ runtime, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", readyTimeoutMs = 300000, }) {
|
|
32
|
+
super();
|
|
33
|
+
_Supervisor_instances.add(this);
|
|
34
|
+
this.runtime = runtime;
|
|
35
|
+
this.internalPort = internalPort;
|
|
36
|
+
this.host = host;
|
|
37
|
+
this.readyTimeoutMs = readyTimeoutMs;
|
|
38
|
+
this.state = "stopped"; // stopped | starting | ready | failed
|
|
39
|
+
this.child = null;
|
|
40
|
+
this.model = null;
|
|
41
|
+
this.profile = null;
|
|
42
|
+
this.logLines = [];
|
|
43
|
+
this.lastError = null;
|
|
44
|
+
this.startedAt = null;
|
|
45
|
+
this.loadSeconds = null;
|
|
46
|
+
this.vramAtReadyBytes = null;
|
|
47
|
+
this.vramBaselineBytes = null;
|
|
48
|
+
this.command = null;
|
|
49
|
+
}
|
|
50
|
+
get upstreamBase() {
|
|
51
|
+
return `http://${this.host}:${this.internalPort}`;
|
|
52
|
+
}
|
|
53
|
+
/** Start (or restart) the server for a model + profile. */
|
|
54
|
+
async start(model, profile) {
|
|
55
|
+
await this.stop();
|
|
56
|
+
this.model = model;
|
|
57
|
+
this.profile = profile;
|
|
58
|
+
this.lastError = null;
|
|
59
|
+
this.logLines = [];
|
|
60
|
+
this.vramBaselineBytes = await usedBytes();
|
|
61
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
|
|
62
|
+
const args = buildArgs({ ...profile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host });
|
|
63
|
+
this.command = formatCommand(this.runtime, args);
|
|
64
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
|
|
65
|
+
const started = Date.now();
|
|
66
|
+
this.child = spawn(this.runtime.exe, args, {
|
|
67
|
+
cwd: this.runtime.dir,
|
|
68
|
+
env: buildEnv(this.runtime),
|
|
69
|
+
windowsHide: true,
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
});
|
|
72
|
+
const onChunk = (chunk) => {
|
|
73
|
+
for (const line of String(chunk).split(/\r?\n/)) {
|
|
74
|
+
if (line.trim())
|
|
75
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, line.trim());
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
this.child.stdout?.on("data", onChunk);
|
|
79
|
+
this.child.stderr?.on("data", onChunk);
|
|
80
|
+
let exitedEarly = null;
|
|
81
|
+
this.child.once("exit", (code, signal) => {
|
|
82
|
+
const wasReady = this.state === "ready";
|
|
83
|
+
this.child = null;
|
|
84
|
+
if (this.state === "stopping") {
|
|
85
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "stopped");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
exitedEarly = { code, signal };
|
|
89
|
+
// 3221225781 == 0xC0000135 STATUS_DLL_NOT_FOUND: the vendor DLL trap.
|
|
90
|
+
const hint = code === 3221225781 ? " (missing runtime DLLs - the vendor directory was not on PATH)" : "";
|
|
91
|
+
this.lastError = `llama-server exited with code ${code}${signal ? ` signal ${signal}` : ""}${hint}`;
|
|
92
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
|
|
93
|
+
if (wasReady)
|
|
94
|
+
this.emit("crashed", this.lastError);
|
|
95
|
+
});
|
|
96
|
+
this.child.once("error", (error) => {
|
|
97
|
+
this.lastError = `could not launch llama-server: ${error.message}`;
|
|
98
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
|
|
99
|
+
});
|
|
100
|
+
// Poll /health until the model finishes loading.
|
|
101
|
+
const deadline = Date.now() + this.readyTimeoutMs;
|
|
102
|
+
let peakVram = this.vramBaselineBytes || 0;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
if (exitedEarly)
|
|
105
|
+
throw new Error(this.lastError ?? undefined);
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
107
|
+
const used = await usedBytes();
|
|
108
|
+
if (used && used > peakVram)
|
|
109
|
+
peakVram = used;
|
|
110
|
+
const health = await __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_health).call(this);
|
|
111
|
+
if (health) {
|
|
112
|
+
this.loadSeconds = (Date.now() - started) / 1000;
|
|
113
|
+
this.startedAt = new Date();
|
|
114
|
+
this.vramAtReadyBytes = (await usedBytes()) ?? peakVram;
|
|
115
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "ready");
|
|
116
|
+
this.emit("ready", {
|
|
117
|
+
loadSeconds: this.loadSeconds,
|
|
118
|
+
vramBytes: this.vramAtReadyBytes,
|
|
119
|
+
deltaBytes: this.vramAtReadyBytes - (this.vramBaselineBytes || 0),
|
|
120
|
+
});
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this.lastError = `model did not become ready within ${this.readyTimeoutMs / 1000}s`;
|
|
125
|
+
await this.stop();
|
|
126
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
|
|
127
|
+
throw new Error(this.lastError);
|
|
128
|
+
}
|
|
129
|
+
/** Fetch /props from the running server (modalities, template caps, defaults). */
|
|
130
|
+
props() {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const req = http.get({ host: this.host, port: this.internalPort, path: "/props", timeout: 5000 }, (res) => {
|
|
133
|
+
let body = "";
|
|
134
|
+
res.on("data", (c) => (body += c));
|
|
135
|
+
res.on("end", () => {
|
|
136
|
+
try {
|
|
137
|
+
resolve(JSON.parse(body));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
resolve(null);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
req.on("timeout", () => {
|
|
145
|
+
req.destroy();
|
|
146
|
+
resolve(null);
|
|
147
|
+
});
|
|
148
|
+
req.on("error", () => resolve(null));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async stop() {
|
|
152
|
+
if (!this.child) {
|
|
153
|
+
if (this.state !== "stopped")
|
|
154
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "stopped");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "stopping");
|
|
158
|
+
const child = this.child;
|
|
159
|
+
await new Promise((resolve) => {
|
|
160
|
+
const done = setTimeout(() => {
|
|
161
|
+
try {
|
|
162
|
+
child.kill("SIGKILL");
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* already gone */
|
|
166
|
+
}
|
|
167
|
+
resolve();
|
|
168
|
+
}, 6000);
|
|
169
|
+
child.once("exit", () => {
|
|
170
|
+
clearTimeout(done);
|
|
171
|
+
resolve();
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
child.kill();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
clearTimeout(done);
|
|
178
|
+
resolve();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
this.child = null;
|
|
182
|
+
__classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "stopped");
|
|
183
|
+
}
|
|
184
|
+
status() {
|
|
185
|
+
return {
|
|
186
|
+
state: this.state,
|
|
187
|
+
model: this.model ? this.model.displayName : null,
|
|
188
|
+
modelId: this.model ? this.model.id : null,
|
|
189
|
+
pid: this.child ? (this.child.pid ?? null) : null,
|
|
190
|
+
loadSeconds: this.loadSeconds,
|
|
191
|
+
vramBytes: this.vramAtReadyBytes,
|
|
192
|
+
startedAt: this.startedAt ? this.startedAt.toISOString() : null,
|
|
193
|
+
lastError: this.lastError,
|
|
194
|
+
upstream: this.upstreamBase,
|
|
195
|
+
runtime: `${this.runtime.label} v${this.runtime.version}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
_Supervisor_instances = new WeakSet(), _Supervisor_setState = function _Supervisor_setState(state, detail) {
|
|
200
|
+
this.state = state;
|
|
201
|
+
this.emit("state", { state, detail });
|
|
202
|
+
}, _Supervisor_log = function _Supervisor_log(line) {
|
|
203
|
+
if (!line)
|
|
204
|
+
return;
|
|
205
|
+
this.logLines.push(line);
|
|
206
|
+
if (this.logLines.length > LOG_LINES_KEPT)
|
|
207
|
+
this.logLines.shift();
|
|
208
|
+
this.emit("log", line);
|
|
209
|
+
}, _Supervisor_health = function _Supervisor_health() {
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
const req = http.get({ host: this.host, port: this.internalPort, path: "/health", timeout: 2500 }, (res) => {
|
|
212
|
+
res.resume();
|
|
213
|
+
resolve(res.statusCode === 200);
|
|
214
|
+
});
|
|
215
|
+
req.on("timeout", () => {
|
|
216
|
+
req.destroy();
|
|
217
|
+
resolve(false);
|
|
218
|
+
});
|
|
219
|
+
req.on("error", () => resolve(false));
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
//# sourceMappingURL=supervisor.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const DEFAULT_TAILSCALE_EXE = "tailscale";
|
|
2
|
+
export interface RunResult {
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
}
|
|
6
|
+
/** Promisified execFile with a hard timeout and a useful error message. */
|
|
7
|
+
export declare function run(exe: string, args: string[], timeout?: number): Promise<RunResult>;
|
|
8
|
+
/** The tailnet IPv4 address of this machine (for a tailnet-only bind). */
|
|
9
|
+
export declare function ipv4(exe?: string): Promise<string>;
|
|
10
|
+
/** This machine's MagicDNS name, e.g. greyskull.tail279562.ts.net (trailing dot stripped). */
|
|
11
|
+
export declare function dnsName(exe?: string): Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Ask tailscaled for a Let's Encrypt cert for `hostname`, written to disk.
|
|
14
|
+
* Idempotent: tailscaled serves a cached cert until it is near expiry.
|
|
15
|
+
*/
|
|
16
|
+
export declare function issueCert(exe: string, hostname: string, certFile: string, keyFile: string): Promise<void>;
|
|
17
|
+
/** True if the tailscale CLI is present and the daemon answers. */
|
|
18
|
+
export declare function isAvailable(exe?: string): Promise<boolean>;
|
|
19
|
+
//# sourceMappingURL=tailscale.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thin promisified wrapper over the `tailscale` CLI — the OS boundary for the
|
|
3
|
+
* `tailscale` TLS mode and the `listen.host: "tailscale"` bind. All Tailscale
|
|
4
|
+
* interaction goes through here. Ported from otto-brain-relay's `tailscale.js`.
|
|
5
|
+
*
|
|
6
|
+
* The default executable is `tailscale` on PATH; a caller may pass an explicit
|
|
7
|
+
* path (`tls.tailscaleExe`) when the CLI is installed somewhere non-standard
|
|
8
|
+
* (e.g. `C:\Program Files\Tailscale\tailscale.exe`).
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
export const DEFAULT_TAILSCALE_EXE = "tailscale";
|
|
12
|
+
/** Promisified execFile with a hard timeout and a useful error message. */
|
|
13
|
+
export function run(exe, args, timeout = 120000) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
execFile(exe, args, { timeout, windowsHide: true }, (error, stdout, stderr) => {
|
|
16
|
+
if (error) {
|
|
17
|
+
const detail = (stderr || stdout || error.message).toString().trim();
|
|
18
|
+
reject(new Error(`\`${exe} ${args.join(" ")}\` failed: ${detail}`));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** The tailnet IPv4 address of this machine (for a tailnet-only bind). */
|
|
26
|
+
export async function ipv4(exe = DEFAULT_TAILSCALE_EXE) {
|
|
27
|
+
const { stdout } = await run(exe, ["ip", "-4"], 15000);
|
|
28
|
+
const address = stdout.trim().split(/\r?\n/)[0]?.trim();
|
|
29
|
+
if (!address)
|
|
30
|
+
throw new Error("tailscale reported no IPv4 address - is it connected?");
|
|
31
|
+
return address;
|
|
32
|
+
}
|
|
33
|
+
/** This machine's MagicDNS name, e.g. greyskull.tail279562.ts.net (trailing dot stripped). */
|
|
34
|
+
export async function dnsName(exe = DEFAULT_TAILSCALE_EXE) {
|
|
35
|
+
const { stdout } = await run(exe, ["status", "--json"], 15000);
|
|
36
|
+
const status = JSON.parse(stdout);
|
|
37
|
+
const name = typeof status === "object" && status !== null
|
|
38
|
+
? status.Self?.DNSName
|
|
39
|
+
: undefined;
|
|
40
|
+
if (typeof name !== "string" || !name) {
|
|
41
|
+
throw new Error("could not read Self.DNSName from `tailscale status --json`");
|
|
42
|
+
}
|
|
43
|
+
return name.replace(/\.$/, "");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ask tailscaled for a Let's Encrypt cert for `hostname`, written to disk.
|
|
47
|
+
* Idempotent: tailscaled serves a cached cert until it is near expiry.
|
|
48
|
+
*/
|
|
49
|
+
export async function issueCert(exe, hostname, certFile, keyFile) {
|
|
50
|
+
await run(exe, ["cert", "--cert-file", certFile, "--key-file", keyFile, hostname], 180000);
|
|
51
|
+
}
|
|
52
|
+
/** True if the tailscale CLI is present and the daemon answers. */
|
|
53
|
+
export async function isAvailable(exe = DEFAULT_TAILSCALE_EXE) {
|
|
54
|
+
try {
|
|
55
|
+
await run(exe, ["version"], 10000);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=tailscale.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supplies the TLS keypair for the brain's HTTPS listener and keeps it fresh,
|
|
3
|
+
* so the brain can be exposed over HTTPS with no relay in front. Ported and
|
|
4
|
+
* generalized from otto-brain-relay's `cert.js`.
|
|
5
|
+
*
|
|
6
|
+
* Modes (resolved from `config.tls`):
|
|
7
|
+
* - `files` — read the cert/key paths you provide; no renewal.
|
|
8
|
+
* - `self-signed` — generate a local keypair on first run, cached under
|
|
9
|
+
* `certDir`; regenerated when it nears expiry. Clients must
|
|
10
|
+
* trust it (or pass `-k`); there is no chain of trust.
|
|
11
|
+
* - `tailscale` — issue/renew a real Let's Encrypt cert for this machine's
|
|
12
|
+
* MagicDNS name via `tailscaled`, so tailnet clients see no
|
|
13
|
+
* warnings. Renewal is hot: `renewed` fires with the new
|
|
14
|
+
* { cert, key } and the server swaps its secure context
|
|
15
|
+
* without dropping connections.
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
import type { BrainConfig } from "../config/schema.js";
|
|
19
|
+
import type { BrainPaths } from "../config/paths.js";
|
|
20
|
+
export interface SecurePair {
|
|
21
|
+
cert: Buffer;
|
|
22
|
+
key: Buffer;
|
|
23
|
+
}
|
|
24
|
+
export interface CertInfo {
|
|
25
|
+
subject: string;
|
|
26
|
+
issuer: string;
|
|
27
|
+
validFrom: Date;
|
|
28
|
+
validTo: Date;
|
|
29
|
+
certFile: string;
|
|
30
|
+
keyFile: string;
|
|
31
|
+
}
|
|
32
|
+
/** Minimal logger sink; only `warn`/`info` are used. */
|
|
33
|
+
export interface TlsLogger {
|
|
34
|
+
info?(message: string): void;
|
|
35
|
+
warn?(message: string): void;
|
|
36
|
+
}
|
|
37
|
+
/** Concrete, fully-resolved options — no nulls, no auto-detection left to do. */
|
|
38
|
+
export interface CertManagerOptions {
|
|
39
|
+
mode: "files" | "self-signed" | "tailscale";
|
|
40
|
+
certFile: string;
|
|
41
|
+
keyFile: string;
|
|
42
|
+
hostname: string;
|
|
43
|
+
certDir: string;
|
|
44
|
+
renewBeforeDays: number;
|
|
45
|
+
checkIntervalMs: number;
|
|
46
|
+
tailscaleExe: string;
|
|
47
|
+
logger?: TlsLogger;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve `config.tls` into concrete CertManager options, or `null` when TLS is
|
|
51
|
+
* off. For `tailscale` mode with no configured hostname, this auto-detects the
|
|
52
|
+
* MagicDNS name from `tailscaled` (the one async step, done here so the manager
|
|
53
|
+
* itself is synchronous to construct).
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveTlsOptions(config: BrainConfig, paths: BrainPaths): Promise<CertManagerOptions | null>;
|
|
56
|
+
export declare class CertManager extends EventEmitter {
|
|
57
|
+
private readonly options;
|
|
58
|
+
private readonly logger;
|
|
59
|
+
private timer;
|
|
60
|
+
private current;
|
|
61
|
+
constructor(options: CertManagerOptions);
|
|
62
|
+
/** Issue/generate if needed, then load and return the keypair. */
|
|
63
|
+
load(): Promise<SecurePair>;
|
|
64
|
+
/** Begin periodic expiry checks (renewing modes only). */
|
|
65
|
+
start(): void;
|
|
66
|
+
stop(): void;
|
|
67
|
+
get info(): CertInfo | null;
|
|
68
|
+
/** Days until the loaded cert expires, or null if none is loaded. */
|
|
69
|
+
get daysRemaining(): number | null;
|
|
70
|
+
/** Issue (tailscale) or generate (self-signed) when missing or near expiry. */
|
|
71
|
+
private ensureFresh;
|
|
72
|
+
private read;
|
|
73
|
+
/** Inspect the on-disk cert's expiry without loading it as current. */
|
|
74
|
+
private inspect;
|
|
75
|
+
private check;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=tls.d.ts.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supplies the TLS keypair for the brain's HTTPS listener and keeps it fresh,
|
|
3
|
+
* so the brain can be exposed over HTTPS with no relay in front. Ported and
|
|
4
|
+
* generalized from otto-brain-relay's `cert.js`.
|
|
5
|
+
*
|
|
6
|
+
* Modes (resolved from `config.tls`):
|
|
7
|
+
* - `files` — read the cert/key paths you provide; no renewal.
|
|
8
|
+
* - `self-signed` — generate a local keypair on first run, cached under
|
|
9
|
+
* `certDir`; regenerated when it nears expiry. Clients must
|
|
10
|
+
* trust it (or pass `-k`); there is no chain of trust.
|
|
11
|
+
* - `tailscale` — issue/renew a real Let's Encrypt cert for this machine's
|
|
12
|
+
* MagicDNS name via `tailscaled`, so tailnet clients see no
|
|
13
|
+
* warnings. Renewal is hot: `renewed` fires with the new
|
|
14
|
+
* { cert, key } and the server swaps its secure context
|
|
15
|
+
* without dropping connections.
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { X509Certificate } from "node:crypto";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import generateSelfSigned from "selfsigned";
|
|
22
|
+
import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../config/private-files.js";
|
|
23
|
+
import { DEFAULT_TAILSCALE_EXE, dnsName, issueCert } from "./tailscale.js";
|
|
24
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
// Self-signed certs live ~2 years; regenerated well before expiry by the check loop.
|
|
26
|
+
const SELF_SIGNED_DAYS = 825;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve `config.tls` into concrete CertManager options, or `null` when TLS is
|
|
29
|
+
* off. For `tailscale` mode with no configured hostname, this auto-detects the
|
|
30
|
+
* MagicDNS name from `tailscaled` (the one async step, done here so the manager
|
|
31
|
+
* itself is synchronous to construct).
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveTlsOptions(config, paths) {
|
|
34
|
+
const tls = config.tls;
|
|
35
|
+
if (tls.mode === "off")
|
|
36
|
+
return null;
|
|
37
|
+
const certDir = tls.certDir ?? path.join(paths.root, "certs");
|
|
38
|
+
const tailscaleExe = tls.tailscaleExe ?? DEFAULT_TAILSCALE_EXE;
|
|
39
|
+
if (tls.mode === "files") {
|
|
40
|
+
if (!tls.certFile || !tls.keyFile) {
|
|
41
|
+
throw new Error("tls.mode=files requires tls.certFile and tls.keyFile");
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
mode: "files",
|
|
45
|
+
certFile: path.resolve(tls.certFile),
|
|
46
|
+
keyFile: path.resolve(tls.keyFile),
|
|
47
|
+
hostname: tls.hostname ?? "localhost",
|
|
48
|
+
certDir,
|
|
49
|
+
renewBeforeDays: tls.renewBeforeDays,
|
|
50
|
+
checkIntervalMs: tls.checkIntervalMs,
|
|
51
|
+
tailscaleExe,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const hostname = tls.hostname ?? (tls.mode === "tailscale" ? await dnsName(tailscaleExe) : "localhost");
|
|
55
|
+
return {
|
|
56
|
+
mode: tls.mode,
|
|
57
|
+
certFile: path.join(certDir, `${hostname}.crt`),
|
|
58
|
+
keyFile: path.join(certDir, `${hostname}.key`),
|
|
59
|
+
hostname,
|
|
60
|
+
certDir,
|
|
61
|
+
renewBeforeDays: tls.renewBeforeDays,
|
|
62
|
+
checkIntervalMs: tls.checkIntervalMs,
|
|
63
|
+
tailscaleExe,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export class CertManager extends EventEmitter {
|
|
67
|
+
constructor(options) {
|
|
68
|
+
super();
|
|
69
|
+
this.timer = null;
|
|
70
|
+
this.current = null;
|
|
71
|
+
this.options = options;
|
|
72
|
+
this.logger = options.logger ?? {};
|
|
73
|
+
}
|
|
74
|
+
/** Issue/generate if needed, then load and return the keypair. */
|
|
75
|
+
async load() {
|
|
76
|
+
if (this.options.mode !== "files")
|
|
77
|
+
await this.ensureFresh();
|
|
78
|
+
return this.read();
|
|
79
|
+
}
|
|
80
|
+
/** Begin periodic expiry checks (renewing modes only). */
|
|
81
|
+
start() {
|
|
82
|
+
if (this.options.mode === "files" || this.timer)
|
|
83
|
+
return;
|
|
84
|
+
this.timer = setInterval(() => {
|
|
85
|
+
this.check().catch((error) => this.logger.warn?.(`cert renewal check failed: ${errorMessage(error)}`));
|
|
86
|
+
}, this.options.checkIntervalMs);
|
|
87
|
+
this.timer.unref?.();
|
|
88
|
+
}
|
|
89
|
+
stop() {
|
|
90
|
+
if (this.timer)
|
|
91
|
+
clearInterval(this.timer);
|
|
92
|
+
this.timer = null;
|
|
93
|
+
}
|
|
94
|
+
get info() {
|
|
95
|
+
return this.current?.info ?? null;
|
|
96
|
+
}
|
|
97
|
+
/** Days until the loaded cert expires, or null if none is loaded. */
|
|
98
|
+
get daysRemaining() {
|
|
99
|
+
if (!this.current)
|
|
100
|
+
return null;
|
|
101
|
+
return Math.floor((this.current.info.validTo.getTime() - Date.now()) / DAY_MS);
|
|
102
|
+
}
|
|
103
|
+
/** Issue (tailscale) or generate (self-signed) when missing or near expiry. */
|
|
104
|
+
async ensureFresh() {
|
|
105
|
+
ensurePrivateDirectory(this.options.certDir);
|
|
106
|
+
const existing = this.inspect();
|
|
107
|
+
if (existing && existing.daysRemaining > this.options.renewBeforeDays)
|
|
108
|
+
return false;
|
|
109
|
+
const reason = existing
|
|
110
|
+
? `expires in ${existing.daysRemaining}d (threshold ${this.options.renewBeforeDays}d)`
|
|
111
|
+
: "no cert on disk";
|
|
112
|
+
if (this.options.mode === "tailscale") {
|
|
113
|
+
this.logger.info?.(`requesting TLS certificate from tailscaled (${reason})...`);
|
|
114
|
+
await issueCert(this.options.tailscaleExe, this.options.hostname, this.options.certFile, this.options.keyFile);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
// self-signed
|
|
118
|
+
this.logger.info?.(`generating a self-signed certificate for ${this.options.hostname} (${reason})`);
|
|
119
|
+
const pair = generateSelfSigned.generate([{ name: "commonName", value: this.options.hostname }], {
|
|
120
|
+
days: SELF_SIGNED_DAYS,
|
|
121
|
+
keySize: 2048,
|
|
122
|
+
algorithm: "sha256",
|
|
123
|
+
extensions: [
|
|
124
|
+
{ name: "basicConstraints", cA: false },
|
|
125
|
+
{
|
|
126
|
+
name: "subjectAltName",
|
|
127
|
+
altNames: buildAltNames(this.options.hostname),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
writePrivateFileAtomicSync(this.options.certFile, pair.cert);
|
|
132
|
+
writePrivateFileAtomicSync(this.options.keyFile, pair.private);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
read() {
|
|
136
|
+
const cert = readFileSync(this.options.certFile);
|
|
137
|
+
const key = readFileSync(this.options.keyFile);
|
|
138
|
+
const parsed = new X509Certificate(cert);
|
|
139
|
+
this.current = {
|
|
140
|
+
cert,
|
|
141
|
+
key,
|
|
142
|
+
info: {
|
|
143
|
+
subject: parsed.subject,
|
|
144
|
+
issuer: parsed.issuer.split("\n").find((l) => l.startsWith("CN=")) ?? parsed.issuer,
|
|
145
|
+
validFrom: new Date(parsed.validFrom),
|
|
146
|
+
validTo: new Date(parsed.validTo),
|
|
147
|
+
certFile: this.options.certFile,
|
|
148
|
+
keyFile: this.options.keyFile,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
return { cert, key };
|
|
152
|
+
}
|
|
153
|
+
/** Inspect the on-disk cert's expiry without loading it as current. */
|
|
154
|
+
inspect() {
|
|
155
|
+
try {
|
|
156
|
+
if (!existsSync(this.options.certFile) || !existsSync(this.options.keyFile))
|
|
157
|
+
return null;
|
|
158
|
+
const parsed = new X509Certificate(readFileSync(this.options.certFile));
|
|
159
|
+
const validTo = new Date(parsed.validTo);
|
|
160
|
+
return { validTo, daysRemaining: Math.floor((validTo.getTime() - Date.now()) / DAY_MS) };
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
this.logger.warn?.(`could not read existing certificate, will re-issue: ${errorMessage(error)}`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async check() {
|
|
168
|
+
const before = this.current?.info.validTo.getTime();
|
|
169
|
+
const reissued = await this.ensureFresh();
|
|
170
|
+
if (!reissued)
|
|
171
|
+
return;
|
|
172
|
+
const { cert, key } = this.read();
|
|
173
|
+
if (this.current && this.current.info.validTo.getTime() === before)
|
|
174
|
+
return;
|
|
175
|
+
this.logger.info?.(`certificate renewed - now valid until ${this.current?.info.validTo.toISOString()}`);
|
|
176
|
+
this.emit("renewed", { cert, key });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function buildAltNames(hostname) {
|
|
180
|
+
const names = [
|
|
181
|
+
{ type: 2, value: hostname },
|
|
182
|
+
{ type: 2, value: "localhost" },
|
|
183
|
+
{ type: 7, ip: "127.0.0.1" },
|
|
184
|
+
{ type: 7, ip: "::1" },
|
|
185
|
+
];
|
|
186
|
+
// Deduplicate when the hostname is itself "localhost".
|
|
187
|
+
return names.filter((n, i) => i === names.findIndex((m) => m.type === n.type && m.value === n.value && m.ip === n.ip));
|
|
188
|
+
}
|
|
189
|
+
function errorMessage(error) {
|
|
190
|
+
return error instanceof Error ? error.message : String(error);
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=tls.js.map
|
package/dist/sysmon.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { query } from "./gpu.js";
|
|
2
|
+
/**
|
|
3
|
+
* Live system telemetry for the UI: CPU, system RAM, GPU, and how many
|
|
4
|
+
* inference slots are actually busy.
|
|
5
|
+
*
|
|
6
|
+
* Slot state comes from llama-server's own `/slots` endpoint rather than being
|
|
7
|
+
* inferred from request counting, so it reflects what the engine is really
|
|
8
|
+
* doing. That matters for agentic use: several concurrent requests are only
|
|
9
|
+
* genuinely parallel if there are free slots to take them, and slots share one
|
|
10
|
+
* KV pool — so more concurrency costs context per request.
|
|
11
|
+
*/
|
|
12
|
+
/** A CPU busy-fraction sampler: returns the fraction in [0,1], or null. */
|
|
13
|
+
export interface CpuSampler {
|
|
14
|
+
(): number | null;
|
|
15
|
+
}
|
|
16
|
+
/** Slot occupancy from the running server. */
|
|
17
|
+
export interface SlotInfo {
|
|
18
|
+
total: number;
|
|
19
|
+
busy: number;
|
|
20
|
+
idle: number;
|
|
21
|
+
contexts: number[];
|
|
22
|
+
}
|
|
23
|
+
/** One combined reading for the status panel. */
|
|
24
|
+
export interface SystemSample {
|
|
25
|
+
cpu: number | null;
|
|
26
|
+
cpuCount: number;
|
|
27
|
+
loadAverage: number | null;
|
|
28
|
+
ramUsedBytes: number;
|
|
29
|
+
ramTotalBytes: number;
|
|
30
|
+
gpu: Awaited<ReturnType<typeof query>>;
|
|
31
|
+
slots: SlotInfo | null;
|
|
32
|
+
}
|
|
33
|
+
interface Endpoint {
|
|
34
|
+
host?: string;
|
|
35
|
+
port?: number;
|
|
36
|
+
}
|
|
37
|
+
/** CPU busy fraction, sampled between successive calls. */
|
|
38
|
+
export declare function createCpuSampler(): CpuSampler;
|
|
39
|
+
/**
|
|
40
|
+
* Slot occupancy from the running server.
|
|
41
|
+
* @returns {{total:number, busy:number, idle:number, contexts:number[]}|null}
|
|
42
|
+
*/
|
|
43
|
+
declare function slots({ host, port }: {
|
|
44
|
+
host: string;
|
|
45
|
+
port: number;
|
|
46
|
+
}): Promise<SlotInfo | null>;
|
|
47
|
+
export { slots };
|
|
48
|
+
/** One combined reading for the status panel. */
|
|
49
|
+
export declare function sample(sampler: CpuSampler, { host, port }?: Endpoint): Promise<SystemSample>;
|
|
50
|
+
//# sourceMappingURL=sysmon.d.ts.map
|