miravo 0.1.4 → 0.2.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/README.md +38 -32
- package/package.json +11 -5
- package/src/__tests__/integration/executable-methods-runner.ts +253 -0
- package/src/__tests__/integration/executable-methods.integration.ts +1688 -0
- package/src/commands/fork.ts +205 -0
- package/src/commands/inject.test.ts +6 -9
- package/src/commands/inject.ts +48 -158
- package/src/commands/models.test.ts +32 -8
- package/src/commands/models.ts +71 -45
- package/src/commands/start.test.ts +32 -63
- package/src/commands/start.ts +281 -149
- package/src/commands/status.test.ts +3 -22
- package/src/commands/status.ts +114 -279
- package/src/commands/templates.test.ts +4 -50
- package/src/commands/templates.ts +12 -191
- package/src/index.ts +16 -6
- package/src/lib/admin-client.ts +214 -0
- package/src/lib/admin-server.test.ts +276 -0
- package/src/lib/admin-server.ts +307 -0
- package/src/lib/shutdown.test.ts +2 -2
- package/src/lib/shutdown.ts +218 -35
- package/src/lib/template-category.test.ts +14 -7
- package/src/lib/template-category.ts +6 -1
- package/src/lib/template-locator.ts +5 -8
- package/src/lib/template-utils.ts +14 -0
- package/src/logger.test.ts +2 -2
- package/src/logger.ts +68 -15
- package/src/output.ts +41 -1
- package/src/lib/health-server.test.ts +0 -161
- package/src/lib/health-server.ts +0 -154
package/src/output.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Uses consola/utils for ANSI-aware width calculation.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { AdapterHealthSnapshot, AdapterMetrics } from "@miravo/shared";
|
|
7
|
+
import { colors, stripAnsi } from "consola/utils";
|
|
7
8
|
|
|
8
9
|
/** A single row in a table, where each cell is a string value keyed by column key. */
|
|
9
10
|
interface TableRow {
|
|
@@ -115,3 +116,42 @@ export function formatDuration(ms: number): string {
|
|
|
115
116
|
|
|
116
117
|
return parts.join(" ");
|
|
117
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Format the health suffix for an adapter line in pretty/plain CLI output.
|
|
122
|
+
* Returns a string like: " · State: healthy" or " · Pending: 18,420 · State: saturated"
|
|
123
|
+
*/
|
|
124
|
+
export function formatHealthSuffix(metrics: AdapterMetrics, sep: string): string {
|
|
125
|
+
const health = metrics.health;
|
|
126
|
+
if (!health) return "";
|
|
127
|
+
|
|
128
|
+
let suffix = "";
|
|
129
|
+
|
|
130
|
+
if (health.pendingWorkCount > 0) {
|
|
131
|
+
suffix += `${sep}Pending: ${colors.yellow(formatCount(health.pendingWorkCount))}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
suffix += `${sep}State: ${healthStateColor(health.state)}`;
|
|
135
|
+
|
|
136
|
+
return suffix;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function healthStateColor(state: AdapterHealthSnapshot["state"]): string {
|
|
140
|
+
switch (state) {
|
|
141
|
+
case "healthy":
|
|
142
|
+
return colors.green(state);
|
|
143
|
+
case "busy":
|
|
144
|
+
return colors.yellow(state);
|
|
145
|
+
case "saturated":
|
|
146
|
+
return colors.red(state);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Compute average throughput over the entire run.
|
|
152
|
+
* Returns null when there's no meaningful data (zero uptime or zero messages).
|
|
153
|
+
*/
|
|
154
|
+
export function computeAverageThroughput(totalMessages: number, uptimeMs: number): number | null {
|
|
155
|
+
if (uptimeMs <= 0 || totalMessages === 0) return null;
|
|
156
|
+
return Math.round(totalMessages / (uptimeMs / 1_000));
|
|
157
|
+
}
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
|
|
3
|
-
import { startHealthServer } from "./health-server.ts";
|
|
4
|
-
|
|
5
|
-
function createMockHealthEngine(initialState: EngineCoreMetrics["state"], tickIntervalMs: number) {
|
|
6
|
-
const runtime = {
|
|
7
|
-
state: initialState as EngineCoreMetrics["state"],
|
|
8
|
-
tickCount: 0,
|
|
9
|
-
lastTickProgressAt: Date.now(),
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const snapshot: EngineStateSnapshot = {
|
|
13
|
-
simulationName: "test-sim",
|
|
14
|
-
models: [],
|
|
15
|
-
instances: [],
|
|
16
|
-
config: {
|
|
17
|
-
seed: 1,
|
|
18
|
-
tickIntervalMs,
|
|
19
|
-
timeMultiplier: 1,
|
|
20
|
-
adapters: [],
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
return {
|
|
25
|
-
engine: {
|
|
26
|
-
getMetrics: () => ({
|
|
27
|
-
core: {
|
|
28
|
-
state: runtime.state,
|
|
29
|
-
tickCount: runtime.tickCount,
|
|
30
|
-
simulationTimeSeconds: 0,
|
|
31
|
-
instanceCount: 1,
|
|
32
|
-
unsEntryCount: 1,
|
|
33
|
-
quarantinedInstanceCount: 0,
|
|
34
|
-
engineErrorsCount: 0,
|
|
35
|
-
tickOverrunsCount: 0,
|
|
36
|
-
circuitBreakerTripsCount: 0,
|
|
37
|
-
uptimeMs: 0,
|
|
38
|
-
actualTicksPerSecond: 1,
|
|
39
|
-
},
|
|
40
|
-
}),
|
|
41
|
-
getState: () => snapshot,
|
|
42
|
-
},
|
|
43
|
-
setState(state: EngineCoreMetrics["state"]) {
|
|
44
|
-
runtime.state = state;
|
|
45
|
-
},
|
|
46
|
-
setTickCount(tickCount: number) {
|
|
47
|
-
runtime.tickCount = tickCount;
|
|
48
|
-
},
|
|
49
|
-
setLastTickProgressAt(timestamp: number) {
|
|
50
|
-
runtime.lastTickProgressAt = timestamp;
|
|
51
|
-
},
|
|
52
|
-
getLastTickProgressAt() {
|
|
53
|
-
return runtime.lastTickProgressAt;
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function serverBaseUrl(endpoint: string): string {
|
|
59
|
-
const url = new URL(endpoint);
|
|
60
|
-
return `http://127.0.0.1:${url.port}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface StartServerOptions {
|
|
64
|
-
readonly state: EngineCoreMetrics["state"];
|
|
65
|
-
readonly tickIntervalMs?: number;
|
|
66
|
-
readonly mqttEnabled: boolean;
|
|
67
|
-
readonly isMqttConnected: () => boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function startTestServer(options: StartServerOptions, handles: Array<{ stop: () => void }>) {
|
|
71
|
-
const mock = createMockHealthEngine(options.state, options.tickIntervalMs ?? 1000);
|
|
72
|
-
const handle = startHealthServer({
|
|
73
|
-
engine: mock.engine,
|
|
74
|
-
host: "127.0.0.1",
|
|
75
|
-
port: 0,
|
|
76
|
-
mqttEnabled: options.mqttEnabled,
|
|
77
|
-
isMqttConnected: options.isMqttConnected,
|
|
78
|
-
getLastTickProgressAt: () => mock.getLastTickProgressAt(),
|
|
79
|
-
});
|
|
80
|
-
handles.push(handle);
|
|
81
|
-
const baseUrl = serverBaseUrl(handle.endpoint);
|
|
82
|
-
return { mock, baseUrl };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
describe("health server", () => {
|
|
86
|
-
const handles: Array<{ stop: () => void }> = [];
|
|
87
|
-
|
|
88
|
-
afterEach(() => {
|
|
89
|
-
for (const handle of handles) {
|
|
90
|
-
handle.stop();
|
|
91
|
-
}
|
|
92
|
-
handles.length = 0;
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test.each([
|
|
96
|
-
{
|
|
97
|
-
label: "200 when running and mqtt connected",
|
|
98
|
-
state: "running" as const,
|
|
99
|
-
healthStatus: 200,
|
|
100
|
-
readyStatus: 200,
|
|
101
|
-
},
|
|
102
|
-
{ label: "503 when paused", state: "paused" as const, healthStatus: 200, readyStatus: 503 },
|
|
103
|
-
])("/readyz returns $label", async ({ state, healthStatus, readyStatus }) => {
|
|
104
|
-
const { baseUrl } = startTestServer(
|
|
105
|
-
{ state, mqttEnabled: true, isMqttConnected: () => true },
|
|
106
|
-
handles,
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const health = await fetch(`${baseUrl}/healthz`);
|
|
110
|
-
const ready = await fetch(`${baseUrl}/readyz`);
|
|
111
|
-
|
|
112
|
-
expect(health.status).toBe(healthStatus);
|
|
113
|
-
expect(ready.status).toBe(readyStatus);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("/readyz returns 503 when mqtt is enabled but disconnected", async () => {
|
|
117
|
-
const { baseUrl } = startTestServer(
|
|
118
|
-
{ state: "running", mqttEnabled: true, isMqttConnected: () => false },
|
|
119
|
-
handles,
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
const ready = await fetch(`${baseUrl}/readyz`);
|
|
123
|
-
expect(ready.status).toBe(503);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("/readyz ignores mqtt when mqtt is disabled", async () => {
|
|
127
|
-
const { baseUrl } = startTestServer(
|
|
128
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
129
|
-
handles,
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const ready = await fetch(`${baseUrl}/readyz`);
|
|
133
|
-
expect(ready.status).toBe(200);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("/healthz returns 503 when state is stopped", async () => {
|
|
137
|
-
const { baseUrl } = startTestServer(
|
|
138
|
-
{ state: "stopped", mqttEnabled: false, isMqttConnected: () => false },
|
|
139
|
-
handles,
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
const health = await fetch(`${baseUrl}/healthz`);
|
|
143
|
-
expect(health.status).toBe(503);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("/healthz returns 503 on the first probe after running tick loop stalls", async () => {
|
|
147
|
-
const { mock, baseUrl } = startTestServer(
|
|
148
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
149
|
-
handles,
|
|
150
|
-
);
|
|
151
|
-
mock.setLastTickProgressAt(Date.now() - 3_500);
|
|
152
|
-
|
|
153
|
-
const stalled = await fetch(`${baseUrl}/healthz`);
|
|
154
|
-
expect(stalled.status).toBe(503);
|
|
155
|
-
|
|
156
|
-
mock.setLastTickProgressAt(Date.now());
|
|
157
|
-
mock.setTickCount(1);
|
|
158
|
-
const recovered = await fetch(`${baseUrl}/healthz`);
|
|
159
|
-
expect(recovered.status).toBe(200);
|
|
160
|
-
});
|
|
161
|
-
});
|
package/src/lib/health-server.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
|
|
2
|
-
|
|
3
|
-
interface HealthEngine {
|
|
4
|
-
getMetrics(): { readonly core: EngineCoreMetrics };
|
|
5
|
-
getState(): EngineStateSnapshot;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface HealthServerOptions {
|
|
9
|
-
readonly engine: HealthEngine;
|
|
10
|
-
readonly host: string;
|
|
11
|
-
readonly port: number;
|
|
12
|
-
readonly mqttEnabled: boolean;
|
|
13
|
-
readonly isMqttConnected: () => boolean;
|
|
14
|
-
readonly getLastTickProgressAt: () => number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface HealthServerStatus {
|
|
18
|
-
readonly status: "ok" | "fail";
|
|
19
|
-
readonly reason?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface HealthServerHandle {
|
|
23
|
-
readonly endpoint: string;
|
|
24
|
-
stop(): void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface HealthResponseBody {
|
|
28
|
-
readonly status: "ok" | "fail";
|
|
29
|
-
readonly simulationState: EngineCoreMetrics["state"];
|
|
30
|
-
readonly tickCount: number;
|
|
31
|
-
readonly mqttEnabled: boolean;
|
|
32
|
-
readonly mqttConnected: boolean | null;
|
|
33
|
-
readonly stalled: boolean;
|
|
34
|
-
readonly reason?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function toJsonResponse(body: HealthResponseBody, status: number): Response {
|
|
38
|
-
return new Response(JSON.stringify(body), {
|
|
39
|
-
status,
|
|
40
|
-
headers: {
|
|
41
|
-
"content-type": "application/json; charset=utf-8",
|
|
42
|
-
"cache-control": "no-store",
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function livenessStatus(input: {
|
|
48
|
-
readonly core: EngineCoreMetrics;
|
|
49
|
-
readonly stalled: boolean;
|
|
50
|
-
}): HealthServerStatus {
|
|
51
|
-
if (input.core.state === "stopped") {
|
|
52
|
-
return { status: "fail", reason: 'engine state is "stopped"' };
|
|
53
|
-
}
|
|
54
|
-
if (input.stalled) {
|
|
55
|
-
return { status: "fail", reason: "tick loop stalled" };
|
|
56
|
-
}
|
|
57
|
-
return { status: "ok" };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function readinessStatus(input: {
|
|
61
|
-
readonly core: EngineCoreMetrics;
|
|
62
|
-
readonly stalled: boolean;
|
|
63
|
-
readonly mqttEnabled: boolean;
|
|
64
|
-
readonly mqttConnected: boolean;
|
|
65
|
-
}): HealthServerStatus {
|
|
66
|
-
if (input.core.state !== "running") {
|
|
67
|
-
return { status: "fail", reason: `engine state is "${input.core.state}"` };
|
|
68
|
-
}
|
|
69
|
-
if (input.stalled) {
|
|
70
|
-
return { status: "fail", reason: "tick loop stalled" };
|
|
71
|
-
}
|
|
72
|
-
if (input.mqttEnabled && !input.mqttConnected) {
|
|
73
|
-
return { status: "fail", reason: "mqtt disconnected" };
|
|
74
|
-
}
|
|
75
|
-
return { status: "ok" };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Start a lightweight HTTP health server for container probes.
|
|
80
|
-
*
|
|
81
|
-
* - `/healthz`: liveness (process/tick loop health)
|
|
82
|
-
* - `/readyz`: readiness (engine running + MQTT connected when enabled)
|
|
83
|
-
*/
|
|
84
|
-
function startHealthServer(options: HealthServerOptions): HealthServerHandle {
|
|
85
|
-
const { engine, host, port, mqttEnabled, isMqttConnected, getLastTickProgressAt } = options;
|
|
86
|
-
|
|
87
|
-
const server = Bun.serve({
|
|
88
|
-
hostname: host,
|
|
89
|
-
port,
|
|
90
|
-
fetch(request) {
|
|
91
|
-
const pathname = new URL(request.url).pathname;
|
|
92
|
-
const metrics = engine.getMetrics();
|
|
93
|
-
const state = engine.getState();
|
|
94
|
-
|
|
95
|
-
const now = Date.now();
|
|
96
|
-
const stallThresholdMs = Math.max(100, state.config.tickIntervalMs * 3);
|
|
97
|
-
const lastTickProgressAt = getLastTickProgressAt();
|
|
98
|
-
const stalled =
|
|
99
|
-
metrics.core.state === "running" &&
|
|
100
|
-
Number.isFinite(lastTickProgressAt) &&
|
|
101
|
-
now - lastTickProgressAt > stallThresholdMs;
|
|
102
|
-
|
|
103
|
-
const mqttConnected = mqttEnabled ? isMqttConnected() : false;
|
|
104
|
-
const bodyBase = {
|
|
105
|
-
simulationState: metrics.core.state,
|
|
106
|
-
tickCount: metrics.core.tickCount,
|
|
107
|
-
mqttEnabled,
|
|
108
|
-
mqttConnected: mqttEnabled ? mqttConnected : null,
|
|
109
|
-
stalled,
|
|
110
|
-
} as const;
|
|
111
|
-
|
|
112
|
-
if (pathname === "/healthz") {
|
|
113
|
-
const status = livenessStatus({ core: metrics.core, stalled });
|
|
114
|
-
return toJsonResponse(
|
|
115
|
-
{
|
|
116
|
-
status: status.status,
|
|
117
|
-
...bodyBase,
|
|
118
|
-
...(status.reason ? { reason: status.reason } : {}),
|
|
119
|
-
},
|
|
120
|
-
status.status === "ok" ? 200 : 503,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (pathname === "/readyz") {
|
|
125
|
-
const status = readinessStatus({
|
|
126
|
-
core: metrics.core,
|
|
127
|
-
stalled,
|
|
128
|
-
mqttEnabled,
|
|
129
|
-
mqttConnected,
|
|
130
|
-
});
|
|
131
|
-
return toJsonResponse(
|
|
132
|
-
{
|
|
133
|
-
status: status.status,
|
|
134
|
-
...bodyBase,
|
|
135
|
-
...(status.reason ? { reason: status.reason } : {}),
|
|
136
|
-
},
|
|
137
|
-
status.status === "ok" ? 200 : 503,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return new Response("Not Found", { status: 404 });
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
endpoint: `http://${host}:${server.port}`,
|
|
147
|
-
stop() {
|
|
148
|
-
server.stop(true);
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export { startHealthServer };
|
|
154
|
-
export type { HealthEngine, HealthServerHandle, HealthServerOptions };
|