openmerit 0.4.0 → 0.4.2
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/README.md +239 -58
- package/data/catalogue.json +6778 -0
- package/dist/catalogue/catalogue.d.ts +41 -0
- package/dist/catalogue/catalogue.js +33 -0
- package/dist/cli/prompt.d.ts +26 -0
- package/dist/cli/prompt.js +114 -0
- package/dist/cli/render.d.ts +12 -0
- package/dist/cli/render.js +104 -0
- package/dist/cli.js +245 -158
- package/dist/connect.d.ts +34 -0
- package/dist/connect.js +158 -0
- package/dist/connectors/langfuse/index.d.ts +39 -112
- package/dist/connectors/langfuse/index.js +95 -246
- package/dist/daemon/daemon.d.ts +81 -0
- package/dist/daemon/daemon.js +408 -0
- package/dist/daemon/registry.d.ts +111 -0
- package/dist/daemon/registry.js +109 -0
- package/dist/eval/proxy.d.ts +54 -0
- package/dist/eval/proxy.js +174 -0
- package/dist/eval/runner.d.ts +29 -0
- package/dist/eval/runner.js +80 -0
- package/dist/harness/claude-bridge.d.ts +21 -0
- package/dist/harness/claude-bridge.js +65 -0
- package/dist/harness/harness.d.ts +60 -0
- package/dist/harness/harness.js +221 -0
- package/dist/index.d.ts +10 -11
- package/dist/index.js +9 -10
- package/dist/local.d.ts +12 -0
- package/dist/local.js +40 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +155 -0
- package/dist/scout/candidates.d.ts +1 -1
- package/dist/scout/pool.d.ts +17 -0
- package/dist/scout/pool.js +56 -0
- package/dist/scout/scan.d.ts +42 -0
- package/dist/scout/scan.js +169 -0
- package/dist/scout/search.d.ts +25 -98
- package/dist/scout/search.js +56 -156
- package/dist/scout/workflow.d.ts +221 -0
- package/dist/scout/workflow.js +941 -0
- package/dist/setup.d.ts +44 -0
- package/dist/setup.js +211 -0
- package/dist/state/config.d.ts +116 -0
- package/dist/state/config.js +60 -0
- package/dist/state/db.d.ts +159 -0
- package/dist/state/db.js +260 -0
- package/dist/state/paths.d.ts +12 -0
- package/dist/state/paths.js +23 -0
- package/dist/update.d.ts +22 -0
- package/dist/update.js +58 -0
- package/package.json +13 -18
- package/dist/connectors/langfuse/otlp.d.ts +0 -60
- package/dist/connectors/langfuse/otlp.js +0 -40
- package/dist/pi/extension.d.ts +0 -13
- package/dist/pi/extension.js +0 -148
- package/dist/pi/harness.d.ts +0 -20
- package/dist/pi/harness.js +0 -49
- package/dist/pi/instructions.d.ts +0 -2
- package/dist/pi/instructions.js +0 -12
- package/dist/scout/evaluators/module.d.ts +0 -24
- package/dist/scout/evaluators/module.js +0 -19
- package/dist/scout/evaluators/pi.d.ts +0 -53
- package/dist/scout/evaluators/pi.js +0 -117
- package/dist/scout/pipeline.d.ts +0 -111
- package/dist/scout/pipeline.js +0 -157
- package/dist/scout/report.d.ts +0 -3
- package/dist/scout/report.js +0 -42
- package/dist/scout/service.d.ts +0 -148
- package/dist/scout/service.js +0 -143
- package/examples/health-declaration-codes/dataset.jsonl +0 -14
- package/examples/sahra-enrollment/evaluator.ts +0 -151
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// The OpenMerit daemon: one background process per OpenMerit home that runs the local Rivet engine and
|
|
2
|
+
// the actors (registry.ts). The CLI and the MCP server are its clients; the first one that needs it
|
|
3
|
+
// starts it, and it keeps running after they exit.
|
|
4
|
+
//
|
|
5
|
+
// - The engine listens on 127.0.0.1 only (port 6720 by default, peers on 6721, metrics on 6730), so it
|
|
6
|
+
// does not collide with a Rivet dev server on 6420. No connection leaves the machine.
|
|
7
|
+
// - Its data lives in ~/.openmerit/rivet. ~/.openmerit/daemon.json (mode 0600) holds the pid, port,
|
|
8
|
+
// version and a random token generated at each start; the engine refuses requests without it. The
|
|
9
|
+
// daemon refreshes the file's modification time every few seconds, so a client can tell a frozen
|
|
10
|
+
// daemon from a working one: the engine's health check answers from the engine's own process.
|
|
11
|
+
// - Its output goes to ~/.openmerit/daemon.log, errors only, moved to daemon.log.1 when it grows too large.
|
|
12
|
+
// - A client of another package version stops the daemon and starts its own, so an update takes
|
|
13
|
+
// effect on the next command.
|
|
14
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
15
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
16
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { createServer } from "node:net";
|
|
18
|
+
import { extname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { createClient } from "rivetkit/client";
|
|
21
|
+
import { openmeritHome } from "../state/paths.js";
|
|
22
|
+
import { packageVersion } from "../update.js";
|
|
23
|
+
export const DEFAULT_PORT = 6720;
|
|
24
|
+
const START_TIMEOUT_MS = 90_000;
|
|
25
|
+
/** How often the daemon refreshes daemon.json's modification time, its heartbeat. */
|
|
26
|
+
const HEARTBEAT_MS = 5_000;
|
|
27
|
+
/**
|
|
28
|
+
* A heartbeat older than this means the daemon is frozen: its event loop is blocked or the process is
|
|
29
|
+
* stopped. Six heartbeats, and as long as a client waits for a call (CALL_TIMEOUT_MS): a daemon busy for
|
|
30
|
+
* longer already counts as a lost connection, so a client's first or second reconnect replaces it.
|
|
31
|
+
*/
|
|
32
|
+
const STALE_MS = 30_000;
|
|
33
|
+
/** A daemon.log larger than this moves to daemon.log.1 when the next daemon starts. */
|
|
34
|
+
const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
35
|
+
/** Engine starts whose logs are kept. rivetkit writes a stdout and a stderr log at every start and deletes none. */
|
|
36
|
+
const ENGINE_LOG_STARTS = 5;
|
|
37
|
+
const paths = (env) => {
|
|
38
|
+
const home = openmeritHome(env);
|
|
39
|
+
return { home, info: join(home, "daemon.json"), lock: join(home, "daemon.lock"), log: join(home, "daemon.log"), storage: join(home, "rivet") };
|
|
40
|
+
};
|
|
41
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
42
|
+
const alive = (pid) => { try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
return error.code === "EPERM";
|
|
48
|
+
} };
|
|
49
|
+
export function readDaemonInfo(env = process.env) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(paths(env).info, "utf8"));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The engine's ports: its gateway on `port`, its peer API on port+1 and its metrics on port+10. */
|
|
58
|
+
const enginePorts = (port) => [port, port + 1, port + 10];
|
|
59
|
+
/** Those of `ports` that something already listens on at 127.0.0.1. */
|
|
60
|
+
async function portsInUse(ports) {
|
|
61
|
+
const taken = await Promise.all(ports.map(port => new Promise(resolve => {
|
|
62
|
+
const server = createServer().once("error", () => resolve(true));
|
|
63
|
+
server.listen({ port, host: "127.0.0.1", exclusive: true }, () => server.close(() => resolve(false)));
|
|
64
|
+
})));
|
|
65
|
+
return ports.filter((_, index) => taken[index]);
|
|
66
|
+
}
|
|
67
|
+
async function portsError(port, taken) {
|
|
68
|
+
let free;
|
|
69
|
+
for (let base = port + 100; free === undefined && base <= port + 1000 && base + 10 <= 65535; base += 100)
|
|
70
|
+
if (!(await portsInUse(enginePorts(base))).length)
|
|
71
|
+
free = base;
|
|
72
|
+
const list = taken.length > 1 ? `${taken.slice(0, -1).join(", ")} and ${taken.at(-1)}` : String(taken[0]);
|
|
73
|
+
return new Error(`${taken.length > 1 ? "Ports" : "Port"} ${list} on 127.0.0.1 ${taken.length > 1 ? "are" : "is"} already in use by another program, possibly another user's OpenMerit daemon. `
|
|
74
|
+
+ `The daemon's engine needs OPENMERIT_DAEMON_PORT (${port}), that port + 1 and that port + 10. Set OPENMERIT_DAEMON_PORT to another port${free ? `, such as ${free}` : ""}.`);
|
|
75
|
+
}
|
|
76
|
+
/** Where rivetkit records the engine it spawned, and where that engine's output goes. */
|
|
77
|
+
const engineFiles = (storage) => ({ stamp: join(storage, ".rivetkit", "var", "engine", "runtime.json"), logs: join(storage, ".rivetkit", "var", "logs", "rivet-engine") });
|
|
78
|
+
/** The pid of the engine an earlier daemon of this home started, while that engine still runs. */
|
|
79
|
+
function ownEngine(storage) {
|
|
80
|
+
try {
|
|
81
|
+
const { pid } = JSON.parse(readFileSync(engineFiles(storage).stamp, "utf8"));
|
|
82
|
+
// The pid may belong to another process by now: only an engine started with this home's storage counts.
|
|
83
|
+
const command = execFileSync("ps", ["-ww", "-o", "command=", "-p", String(pid)], { encoding: "utf8" });
|
|
84
|
+
return command.includes("rivet-engine") && command.includes(storage) ? pid : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Whether `pid` is an OpenMerit daemon, and not another process that reused a crashed daemon's pid. */
|
|
91
|
+
function ownDaemon(pid) {
|
|
92
|
+
try {
|
|
93
|
+
return /\bcli\.\w+ daemon$/.test(execFileSync("ps", ["-ww", "-o", "command=", "-p", String(pid)], { encoding: "utf8" }).trim());
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function terminate(pid, graceMs) {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, "SIGTERM");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (let waited = 0; alive(pid) && waited < graceMs; waited += 200)
|
|
107
|
+
await sleep(200);
|
|
108
|
+
if (!alive(pid))
|
|
109
|
+
return;
|
|
110
|
+
process.kill(pid, "SIGKILL");
|
|
111
|
+
for (let waited = 0; alive(pid) && waited < 2000; waited += 100)
|
|
112
|
+
await sleep(100);
|
|
113
|
+
}
|
|
114
|
+
/** Deletes the engine logs of all but the last ENGINE_LOG_STARTS starts. */
|
|
115
|
+
function pruneEngineLogs(storage) {
|
|
116
|
+
const { logs } = engineFiles(storage);
|
|
117
|
+
let names;
|
|
118
|
+
try {
|
|
119
|
+
names = readdirSync(logs);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const start = (name) => /^engine-(\d+)-/.exec(name)?.[1];
|
|
125
|
+
const starts = [...new Set(names.map(start).filter(value => value !== undefined))].sort((a, b) => Number(a) - Number(b));
|
|
126
|
+
const old = new Set(starts.slice(0, -ENGINE_LOG_STARTS));
|
|
127
|
+
for (const name of names)
|
|
128
|
+
if (old.has(start(name)))
|
|
129
|
+
rmSync(join(logs, name), { force: true });
|
|
130
|
+
}
|
|
131
|
+
const stderrLogs = (logs) => { try {
|
|
132
|
+
return readdirSync(logs).filter(name => name.endsWith("-stderr.log"));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
} };
|
|
137
|
+
/**
|
|
138
|
+
* Rejects once an engine spawned from now on exits with an error, which it writes to its own stderr log
|
|
139
|
+
* as `Error: …`. rivetkit only notices after its 60 s health check. Stops watching when `signal` aborts.
|
|
140
|
+
*/
|
|
141
|
+
async function engineFailure(storage, port, signal) {
|
|
142
|
+
const { logs } = engineFiles(storage);
|
|
143
|
+
const earlier = new Set(stderrLogs(logs));
|
|
144
|
+
while (!signal.aborted) {
|
|
145
|
+
await sleep(250);
|
|
146
|
+
for (const name of stderrLogs(logs)) {
|
|
147
|
+
if (earlier.has(name))
|
|
148
|
+
continue;
|
|
149
|
+
const file = join(logs, name);
|
|
150
|
+
const error = readFileSync(file, "utf8").split("\n").find(line => line.startsWith("Error: "));
|
|
151
|
+
if (!error)
|
|
152
|
+
continue;
|
|
153
|
+
const taken = /Address (already )?in use/i.test(error) ? await portsInUse(enginePorts(port)) : [];
|
|
154
|
+
throw taken.length ? await portsError(port, taken) : new Error(`The Rivet engine exited while starting: ${error.slice("Error: ".length)} (log: ${file})`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return new Promise(() => { });
|
|
158
|
+
}
|
|
159
|
+
const heartbeatAge = (env) => { try {
|
|
160
|
+
return Date.now() - statSync(paths(env).info).mtimeMs;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return Infinity;
|
|
164
|
+
} };
|
|
165
|
+
/** Whether the daemon of `info` runs, has a recent heartbeat and its engine answers. `openmerit daemon status` uses it too. */
|
|
166
|
+
export async function healthy(info, env = process.env) {
|
|
167
|
+
if (!alive(info.pid))
|
|
168
|
+
return false;
|
|
169
|
+
// A daemon on a machine that just woke from sleep refreshes its heartbeat within one interval; a frozen one does not.
|
|
170
|
+
for (let waited = 0; heartbeatAge(env) > STALE_MS; waited += 250) {
|
|
171
|
+
if (waited >= HEARTBEAT_MS + 2000)
|
|
172
|
+
return false;
|
|
173
|
+
await sleep(250);
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
return (await fetch(`http://127.0.0.1:${info.port}/health`, { signal: AbortSignal.timeout(2000) })).ok;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Runs the daemon in this process until it receives SIGINT or SIGTERM. `openmerit daemon` calls it. */
|
|
183
|
+
export async function serveDaemon(env = process.env) {
|
|
184
|
+
const { home, info, storage } = paths(env);
|
|
185
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
186
|
+
const port = Number(env.OPENMERIT_DAEMON_PORT ?? DEFAULT_PORT);
|
|
187
|
+
const token = `om_${randomBytes(24).toString("hex")}`;
|
|
188
|
+
// Read by the engine process rivetkit spawns: its storage, and the admin token that replaces Rivet's local default.
|
|
189
|
+
process.env.RIVETKIT_STORAGE_PATH = storage;
|
|
190
|
+
process.env.RIVET__AUTH__ADMIN_TOKEN = token;
|
|
191
|
+
// An engine that a crashed daemon of this home left running has the old token. rivetkit replaces it on
|
|
192
|
+
// Linux but reuses it on macOS, where it then refuses the new token: stop it on every platform.
|
|
193
|
+
const leftover = ownEngine(storage);
|
|
194
|
+
if (leftover !== null)
|
|
195
|
+
await terminate(leftover, 10_000);
|
|
196
|
+
pruneEngineLogs(storage);
|
|
197
|
+
// Any other holder of the ports fails the start: rivetkit would use another home's engine, or wait 60 s on a port it cannot bind.
|
|
198
|
+
const taken = await portsInUse(enginePorts(port));
|
|
199
|
+
if (taken.length)
|
|
200
|
+
throw await portsError(port, taken);
|
|
201
|
+
const { createRegistry } = await import("./registry.js");
|
|
202
|
+
const starting = new AbortController();
|
|
203
|
+
try {
|
|
204
|
+
await Promise.race([createRegistry({ port, token }).startAndWait(), engineFailure(storage, port, starting.signal)]);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
// rivetkit keeps checking the failed engine's health in the background; do not wait for it to give up.
|
|
208
|
+
setTimeout(() => process.exit(1), 100).unref();
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
starting.abort();
|
|
213
|
+
}
|
|
214
|
+
const record = { pid: process.pid, port, token, version: packageVersion(), startedAt: new Date().toISOString() };
|
|
215
|
+
writeFileSync(info, JSON.stringify(record), { mode: 0o600 });
|
|
216
|
+
process.on("exit", () => { if (readDaemonInfo(env)?.pid === process.pid)
|
|
217
|
+
rmSync(info, { force: true }); });
|
|
218
|
+
setInterval(() => {
|
|
219
|
+
const now = new Date();
|
|
220
|
+
try {
|
|
221
|
+
if (readDaemonInfo(env)?.pid === process.pid)
|
|
222
|
+
utimesSync(info, now, now);
|
|
223
|
+
}
|
|
224
|
+
catch { /* removed meanwhile */ }
|
|
225
|
+
}, HEARTBEAT_MS).unref();
|
|
226
|
+
process.stderr.write(`OpenMerit daemon ${record.version} on 127.0.0.1:${port} (pid ${process.pid}), data in ${storage}\n`);
|
|
227
|
+
}
|
|
228
|
+
/** Stops the daemon, if one runs. Its runs stop as interrupted and continue with `openmerit scout --resume`. */
|
|
229
|
+
export async function stopDaemon(env = process.env) {
|
|
230
|
+
const info = readDaemonInfo(env);
|
|
231
|
+
if (!info || !alive(info.pid)) {
|
|
232
|
+
rmSync(paths(env).info, { force: true });
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
// A frozen daemon never handles SIGTERM: do not wait for it.
|
|
236
|
+
await terminate(info.pid, heartbeatAge(env) > STALE_MS ? 0 : 20_000);
|
|
237
|
+
rmSync(paths(env).info, { force: true });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
/** The daemon could not start. Unlike a lost connection, trying again does not help until its cause is fixed. */
|
|
241
|
+
export class DaemonStartError extends Error {
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* What the daemon wrote to its log after byte `from`: its own error when it printed one, else the last
|
|
245
|
+
* lines. rivetkit's warnings are left out, and its errors, JSON inside JSON, are unescaped and shortened.
|
|
246
|
+
*/
|
|
247
|
+
function startLog(log, from) {
|
|
248
|
+
const lines = (existsSync(log) ? readFileSync(log).subarray(from).toString("utf8").split("\n") : [])
|
|
249
|
+
.filter(line => line.trim() && !/^level=(warn|info|debug|trace)\b/.test(line))
|
|
250
|
+
.map(line => line.startsWith("level=") ? line.replace(/\\+"/g, "\"").slice(0, 400) : line);
|
|
251
|
+
const error = lines.findLastIndex(line => line.startsWith("openmerit: "));
|
|
252
|
+
if (error < 0)
|
|
253
|
+
return lines.slice(-5).join("\n").trim();
|
|
254
|
+
const own = lines.slice(error);
|
|
255
|
+
const end = own.findIndex(line => line.startsWith("level="));
|
|
256
|
+
return own.slice(0, end < 0 ? undefined : end).join("\n").slice("openmerit: ".length).trim();
|
|
257
|
+
}
|
|
258
|
+
/** The running daemon of this package version, started in the background when there is none. Throws DaemonStartError when it does not start. */
|
|
259
|
+
export async function ensureDaemon(env = process.env) {
|
|
260
|
+
const current = readDaemonInfo(env);
|
|
261
|
+
if (current && current.version === packageVersion() && await healthy(current, env))
|
|
262
|
+
return current;
|
|
263
|
+
const { home, lock, log } = paths(env);
|
|
264
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
265
|
+
// One starter at a time: the CLI and an MCP server may both find no daemon.
|
|
266
|
+
let owner = false;
|
|
267
|
+
try {
|
|
268
|
+
closeSync(openSync(lock, "wx"));
|
|
269
|
+
owner = true;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
if (Date.now() - statSync(lock).mtimeMs > START_TIMEOUT_MS) {
|
|
273
|
+
rmSync(lock, { force: true });
|
|
274
|
+
return ensureDaemon(env);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (owner && existsSync(log) && statSync(log).size > LOG_MAX_BYTES)
|
|
278
|
+
renameSync(log, `${log}.1`);
|
|
279
|
+
const from = existsSync(log) ? statSync(log).size : 0;
|
|
280
|
+
const failed = (what) => {
|
|
281
|
+
const said = startLog(log, from);
|
|
282
|
+
return new DaemonStartError(`The OpenMerit daemon ${what}.${said ? `\n${said}` : ""}\nIts log: ${log}`);
|
|
283
|
+
};
|
|
284
|
+
try {
|
|
285
|
+
let exited = null;
|
|
286
|
+
if (owner) {
|
|
287
|
+
const stale = readDaemonInfo(env);
|
|
288
|
+
if (stale && stale.version !== packageVersion())
|
|
289
|
+
await stopDaemon(env);
|
|
290
|
+
else if (stale) {
|
|
291
|
+
// Its heartbeat stopped or its engine does not answer. SIGKILL also ends a stopped process, which never handles SIGTERM.
|
|
292
|
+
if (ownDaemon(stale.pid))
|
|
293
|
+
await terminate(stale.pid, 0);
|
|
294
|
+
rmSync(paths(env).info, { force: true });
|
|
295
|
+
}
|
|
296
|
+
const cli = fileURLToPath(new URL(`../cli${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
|
|
297
|
+
const out = openSync(log, "a");
|
|
298
|
+
// rivetkit logs warnings by default, such as one for every unnamed SQLite transaction; the log keeps its errors.
|
|
299
|
+
const childEnv = { ...env, OPENMERIT_NO_UPDATE_CHECK: "1", RIVET_LOG_LEVEL: env.RIVET_LOG_LEVEL ?? "error" };
|
|
300
|
+
const child = spawn(process.execPath, [cli, "daemon"], { detached: true, stdio: ["ignore", out, out], env: childEnv });
|
|
301
|
+
child.once("exit", (code, signal) => { exited = signal ?? `code ${code}`; });
|
|
302
|
+
child.unref();
|
|
303
|
+
closeSync(out);
|
|
304
|
+
}
|
|
305
|
+
for (let waited = 0; waited < START_TIMEOUT_MS; waited += 250) {
|
|
306
|
+
// Checked before the health check: a starter that finished before a failed check gave up.
|
|
307
|
+
const starterDone = !owner && !existsSync(lock);
|
|
308
|
+
const info = readDaemonInfo(env);
|
|
309
|
+
if (info && info.version === packageVersion() && await healthy(info, env))
|
|
310
|
+
return info;
|
|
311
|
+
if (exited !== null)
|
|
312
|
+
throw failed(`could not start (it exited with ${exited})`);
|
|
313
|
+
if (starterDone)
|
|
314
|
+
throw failed("that another OpenMerit process was starting did not start");
|
|
315
|
+
await sleep(250);
|
|
316
|
+
}
|
|
317
|
+
throw failed(`did not start within ${START_TIMEOUT_MS / 1000} s`);
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
if (owner)
|
|
321
|
+
rmSync(lock, { force: true });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export function daemonClient(info) {
|
|
325
|
+
// rivetkit's client logs each retry at warn level to the terminal (or the MCP client's log), on top of
|
|
326
|
+
// the reconnect lines the CLI prints. Read when rivetkit makes its first logger; the user's setting wins.
|
|
327
|
+
process.env.RIVET_LOG_LEVEL ??= "error";
|
|
328
|
+
return createClient({ endpoint: `http://127.0.0.1:${info.port}`, token: info.token });
|
|
329
|
+
}
|
|
330
|
+
/** A daemon call that takes longer than this counts as a lost connection. A daemon that just started can take a while to wake a run. */
|
|
331
|
+
const CALL_TIMEOUT_MS = 30_000;
|
|
332
|
+
/** Reconnect attempts after a lost connection, before the client gives up. */
|
|
333
|
+
export const RECONNECTS = 3;
|
|
334
|
+
/** `call`, or a rejection once it has taken CALL_TIMEOUT_MS. */
|
|
335
|
+
function within(call) {
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
const timer = setTimeout(() => reject(new Error(`no answer in ${CALL_TIMEOUT_MS / 1000} s`)), CALL_TIMEOUT_MS);
|
|
338
|
+
call.then(value => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error)).split("\n")[0].replace(/^HTTP request error: Request failed: /, "").slice(0, 160);
|
|
342
|
+
/**
|
|
343
|
+
* Advances a Scout run in the daemon: attaches this client's directory and environment, sends the
|
|
344
|
+
* answers, and waits for the step's outcome while relaying its progress lines. Aborting `signal` stops
|
|
345
|
+
* the step, which saves the run as interrupted. When the daemon stops answering, the client reconnects
|
|
346
|
+
* (starting a new daemon if needed) up to RECONNECTS times, then gives up with the resume command. A
|
|
347
|
+
* daemon that cannot start is not a lost connection: that error ends the call at once, with the resume command.
|
|
348
|
+
*/
|
|
349
|
+
export async function advanceInDaemon(input) {
|
|
350
|
+
// process.env is not a plain object, and the client cannot serialize it: send a copy with only the set variables.
|
|
351
|
+
const env = Object.fromEntries(Object.entries(input.env).filter((entry) => typeof entry[1] === "string"));
|
|
352
|
+
const connect = async () => {
|
|
353
|
+
const handle = daemonClient(await ensureDaemon(input.env)).scoutRun.getOrCreate([input.runId]);
|
|
354
|
+
const { since } = await within(handle.attach({ cwd: input.cwd, env }));
|
|
355
|
+
return { handle, since };
|
|
356
|
+
};
|
|
357
|
+
const notStarted = (error) => error instanceof DaemonStartError ? new Error(`${error.message}\nThen continue with: openmerit scout --resume ${input.runId}`) : null;
|
|
358
|
+
const reconnect = async (error) => {
|
|
359
|
+
const start = notStarted(error);
|
|
360
|
+
if (start)
|
|
361
|
+
throw start;
|
|
362
|
+
input.log(`Connection to the OpenMerit daemon lost${/no answer in/.test(reason(error)) ? ` (no answer in ${CALL_TIMEOUT_MS / 1000} s)` : ""}. Reconnecting…`);
|
|
363
|
+
for (let attempt = 1; attempt <= RECONNECTS; attempt++) {
|
|
364
|
+
await sleep(1000 * attempt);
|
|
365
|
+
try {
|
|
366
|
+
const next = await connect();
|
|
367
|
+
input.log("Reconnected.");
|
|
368
|
+
return next;
|
|
369
|
+
}
|
|
370
|
+
catch (retry) {
|
|
371
|
+
const failed = notStarted(retry);
|
|
372
|
+
if (failed)
|
|
373
|
+
throw failed;
|
|
374
|
+
input.log(` Reconnect ${attempt}/${RECONNECTS} failed: ${reason(retry)}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
throw new Error(`Could not reconnect to the OpenMerit daemon. Resume later with: openmerit scout --resume ${input.runId}`);
|
|
378
|
+
};
|
|
379
|
+
// Attaching is safe to repeat, so a first attach that gets no answer reconnects too; the send below is not repeated.
|
|
380
|
+
let { handle, since: cursor } = await connect().catch(reconnect);
|
|
381
|
+
// The request waits in the run's queue and its outcome in the run's state, so both survive a daemon restart.
|
|
382
|
+
const requestId = randomUUID();
|
|
383
|
+
await within(handle.send("advance", { requestId, answers: input.answers }));
|
|
384
|
+
let stopping = false;
|
|
385
|
+
for (;;) {
|
|
386
|
+
let polled;
|
|
387
|
+
try {
|
|
388
|
+
if (input.signal?.aborted && !stopping) {
|
|
389
|
+
stopping = true;
|
|
390
|
+
await within(handle.stop());
|
|
391
|
+
}
|
|
392
|
+
polled = await within(handle.poll({ requestId, since: cursor }));
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
({ handle, since: cursor } = await reconnect(error));
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
for (const line of polled.lines)
|
|
399
|
+
input.log(line);
|
|
400
|
+
cursor = polled.next;
|
|
401
|
+
if (polled.outcome) {
|
|
402
|
+
if ("error" in polled.outcome)
|
|
403
|
+
throw new Error(polled.outcome.error);
|
|
404
|
+
return polled.outcome.step;
|
|
405
|
+
}
|
|
406
|
+
await sleep(250);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type Answers, type Step } from "../scout/workflow.js";
|
|
2
|
+
export interface AdvanceRequest {
|
|
3
|
+
requestId: string;
|
|
4
|
+
answers: Answers;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The shape of rivetkit's `queue<AdvanceRequest>()` token. rivetkit does not export the token's type,
|
|
8
|
+
* and the package's type declarations must be able to name it.
|
|
9
|
+
*/
|
|
10
|
+
export interface AdvanceQueue {
|
|
11
|
+
readonly _queueMessage?: AdvanceRequest;
|
|
12
|
+
readonly _queueComplete?: never;
|
|
13
|
+
}
|
|
14
|
+
export type Outcome = {
|
|
15
|
+
requestId: string;
|
|
16
|
+
step: Step;
|
|
17
|
+
} | {
|
|
18
|
+
requestId: string;
|
|
19
|
+
error: string;
|
|
20
|
+
};
|
|
21
|
+
export declare const scoutRun: import("rivetkit").ActorDefinition<{
|
|
22
|
+
inFlight: string | null;
|
|
23
|
+
outcome: Outcome | null;
|
|
24
|
+
}, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
|
|
25
|
+
advance: AdvanceQueue;
|
|
26
|
+
}, {
|
|
27
|
+
/** Attaches the calling client: its working directory and environment, held in memory only. */
|
|
28
|
+
attach: (c: import("rivetkit").ActionContext<{
|
|
29
|
+
inFlight: string | null;
|
|
30
|
+
outcome: Outcome | null;
|
|
31
|
+
}, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
|
|
32
|
+
advance: AdvanceQueue;
|
|
33
|
+
}>, input: {
|
|
34
|
+
cwd: string;
|
|
35
|
+
env: Record<string, string | undefined>;
|
|
36
|
+
}) => {
|
|
37
|
+
inFlight: string | null;
|
|
38
|
+
since: number;
|
|
39
|
+
};
|
|
40
|
+
/** The outcome of a request once it has one, and the progress lines logged after line `since`. */
|
|
41
|
+
poll: (c: import("rivetkit").ActionContext<{
|
|
42
|
+
inFlight: string | null;
|
|
43
|
+
outcome: Outcome | null;
|
|
44
|
+
}, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
|
|
45
|
+
advance: AdvanceQueue;
|
|
46
|
+
}>, input: {
|
|
47
|
+
requestId: string;
|
|
48
|
+
since: number;
|
|
49
|
+
}) => {
|
|
50
|
+
outcome: Outcome | null;
|
|
51
|
+
lines: string[];
|
|
52
|
+
next: number;
|
|
53
|
+
inFlight: string | null;
|
|
54
|
+
};
|
|
55
|
+
/** Stops the step in progress, as Ctrl-C did before the daemon: the run is saved as interrupted. */
|
|
56
|
+
stop: (c: import("rivetkit").ActionContext<{
|
|
57
|
+
inFlight: string | null;
|
|
58
|
+
outcome: Outcome | null;
|
|
59
|
+
}, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
|
|
60
|
+
advance: AdvanceQueue;
|
|
61
|
+
}>) => void;
|
|
62
|
+
}>;
|
|
63
|
+
/** The daemon's registry: a local engine on 127.0.0.1:`port` (peers on port+1, metrics on port+10) behind `token`. */
|
|
64
|
+
export declare function createRegistry(options: {
|
|
65
|
+
port: number;
|
|
66
|
+
token: string;
|
|
67
|
+
}): import("rivetkit").Registry<{
|
|
68
|
+
scoutRun: import("rivetkit").ActorDefinition<{
|
|
69
|
+
inFlight: string | null;
|
|
70
|
+
outcome: Outcome | null;
|
|
71
|
+
}, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
|
|
72
|
+
advance: AdvanceQueue;
|
|
73
|
+
}, {
|
|
74
|
+
/** Attaches the calling client: its working directory and environment, held in memory only. */
|
|
75
|
+
attach: (c: import("rivetkit").ActionContext<{
|
|
76
|
+
inFlight: string | null;
|
|
77
|
+
outcome: Outcome | null;
|
|
78
|
+
}, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
|
|
79
|
+
advance: AdvanceQueue;
|
|
80
|
+
}>, input: {
|
|
81
|
+
cwd: string;
|
|
82
|
+
env: Record<string, string | undefined>;
|
|
83
|
+
}) => {
|
|
84
|
+
inFlight: string | null;
|
|
85
|
+
since: number;
|
|
86
|
+
};
|
|
87
|
+
/** The outcome of a request once it has one, and the progress lines logged after line `since`. */
|
|
88
|
+
poll: (c: import("rivetkit").ActionContext<{
|
|
89
|
+
inFlight: string | null;
|
|
90
|
+
outcome: Outcome | null;
|
|
91
|
+
}, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
|
|
92
|
+
advance: AdvanceQueue;
|
|
93
|
+
}>, input: {
|
|
94
|
+
requestId: string;
|
|
95
|
+
since: number;
|
|
96
|
+
}) => {
|
|
97
|
+
outcome: Outcome | null;
|
|
98
|
+
lines: string[];
|
|
99
|
+
next: number;
|
|
100
|
+
inFlight: string | null;
|
|
101
|
+
};
|
|
102
|
+
/** Stops the step in progress, as Ctrl-C did before the daemon: the run is saved as interrupted. */
|
|
103
|
+
stop: (c: import("rivetkit").ActionContext<{
|
|
104
|
+
inFlight: string | null;
|
|
105
|
+
outcome: Outcome | null;
|
|
106
|
+
}, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
|
|
107
|
+
advance: AdvanceQueue;
|
|
108
|
+
}>) => void;
|
|
109
|
+
}>;
|
|
110
|
+
}>;
|
|
111
|
+
export type Registry = ReturnType<typeof createRegistry>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The daemon's Rivet registry. Each Scout run is a `scoutRun` Workflow actor keyed by its run ID.
|
|
2
|
+
//
|
|
3
|
+
// The workflow loops: wait for an `advance` message (the user's answers), then run one advance step:
|
|
4
|
+
// Scout's state machine (scout/workflow.ts) until it needs a person, finishes, pauses on the budget or
|
|
5
|
+
// fails. The step's outcome goes to actor state, where the client polls for it. Rivet keeps the wait
|
|
6
|
+
// for answers across daemon restarts.
|
|
7
|
+
//
|
|
8
|
+
// Steps run at least once: a step cut off by a crash runs again when the daemon comes back. That is
|
|
9
|
+
// safe because the state machine records every phase and eval unit in SQLite before it moves on, and a
|
|
10
|
+
// unit that was running when the process died is marked interrupted and asked about before it runs
|
|
11
|
+
// again. Nothing is paid twice without a Yes.
|
|
12
|
+
//
|
|
13
|
+
// The client's environment (the user's shell: endpoint keys, the app's Langfuse keys, PATH) reaches
|
|
14
|
+
// a run through the `attach` action and stays in this process's memory. It is never written to actor
|
|
15
|
+
// state or queue messages, which are on disk. A step that resumes after a restart without an attached
|
|
16
|
+
// client marks the run interrupted, and the next `openmerit scout --resume` continues it.
|
|
17
|
+
import { workflow } from "@rivet-dev/workflows";
|
|
18
|
+
import { queue, setup } from "rivetkit";
|
|
19
|
+
import { localContext } from "../local.js";
|
|
20
|
+
import { redact } from "../scout/scan.js";
|
|
21
|
+
import { advance } from "../scout/workflow.js";
|
|
22
|
+
import { State } from "../state/db.js";
|
|
23
|
+
/** Attached clients by run ID, in this process only. */
|
|
24
|
+
const sessions = new Map();
|
|
25
|
+
const KEEP_LINES = 500;
|
|
26
|
+
function logTo(session) {
|
|
27
|
+
return (line) => {
|
|
28
|
+
session.lines.push(line);
|
|
29
|
+
session.total++;
|
|
30
|
+
if (session.lines.length > KEEP_LINES)
|
|
31
|
+
session.lines.splice(0, session.lines.length - KEEP_LINES);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function advanceAttached(runId, answers) {
|
|
35
|
+
const session = sessions.get(runId);
|
|
36
|
+
if (!session) {
|
|
37
|
+
const state = new State();
|
|
38
|
+
try {
|
|
39
|
+
if (state.getRun(runId)?.status === "running")
|
|
40
|
+
state.markInterrupted(runId, "The OpenMerit daemon restarted during this step; completed work and spend are kept.");
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
state.close();
|
|
44
|
+
}
|
|
45
|
+
return { error: `The OpenMerit daemon restarted while this run was working. Continue it with \`openmerit scout --resume ${runId}\`.` };
|
|
46
|
+
}
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
session.controller = controller;
|
|
49
|
+
const ctx = localContext({ cwd: session.cwd, env: session.env, log: logTo(session), signal: controller.signal });
|
|
50
|
+
try {
|
|
51
|
+
return await advance(ctx, runId, answers);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return { error: redact(error instanceof Error ? error.message : String(error)) };
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
session.controller = null;
|
|
58
|
+
ctx.state.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export const scoutRun = workflow({
|
|
62
|
+
state: { inFlight: null, outcome: null },
|
|
63
|
+
// Rivet deprecated actor queues in 3.0, and `ctx.queue.next` with them, but they keep working until
|
|
64
|
+
// Rivet ships its replacement for sending messages to a running workflow. Move to it then.
|
|
65
|
+
queues: { advance: queue() },
|
|
66
|
+
run: async (ctx) => {
|
|
67
|
+
const runId = ctx.key[0];
|
|
68
|
+
await ctx.loop("requests", async (loop) => {
|
|
69
|
+
const message = await loop.queue.next("next", { names: ["advance"] });
|
|
70
|
+
const { requestId, answers } = message.body;
|
|
71
|
+
await loop.step("accept", async (s) => { s.state.inFlight = requestId; });
|
|
72
|
+
// No timeout and no retries: the step can run evals for an hour, and a retry would pay again.
|
|
73
|
+
await loop.step({
|
|
74
|
+
name: "advance", timeout: 0, maxRetries: 0,
|
|
75
|
+
run: async (s) => {
|
|
76
|
+
const result = await advanceAttached(runId, answers);
|
|
77
|
+
// A failed run is a Step too, with its own `error`: only a step that threw has no runId.
|
|
78
|
+
s.state.outcome = "runId" in result ? { requestId, step: result } : { requestId, error: result.error };
|
|
79
|
+
s.state.inFlight = null;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
actions: {
|
|
85
|
+
/** Attaches the calling client: its working directory and environment, held in memory only. */
|
|
86
|
+
attach: (c, input) => {
|
|
87
|
+
const runId = c.key[0];
|
|
88
|
+
const previous = sessions.get(runId);
|
|
89
|
+
if (previous)
|
|
90
|
+
Object.assign(previous, { cwd: input.cwd, env: input.env });
|
|
91
|
+
else
|
|
92
|
+
sessions.set(runId, { cwd: input.cwd, env: input.env, lines: [], total: 0, controller: null });
|
|
93
|
+
return { inFlight: c.state.inFlight, since: sessions.get(runId).total };
|
|
94
|
+
},
|
|
95
|
+
/** The outcome of a request once it has one, and the progress lines logged after line `since`. */
|
|
96
|
+
poll: (c, input) => {
|
|
97
|
+
const session = sessions.get(c.key[0]);
|
|
98
|
+
const lines = session ? session.lines.slice(Math.max(0, input.since - (session.total - session.lines.length))) : [];
|
|
99
|
+
const outcome = c.state.outcome?.requestId === input.requestId ? c.state.outcome : null;
|
|
100
|
+
return { outcome, lines, next: session?.total ?? 0, inFlight: c.state.inFlight };
|
|
101
|
+
},
|
|
102
|
+
/** Stops the step in progress, as Ctrl-C did before the daemon: the run is saved as interrupted. */
|
|
103
|
+
stop: c => { sessions.get(c.key[0])?.controller?.abort(); },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
/** The daemon's registry: a local engine on 127.0.0.1:`port` (peers on port+1, metrics on port+10) behind `token`. */
|
|
107
|
+
export function createRegistry(options) {
|
|
108
|
+
return setup({ use: { scoutRun }, startEngine: true, startServices: false, enginePort: options.port, token: options.token });
|
|
109
|
+
}
|