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.
@@ -21,7 +21,10 @@ function discoveredTemplate(id: string, name: string): DiscoveredTemplate {
21
21
 
22
22
  describe("template category helpers", () => {
23
23
  test("categorizeTemplate maps stress templates to Stress & Benchmark", () => {
24
- expect(categorizeTemplate(discoveredTemplate("stress-10k-uns", "Stress 10K"))).toBe(
24
+ expect(categorizeTemplate(discoveredTemplate("stress-5k", "Stress 5K"))).toBe(
25
+ "Stress & Benchmark",
26
+ );
27
+ expect(categorizeTemplate(discoveredTemplate("stress-10k", "Stress 10K"))).toBe(
25
28
  "Stress & Benchmark",
26
29
  );
27
30
  expect(categorizeTemplate(discoveredTemplate("smart-factory", "Smart Factory"))).toBe(
@@ -31,24 +34,28 @@ describe("template category helpers", () => {
31
34
 
32
35
  test("sortTemplatesByCategoryAndName sorts by category then name", () => {
33
36
  const templates = [
34
- discoveredTemplate("stress-100k-uns", "Stress 100K"),
37
+ discoveredTemplate("stress-100k", "Stress 100K"),
35
38
  discoveredTemplate("water-treatment", "Water Treatment"),
36
39
  discoveredTemplate("smart-factory", "Smart Factory"),
37
- discoveredTemplate("stress-10k-uns", "Stress 10K"),
40
+ discoveredTemplate("stress-10k", "Stress 10K"),
41
+ discoveredTemplate("stress-5k", "Stress 5K"),
42
+ discoveredTemplate("stress-50k", "Stress 50K"),
38
43
  ];
39
44
 
40
45
  const sortedIds = sortTemplatesByCategoryAndName(templates).map((template) => template.id);
41
46
  expect(sortedIds).toEqual([
42
47
  "smart-factory",
43
48
  "water-treatment",
44
- "stress-100k-uns",
45
- "stress-10k-uns",
49
+ "stress-100k",
50
+ "stress-10k",
51
+ "stress-50k",
52
+ "stress-5k",
46
53
  ]);
47
54
  });
48
55
 
49
56
  test("groupTemplatesByCategory returns non-empty groups in category order", () => {
50
57
  const templates = sortTemplatesByCategoryAndName([
51
- discoveredTemplate("stress-50k-uns", "Stress 50K"),
58
+ discoveredTemplate("stress-50k", "Stress 50K"),
52
59
  discoveredTemplate("water-treatment", "Water Treatment"),
53
60
  ]);
54
61
 
@@ -58,6 +65,6 @@ describe("template category helpers", () => {
58
65
  "Stress & Benchmark",
59
66
  ]);
60
67
  expect(grouped[0]?.templates.map((template) => template.id)).toEqual(["water-treatment"]);
61
- expect(grouped[1]?.templates.map((template) => template.id)).toEqual(["stress-50k-uns"]);
68
+ expect(grouped[1]?.templates.map((template) => template.id)).toEqual(["stress-50k"]);
62
69
  });
63
70
  });
@@ -4,8 +4,13 @@ export type TemplateCategory = "Industrial Scenarios" | "Stress & Benchmark";
4
4
 
5
5
  const CATEGORY_ORDER: readonly TemplateCategory[] = ["Industrial Scenarios", "Stress & Benchmark"];
6
6
 
7
+ /** True if the template is a stress/benchmark template (excluded from built-in list in CLI, same as in standalone binaries). */
8
+ export function isStressBenchmarkTemplate(id: string): boolean {
9
+ return id.startsWith("stress-");
10
+ }
11
+
7
12
  export function categorizeTemplate(template: Pick<DiscoveredTemplate, "id">): TemplateCategory {
8
- if (template.id.startsWith("stress-")) {
13
+ if (isStressBenchmarkTemplate(template.id)) {
9
14
  return "Stress & Benchmark";
10
15
  }
11
16
  return "Industrial Scenarios";
@@ -1,16 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import { ContentCatalog, type DiscoveredTemplateWithSource } from "@miravo/core";
3
- import { MIRAVO_CONTENT_DIR_ENV, MiravoError, TEMPLATE_FILE_EXTENSION } from "@miravo/shared";
3
+ import { MiravoError, TEMPLATE_FILE_EXTENSION } from "@miravo/shared";
4
4
 
5
5
  /**
6
- * Create a ContentCatalog with standard env-var fallback for content-dir.
6
+ * Create a ContentCatalog with default settings.
7
7
  * Shared across all CLI commands to ensure consistent resolution.
8
8
  */
9
- export function createCatalog(contentDir: string | undefined): ContentCatalog {
10
- const resolved = contentDir ?? Bun.env[MIRAVO_CONTENT_DIR_ENV];
11
- return new ContentCatalog({
12
- ...(resolved && resolved.trim().length > 0 ? { contentDir: resolved } : {}),
13
- });
9
+ export function createCatalog(): ContentCatalog {
10
+ return new ContentCatalog();
14
11
  }
15
12
 
16
13
  export function isTemplatePathLike(idNameOrPath: string): boolean {
@@ -23,7 +20,7 @@ export function isTemplatePathLike(idNameOrPath: string): boolean {
23
20
  }
24
21
 
25
22
  /**
26
- * Return local (non-builtin) templates from the catalog: content-dir + cwd.
23
+ * Return local (non-builtin) templates from the catalog: cwd + local registry.
27
24
  */
28
25
  export async function discoverPreferredLocalTemplates(
29
26
  catalog: ContentCatalog,
@@ -0,0 +1,14 @@
1
+ import type { MiravoTemplate } from "@miravo/shared";
2
+
3
+ /** Collect all unique model names referenced by a template's twin assignments. */
4
+ export function collectReferencedModels(template: MiravoTemplate): Set<string> {
5
+ const models = new Set<string>();
6
+ for (const area of template.areas) {
7
+ for (const line of area.lines) {
8
+ for (const twin of line.twins) {
9
+ models.add(twin.model);
10
+ }
11
+ }
12
+ }
13
+ return models;
14
+ }
@@ -43,13 +43,13 @@ async function captureConsoleLogs<T>(
43
43
  describe("configureLogger", () => {
44
44
  test("--silent trumps --verbose", () => {
45
45
  configureLogger({ verbose: true, silent: true });
46
- expect(logger.level).toBe(1);
46
+ expect(logger.level).toBe(0);
47
47
  expect(isSilent()).toBe(true);
48
48
  });
49
49
 
50
50
  test("--silent sets output suppressed", () => {
51
51
  configureLogger({ silent: true });
52
- expect(logger.level).toBe(1);
52
+ expect(logger.level).toBe(0);
53
53
  expect(isSilent()).toBe(true);
54
54
  });
55
55
 
package/src/logger.ts CHANGED
@@ -4,20 +4,42 @@ import { createConsola } from "consola";
4
4
  import { colors, stripAnsi } from "consola/utils";
5
5
 
6
6
  export type LogFormat = "pretty" | "json";
7
+ export type LogLevel = "debug" | "info" | "warn" | "error";
8
+
9
+ const LOG_LEVEL_MAP: Readonly<Record<LogLevel, number>> = {
10
+ debug: 4,
11
+ info: 3,
12
+ warn: 2,
13
+ error: 0,
14
+ };
7
15
 
8
16
  /**
9
- * Color map for log tags. Consola wraps tags in `colors.gray()`, but inner
10
- * ANSI codes take precedence, so pre-coloring the tag overrides the gray.
17
+ * Color map for log tags. Used in pretty mode to prepend a colored label.
11
18
  */
12
19
  const TAG_COLORS: Readonly<Record<string, (s: string) => string>> = {
13
20
  engine: colors.cyan,
14
21
  mqtt: colors.green,
22
+ opcua: colors.blue,
15
23
  config: colors.yellow,
16
24
  lifecycle: colors.magenta,
17
- fault: colors.red,
25
+ fault: colors.yellowBright,
18
26
  control: colors.blue,
19
27
  };
20
28
 
29
+ /**
30
+ * Display names for log tags. Tags are internal identifiers; these are
31
+ * the user-facing labels shown at the start of each log line.
32
+ */
33
+ const TAG_DISPLAY_NAMES: Readonly<Record<string, string>> = {
34
+ engine: "Engine",
35
+ mqtt: "MQTT",
36
+ opcua: "OPC UA",
37
+ config: "Config",
38
+ lifecycle: "Lifecycle",
39
+ fault: "Fault",
40
+ control: "Control",
41
+ };
42
+
21
43
  /**
22
44
  * Root consola logger for the Miravo CLI.
23
45
  * `fancy: true` enables icons and color when a TTY is available.
@@ -43,14 +65,21 @@ interface ReporterLogObject {
43
65
  const fancyReporters = [...logger.options.reporters];
44
66
  const prettyReporter = {
45
67
  log(logObj: ReporterLogObject, ctx: unknown) {
46
- if (logObj.args.length > 0 && typeof logObj.args[0] === "string") {
47
- logObj.args[0] = ` ${logObj.args[0]}`;
48
- }
68
+ // Move tag from the right side to a colored prefix at the start of the message
49
69
  if (logObj.tag) {
50
- const colorFn = TAG_COLORS[logObj.tag];
51
- if (colorFn) {
52
- logObj.tag = colorFn(logObj.tag);
70
+ const rawTag = logObj.tag;
71
+ const displayName = TAG_DISPLAY_NAMES[rawTag] ?? rawTag;
72
+ const colorFn = TAG_COLORS[rawTag] ?? colors.gray;
73
+ const prefix = `${colorFn(displayName)}${colors.dim(":")}`;
74
+ if (logObj.args.length > 0 && typeof logObj.args[0] === "string") {
75
+ logObj.args[0] = ` ${prefix} ${logObj.args[0]}`;
76
+ } else {
77
+ logObj.args.unshift(` ${prefix}`);
53
78
  }
79
+ // Clear the tag so consola doesn't also render it on the right
80
+ logObj.tag = "";
81
+ } else if (logObj.args.length > 0 && typeof logObj.args[0] === "string") {
82
+ logObj.args[0] = ` ${logObj.args[0]}`;
54
83
  }
55
84
  for (const reporter of fancyReporters) {
56
85
  reporter.log(logObj as never, ctx as never);
@@ -81,7 +110,8 @@ function levelToString(level: number): "trace" | "debug" | "info" | "warn" | "er
81
110
  if (level === 4) return "debug";
82
111
  if (level === 3) return "info";
83
112
  if (level === 2) return "warn";
84
- if (level === 1) return "error";
113
+ if (level === 1) return "warn";
114
+ if (level === 0) return "error";
85
115
  return "fatal";
86
116
  }
87
117
 
@@ -137,6 +167,9 @@ export const lifecycleLog: ConsolaInstance = logger.withTag("lifecycle");
137
167
  /** Fault trigger and clear events. */
138
168
  export const faultLog: ConsolaInstance = logger.withTag("fault");
139
169
 
170
+ /** OPC UA adapter connection and server events. */
171
+ export const opcuaLog: ConsolaInstance = logger.withTag("opcua");
172
+
140
173
  /** Control plane status and command routing events. */
141
174
  export const controlLog: ConsolaInstance = logger.withTag("control");
142
175
 
@@ -145,6 +178,7 @@ const allLoggers: ConsolaInstance[] = [
145
178
  logger,
146
179
  engineLog,
147
180
  mqttLog,
181
+ opcuaLog,
148
182
  configLog,
149
183
  lifecycleLog,
150
184
  faultLog,
@@ -164,12 +198,14 @@ export interface LoggerOptions {
164
198
  readonly silent?: boolean | undefined;
165
199
  readonly plain?: boolean | undefined;
166
200
  readonly logFormat?: LogFormat | undefined;
201
+ readonly logLevel?: LogLevel | undefined;
167
202
  }
168
203
 
169
204
  let _outputSuppressed = false;
170
205
  let _plainOutput = false;
171
206
  let _locked = false;
172
207
  let _logFormat: LogFormat = "pretty";
208
+ let _currentLevel = LOG_LEVEL_MAP.info;
173
209
  const runtimeLogContext: { simulationName: string | undefined; runId: string | undefined } = {
174
210
  simulationName: undefined,
175
211
  runId: undefined,
@@ -183,6 +219,14 @@ export function parseLogFormat(raw: string): LogFormat {
183
219
  );
184
220
  }
185
221
 
222
+ export function parseLogLevel(raw: string): LogLevel {
223
+ if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") return raw;
224
+ throw new MiravoError(
225
+ `Invalid log level "${raw}".`,
226
+ "Use --log-level debug | info | warn | error.",
227
+ );
228
+ }
229
+
186
230
  export function setRuntimeLogContext(context: { simulationName?: string; runId?: string }): void {
187
231
  runtimeLogContext.simulationName = context.simulationName;
188
232
  runtimeLogContext.runId = context.runId;
@@ -195,9 +239,9 @@ export function clearRuntimeLogContext(): void {
195
239
 
196
240
  /**
197
241
  * Set the global log level based on CLI flags.
198
- * --silent trumps --verbose when both are provided.
242
+ * Priority: --silent > --log-level > --verbose > default (info).
199
243
  *
200
- * Levels: 1 = errors/fatals only, 3 = info (default), 4 = debug.
244
+ * Consola levels: 0 = errors/fatals, 2 = warn, 3 = info, 4 = debug.
201
245
  */
202
246
  export function configureLogger(opts: LoggerOptions): void {
203
247
  if (_locked) return;
@@ -207,15 +251,19 @@ export function configureLogger(opts: LoggerOptions): void {
207
251
 
208
252
  let level: number;
209
253
  if (opts.silent) {
210
- level = 1;
254
+ level = 0;
211
255
  _outputSuppressed = true;
256
+ } else if (opts.logLevel) {
257
+ level = LOG_LEVEL_MAP[opts.logLevel];
258
+ _outputSuppressed = false;
212
259
  } else if (opts.verbose) {
213
- level = 4;
260
+ level = LOG_LEVEL_MAP.debug;
214
261
  _outputSuppressed = false;
215
262
  } else {
216
- level = 3;
263
+ level = LOG_LEVEL_MAP.info;
217
264
  _outputSuppressed = false;
218
265
  }
266
+ _currentLevel = level;
219
267
  for (const l of allLoggers) {
220
268
  l.level = level;
221
269
  }
@@ -280,6 +328,11 @@ export function isPlain(): boolean {
280
328
  return _plainOutput;
281
329
  }
282
330
 
331
+ /** Returns true when log level is debug (--verbose or --log-level debug). */
332
+ export function isDebugLevel(): boolean {
333
+ return _currentLevel >= LOG_LEVEL_MAP.debug;
334
+ }
335
+
283
336
  /**
284
337
  * Handle a caught error in a CLI command action. If it's a `MiravoError`,
285
338
  * log the message and fix string and set a non-zero exit code. Otherwise
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 { stripAnsi } from "consola/utils";
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
- });
@@ -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 };