@otto-code/brain 0.8.4 → 0.8.6
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/dist/service/host-api.d.ts +28 -1
- package/dist/service/host-api.js +186 -1
- package/dist/service/serve.d.ts +0 -11
- package/dist/service/serve.js +135 -0
- package/package.json +1 -1
|
@@ -35,7 +35,7 @@ import type { Supervisor } from "./supervisor.js";
|
|
|
35
35
|
* the API is this" for the rare change that no single flag describes. A daemon
|
|
36
36
|
* reads both and never requires an exact package-version match.
|
|
37
37
|
*/
|
|
38
|
-
export declare const HOST_API_VERSION =
|
|
38
|
+
export declare const HOST_API_VERSION = 3;
|
|
39
39
|
/**
|
|
40
40
|
* What this brain can serve. The daemon folds this into `brain.host.status` and
|
|
41
41
|
* Otto gates each tab on it, because the daemon and the brain version
|
|
@@ -74,6 +74,29 @@ export interface HostCapabilities {
|
|
|
74
74
|
liveInference: boolean;
|
|
75
75
|
/** Whether writes are currently permitted (allowRemoteConfig). */
|
|
76
76
|
writable: boolean;
|
|
77
|
+
/** POST/GET /__host/jobs and POST /__host/jobs/cancel. */
|
|
78
|
+
jobs: boolean;
|
|
79
|
+
/** POST /__host/restart delegates a restart to the service owner. */
|
|
80
|
+
restart: boolean;
|
|
81
|
+
}
|
|
82
|
+
/** A long-running operation owned by this brain host, not its caller. */
|
|
83
|
+
export interface HostJob {
|
|
84
|
+
id: string;
|
|
85
|
+
kind: "pull" | "runtime-install" | "calibrate" | "sweep" | "bench";
|
|
86
|
+
label: string;
|
|
87
|
+
target: string | null;
|
|
88
|
+
status: "running" | "succeeded" | "failed" | "canceled";
|
|
89
|
+
percent: null;
|
|
90
|
+
message: string | null;
|
|
91
|
+
error: string | null;
|
|
92
|
+
startedAt: string;
|
|
93
|
+
finishedAt: string | null;
|
|
94
|
+
}
|
|
95
|
+
export interface HostJobRunner {
|
|
96
|
+
start: (kind: HostJob["kind"], target: string | null, args: string[]) => HostJob;
|
|
97
|
+
list: () => HostJob[];
|
|
98
|
+
cancel: (jobId: string) => Promise<HostJob[]>;
|
|
99
|
+
query: (args: string[]) => Promise<unknown>;
|
|
77
100
|
}
|
|
78
101
|
export interface HostApiDeps {
|
|
79
102
|
supervisor: Supervisor;
|
|
@@ -98,6 +121,10 @@ export interface HostApiDeps {
|
|
|
98
121
|
* its daemon keeps polling status.
|
|
99
122
|
*/
|
|
100
123
|
statusEvents?: BrainStatusPublisher | null;
|
|
124
|
+
/** Long operations that must execute on this brain's machine. */
|
|
125
|
+
jobs?: HostJobRunner;
|
|
126
|
+
/** Gracefully restart the serving process after its HTTP acknowledgement. */
|
|
127
|
+
restart?: () => void;
|
|
101
128
|
}
|
|
102
129
|
/** One row of the model inventory: the scan, metadata, profile and score joined. */
|
|
103
130
|
export interface InventoryRow {
|
package/dist/service/host-api.js
CHANGED
|
@@ -14,7 +14,7 @@ const DEFAULT_LOG_LINES = 200;
|
|
|
14
14
|
* the API is this" for the rare change that no single flag describes. A daemon
|
|
15
15
|
* reads both and never requires an exact package-version match.
|
|
16
16
|
*/
|
|
17
|
-
export const HOST_API_VERSION =
|
|
17
|
+
export const HOST_API_VERSION = 3;
|
|
18
18
|
/**
|
|
19
19
|
* How often the SSE stream writes a comment line when nothing has changed.
|
|
20
20
|
*
|
|
@@ -128,6 +128,8 @@ export function createHostApi(deps) {
|
|
|
128
128
|
events: Boolean(deps.statusEvents?.ready),
|
|
129
129
|
liveInference: Boolean(deps.statusEvents?.ready),
|
|
130
130
|
writable: deps.getAllowWrite(),
|
|
131
|
+
jobs: Boolean(deps.jobs),
|
|
132
|
+
restart: Boolean(deps.restart),
|
|
131
133
|
});
|
|
132
134
|
/** Refuse a write unless the owner opted into remote configuration. */
|
|
133
135
|
const guardWrite = (res) => {
|
|
@@ -432,10 +434,193 @@ export function createHostApi(deps) {
|
|
|
432
434
|
handleEvents(req, res, publisher);
|
|
433
435
|
return true;
|
|
434
436
|
}
|
|
437
|
+
// A remote caller can restart a managed brain without gaining start/stop
|
|
438
|
+
// control over the host daemon. Acknowledge first so it does not race the
|
|
439
|
+
// connection it is about to close.
|
|
440
|
+
if (route === "/__host/restart" && method === "POST") {
|
|
441
|
+
if (!deps.restart) {
|
|
442
|
+
sendError(res, 404, "this brain cannot restart itself");
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
if (!guardWrite(res))
|
|
446
|
+
return true;
|
|
447
|
+
sendJson(res, { accepted: true });
|
|
448
|
+
queueMicrotask(() => deps.restart?.());
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
435
451
|
if (route === "/__host/logs" && method === "GET") {
|
|
436
452
|
handleLogs(res, params);
|
|
437
453
|
return true;
|
|
438
454
|
}
|
|
455
|
+
// Benchmark jobs are deliberately host-owned. A remote daemon only proxies
|
|
456
|
+
// these calls, so selecting a remote brain can never wake the local GPU.
|
|
457
|
+
if (route === "/__host/jobs" && method === "GET") {
|
|
458
|
+
if (!deps.jobs) {
|
|
459
|
+
sendError(res, 404, "this brain does not serve remote jobs");
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
sendJson(res, { jobs: deps.jobs.list() });
|
|
463
|
+
}
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
if (route === "/__host/jobs/bench" && method === "POST") {
|
|
467
|
+
if (!deps.jobs) {
|
|
468
|
+
sendError(res, 404, "this brain does not serve remote jobs");
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
readJsonBody(req, 4096, (result) => {
|
|
472
|
+
if (!result.ok) {
|
|
473
|
+
sendError(res, 400, result.error);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const model = result.body.model;
|
|
477
|
+
if (model !== undefined && model !== null && typeof model !== "string") {
|
|
478
|
+
sendError(res, 400, "model must be a string or null");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
sendJson(res, {
|
|
483
|
+
job: deps.jobs?.start("bench", model ?? null, [
|
|
484
|
+
"bench",
|
|
485
|
+
...(model ? ["--model", model] : []),
|
|
486
|
+
]) ?? null,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
sendError(res, 409, errorMessage(error));
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
const jobStarts = {
|
|
496
|
+
"/__host/jobs/pull": {
|
|
497
|
+
kind: "pull",
|
|
498
|
+
makeArgs: (body) => {
|
|
499
|
+
const model = body.model;
|
|
500
|
+
if (typeof model !== "string" || !model)
|
|
501
|
+
throw new Error("model is required");
|
|
502
|
+
return { target: model, args: ["pull", "--json", "--", model] };
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
"/__host/jobs/add": {
|
|
506
|
+
kind: "pull",
|
|
507
|
+
makeArgs: (body) => {
|
|
508
|
+
const repo = body.repo;
|
|
509
|
+
const quant = body.quant;
|
|
510
|
+
if (typeof repo !== "string" || !repo || typeof quant !== "string" || !quant)
|
|
511
|
+
throw new Error("repo and quant are required");
|
|
512
|
+
return {
|
|
513
|
+
target: `${repo}#${quant}`,
|
|
514
|
+
args: ["add", "--quant", quant, "--json", "--", repo],
|
|
515
|
+
};
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
"/__host/jobs/runtime-install": {
|
|
519
|
+
kind: "runtime-install",
|
|
520
|
+
makeArgs: (body) => {
|
|
521
|
+
const build = body.build;
|
|
522
|
+
if (build !== undefined && build !== null && typeof build !== "string")
|
|
523
|
+
throw new Error("build must be a string or null");
|
|
524
|
+
return {
|
|
525
|
+
target: typeof build === "string" ? build : null,
|
|
526
|
+
args: [
|
|
527
|
+
"runtime",
|
|
528
|
+
"install",
|
|
529
|
+
"--json",
|
|
530
|
+
...(typeof build === "string" ? ["--build", build] : []),
|
|
531
|
+
],
|
|
532
|
+
};
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
"/__host/jobs/calibrate": {
|
|
536
|
+
kind: "calibrate",
|
|
537
|
+
makeArgs: (body) => {
|
|
538
|
+
const model = body.model;
|
|
539
|
+
if (typeof model !== "string" || !model)
|
|
540
|
+
throw new Error("model is required");
|
|
541
|
+
return { target: model, args: ["calibrate", "--model", model, "--json"] };
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
"/__host/jobs/sweep": {
|
|
545
|
+
kind: "sweep",
|
|
546
|
+
makeArgs: (body) => {
|
|
547
|
+
const model = body.model;
|
|
548
|
+
if (typeof model !== "string" || !model)
|
|
549
|
+
throw new Error("model is required");
|
|
550
|
+
return { target: model, args: ["sweep", "--model", model, "--json"] };
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
const start = jobStarts[route];
|
|
555
|
+
if (start && method === "POST") {
|
|
556
|
+
if (!deps.jobs) {
|
|
557
|
+
sendError(res, 404, "this brain does not serve remote jobs");
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
if (!guardWrite(res))
|
|
561
|
+
return true;
|
|
562
|
+
readJsonBody(req, 4096, (result) => {
|
|
563
|
+
if (!result.ok) {
|
|
564
|
+
sendError(res, 400, result.error);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const spec = start.makeArgs(result.body);
|
|
569
|
+
sendJson(res, { job: deps.jobs?.start(start.kind, spec.target, spec.args) ?? null });
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
sendError(res, 400, errorMessage(error));
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
if (route === "/__host/catalog" && method === "GET") {
|
|
578
|
+
void deps.jobs?.query(["catalog", "--json"]).then((models) => sendJson(res, { models }));
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
if (route === "/__host/runtimes" && method === "GET") {
|
|
582
|
+
void deps.jobs
|
|
583
|
+
?.query(["runtime", "list", "--json"])
|
|
584
|
+
.then((runtimes) => sendJson(res, { runtimes }));
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
if (route === "/__host/hf/search" && method === "GET") {
|
|
588
|
+
const query = params.get("query") ?? "";
|
|
589
|
+
const limit = Math.max(1, Math.min(100, Number(params.get("limit")) || 25));
|
|
590
|
+
void deps.jobs
|
|
591
|
+
?.query(["search", "--json", "--limit", String(limit), "--", query])
|
|
592
|
+
.then((results) => sendJson(res, { results }));
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
if (route === "/__host/hf/quants" && method === "GET") {
|
|
596
|
+
const repo = params.get("repo") ?? "";
|
|
597
|
+
void deps.jobs
|
|
598
|
+
?.query(["add", "--list-quants", "--json", "--", repo])
|
|
599
|
+
.then((quants) => sendJson(res, { quants }));
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
if (route === "/__host/jobs/cancel" && method === "POST") {
|
|
603
|
+
if (!deps.jobs) {
|
|
604
|
+
sendError(res, 404, "this brain does not serve remote jobs");
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
readJsonBody(req, 4096, (result) => {
|
|
608
|
+
if (!result.ok) {
|
|
609
|
+
sendError(res, 400, result.error);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const jobId = result.body.jobId;
|
|
613
|
+
if (typeof jobId !== "string" || !jobId) {
|
|
614
|
+
sendError(res, 400, "jobId is required");
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
void deps.jobs
|
|
618
|
+
?.cancel(jobId)
|
|
619
|
+
.then((jobs) => sendJson(res, { jobs }))
|
|
620
|
+
.catch((error) => sendError(res, 500, errorMessage(error)));
|
|
621
|
+
});
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
439
624
|
if (route === "/__host/resources" && method === "GET") {
|
|
440
625
|
void (async () => {
|
|
441
626
|
try {
|
package/dist/service/serve.d.ts
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The headless brain service: the router + supervisor bound to a port, with the
|
|
3
|
-
* VRAM fit, on-demand model switching, remote auth, built-in TLS, and pid-file
|
|
4
|
-
* lifecycle. Used both by `otto brain serve` (foreground) and by a detached
|
|
5
|
-
* `otto brain start`. It stays provider-neutral about the runtime source - it
|
|
6
|
-
* takes whatever resolveRuntime picks (managed or LM Studio).
|
|
7
|
-
*
|
|
8
|
-
* TLS is served in-process (config.tls): HTTPS with a files / self-signed /
|
|
9
|
-
* tailscale certificate, hot-swapped on renewal. This is what lets the brain be
|
|
10
|
-
* exposed securely over a network with no relay in front of it.
|
|
11
|
-
*/
|
|
12
1
|
import http from "node:http";
|
|
13
2
|
import type { BrainConfig } from "../config/schema.js";
|
|
14
3
|
import type { Model } from "../types.js";
|
package/dist/service/serve.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* tailscale certificate, hot-swapped on renewal. This is what lets the brain be
|
|
10
10
|
* exposed securely over a network with no relay in front of it.
|
|
11
11
|
*/
|
|
12
|
+
import { execFile, spawn } from "node:child_process";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
12
14
|
import http from "node:http";
|
|
13
15
|
import https from "node:https";
|
|
14
16
|
import { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
|
|
@@ -54,6 +56,130 @@ function collectEvals() {
|
|
|
54
56
|
return { rankings: [], latest: [], variance: [], runCount: 0 };
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
const REMOTE_JOB_RETENTION_MS = 5 * 60000;
|
|
60
|
+
/**
|
|
61
|
+
* Runs a benchmark as a child of the brain service. This is intentionally here
|
|
62
|
+
* rather than in the connecting daemon: its process, model store, results
|
|
63
|
+
* directory and GPU all belong to the host that is being benchmarked.
|
|
64
|
+
*/
|
|
65
|
+
class ServiceJobRunner {
|
|
66
|
+
constructor() {
|
|
67
|
+
this.jobs = new Map();
|
|
68
|
+
}
|
|
69
|
+
start(kind, target, args) {
|
|
70
|
+
const running = [...this.jobs.values()].find((job) => job.status === "running");
|
|
71
|
+
if (running)
|
|
72
|
+
throw new Error(`Another operation is already running (${running.label}).`);
|
|
73
|
+
const job = {
|
|
74
|
+
id: `brainjob_${randomUUID()}`,
|
|
75
|
+
kind,
|
|
76
|
+
label: kind === "bench"
|
|
77
|
+
? target
|
|
78
|
+
? `Benchmark ${target}`
|
|
79
|
+
: "Benchmark models"
|
|
80
|
+
: `${kind} ${target ?? ""}`.trim(),
|
|
81
|
+
target,
|
|
82
|
+
status: "running",
|
|
83
|
+
percent: null,
|
|
84
|
+
message: null,
|
|
85
|
+
error: null,
|
|
86
|
+
startedAt: new Date().toISOString(),
|
|
87
|
+
finishedAt: null,
|
|
88
|
+
child: null,
|
|
89
|
+
};
|
|
90
|
+
// The service is launched by the same CLI entry point as `otto-brain bench`.
|
|
91
|
+
// Reusing that entry point keeps its config/path resolution on this host.
|
|
92
|
+
const entry = process.argv[1];
|
|
93
|
+
if (!entry)
|
|
94
|
+
throw new Error("The brain service has no CLI entry point.");
|
|
95
|
+
const child = spawn(process.execPath, [entry, ...args], {
|
|
96
|
+
cwd: process.cwd(),
|
|
97
|
+
env: process.env,
|
|
98
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
});
|
|
101
|
+
job.child = child;
|
|
102
|
+
this.jobs.set(job.id, job);
|
|
103
|
+
child.stderr?.setEncoding("utf8");
|
|
104
|
+
child.stderr?.on("data", (chunk) => {
|
|
105
|
+
const last = chunk.trim().split(/\r?\n/).at(-1)?.trim();
|
|
106
|
+
if (last)
|
|
107
|
+
job.message = last.slice(-1000);
|
|
108
|
+
});
|
|
109
|
+
child.once("error", (error) => this.finish(job, "failed", error.message));
|
|
110
|
+
child.once("close", (code) => {
|
|
111
|
+
if (job.status !== "running")
|
|
112
|
+
return;
|
|
113
|
+
this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : `Exited with code ${code}.`);
|
|
114
|
+
});
|
|
115
|
+
return this.publicJob(job);
|
|
116
|
+
}
|
|
117
|
+
async query(args) {
|
|
118
|
+
const entry = process.argv[1];
|
|
119
|
+
if (!entry)
|
|
120
|
+
throw new Error("The brain service has no CLI entry point.");
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const child = spawn(process.execPath, [entry, ...args], {
|
|
123
|
+
cwd: process.cwd(),
|
|
124
|
+
env: process.env,
|
|
125
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
});
|
|
128
|
+
let out = "";
|
|
129
|
+
let err = "";
|
|
130
|
+
child.stdout?.setEncoding("utf8");
|
|
131
|
+
child.stderr?.setEncoding("utf8");
|
|
132
|
+
child.stdout?.on("data", (chunk) => (out += chunk));
|
|
133
|
+
child.stderr?.on("data", (chunk) => (err += chunk));
|
|
134
|
+
child.once("error", reject);
|
|
135
|
+
child.once("close", (code) => {
|
|
136
|
+
if (code !== 0)
|
|
137
|
+
return reject(new Error(err.trim() || `Exited with code ${code}.`));
|
|
138
|
+
try {
|
|
139
|
+
resolve(JSON.parse(out));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
reject(new Error("The brain command returned invalid JSON."));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
list() {
|
|
148
|
+
const cutoff = Date.now() - REMOTE_JOB_RETENTION_MS;
|
|
149
|
+
for (const [id, job] of this.jobs) {
|
|
150
|
+
if (job.finishedAt && Date.parse(job.finishedAt) < cutoff)
|
|
151
|
+
this.jobs.delete(id);
|
|
152
|
+
}
|
|
153
|
+
return [...this.jobs.values()]
|
|
154
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
155
|
+
.map((job) => this.publicJob(job));
|
|
156
|
+
}
|
|
157
|
+
async cancel(jobId) {
|
|
158
|
+
const job = this.jobs.get(jobId);
|
|
159
|
+
if (!job || job.status !== "running" || !job.child)
|
|
160
|
+
return this.list();
|
|
161
|
+
const child = job.child;
|
|
162
|
+
if (process.platform === "win32" && child.pid) {
|
|
163
|
+
await new Promise((resolve) => execFile("taskkill", ["/pid", String(child.pid), "/t", "/f"], () => resolve()));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
child.kill("SIGTERM");
|
|
167
|
+
}
|
|
168
|
+
this.finish(job, "canceled", "Canceled.");
|
|
169
|
+
return this.list();
|
|
170
|
+
}
|
|
171
|
+
finish(job, status, error) {
|
|
172
|
+
if (job.status !== "running")
|
|
173
|
+
return;
|
|
174
|
+
job.child = null;
|
|
175
|
+
job.status = status;
|
|
176
|
+
job.error = error;
|
|
177
|
+
job.finishedAt = new Date().toISOString();
|
|
178
|
+
}
|
|
179
|
+
publicJob({ child: _child, ...job }) {
|
|
180
|
+
return job;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
57
183
|
function isLoopback(host) {
|
|
58
184
|
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
59
185
|
}
|
|
@@ -263,6 +389,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
263
389
|
// /__host/events. One instance, so `capabilities.events` and the stream can
|
|
264
390
|
// never disagree about whether this brain publishes.
|
|
265
391
|
const statusEvents = new BrainStatusPublisher();
|
|
392
|
+
const jobs = new ServiceJobRunner();
|
|
393
|
+
// Assigned once `stop` exists below. This indirection lets the management API
|
|
394
|
+
// answer a remote restart request before closing its own socket.
|
|
395
|
+
let requestRestart = () => { };
|
|
266
396
|
const hostApi = createHostApi({
|
|
267
397
|
supervisor,
|
|
268
398
|
getCatalog: () => catalog,
|
|
@@ -290,6 +420,8 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
290
420
|
getModelsDir: () => managedModelsDir(config, env),
|
|
291
421
|
sampleResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
|
|
292
422
|
statusEvents,
|
|
423
|
+
jobs,
|
|
424
|
+
restart: () => requestRestart(),
|
|
293
425
|
});
|
|
294
426
|
const handler = withAuth(createRouter({
|
|
295
427
|
supervisor,
|
|
@@ -366,6 +498,9 @@ export async function startService({ config, modelNeedle, env = process.env, onL
|
|
|
366
498
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
367
499
|
removePidFile(env);
|
|
368
500
|
};
|
|
501
|
+
requestRestart = () => {
|
|
502
|
+
void stop().finally(() => process.exit(75));
|
|
503
|
+
};
|
|
369
504
|
return {
|
|
370
505
|
server,
|
|
371
506
|
supervisor,
|
package/package.json
CHANGED