@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jeremy Combs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/steward/preview.png" width="250" alt="@jmcombs/pi-steward">
|
|
3
|
+
<br>
|
|
4
|
+
<a href="https://www.npmjs.com/package/@jmcombs/pi-steward"><img src="https://img.shields.io/npm/v/@jmcombs/pi-steward.svg" alt="npm version"></a>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@jmcombs/pi-steward"><img src="https://img.shields.io/npm/dm/@jmcombs/pi-steward.svg" alt="npm downloads"></a>
|
|
6
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
7
|
+
<a href="https://github.com/jmcombs/pi-extensions/stargazers"><img src="https://img.shields.io/github/stars/jmcombs/pi-extensions?style=social" alt="GitHub stars"></a>
|
|
8
|
+
<a href="https://github.com/jmcombs/pi-extensions/issues"><img src="https://img.shields.io/github/issues/jmcombs/pi-extensions" alt="Open issues"></a>
|
|
9
|
+
<a href="https://github.com/sponsors/jmcombs"><img src="https://img.shields.io/badge/Sponsor-30363D?style=flat&logo=GitHub-Sponsors&logoColor=EA4AAA" alt="Sponsor"></a>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
# @jmcombs/pi-steward
|
|
13
|
+
|
|
14
|
+
> **Steward — the llama.cpp control panel for [Pi](https://pi.dev).** *How may we serve your models?*
|
|
15
|
+
|
|
16
|
+
A single-page operator dashboard for the local `llama-server` behind Pi's llama.cpp provider, plus a
|
|
17
|
+
status bar inside Pi itself. It answers four questions at a glance, and lets you act on all of them
|
|
18
|
+
without leaving your editor:
|
|
19
|
+
|
|
20
|
+
1. **Is the service up**, and for how long?
|
|
21
|
+
2. **Which models are resident**, and what is each costing you in memory?
|
|
22
|
+
3. **Is the box healthy** — GPU, CPU, memory, temperature?
|
|
23
|
+
4. **What is the server doing right now** — slots, throughput, requests, and its live log?
|
|
24
|
+
|
|
25
|
+
Start, stop and restart the service; load and unload models; watch the log — all from the dashboard.
|
|
26
|
+
|
|
27
|
+
<div align="center">
|
|
28
|
+
<img src="https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/steward/dashboard.png" width="900" alt="The Steward dashboard">
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
| | |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| **Node** | ≥ 22.13 |
|
|
36
|
+
| **[Pi](https://pi.dev)** | any recent version |
|
|
37
|
+
| **`llama-server`** | running in **router mode** — `--models-dir` and/or `--models-preset`, with no `-m`/`--model`/`-hf`. Pi's own llama.cpp provider requires this too. |
|
|
38
|
+
| **A [Nerd Font](https://www.nerdfonts.com)** | for the status bar's mark and separators. Set `STEWARD_GLYPH=""` to drop the mark if you would rather not install one. |
|
|
39
|
+
|
|
40
|
+
`/steward_initialize` checks everything else — metrics, slots, log capture, host sensors — and offers
|
|
41
|
+
to fix whatever is missing.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Globally (recommended)
|
|
47
|
+
pi install npm:@jmcombs/pi-steward
|
|
48
|
+
|
|
49
|
+
# For a single session, without installing
|
|
50
|
+
pi -e ./packages/steward
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See the [Pi packages documentation](https://pi.dev/docs/packages) for git, local path,
|
|
54
|
+
project-scoped install, and filtering options.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
Start Pi, then:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
/steward_initialize 1. connect this machine — review, approve, done
|
|
62
|
+
/steward_start 2. start the dashboard service
|
|
63
|
+
/steward_dashboard 3. open it in your browser
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
That is the whole path. Step 1 is once per machine; steps 2 and 3 are once per session — and
|
|
67
|
+
`/steward_dashboard` starts the service for you, so you can skip straight to it.
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
| Command | What it does |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `/steward_initialize` | Connects this machine to Steward. Works out how `llama-server` runs here, proposes the changes it needs, and writes `steward.json` — **asking before every change**. Run once per machine, or again whenever the setup moves. |
|
|
74
|
+
| `/steward_start` | Starts the dashboard service on `127.0.0.1:8788` for this session. Prints the URL and opens nothing — useful on a headless box, or when you only want the status bar. |
|
|
75
|
+
| `/steward_dashboard` | Opens the dashboard in your browser, starting the service first if it is not already running. |
|
|
76
|
+
| `/steward_stop` | Stops the dashboard service. It also stops on its own when the Pi session ends. |
|
|
77
|
+
|
|
78
|
+
## The status bar
|
|
79
|
+
|
|
80
|
+
Steward draws a line above the editor whenever it has something to say. It reports **Steward's own
|
|
81
|
+
state** first, with `llama.cpp` as detail.
|
|
82
|
+
|
|
83
|
+
<div align="center">
|
|
84
|
+
<img src="https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/steward/status-states.svg" width="620" alt="Steward status bar states">
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
- **Everything up** — the dashboard is on `:8788`, `llama.cpp` answers on `:8080`, and 1 of its 10
|
|
88
|
+
models is resident. The count is models holding weights in memory, not models on disk.
|
|
89
|
+
- **llama.cpp down** — Steward is fine and says so in green; the server gets its own red block. The
|
|
90
|
+
dashboard still opens, and Start is there if you recorded one.
|
|
91
|
+
- **Not read yet** — Steward is up but has not completed a read. Deliberately its own colour: an
|
|
92
|
+
absent reading is not a healthy one.
|
|
93
|
+
- **Dashboard not running** — no `/steward_start` yet, or you stopped it. Nothing is said about
|
|
94
|
+
`llama.cpp`, because Steward is not watching it.
|
|
95
|
+
- **Pointed elsewhere** — Pi is dialling a different address than the server Steward watches, so
|
|
96
|
+
chat fails while everything else looks healthy. The bar names the fix.
|
|
97
|
+
|
|
98
|
+
Three colours, and only three: **green** started, **red** stopped, **orange** running but something
|
|
99
|
+
needs a person.
|
|
100
|
+
|
|
101
|
+
It refreshes while the dashboard is up, and reads the same data the dashboard does — no extra
|
|
102
|
+
connection to your server.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
Run `/steward_initialize` and let your clanker set me up. It reviews the local environment, captures
|
|
108
|
+
how `llama.cpp` is currently configured, works out what is needed for Pi, Steward and `llama.cpp` to
|
|
109
|
+
run together, then hands you a plan to approve. Nothing is applied before you say so.
|
|
110
|
+
|
|
111
|
+
It writes `~/.config/steward/steward.json` at mode `0600` — the collector, the log path, the base
|
|
112
|
+
URL, the service commands, and a consent hash for every command Steward may run. Steward ignores
|
|
113
|
+
that file if it is not owned by you or is world-writable, because it is a code-execution surface.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
### Environment variables
|
|
117
|
+
|
|
118
|
+
| Variable | Default | Purpose |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| `STEWARD_CONFIG` | `~/.config/steward/steward.json` | Where the artifact lives. |
|
|
121
|
+
| `STEWARD_PORT` | `8788` | Dashboard port. `0` asks the OS for any free port; a port already taken costs an ephemeral one, not the dashboard. |
|
|
122
|
+
| `STEWARD_LOG_FILE` | — | Overrides the recorded `log.path` for the log console. |
|
|
123
|
+
| `STEWARD_GLYPH` | `` | The status bar's mark. `""` drops it; any other value replaces it. |
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# From the repo root
|
|
130
|
+
npx vitest run packages/steward # tests
|
|
131
|
+
node scripts/typecheck.mjs # types
|
|
132
|
+
npx biome check packages/steward # lint and format
|
|
133
|
+
|
|
134
|
+
# Run the dashboard without Pi
|
|
135
|
+
npx tsx packages/steward/scripts/dev.ts
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT © [Jeremy Combs](https://github.com/jmcombs)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The source Steward uses when it has nothing to read.
|
|
3
|
+
*
|
|
4
|
+
* This replaces the simulated source the dashboard used to fall back on. That
|
|
5
|
+
* fallback was a liability: a machine with no `steward.json`, or one whose
|
|
6
|
+
* `llama-server` could not be reached, rendered a complete, plausible, moving
|
|
7
|
+
* dashboard made entirely of invented numbers — indistinguishable from the real
|
|
8
|
+
* thing. It was observed serving three "loaded" models on a machine whose router
|
|
9
|
+
* had ten models and none loaded.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here invents a reading. Every gauge is `NaN` (which the UI renders as
|
|
12
|
+
* a no-reading track, never a zero), every list is empty, drift is `unknown`
|
|
13
|
+
* with a reason, and the service reports itself not running with no controls.
|
|
14
|
+
* The panels go blank and say so, which is the only honest thing a dashboard can
|
|
15
|
+
* do about a machine it cannot see.
|
|
16
|
+
*
|
|
17
|
+
* Keep this module free of Node and DOM APIs — see `./types.ts`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { unknownDrift } from "./drift.js";
|
|
21
|
+
import type { LogAttachment, StewardDataSource, Unsubscribe } from "./source.js";
|
|
22
|
+
import type { HostMetrics, LogLine, ModelAction, ServiceAction, Snapshot } from "./types.js";
|
|
23
|
+
|
|
24
|
+
/** Why the dashboard has nothing to show, in the operator's vocabulary. */
|
|
25
|
+
export const NOT_CONNECTED_REASON =
|
|
26
|
+
"Steward is not connected to this machine — run /steward_initialize";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every gauge as a no-reading. `NaN` is deliberate and load-bearing: the UI
|
|
30
|
+
* distinguishes it from `0`, so an unmeasured host reads as blank rather than as
|
|
31
|
+
* an idle one.
|
|
32
|
+
*/
|
|
33
|
+
function noReadings(): HostMetrics {
|
|
34
|
+
return {
|
|
35
|
+
vramUsedGB: Number.NaN,
|
|
36
|
+
vramTotalGB: Number.NaN,
|
|
37
|
+
ramUsedGB: Number.NaN,
|
|
38
|
+
ramTotalGB: Number.NaN,
|
|
39
|
+
gpuUtil: Number.NaN,
|
|
40
|
+
cpuUtil: Number.NaN,
|
|
41
|
+
gpuTempC: null,
|
|
42
|
+
cpuTempC: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A complete Snapshot that asserts nothing. `now` is the caller's clock so the
|
|
48
|
+
* dashboard's own staleness logic still works; everything else is absent.
|
|
49
|
+
*/
|
|
50
|
+
export function disconnectedSnapshot(now: number): Snapshot {
|
|
51
|
+
return {
|
|
52
|
+
now,
|
|
53
|
+
service: {
|
|
54
|
+
running: false,
|
|
55
|
+
startedAt: null,
|
|
56
|
+
pid: null,
|
|
57
|
+
host: "",
|
|
58
|
+
port: 0,
|
|
59
|
+
build: "",
|
|
60
|
+
controls: [],
|
|
61
|
+
},
|
|
62
|
+
models: [],
|
|
63
|
+
slots: [],
|
|
64
|
+
metrics: noReadings(),
|
|
65
|
+
memoryTopology: "unified",
|
|
66
|
+
drift: unknownDrift(NOT_CONNECTED_REASON),
|
|
67
|
+
throughputTps: null,
|
|
68
|
+
throughputWindowSeconds: null,
|
|
69
|
+
requestsInFlight: null,
|
|
70
|
+
throughputHistory: [],
|
|
71
|
+
requestsQueued: null,
|
|
72
|
+
config: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A source with no machine behind it. Every read is empty; every write refuses. */
|
|
77
|
+
export function createDisconnectedSource(): StewardDataSource {
|
|
78
|
+
return {
|
|
79
|
+
name: "disconnected",
|
|
80
|
+
|
|
81
|
+
async snapshot(): Promise<Snapshot> {
|
|
82
|
+
return disconnectedSnapshot(Date.now());
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
recentLogs(): LogLine[] {
|
|
86
|
+
return [];
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
subscribeLogs(): Unsubscribe {
|
|
90
|
+
// Nothing will ever arrive, but the contract is a working unsubscribe.
|
|
91
|
+
return () => {};
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
attachLogs(): LogAttachment {
|
|
95
|
+
return { backlog: [], unsubscribe: () => {} };
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Refusing beats resolving: a caller that thinks a start succeeded here
|
|
99
|
+
// would poll forever for a service that was never asked to do anything.
|
|
100
|
+
async setService(action: ServiceAction): Promise<void> {
|
|
101
|
+
throw new Error(`cannot ${action} — ${NOT_CONNECTED_REASON}`);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async setModel(modelId: string, action: ModelAction): Promise<void> {
|
|
105
|
+
throw new Error(`cannot ${action} ${modelId} — ${NOT_CONNECTED_REASON}`);
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
close(): void {},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/core/drift.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config drift — the honesty check behind everything `steward.json` asserts.
|
|
3
|
+
*
|
|
4
|
+
* Steward's facts about a machine (which flags `llama-server` was launched
|
|
5
|
+
* with, which commands the operator approved) come from an artifact written
|
|
6
|
+
* once, by the `/steward_initialize` skill. Nothing stops the operator editing
|
|
7
|
+
* their launchd plist or systemd unit afterwards, and nothing announces it when
|
|
8
|
+
* they do. Without a re-check, a machine that lost `--metrics` shows a dark
|
|
9
|
+
* throughput tile AND — because "fully compliant renders nothing" — an implicit
|
|
10
|
+
* all-clear. This module is the type surface that makes the mismatch visible.
|
|
11
|
+
*
|
|
12
|
+
* Two independent producers feed one notice:
|
|
13
|
+
* - {@link LaunchDrift}: the live process argv diffed against what was
|
|
14
|
+
* recorded (the Node body that reads it lives in `server/drift-probe.ts`;
|
|
15
|
+
* the comparison itself is {@link diffLaunchArgv}, here, so it is testable
|
|
16
|
+
* without a real `llama-server`).
|
|
17
|
+
* - {@link ConsentDrift}: a collector or control command `steward.json`
|
|
18
|
+
* declares but whose exact command is NOT in the consent map. Today that
|
|
19
|
+
* silently does nothing — no collector, no button — and the operator has no
|
|
20
|
+
* way to tell an inert panel from an unconfigured one.
|
|
21
|
+
*
|
|
22
|
+
* The load-bearing rule for both: a check that could not be made is `unknown`,
|
|
23
|
+
* never a quiet "clean" and never a fabricated "drifted". A false alarm on a
|
|
24
|
+
* correctly configured machine costs exactly as much trust as a missed one.
|
|
25
|
+
*
|
|
26
|
+
* Keep this module free of Node and DOM APIs — see `./types.ts`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ServiceAction } from "./types.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The verdict of the launch-argv re-check.
|
|
33
|
+
*
|
|
34
|
+
* `unknown` is the honest degrade: no listening process, no `ps`, a permission
|
|
35
|
+
* error, a command line the process list truncated, or no recorded argv to
|
|
36
|
+
* compare against. It renders nothing, exactly as `clean` does — the difference
|
|
37
|
+
* is that `clean` is a statement and `unknown` is an absence of one.
|
|
38
|
+
*/
|
|
39
|
+
export type LaunchDriftStatus = "clean" | "drifted" | "unknown";
|
|
40
|
+
|
|
41
|
+
/** The program path recorded at setup against the one running now. */
|
|
42
|
+
export interface ProgramChange {
|
|
43
|
+
recorded: string;
|
|
44
|
+
observed: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How the running process's argv compares to the argv `/steward_initialize`
|
|
49
|
+
* recorded. `added` and `removed` are whole flag GROUPS (`--port 8080`, not two
|
|
50
|
+
* loose tokens), so the notice can name what actually changed rather than
|
|
51
|
+
* printing two command lines and leaving the operator to spot the difference.
|
|
52
|
+
*/
|
|
53
|
+
export interface LaunchDrift {
|
|
54
|
+
status: LaunchDriftStatus;
|
|
55
|
+
/** Groups the live process carries that were not recorded, e.g. `--no-slots`. */
|
|
56
|
+
added: string[];
|
|
57
|
+
/** Groups that were recorded and are gone now, e.g. `--metrics`. */
|
|
58
|
+
removed: string[];
|
|
59
|
+
/** Set when the binary itself changed; `null` when it did not. */
|
|
60
|
+
program: ProgramChange | null;
|
|
61
|
+
/** Why no verdict could be reached (`unknown` only); `null` otherwise. */
|
|
62
|
+
reason: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Commands `steward.json` declares but that carry no matching entry in its
|
|
67
|
+
* consent map. Steward refuses to run them — that is the security gate working
|
|
68
|
+
* — but silence makes the resulting empty panel indistinguishable from one that
|
|
69
|
+
* was never configured.
|
|
70
|
+
*/
|
|
71
|
+
export interface ConsentDrift {
|
|
72
|
+
/** True when a collector is declared and its exact command is unapproved. */
|
|
73
|
+
hostCollector: boolean;
|
|
74
|
+
/** Declared-but-unapproved control actions, in start/stop/restart order. */
|
|
75
|
+
controls: ServiceAction[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Both producers, as carried on a {@link import("./types.js").Snapshot}. */
|
|
79
|
+
export interface DriftState {
|
|
80
|
+
launch: LaunchDrift;
|
|
81
|
+
consent: ConsentDrift;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Re-validates the launch argv of one already-identified process. Node-side and
|
|
86
|
+
* platform-specific (it shells out to `ps`), so it is injected rather than
|
|
87
|
+
* imported by the source — this module stays free of Node APIs, mirroring
|
|
88
|
+
* {@link import("./llama-source.js").ServiceProbe}.
|
|
89
|
+
*
|
|
90
|
+
* It takes the pid the snapshot already resolved rather than a host and port,
|
|
91
|
+
* so the drift notice and the SERVICE block can never end up describing two
|
|
92
|
+
* different processes (and so a second `lsof` is not run per poll). A `null`
|
|
93
|
+
* pid — nothing listening, or no process probe configured — is a check that
|
|
94
|
+
* could not be made. It never rejects: every failure is an `unknown`
|
|
95
|
+
* {@link LaunchDrift}.
|
|
96
|
+
*/
|
|
97
|
+
export type DriftProbe = (pid: number | null) => Promise<LaunchDrift>;
|
|
98
|
+
|
|
99
|
+
/** Nothing declared, nothing unapproved. */
|
|
100
|
+
export const NO_CONSENT_DRIFT: ConsentDrift = { hostCollector: false, controls: [] };
|
|
101
|
+
|
|
102
|
+
/** An "it could not be checked" verdict carrying the reason it could not. */
|
|
103
|
+
export function unknownLaunchDrift(reason: string): LaunchDrift {
|
|
104
|
+
return { status: "unknown", added: [], removed: [], program: null, reason };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The drift state of a source that does not re-validate anything. */
|
|
108
|
+
export function unknownDrift(reason: string): DriftState {
|
|
109
|
+
return { launch: unknownLaunchDrift(reason), consent: NO_CONSENT_DRIFT };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What `ps` prints when it has a process but no argv to show for it: a zombie,
|
|
114
|
+
* or a Linux `/proc` mounted with `hidepid`, comes back as `(llama-server)` or
|
|
115
|
+
* `[llama-server]`. It is a placeholder, not a command line — diffing against it
|
|
116
|
+
* would report every recorded flag removed AND a changed binary, the loudest
|
|
117
|
+
* possible false alarm on a machine that changed nothing.
|
|
118
|
+
*/
|
|
119
|
+
const PLACEHOLDER_COMMAND = /^\(.*\)$|^\[.*\]$/u;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Splits an argv into flag groups: a token starting with `-` opens a group and
|
|
123
|
+
* every following non-flag token belongs to it, so `--port 8080` is one unit and
|
|
124
|
+
* `--metrics` is another. Tokens before the first flag are the program and its
|
|
125
|
+
* bare operands, returned separately — the binary changing is a different fact
|
|
126
|
+
* from a flag changing, and reads differently in the notice.
|
|
127
|
+
*
|
|
128
|
+
* `--flag=value` is normalised to `--flag value`: llama.cpp accepts a flag
|
|
129
|
+
* either way, so treating the two spellings as different would nag an operator
|
|
130
|
+
* whose plist merely writes them differently.
|
|
131
|
+
*
|
|
132
|
+
* Known limitation: a quoted VALUE that itself starts with a dash (`--alias
|
|
133
|
+
* "-Fast- Model"`) opens a group of its own, because a `ps` line has lost the
|
|
134
|
+
* quoting that would say otherwise. It costs only precision in the wording of a
|
|
135
|
+
* diff that is already being reported — the verdict is unaffected while nothing
|
|
136
|
+
* else changed, since both sides group the same way.
|
|
137
|
+
*/
|
|
138
|
+
function groupArgv(tokens: readonly string[]): { program: string; groups: string[] } {
|
|
139
|
+
const groups: string[] = [];
|
|
140
|
+
const leading: string[] = [];
|
|
141
|
+
let current: string[] | null = null;
|
|
142
|
+
|
|
143
|
+
const flush = (): void => {
|
|
144
|
+
if (current !== null) groups.push(current.join(" "));
|
|
145
|
+
current = null;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const token of tokens) {
|
|
149
|
+
if (token.startsWith("-") && token !== "-" && token !== "--") {
|
|
150
|
+
flush();
|
|
151
|
+
const equals = token.indexOf("=");
|
|
152
|
+
// Only a flag's own `=` is split; a VALUE may legitimately contain one
|
|
153
|
+
// (`--override-kv tokenizer.ggml.add_bos_token=bool:false`) and is left
|
|
154
|
+
// exactly as written.
|
|
155
|
+
current = equals > 0 ? [token.slice(0, equals), token.slice(equals + 1)] : [token];
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (current === null) leading.push(token);
|
|
159
|
+
else current.push(token);
|
|
160
|
+
}
|
|
161
|
+
flush();
|
|
162
|
+
|
|
163
|
+
return { program: leading.join(" "), groups };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The groups in `a` that `b` does not have, counting duplicates. */
|
|
167
|
+
function missingFrom(a: readonly string[], b: readonly string[]): string[] {
|
|
168
|
+
const remaining = new Map<string, number>();
|
|
169
|
+
for (const group of b) remaining.set(group, (remaining.get(group) ?? 0) + 1);
|
|
170
|
+
|
|
171
|
+
const missing: string[] = [];
|
|
172
|
+
for (const group of a) {
|
|
173
|
+
const count = remaining.get(group) ?? 0;
|
|
174
|
+
if (count > 0) remaining.set(group, count - 1);
|
|
175
|
+
else missing.push(group);
|
|
176
|
+
}
|
|
177
|
+
return missing;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Whitespace-separated tokens, with the empties dropped. */
|
|
181
|
+
function tokenize(line: string): string[] {
|
|
182
|
+
return line.split(/\s+/u).filter((token) => token !== "");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Diffs the live command line against the argv recorded at setup.
|
|
187
|
+
*
|
|
188
|
+
* `observed` is one `ps` line: the argv joined on single spaces, which is
|
|
189
|
+
* exactly what `recorded.join(" ")` produces — so an equal join is a clean
|
|
190
|
+
* verdict no matter how the argv was quoted, and quoting can never fake a drift.
|
|
191
|
+
*
|
|
192
|
+
* What counts as drift is deliberately narrow, because a false alarm on a
|
|
193
|
+
* correctly configured machine is as damaging as a missed one:
|
|
194
|
+
* - ORDER is not drift. The groups are compared as multisets; a plist that
|
|
195
|
+
* lists the same flags in a different order changed nothing about how the
|
|
196
|
+
* server runs.
|
|
197
|
+
* - A line the process list CUT SHORT is not drift. A truncated line is a
|
|
198
|
+
* strict prefix of the recorded one that stops mid-token; that is reported
|
|
199
|
+
* `unknown`. A line that stops exactly at a token boundary is NOT treated as
|
|
200
|
+
* truncation — that is the single most likely real edit (deleting the last
|
|
201
|
+
* flag), and swallowing it would defeat the whole check.
|
|
202
|
+
* - Nothing recorded, nothing observed, or a `ps` PLACEHOLDER in place of a
|
|
203
|
+
* command line is `unknown`, never `clean`.
|
|
204
|
+
*/
|
|
205
|
+
export function diffLaunchArgv(recorded: readonly string[], observed: string): LaunchDrift {
|
|
206
|
+
const expectedTokens = recorded.filter((token) => token !== "");
|
|
207
|
+
if (expectedTokens.length === 0) {
|
|
208
|
+
return unknownLaunchDrift("no launch command was recorded for this machine");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const line = observed.trim();
|
|
212
|
+
if (line === "" || PLACEHOLDER_COMMAND.test(line)) {
|
|
213
|
+
return unknownLaunchDrift("the process list reported no command line");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const expected = expectedTokens.join(" ");
|
|
217
|
+
if (line === expected) {
|
|
218
|
+
return { status: "clean", added: [], removed: [], program: null, reason: null };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Cut mid-token: `ps` gave us less than the process actually holds, so there
|
|
222
|
+
// is no verdict to reach — saying "these flags were removed" here would
|
|
223
|
+
// invent drift out of a display width.
|
|
224
|
+
if (expected.startsWith(line) && expected.charAt(line.length) !== " ") {
|
|
225
|
+
return unknownLaunchDrift("the process list truncated the command line");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const before = groupArgv(expectedTokens);
|
|
229
|
+
const after = groupArgv(tokenize(line));
|
|
230
|
+
const added = missingFrom(after.groups, before.groups);
|
|
231
|
+
const removed = missingFrom(before.groups, after.groups);
|
|
232
|
+
const program = before.program === after.program ? null : after.program;
|
|
233
|
+
|
|
234
|
+
if (added.length === 0 && removed.length === 0 && program === null) {
|
|
235
|
+
// Same program, same groups: the two lines differ only in the order the
|
|
236
|
+
// flags were written, which changes nothing the server does.
|
|
237
|
+
return { status: "clean", added: [], removed: [], program: null, reason: null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
status: "drifted",
|
|
242
|
+
added,
|
|
243
|
+
removed,
|
|
244
|
+
program: program === null ? null : { recorded: before.program, observed: program },
|
|
245
|
+
reason: null,
|
|
246
|
+
};
|
|
247
|
+
}
|