miravo 0.1.4 → 0.2.1
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 +333 -193
- 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 +42 -8
- 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 +221 -93
- package/src/lib/shutdown.ts +478 -98
- 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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
|
|
3
|
+
import type { CommandResult, EngineCommand } from "@miravo/shared";
|
|
4
|
+
import { startAdminServer } from "./admin-server.ts";
|
|
5
|
+
|
|
6
|
+
function createMockAdminEngine(initialState: EngineCoreMetrics["state"], tickIntervalMs: number) {
|
|
7
|
+
const runtime = {
|
|
8
|
+
state: initialState as EngineCoreMetrics["state"],
|
|
9
|
+
tickCount: 0,
|
|
10
|
+
lastTickProgressAt: performance.now(),
|
|
11
|
+
executedCommands: [] as EngineCommand[],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const snapshot: EngineStateSnapshot = {
|
|
15
|
+
simulationName: "test-sim",
|
|
16
|
+
models: [],
|
|
17
|
+
instances: [],
|
|
18
|
+
config: {
|
|
19
|
+
seed: 1,
|
|
20
|
+
tickIntervalMs,
|
|
21
|
+
timeMultiplier: 1,
|
|
22
|
+
adapters: [],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
engine: {
|
|
28
|
+
getMetrics: () => ({
|
|
29
|
+
simulationName: snapshot.simulationName,
|
|
30
|
+
core: {
|
|
31
|
+
state: runtime.state,
|
|
32
|
+
tickCount: runtime.tickCount,
|
|
33
|
+
simulationTimeSeconds: 0,
|
|
34
|
+
instanceCount: 1,
|
|
35
|
+
memberValueCount: 1,
|
|
36
|
+
quarantinedInstanceCount: 0,
|
|
37
|
+
engineErrorsCount: 0,
|
|
38
|
+
tickOverrunsCount: 0,
|
|
39
|
+
circuitBreakerTripsCount: 0,
|
|
40
|
+
uptimeMs: 0,
|
|
41
|
+
actualTicksPerSecond: 1,
|
|
42
|
+
},
|
|
43
|
+
fleet: {
|
|
44
|
+
modelBreakdown: [{ model: "test-sensor", count: 1 }],
|
|
45
|
+
lifecycleBreakdown: { running: 1 },
|
|
46
|
+
activeFaultCount: 0,
|
|
47
|
+
faultBreakdown: [],
|
|
48
|
+
},
|
|
49
|
+
adapters: [],
|
|
50
|
+
}),
|
|
51
|
+
getState: () => snapshot,
|
|
52
|
+
execute: (command: EngineCommand): CommandResult => {
|
|
53
|
+
runtime.executedCommands.push(command);
|
|
54
|
+
return {
|
|
55
|
+
success: true,
|
|
56
|
+
command: command.type,
|
|
57
|
+
correlationId: command.correlationId,
|
|
58
|
+
message: `Executed ${command.type}`,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
setState(state: EngineCoreMetrics["state"]) {
|
|
63
|
+
runtime.state = state;
|
|
64
|
+
},
|
|
65
|
+
setTickCount(tickCount: number) {
|
|
66
|
+
runtime.tickCount = tickCount;
|
|
67
|
+
},
|
|
68
|
+
setLastTickProgressAt(timestamp: number) {
|
|
69
|
+
runtime.lastTickProgressAt = timestamp;
|
|
70
|
+
},
|
|
71
|
+
getLastTickProgressAt() {
|
|
72
|
+
return runtime.lastTickProgressAt;
|
|
73
|
+
},
|
|
74
|
+
getExecutedCommands() {
|
|
75
|
+
return [...runtime.executedCommands];
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function serverBaseUrl(endpoint: string): string {
|
|
81
|
+
const url = new URL(endpoint);
|
|
82
|
+
return `http://127.0.0.1:${url.port}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface StartServerOptions {
|
|
86
|
+
readonly state: EngineCoreMetrics["state"];
|
|
87
|
+
readonly tickIntervalMs?: number;
|
|
88
|
+
readonly mqttEnabled: boolean;
|
|
89
|
+
readonly isMqttConnected: () => boolean;
|
|
90
|
+
readonly opcuaEnabled?: boolean;
|
|
91
|
+
readonly isOpcuaConnected?: () => boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function startTestServer(options: StartServerOptions, handles: Array<{ stop: () => void }>) {
|
|
95
|
+
const mock = createMockAdminEngine(options.state, options.tickIntervalMs ?? 1000);
|
|
96
|
+
const handle = startAdminServer({
|
|
97
|
+
engine: mock.engine,
|
|
98
|
+
host: "127.0.0.1",
|
|
99
|
+
port: 0,
|
|
100
|
+
tickIntervalMs: options.tickIntervalMs ?? 1000,
|
|
101
|
+
mqttEnabled: options.mqttEnabled,
|
|
102
|
+
isMqttConnected: options.isMqttConnected,
|
|
103
|
+
opcuaEnabled: options.opcuaEnabled ?? false,
|
|
104
|
+
isOpcuaConnected: options.isOpcuaConnected ?? (() => false),
|
|
105
|
+
getLastTickProgressAt: () => mock.getLastTickProgressAt(),
|
|
106
|
+
});
|
|
107
|
+
handles.push(handle);
|
|
108
|
+
const baseUrl = serverBaseUrl(handle.endpoint);
|
|
109
|
+
return { mock, baseUrl };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("admin server", () => {
|
|
113
|
+
const handles: Array<{ stop: () => void }> = [];
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
for (const handle of handles) {
|
|
117
|
+
handle.stop();
|
|
118
|
+
}
|
|
119
|
+
handles.length = 0;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test.each([
|
|
123
|
+
{
|
|
124
|
+
label: "200 when running and mqtt connected",
|
|
125
|
+
state: "running" as const,
|
|
126
|
+
healthStatus: 200,
|
|
127
|
+
readyStatus: 200,
|
|
128
|
+
},
|
|
129
|
+
{ label: "503 when paused", state: "paused" as const, healthStatus: 200, readyStatus: 503 },
|
|
130
|
+
])("/readyz returns $label", async ({ state, healthStatus, readyStatus }) => {
|
|
131
|
+
const { baseUrl } = startTestServer(
|
|
132
|
+
{ state, mqttEnabled: true, isMqttConnected: () => true },
|
|
133
|
+
handles,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const health = await fetch(`${baseUrl}/healthz`);
|
|
137
|
+
const ready = await fetch(`${baseUrl}/readyz`);
|
|
138
|
+
|
|
139
|
+
expect(health.status).toBe(healthStatus);
|
|
140
|
+
expect(ready.status).toBe(readyStatus);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("/readyz returns 503 when mqtt is enabled but disconnected", async () => {
|
|
144
|
+
const { baseUrl } = startTestServer(
|
|
145
|
+
{ state: "running", mqttEnabled: true, isMqttConnected: () => false },
|
|
146
|
+
handles,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const ready = await fetch(`${baseUrl}/readyz`);
|
|
150
|
+
expect(ready.status).toBe(503);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("/readyz returns 503 when opcua is enabled but disconnected", async () => {
|
|
154
|
+
const { baseUrl } = startTestServer(
|
|
155
|
+
{
|
|
156
|
+
state: "running",
|
|
157
|
+
mqttEnabled: false,
|
|
158
|
+
isMqttConnected: () => false,
|
|
159
|
+
opcuaEnabled: true,
|
|
160
|
+
isOpcuaConnected: () => false,
|
|
161
|
+
},
|
|
162
|
+
handles,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const ready = await fetch(`${baseUrl}/readyz`);
|
|
166
|
+
expect(ready.status).toBe(503);
|
|
167
|
+
const body = (await ready.json()) as { reason?: string };
|
|
168
|
+
expect(body.reason).toBe("opcua disconnected");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("/healthz returns 503 on the first probe after running tick loop stalls", async () => {
|
|
172
|
+
const { mock, baseUrl } = startTestServer(
|
|
173
|
+
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
174
|
+
handles,
|
|
175
|
+
);
|
|
176
|
+
mock.setLastTickProgressAt(performance.now() - 3_500);
|
|
177
|
+
|
|
178
|
+
const stalled = await fetch(`${baseUrl}/healthz`);
|
|
179
|
+
expect(stalled.status).toBe(503);
|
|
180
|
+
|
|
181
|
+
mock.setLastTickProgressAt(performance.now());
|
|
182
|
+
mock.setTickCount(1);
|
|
183
|
+
const recovered = await fetch(`${baseUrl}/healthz`);
|
|
184
|
+
expect(recovered.status).toBe(200);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("GET /metrics returns the engine metrics snapshot", async () => {
|
|
188
|
+
const { mock, baseUrl } = startTestServer(
|
|
189
|
+
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
190
|
+
handles,
|
|
191
|
+
);
|
|
192
|
+
mock.setTickCount(12);
|
|
193
|
+
|
|
194
|
+
const response = await fetch(`${baseUrl}/metrics`);
|
|
195
|
+
const body = (await response.json()) as {
|
|
196
|
+
core: { tickCount: number; state: EngineCoreMetrics["state"] };
|
|
197
|
+
simulationName: string;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
expect(response.status).toBe(200);
|
|
201
|
+
expect(body.simulationName).toBe("test-sim");
|
|
202
|
+
expect(body.core.tickCount).toBe(12);
|
|
203
|
+
expect(body.core.state).toBe("running");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("GET /state returns the engine state snapshot", async () => {
|
|
207
|
+
const { baseUrl } = startTestServer(
|
|
208
|
+
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
209
|
+
handles,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const response = await fetch(`${baseUrl}/state`);
|
|
213
|
+
const body = (await response.json()) as EngineStateSnapshot;
|
|
214
|
+
|
|
215
|
+
expect(response.status).toBe(200);
|
|
216
|
+
expect(body.simulationName).toBe("test-sim");
|
|
217
|
+
expect(body.config.tickIntervalMs).toBe(1000);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("POST /commands validates and dispatches engine commands", async () => {
|
|
221
|
+
const { mock, baseUrl } = startTestServer(
|
|
222
|
+
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
223
|
+
handles,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const response = await fetch(`${baseUrl}/commands`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "content-type": "application/json" },
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
type: "pause",
|
|
231
|
+
correlationId: "req-1",
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
const body = (await response.json()) as CommandResult;
|
|
235
|
+
|
|
236
|
+
expect(response.status).toBe(200);
|
|
237
|
+
expect(body.success).toBe(true);
|
|
238
|
+
expect(body.command).toBe("pause");
|
|
239
|
+
expect(mock.getExecutedCommands()).toEqual([{ type: "pause", correlationId: "req-1" }]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("POST /commands returns 400 for invalid JSON", async () => {
|
|
243
|
+
const { baseUrl } = startTestServer(
|
|
244
|
+
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
245
|
+
handles,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const response = await fetch(`${baseUrl}/commands`, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: { "content-type": "application/json" },
|
|
251
|
+
body: "{bad json",
|
|
252
|
+
});
|
|
253
|
+
const body = (await response.json()) as { message: string };
|
|
254
|
+
|
|
255
|
+
expect(response.status).toBe(400);
|
|
256
|
+
expect(body.message).toBe("Invalid JSON in command request body.");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("POST /commands returns 400 for invalid command shape", async () => {
|
|
260
|
+
const { baseUrl } = startTestServer(
|
|
261
|
+
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
262
|
+
handles,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const response = await fetch(`${baseUrl}/commands`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "content-type": "application/json" },
|
|
268
|
+
body: JSON.stringify({ type: "setSpeed" }),
|
|
269
|
+
});
|
|
270
|
+
const body = (await response.json()) as CommandResult;
|
|
271
|
+
|
|
272
|
+
expect(response.status).toBe(400);
|
|
273
|
+
expect(body.success).toBe(false);
|
|
274
|
+
expect(body.command).toBe("setSpeed");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
|
|
2
|
+
import type { CommandResult, EngineCommand } from "@miravo/shared";
|
|
3
|
+
import { EngineCommandSchema } from "@miravo/shared";
|
|
4
|
+
|
|
5
|
+
interface AdminEngine {
|
|
6
|
+
getMetrics(): { readonly core: EngineCoreMetrics };
|
|
7
|
+
getState(): EngineStateSnapshot;
|
|
8
|
+
execute(command: EngineCommand): CommandResult;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AdminServerOptions {
|
|
12
|
+
readonly engine: AdminEngine;
|
|
13
|
+
readonly host: string;
|
|
14
|
+
readonly port: number;
|
|
15
|
+
readonly tickIntervalMs: number;
|
|
16
|
+
readonly mqttEnabled: boolean;
|
|
17
|
+
readonly isMqttConnected: () => boolean;
|
|
18
|
+
readonly opcuaEnabled: boolean;
|
|
19
|
+
readonly isOpcuaConnected: () => boolean;
|
|
20
|
+
readonly getLastTickProgressAt: () => number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AdminServerStatus {
|
|
24
|
+
readonly status: "ok" | "fail";
|
|
25
|
+
readonly reason?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface AdminServerHandle {
|
|
29
|
+
readonly endpoint: string;
|
|
30
|
+
stop(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface HealthResponseBody {
|
|
34
|
+
readonly status: "ok" | "fail";
|
|
35
|
+
readonly simulationState: EngineCoreMetrics["state"];
|
|
36
|
+
readonly tickCount: number;
|
|
37
|
+
readonly mqttEnabled: boolean;
|
|
38
|
+
readonly mqttConnected: boolean | null;
|
|
39
|
+
readonly opcuaEnabled: boolean;
|
|
40
|
+
readonly opcuaConnected: boolean | null;
|
|
41
|
+
readonly stalled: boolean;
|
|
42
|
+
readonly reason?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface AdminErrorResponse {
|
|
46
|
+
readonly message: string;
|
|
47
|
+
readonly error: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function jsonResponse(body: unknown, status: number): Response {
|
|
51
|
+
return new Response(JSON.stringify(body), {
|
|
52
|
+
status,
|
|
53
|
+
headers: {
|
|
54
|
+
"content-type": "application/json; charset=utf-8",
|
|
55
|
+
"cache-control": "no-store",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function plainResponse(body: string, status: number, allow?: string): Response {
|
|
61
|
+
return new Response(body, {
|
|
62
|
+
status,
|
|
63
|
+
headers: {
|
|
64
|
+
"content-type": "text/plain; charset=utf-8",
|
|
65
|
+
"cache-control": "no-store",
|
|
66
|
+
...(allow ? { allow } : {}),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function livenessStatus(input: {
|
|
72
|
+
readonly core: EngineCoreMetrics;
|
|
73
|
+
readonly stalled: boolean;
|
|
74
|
+
}): AdminServerStatus {
|
|
75
|
+
if (input.core.state === "stopped") {
|
|
76
|
+
return { status: "fail", reason: 'engine state is "stopped"' };
|
|
77
|
+
}
|
|
78
|
+
if (input.stalled) {
|
|
79
|
+
return { status: "fail", reason: "tick loop stalled" };
|
|
80
|
+
}
|
|
81
|
+
return { status: "ok" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readinessStatus(input: {
|
|
85
|
+
readonly core: EngineCoreMetrics;
|
|
86
|
+
readonly stalled: boolean;
|
|
87
|
+
readonly mqttEnabled: boolean;
|
|
88
|
+
readonly mqttConnected: boolean;
|
|
89
|
+
readonly opcuaEnabled: boolean;
|
|
90
|
+
readonly opcuaConnected: boolean;
|
|
91
|
+
}): AdminServerStatus {
|
|
92
|
+
if (input.core.state !== "running") {
|
|
93
|
+
return { status: "fail", reason: `engine state is "${input.core.state}"` };
|
|
94
|
+
}
|
|
95
|
+
if (input.stalled) {
|
|
96
|
+
return { status: "fail", reason: "tick loop stalled" };
|
|
97
|
+
}
|
|
98
|
+
if (input.mqttEnabled && !input.mqttConnected) {
|
|
99
|
+
return { status: "fail", reason: "mqtt disconnected" };
|
|
100
|
+
}
|
|
101
|
+
if (input.opcuaEnabled && !input.opcuaConnected) {
|
|
102
|
+
return { status: "fail", reason: "opcua disconnected" };
|
|
103
|
+
}
|
|
104
|
+
return { status: "ok" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function commandValidationErrorResponse(
|
|
108
|
+
commandType: string,
|
|
109
|
+
correlationId: string | undefined,
|
|
110
|
+
issues: string,
|
|
111
|
+
): Response {
|
|
112
|
+
return jsonResponse(
|
|
113
|
+
{
|
|
114
|
+
success: false,
|
|
115
|
+
command: commandType,
|
|
116
|
+
correlationId,
|
|
117
|
+
message: `Command validation failed: ${issues}`,
|
|
118
|
+
error:
|
|
119
|
+
"Check the command shape. Required: { type, ...fields }. See documentation for valid command types.",
|
|
120
|
+
},
|
|
121
|
+
400,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function extractCorrelationId(payload: unknown): string | undefined {
|
|
126
|
+
if (typeof payload !== "object" || payload === null) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const maybeCorrelationId = (payload as Record<string, unknown>)["correlationId"];
|
|
130
|
+
return typeof maybeCorrelationId === "string" ? maybeCorrelationId : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function extractCommandType(payload: unknown): string {
|
|
134
|
+
if (typeof payload !== "object" || payload === null) {
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
const maybeType = (payload as Record<string, unknown>)["type"];
|
|
138
|
+
return typeof maybeType === "string" ? maybeType : "unknown";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildHealthBody(input: {
|
|
142
|
+
readonly metrics: { readonly core: EngineCoreMetrics };
|
|
143
|
+
readonly stalled: boolean;
|
|
144
|
+
readonly mqttEnabled: boolean;
|
|
145
|
+
readonly mqttConnected: boolean;
|
|
146
|
+
readonly opcuaEnabled: boolean;
|
|
147
|
+
readonly opcuaConnected: boolean;
|
|
148
|
+
}): Omit<HealthResponseBody, "status" | "reason"> {
|
|
149
|
+
return {
|
|
150
|
+
simulationState: input.metrics.core.state,
|
|
151
|
+
tickCount: input.metrics.core.tickCount,
|
|
152
|
+
mqttEnabled: input.mqttEnabled,
|
|
153
|
+
mqttConnected: input.mqttEnabled ? input.mqttConnected : null,
|
|
154
|
+
opcuaEnabled: input.opcuaEnabled,
|
|
155
|
+
opcuaConnected: input.opcuaEnabled ? input.opcuaConnected : null,
|
|
156
|
+
stalled: input.stalled,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function parseCommandRequest(request: Request): Promise<Response | EngineCommand> {
|
|
161
|
+
let payload: unknown;
|
|
162
|
+
try {
|
|
163
|
+
payload = await request.json();
|
|
164
|
+
} catch {
|
|
165
|
+
return jsonResponse(
|
|
166
|
+
{
|
|
167
|
+
message: "Invalid JSON in command request body.",
|
|
168
|
+
error: 'Send a valid JSON object. Example: {"type":"pause"}',
|
|
169
|
+
} satisfies AdminErrorResponse,
|
|
170
|
+
400,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const validation = EngineCommandSchema.safeParse(payload);
|
|
175
|
+
if (!validation.success) {
|
|
176
|
+
const issues = validation.error.issues
|
|
177
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
178
|
+
.join("; ");
|
|
179
|
+
return commandValidationErrorResponse(
|
|
180
|
+
extractCommandType(payload),
|
|
181
|
+
extractCorrelationId(payload),
|
|
182
|
+
issues,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return validation.data;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Start the Miravo admin HTTP server.
|
|
191
|
+
*
|
|
192
|
+
* - `/healthz`: liveness (process/tick loop health)
|
|
193
|
+
* - `/readyz`: readiness (engine running + adapters connected when enabled)
|
|
194
|
+
* - `/metrics`: engine metrics snapshot
|
|
195
|
+
* - `/state`: engine state snapshot
|
|
196
|
+
* - `/commands`: command execution endpoint using EngineCommandSchema
|
|
197
|
+
*/
|
|
198
|
+
function startAdminServer(options: AdminServerOptions): AdminServerHandle {
|
|
199
|
+
const {
|
|
200
|
+
engine,
|
|
201
|
+
host,
|
|
202
|
+
port,
|
|
203
|
+
mqttEnabled,
|
|
204
|
+
isMqttConnected,
|
|
205
|
+
opcuaEnabled,
|
|
206
|
+
isOpcuaConnected,
|
|
207
|
+
getLastTickProgressAt,
|
|
208
|
+
} = options;
|
|
209
|
+
|
|
210
|
+
const server = Bun.serve({
|
|
211
|
+
hostname: host,
|
|
212
|
+
port,
|
|
213
|
+
async fetch(request) {
|
|
214
|
+
const url = new URL(request.url);
|
|
215
|
+
const pathname = url.pathname;
|
|
216
|
+
|
|
217
|
+
const metrics = engine.getMetrics();
|
|
218
|
+
|
|
219
|
+
const now = performance.now();
|
|
220
|
+
const stallThresholdMs = Math.max(100, options.tickIntervalMs * 3);
|
|
221
|
+
const lastTickProgressAt = getLastTickProgressAt();
|
|
222
|
+
const stalled =
|
|
223
|
+
metrics.core.state === "running" &&
|
|
224
|
+
Number.isFinite(lastTickProgressAt) &&
|
|
225
|
+
now - lastTickProgressAt > stallThresholdMs;
|
|
226
|
+
|
|
227
|
+
const mqttConnected = mqttEnabled ? isMqttConnected() : false;
|
|
228
|
+
const opcuaConnected = opcuaEnabled ? isOpcuaConnected() : false;
|
|
229
|
+
const healthBody = buildHealthBody({
|
|
230
|
+
metrics,
|
|
231
|
+
stalled,
|
|
232
|
+
mqttEnabled,
|
|
233
|
+
mqttConnected,
|
|
234
|
+
opcuaEnabled,
|
|
235
|
+
opcuaConnected,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (pathname === "/healthz") {
|
|
239
|
+
const status = livenessStatus({ core: metrics.core, stalled });
|
|
240
|
+
return jsonResponse(
|
|
241
|
+
{
|
|
242
|
+
status: status.status,
|
|
243
|
+
...healthBody,
|
|
244
|
+
...(status.reason ? { reason: status.reason } : {}),
|
|
245
|
+
} satisfies HealthResponseBody,
|
|
246
|
+
status.status === "ok" ? 200 : 503,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (pathname === "/readyz") {
|
|
251
|
+
const status = readinessStatus({
|
|
252
|
+
core: metrics.core,
|
|
253
|
+
stalled,
|
|
254
|
+
mqttEnabled,
|
|
255
|
+
mqttConnected,
|
|
256
|
+
opcuaEnabled,
|
|
257
|
+
opcuaConnected,
|
|
258
|
+
});
|
|
259
|
+
return jsonResponse(
|
|
260
|
+
{
|
|
261
|
+
status: status.status,
|
|
262
|
+
...healthBody,
|
|
263
|
+
...(status.reason ? { reason: status.reason } : {}),
|
|
264
|
+
} satisfies HealthResponseBody,
|
|
265
|
+
status.status === "ok" ? 200 : 503,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (pathname === "/metrics") {
|
|
270
|
+
if (request.method !== "GET") {
|
|
271
|
+
return plainResponse("Method Not Allowed", 405, "GET");
|
|
272
|
+
}
|
|
273
|
+
return jsonResponse(metrics, 200);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (pathname === "/state") {
|
|
277
|
+
if (request.method !== "GET") {
|
|
278
|
+
return plainResponse("Method Not Allowed", 405, "GET");
|
|
279
|
+
}
|
|
280
|
+
return jsonResponse(engine.getState(), 200);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (pathname === "/commands") {
|
|
284
|
+
if (request.method !== "POST") {
|
|
285
|
+
return plainResponse("Method Not Allowed", 405, "POST");
|
|
286
|
+
}
|
|
287
|
+
const commandOrError = await parseCommandRequest(request);
|
|
288
|
+
if (commandOrError instanceof Response) {
|
|
289
|
+
return commandOrError;
|
|
290
|
+
}
|
|
291
|
+
return jsonResponse(engine.execute(commandOrError), 200);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return plainResponse("Not Found", 404);
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
endpoint: `http://${host}:${server.port}`,
|
|
300
|
+
stop() {
|
|
301
|
+
server.stop(true);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export { startAdminServer };
|
|
307
|
+
export type { AdminEngine, AdminServerHandle, AdminServerOptions };
|