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.
@@ -1,7 +1,9 @@
1
1
  import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
2
- import { box, colors } from "consola/utils";
3
- import { engineLog, isPlain, isSilent, mqttLog, writeOutput } from "../logger.ts";
4
- import { formatCount, formatDuration } from "../output.ts";
2
+ import type { AdapterHealthSnapshot } from "@miravo/shared";
3
+ import type { TreeItem } from "consola/utils";
4
+ import { box, colors, formatTree } from "consola/utils";
5
+ import { engineLog, isJsonLogFormat, isPlain, isSilent, writeOutput } from "../logger.ts";
6
+ import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
5
7
 
6
8
  /** Parameters for the shutdown handler. Supports dependency injection for tests. */
7
9
  export interface ShutdownOptions {
@@ -54,14 +56,13 @@ export function installShutdownHandlers(options: ShutdownOptions): () => void {
54
56
  // Capture metrics before stop() clears bus listeners
55
57
  const finalMetrics = engine.getMetrics();
56
58
 
57
- cleanup?.();
58
-
59
59
  let timedOut = false;
60
60
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
61
61
  try {
62
- mqttLog.start("Flushing pending messages (5s timeout)...");
62
+ engineLog.start("Flushing pending messages (5s timeout)...");
63
63
  const stopResult = engine.stop(signal === "SIGINT" ? "user interrupt" : signal);
64
- // Race engine.stop() against a 5s timeout so shutdown never hangs
64
+ // Suppress unhandled rejection if stop() rejects after timeout wins the race
65
+ stopResult.catch(() => {});
65
66
  await Promise.race([
66
67
  stopResult,
67
68
  new Promise<void>((resolve) => {
@@ -74,7 +75,7 @@ export function installShutdownHandlers(options: ShutdownOptions): () => void {
74
75
  if (timedOut) {
75
76
  engineLog.warn("Engine stop timed out after 5s, proceeding with exit.");
76
77
  } else {
77
- mqttLog.success("Stopped.");
78
+ engineLog.success("Stopped.");
78
79
  }
79
80
  } catch (err) {
80
81
  engineLog.error("Error during shutdown:", err);
@@ -84,11 +85,16 @@ export function installShutdownHandlers(options: ShutdownOptions): () => void {
84
85
  }
85
86
  }
86
87
 
88
+ // Clean up timers, admin server, and bus listeners AFTER engine.stop()
89
+ // so that adapter shutdown errors are still observable on the bus.
90
+ cleanup?.();
91
+
87
92
  if (forcedExit) {
88
93
  return;
89
94
  }
90
95
 
91
96
  printShutdownSummary(finalMetrics, seed, seedExplicit, errorContextCounts);
97
+ Bun.stdout.writer().flush();
92
98
  exit(0);
93
99
  };
94
100
 
@@ -109,52 +115,212 @@ export function installShutdownHandlers(options: ShutdownOptions): () => void {
109
115
  };
110
116
  }
111
117
 
112
- function printShutdownSummary(
113
- metrics: EngineMetricsSnapshot,
114
- seed: number,
115
- seedExplicit: boolean,
116
- errorContextCounts?: ReadonlyMap<string, number>,
117
- ): void {
118
- if (isSilent()) return;
118
+ // ---------------------------------------------------------------------------
119
+ // Shared summary rendering
120
+ // ---------------------------------------------------------------------------
121
+
122
+ interface SummaryInput {
123
+ readonly metrics: EngineMetricsSnapshot;
124
+ readonly seed?: number | undefined;
125
+ readonly seedExplicit?: boolean | undefined;
126
+ readonly errorContextCounts?: ReadonlyMap<string, number> | undefined;
127
+ }
119
128
 
120
- const { core, fleet, adapters } = metrics;
121
- const mqttAdapter = adapters.find((a) => a.name === "mqtt");
129
+ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
130
+ switch (state) {
131
+ case "healthy":
132
+ return colors.green(state);
133
+ case "busy":
134
+ return colors.yellow(state);
135
+ case "saturated":
136
+ return colors.red(state);
137
+ }
138
+ }
122
139
 
140
+ /**
141
+ * Build the pretty summary lines from an engine metrics snapshot.
142
+ * Used by both the shutdown summary and periodic summary.
143
+ */
144
+ function buildSummaryLines(input: SummaryInput): string[] {
145
+ const { core, fleet, adapters } = input.metrics;
123
146
  const sep = colors.dim(" \u00B7 ");
124
147
  const lines: string[] = [];
125
148
 
149
+ // --- Core stats ---
126
150
  lines.push(
127
- `${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics`,
151
+ `${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(String(core.actualTicksPerSecond.toFixed(1)))} ticks/s`,
152
+ );
153
+ lines.push(
154
+ `${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.memberValueCount))} data points${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s`,
128
155
  );
129
156
 
130
- if (mqttAdapter) {
131
- const errCount = mqttAdapter.metrics.errorsCount;
157
+ // --- Protocol adapters ---
158
+ for (const adapter of adapters) {
159
+ const m = adapter.metrics;
160
+ const label = adapter.name === "opcua" ? "OPC UA" : adapter.name.toUpperCase();
161
+ const verb = adapter.name === "opcua" ? "updates" : "published";
162
+ const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
132
163
  const errStr =
133
- errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
164
+ m.errorsCount === 0
165
+ ? colors.green(formatCount(m.errorsCount))
166
+ : colors.yellow(formatCount(m.errorsCount));
167
+ const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
168
+ const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
169
+ const filteredStr =
170
+ m.messagesFiltered > 0 ? `${sep}${colors.dim(formatCount(m.messagesFiltered))} filtered` : "";
134
171
  lines.push(
135
- `MQTT: ${colors.bold(formatCount(mqttAdapter.metrics.messagesPublished))} published${sep}${errStr} errors`,
172
+ `${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}${filteredStr}`,
136
173
  );
174
+ // Health on a separate line to keep the box narrow
175
+ const health = m.health;
176
+ if (health) {
177
+ const pendingStr =
178
+ health.pendingWorkCount > 0 ? `${formatCount(health.pendingWorkCount)} pending${sep}` : "";
179
+ lines.push(` ${pendingStr}${healthStateLabel(health.state)}`);
180
+ }
137
181
  }
138
182
 
183
+ // --- Fleet health ---
184
+ const healthParts: string[] = [];
139
185
  if (fleet.activeFaultCount > 0) {
140
- lines.push(`Active faults at shutdown: ${colors.yellow(String(fleet.activeFaultCount))}`);
186
+ healthParts.push(`${colors.yellow(String(fleet.activeFaultCount))} active faults`);
187
+ }
188
+ if (core.quarantinedInstanceCount > 0) {
189
+ healthParts.push(`${colors.yellow(String(core.quarantinedInstanceCount))} quarantined`);
190
+ }
191
+ if (core.engineErrorsCount > 0) {
192
+ healthParts.push(`${colors.yellow(formatCount(core.engineErrorsCount))} engine errors`);
193
+ }
194
+ if (core.tickOverrunsCount > 0) {
195
+ healthParts.push(`${colors.yellow(formatCount(core.tickOverrunsCount))} overruns`);
196
+ }
197
+ if (core.circuitBreakerTripsCount > 0) {
198
+ healthParts.push(`${colors.yellow(formatCount(core.circuitBreakerTripsCount))} breaker trips`);
199
+ }
200
+ if (healthParts.length > 0) {
201
+ lines.push(healthParts.join(sep));
141
202
  }
142
203
 
143
- const seedLine = seedExplicit
144
- ? `Seed: ${colors.bold(String(seed))}`
145
- : `Seed: ${colors.bold(String(seed))} ${colors.gray("(add seed to config to reproduce)")}`;
146
- lines.push(seedLine);
204
+ // --- Fault breakdown (tree) ---
205
+ if (fleet.faultBreakdown.length > 0) {
206
+ const faultItems: TreeItem[] = fleet.faultBreakdown.map(
207
+ (f) => `${colors.yellow(String(f.count))} ${f.fault}`,
208
+ );
209
+ lines.push(formatTree(faultItems, { color: "yellow", prefix: " " }).trimEnd());
210
+ }
147
211
 
148
- if (errorContextCounts && errorContextCounts.size > 0) {
149
- const topContexts = [...errorContextCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
150
- lines.push("Top error contexts:");
151
- for (const [context, count] of topContexts) {
152
- lines.push(` ${context} ${colors.dim("·")} ${colors.bold(formatCount(count))}`);
153
- }
212
+ // --- Model & lifecycle (tree) ---
213
+ const treeItems: TreeItem[] = [];
214
+ if (fleet.modelBreakdown.length > 0) {
215
+ treeItems.push({
216
+ text: `${colors.cyan("Models")} ${colors.dim(`(${fleet.modelBreakdown.length})`)}`,
217
+ children: fleet.modelBreakdown.map((m) => `${colors.bold(String(m.count))} ${m.model}`),
218
+ });
219
+ }
220
+ const lifecycleEntries = Object.entries(fleet.lifecycleBreakdown).filter(
221
+ ([, count]) => count > 0,
222
+ );
223
+ if (lifecycleEntries.length > 0) {
224
+ treeItems.push({
225
+ text: colors.cyan("Lifecycle"),
226
+ children: lifecycleEntries.map(([stage, count]) => `${colors.bold(String(count))} ${stage}`),
227
+ });
228
+ }
229
+ if (treeItems.length > 0) {
230
+ lines.push(formatTree(treeItems, { color: "gray", prefix: " " }).trimEnd());
231
+ }
232
+
233
+ // --- Seed ---
234
+ if (input.seed !== undefined) {
235
+ const seedLine = input.seedExplicit
236
+ ? `Seed: ${colors.bold(String(input.seed))}`
237
+ : `Seed: ${colors.bold(String(input.seed))} ${colors.gray("(add seed to config to reproduce)")}`;
238
+ lines.push(seedLine);
239
+ }
240
+
241
+ // --- Top error contexts (tree) ---
242
+ if (input.errorContextCounts && input.errorContextCounts.size > 0) {
243
+ const topContexts = [...input.errorContextCounts.entries()]
244
+ .sort((a, b) => b[1] - a[1])
245
+ .slice(0, 5);
246
+ const errorItems: TreeItem[] = topContexts.map(
247
+ ([context, count]) => `${context} ${colors.dim("\u00B7")} ${colors.bold(formatCount(count))}`,
248
+ );
249
+ lines.push(`${colors.cyan("Top errors")}`);
250
+ lines.push(formatTree(errorItems, { color: "red", prefix: " " }).trimEnd());
251
+ }
252
+
253
+ return lines;
254
+ }
255
+
256
+ /**
257
+ * Build a structured summary object for JSON log output.
258
+ */
259
+ function buildJsonSummary(input: SummaryInput): Record<string, unknown> {
260
+ const { core, fleet, adapters } = input.metrics;
261
+
262
+ const adapterSummaries: Record<string, unknown>[] = [];
263
+ for (const adapter of adapters) {
264
+ const m = adapter.metrics;
265
+ const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
266
+ adapterSummaries.push({
267
+ name: adapter.name,
268
+ messagesPublished: m.messagesPublished,
269
+ errorsCount: m.errorsCount,
270
+ connected: m.connected,
271
+ ...(avgThroughput !== null ? { avgMsgPerSecond: avgThroughput } : {}),
272
+ ...(m.health
273
+ ? { health: { state: m.health.state, pendingWorkCount: m.health.pendingWorkCount } }
274
+ : {}),
275
+ });
276
+ }
277
+
278
+ const summary: Record<string, unknown> = {
279
+ uptimeMs: core.uptimeMs,
280
+ tickCount: core.tickCount,
281
+ ticksPerSecond: Number(core.actualTicksPerSecond.toFixed(1)),
282
+ simulationTimeSeconds: Number(core.simulationTimeSeconds.toFixed(1)),
283
+ instanceCount: core.instanceCount,
284
+ memberValueCount: core.memberValueCount,
285
+ engineErrorsCount: core.engineErrorsCount,
286
+ activeFaultCount: fleet.activeFaultCount,
287
+ quarantinedInstanceCount: core.quarantinedInstanceCount,
288
+ tickOverrunsCount: core.tickOverrunsCount,
289
+ circuitBreakerTripsCount: core.circuitBreakerTripsCount,
290
+ lifecycleBreakdown: fleet.lifecycleBreakdown,
291
+ adapters: adapterSummaries,
292
+ };
293
+
294
+ if (input.seed !== undefined) {
295
+ summary["seed"] = input.seed;
296
+ summary["seedExplicit"] = input.seedExplicit ?? false;
297
+ }
298
+
299
+ if (input.errorContextCounts && input.errorContextCounts.size > 0) {
300
+ const topContexts = [...input.errorContextCounts.entries()]
301
+ .sort((a, b) => b[1] - a[1])
302
+ .slice(0, 5);
303
+ summary["topErrorContexts"] = Object.fromEntries(topContexts);
304
+ }
305
+
306
+ return summary;
307
+ }
308
+
309
+ /**
310
+ * Render a summary box (or plain/JSON equivalent) with a given title.
311
+ * Shared renderer for shutdown and periodic summaries.
312
+ */
313
+ export function printSummaryBox(title: string, input: SummaryInput, logLabel: string): void {
314
+ if (isSilent()) return;
315
+
316
+ if (isJsonLogFormat()) {
317
+ engineLog.info(logLabel, buildJsonSummary(input));
154
318
  }
155
319
 
320
+ const lines = buildSummaryLines(input);
321
+
156
322
  if (isPlain()) {
157
- writeOutput("Shutdown Summary");
323
+ writeOutput(title);
158
324
  for (const line of lines) {
159
325
  writeOutput(` ${line}`);
160
326
  }
@@ -164,8 +330,25 @@ function printShutdownSummary(
164
330
 
165
331
  writeOutput(
166
332
  box(lines.join("\n"), {
167
- title: " Shutdown Summary ",
333
+ title: ` ${title} `,
168
334
  style: { borderColor: "gray", borderStyle: "rounded" },
169
335
  }),
170
336
  );
171
337
  }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Shutdown-specific
341
+ // ---------------------------------------------------------------------------
342
+
343
+ function printShutdownSummary(
344
+ metrics: EngineMetricsSnapshot,
345
+ seed: number,
346
+ seedExplicit: boolean,
347
+ errorContextCounts?: ReadonlyMap<string, number>,
348
+ ): void {
349
+ printSummaryBox(
350
+ "Shutdown Summary",
351
+ { metrics, seed, seedExplicit, errorContextCounts },
352
+ "Shutdown summary",
353
+ );
354
+ }
@@ -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.green,
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