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
package/src/lib/shutdown.ts
CHANGED
|
@@ -1,171 +1,551 @@
|
|
|
1
1
|
import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
import type { AdapterHealthSnapshot } from "@miravo/shared";
|
|
3
|
+
import type { TreeItem } from "consola/utils";
|
|
4
|
+
import { box, colors, formatTree, stripAnsi } from "consola/utils";
|
|
5
|
+
import { engineLog, isJsonLogFormat, isPlain, isSilent, mqttLog, opcuaLog } from "../logger.ts";
|
|
6
|
+
import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
|
|
7
|
+
|
|
8
|
+
const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
|
|
9
|
+
const FINAL_OUTPUT_GRACE_MS = 50;
|
|
10
|
+
|
|
11
|
+
type ShutdownTaskStage = "cleanup" | "finalize";
|
|
12
|
+
type ShutdownPhase = "booting" | "running" | "shutting_down" | "forced_exit" | "exited";
|
|
13
|
+
|
|
14
|
+
interface ShutdownTask {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly run: () => void | Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parameters for the shutdown controller. Supports dependency injection for tests. */
|
|
20
|
+
export interface ShutdownControllerOptions {
|
|
10
21
|
/** The RNG seed used for this run (printed in summary for reproducibility). */
|
|
11
22
|
readonly seed: number;
|
|
12
23
|
/** Whether the seed was explicitly configured (vs. randomly generated). */
|
|
13
24
|
readonly seedExplicit: boolean;
|
|
14
|
-
/** Optional cleanup callback for timers, intervals, etc. */
|
|
15
|
-
readonly cleanup?: () => void;
|
|
16
25
|
/** Optional aggregated engine-error counts by context. */
|
|
17
26
|
readonly errorContextCounts?: ReadonlyMap<string, number>;
|
|
18
27
|
/** Process exit function. Defaults to process.exit. Inject for testing. */
|
|
19
28
|
readonly exit?: (code: number) => void;
|
|
29
|
+
/** Optional writer override for the final shutdown summary render. */
|
|
30
|
+
readonly writeFinalOutput?: (text: string) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ShutdownController {
|
|
34
|
+
readonly completion: Promise<void>;
|
|
35
|
+
addTask(stage: ShutdownTaskStage, name: string, run: () => void | Promise<void>): void;
|
|
36
|
+
isShuttingDown(): boolean;
|
|
37
|
+
markRunning(): void;
|
|
38
|
+
requestShutdown(reason: string): void;
|
|
39
|
+
runShutdown(): Promise<void>;
|
|
40
|
+
setEngine(engine: Engine): void;
|
|
41
|
+
setErrorContextCounts(counts: ReadonlyMap<string, number>): void;
|
|
42
|
+
teardown(): void;
|
|
43
|
+
waitForShutdownRequest(): Promise<string>;
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* - First signal: stop engine, drain adapters, print summary, exit 0.
|
|
26
|
-
* - Second signal during shutdown: force exit immediately with exit 1.
|
|
47
|
+
* Create a single-owner shutdown controller for `miravo start`.
|
|
27
48
|
*
|
|
28
|
-
*
|
|
49
|
+
* Signals are installed immediately. The controller owns the full shutdown
|
|
50
|
+
* lifecycle: pre-stop cleanup, engine stop, post-stop cleanup, final summary,
|
|
51
|
+
* and process exit. Callers should await `completion` to keep the command alive
|
|
52
|
+
* until shutdown fully finishes.
|
|
29
53
|
*/
|
|
30
|
-
export function
|
|
31
|
-
const { engine, seed, seedExplicit, cleanup, errorContextCounts } = options;
|
|
54
|
+
export function createShutdownController(options: ShutdownControllerOptions): ShutdownController {
|
|
32
55
|
const exit = options.exit ?? ((code: number) => process.exit(code));
|
|
56
|
+
const writeFinalOutput = options.writeFinalOutput ?? writeShutdownOutput;
|
|
33
57
|
|
|
34
|
-
let
|
|
35
|
-
let
|
|
58
|
+
let phase: ShutdownPhase = "booting";
|
|
59
|
+
let forcedExitRequested = false;
|
|
60
|
+
let engine: Engine | null = null;
|
|
61
|
+
let errorContextCounts = options.errorContextCounts;
|
|
62
|
+
let requestedReason: string | null = null;
|
|
63
|
+
let shutdownPromise: Promise<void> | null = null;
|
|
36
64
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
engineLog.warn("Force stopping. Goodbye.");
|
|
41
|
-
exit(1);
|
|
65
|
+
const tasks: Record<ShutdownTaskStage, ShutdownTask[]> = {
|
|
66
|
+
cleanup: [],
|
|
67
|
+
finalize: [],
|
|
42
68
|
};
|
|
43
69
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
engineLog.start("Stopping simulation...");
|
|
70
|
+
let resolveCompletion = () => {};
|
|
71
|
+
const completion = new Promise<void>((resolve) => {
|
|
72
|
+
resolveCompletion = resolve;
|
|
73
|
+
});
|
|
74
|
+
let resolveRequest = (_reason: string) => {};
|
|
75
|
+
const request = new Promise<string>((resolve) => {
|
|
76
|
+
resolveRequest = resolve;
|
|
77
|
+
});
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
const keepAlive = setInterval(() => {}, 60_000);
|
|
80
|
+
const forceSignalHandler = (): void => {
|
|
81
|
+
forceExit();
|
|
82
|
+
};
|
|
56
83
|
|
|
57
|
-
|
|
84
|
+
const forceExit = (): void => {
|
|
85
|
+
if (phase === "forced_exit" || phase === "exited") return;
|
|
86
|
+
forcedExitRequested = true;
|
|
87
|
+
phase = "forced_exit";
|
|
88
|
+
clearInterval(keepAlive);
|
|
89
|
+
engineLog.warn("Force stopping. Goodbye.");
|
|
90
|
+
resolveCompletion();
|
|
91
|
+
exit(1);
|
|
92
|
+
};
|
|
58
93
|
|
|
59
|
-
|
|
60
|
-
let
|
|
94
|
+
const runWithTimeout = async (name: string, run: () => void | Promise<void>): Promise<void> => {
|
|
95
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
61
96
|
try {
|
|
62
|
-
mqttLog.start("Flushing pending messages (5s timeout)...");
|
|
63
|
-
const stopResult = engine.stop(signal === "SIGINT" ? "user interrupt" : signal);
|
|
64
|
-
// Race engine.stop() against a 5s timeout so shutdown never hangs
|
|
65
97
|
await Promise.race([
|
|
66
|
-
|
|
98
|
+
Promise.resolve(run()),
|
|
67
99
|
new Promise<void>((resolve) => {
|
|
68
|
-
|
|
69
|
-
|
|
100
|
+
timer = setTimeout(() => {
|
|
101
|
+
engineLog.warn(`${name} timed out after ${SHUTDOWN_TASK_TIMEOUT_MS / 1000}s.`);
|
|
70
102
|
resolve();
|
|
71
|
-
},
|
|
103
|
+
}, SHUTDOWN_TASK_TIMEOUT_MS);
|
|
72
104
|
}),
|
|
73
105
|
]);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
} else {
|
|
77
|
-
mqttLog.success("Stopped.");
|
|
78
|
-
}
|
|
79
|
-
} catch (err) {
|
|
80
|
-
engineLog.error("Error during shutdown:", err);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
engineLog.error(`Error during ${name}:`, error);
|
|
81
108
|
} finally {
|
|
82
|
-
if (
|
|
83
|
-
clearTimeout(
|
|
109
|
+
if (timer !== null) {
|
|
110
|
+
clearTimeout(timer);
|
|
84
111
|
}
|
|
85
112
|
}
|
|
113
|
+
};
|
|
86
114
|
|
|
87
|
-
|
|
88
|
-
|
|
115
|
+
const runStage = async (stage: ShutdownTaskStage): Promise<void> => {
|
|
116
|
+
const stageTasks = tasks[stage];
|
|
117
|
+
let index = 0;
|
|
118
|
+
while (index < stageTasks.length) {
|
|
119
|
+
const task = stageTasks[index];
|
|
120
|
+
if (task !== undefined) {
|
|
121
|
+
await runWithTimeout(task.name, task.run);
|
|
122
|
+
}
|
|
123
|
+
index++;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const shutdown = async (reason: string): Promise<void> => {
|
|
128
|
+
if (phase === "forced_exit" || phase === "exited") return;
|
|
129
|
+
phase = "shutting_down";
|
|
130
|
+
engineLog.info("Stopping simulation...");
|
|
131
|
+
|
|
132
|
+
const finalMetrics = engine?.getMetrics() ?? null;
|
|
133
|
+
|
|
134
|
+
await runStage("cleanup");
|
|
135
|
+
|
|
136
|
+
const engineToStop = engine;
|
|
137
|
+
if (engineToStop) {
|
|
138
|
+
await runWithTimeout("engine stop", () => engineToStop.stop(reason));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!forcedExitRequested && finalMetrics !== null) {
|
|
142
|
+
await printShutdownSummary(
|
|
143
|
+
finalMetrics,
|
|
144
|
+
options.seed,
|
|
145
|
+
options.seedExplicit,
|
|
146
|
+
errorContextCounts,
|
|
147
|
+
writeFinalOutput,
|
|
148
|
+
).catch((error) => {
|
|
149
|
+
engineLog.error("Error rendering shutdown summary:", error);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await runStage("finalize");
|
|
154
|
+
|
|
155
|
+
const forced = forcedExitRequested;
|
|
156
|
+
clearInterval(keepAlive);
|
|
157
|
+
phase = "exited";
|
|
158
|
+
resolveCompletion();
|
|
159
|
+
|
|
160
|
+
if (!forced) {
|
|
161
|
+
exit(0);
|
|
89
162
|
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const armForceExitHandlers = (): void => {
|
|
166
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
167
|
+
process.removeListener("SIGTERM", sigtermHandler);
|
|
168
|
+
process.once("SIGINT", forceSignalHandler);
|
|
169
|
+
process.once("SIGTERM", forceSignalHandler);
|
|
170
|
+
};
|
|
90
171
|
|
|
91
|
-
|
|
92
|
-
|
|
172
|
+
const requestShutdown = (reason: string): void => {
|
|
173
|
+
if (phase === "forced_exit" || phase === "exited") return;
|
|
174
|
+
if (requestedReason !== null) {
|
|
175
|
+
forceExit();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
requestedReason = reason;
|
|
179
|
+
resolveRequest(reason);
|
|
180
|
+
armForceExitHandlers();
|
|
93
181
|
};
|
|
94
182
|
|
|
95
183
|
const sigintHandler = (): void => {
|
|
96
|
-
|
|
184
|
+
requestShutdown("user interrupt");
|
|
97
185
|
};
|
|
98
186
|
|
|
99
187
|
const sigtermHandler = (): void => {
|
|
100
|
-
|
|
188
|
+
requestShutdown("SIGTERM");
|
|
101
189
|
};
|
|
102
190
|
|
|
103
|
-
process.
|
|
104
|
-
process.
|
|
191
|
+
process.once("SIGINT", sigintHandler);
|
|
192
|
+
process.once("SIGTERM", sigtermHandler);
|
|
105
193
|
|
|
106
|
-
return
|
|
107
|
-
|
|
108
|
-
|
|
194
|
+
return {
|
|
195
|
+
completion,
|
|
196
|
+
addTask(stage, name, run) {
|
|
197
|
+
tasks[stage].push({ name, run });
|
|
198
|
+
},
|
|
199
|
+
isShuttingDown() {
|
|
200
|
+
return requestedReason !== null || phase === "shutting_down" || phase === "forced_exit";
|
|
201
|
+
},
|
|
202
|
+
markRunning() {
|
|
203
|
+
if (phase === "booting") {
|
|
204
|
+
phase = "running";
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
requestShutdown,
|
|
208
|
+
runShutdown() {
|
|
209
|
+
if (shutdownPromise) {
|
|
210
|
+
return shutdownPromise;
|
|
211
|
+
}
|
|
212
|
+
if (requestedReason === null) {
|
|
213
|
+
throw new Error("Shutdown was not requested before runShutdown().");
|
|
214
|
+
}
|
|
215
|
+
shutdownPromise = shutdown(requestedReason);
|
|
216
|
+
return shutdownPromise;
|
|
217
|
+
},
|
|
218
|
+
setEngine(nextEngine) {
|
|
219
|
+
engine = nextEngine;
|
|
220
|
+
},
|
|
221
|
+
setErrorContextCounts(counts) {
|
|
222
|
+
errorContextCounts = counts;
|
|
223
|
+
},
|
|
224
|
+
teardown() {
|
|
225
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
226
|
+
process.removeListener("SIGTERM", sigtermHandler);
|
|
227
|
+
process.removeListener("SIGINT", forceSignalHandler);
|
|
228
|
+
process.removeListener("SIGTERM", forceSignalHandler);
|
|
229
|
+
clearInterval(keepAlive);
|
|
230
|
+
},
|
|
231
|
+
waitForShutdownRequest() {
|
|
232
|
+
return request;
|
|
233
|
+
},
|
|
109
234
|
};
|
|
110
235
|
}
|
|
111
236
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Shared helpers
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
const sep = colors.dim(" \u00B7 ");
|
|
242
|
+
|
|
243
|
+
interface SummaryInput {
|
|
244
|
+
readonly metrics: EngineMetricsSnapshot;
|
|
245
|
+
readonly seed?: number | undefined;
|
|
246
|
+
readonly seedExplicit?: boolean | undefined;
|
|
247
|
+
readonly errorContextCounts?: ReadonlyMap<string, number> | undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
|
|
251
|
+
switch (state) {
|
|
252
|
+
case "healthy":
|
|
253
|
+
return colors.green(state);
|
|
254
|
+
case "busy":
|
|
255
|
+
return colors.yellow(state);
|
|
256
|
+
case "saturated":
|
|
257
|
+
return colors.red(state);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function adapterLogger(name: string) {
|
|
262
|
+
if (name === "mqtt") return mqttLog;
|
|
263
|
+
if (name === "opcua") return opcuaLog;
|
|
264
|
+
return engineLog;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function adapterLabel(name: string): string {
|
|
268
|
+
if (name === "opcua") return "OPC UA";
|
|
269
|
+
return name.toUpperCase();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function formatAdapterLine(
|
|
273
|
+
name: string,
|
|
274
|
+
m: EngineMetricsSnapshot["adapters"][number]["metrics"],
|
|
275
|
+
uptimeMs: number,
|
|
276
|
+
): string {
|
|
277
|
+
const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
|
|
278
|
+
const errStr =
|
|
279
|
+
m.errorsCount === 0
|
|
280
|
+
? colors.green(formatCount(m.errorsCount))
|
|
281
|
+
: colors.yellow(formatCount(m.errorsCount));
|
|
282
|
+
const avgThroughput = computeAverageThroughput(m.messagesPublished, uptimeMs);
|
|
283
|
+
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
284
|
+
const verb = name === "opcua" ? "updates" : "published";
|
|
285
|
+
const health = m.health ? `${sep}${healthStateLabel(m.health.state)}` : "";
|
|
286
|
+
return `${connStr}${sep}${formatCount(m.messagesPublished)} ${verb}${sep}${errStr} errors${throughputStr}${health}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Periodic summary (single-line per component)
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Print a compact periodic summary: one line for core stats, one per adapter.
|
|
295
|
+
*/
|
|
296
|
+
export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
|
|
118
297
|
if (isSilent()) return;
|
|
119
298
|
|
|
299
|
+
if (isJsonLogFormat()) {
|
|
300
|
+
engineLog.info("Periodic summary", buildJsonSummary({ metrics }));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
120
304
|
const { core, fleet, adapters } = metrics;
|
|
121
|
-
const mqttAdapter = adapters.find((a) => a.name === "mqtt");
|
|
122
305
|
|
|
123
|
-
|
|
306
|
+
// Core stats
|
|
307
|
+
const parts = [
|
|
308
|
+
formatDuration(core.uptimeMs),
|
|
309
|
+
`${formatCount(core.tickCount)} ticks`,
|
|
310
|
+
`${core.actualTicksPerSecond.toFixed(1)} ticks/s`,
|
|
311
|
+
`${formatCount(core.instanceCount)} instances`,
|
|
312
|
+
`${formatCount(core.memberValueCount)} data points`,
|
|
313
|
+
];
|
|
314
|
+
if (fleet.activeFaultCount > 0) {
|
|
315
|
+
parts.push(`${colors.yellow(String(fleet.activeFaultCount))} faults`);
|
|
316
|
+
}
|
|
317
|
+
engineLog.info(parts.join(sep));
|
|
318
|
+
|
|
319
|
+
// Per-adapter line
|
|
320
|
+
for (const adapter of adapters) {
|
|
321
|
+
adapterLogger(adapter.name).info(
|
|
322
|
+
formatAdapterLine(adapter.name, adapter.metrics, core.uptimeMs),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Shutdown summary (box)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function buildSummaryLines(input: SummaryInput): string[] {
|
|
332
|
+
const { core, fleet, adapters } = input.metrics;
|
|
124
333
|
const lines: string[] = [];
|
|
125
334
|
|
|
335
|
+
// Core stats
|
|
126
336
|
lines.push(
|
|
127
|
-
`${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(
|
|
337
|
+
`${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(String(core.actualTicksPerSecond.toFixed(1)))} ticks/s`,
|
|
338
|
+
);
|
|
339
|
+
lines.push(
|
|
340
|
+
`${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.memberValueCount))} data points${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s`,
|
|
128
341
|
);
|
|
129
342
|
|
|
130
|
-
|
|
131
|
-
|
|
343
|
+
// Adapters
|
|
344
|
+
for (const adapter of adapters) {
|
|
345
|
+
const m = adapter.metrics;
|
|
346
|
+
const label = adapterLabel(adapter.name);
|
|
347
|
+
const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
|
|
132
348
|
const errStr =
|
|
133
|
-
|
|
349
|
+
m.errorsCount === 0
|
|
350
|
+
? colors.green(formatCount(m.errorsCount))
|
|
351
|
+
: colors.yellow(formatCount(m.errorsCount));
|
|
352
|
+
const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
|
|
353
|
+
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
354
|
+
const verb = adapter.name === "opcua" ? "updates" : "published";
|
|
134
355
|
lines.push(
|
|
135
|
-
|
|
356
|
+
`${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}`,
|
|
136
357
|
);
|
|
358
|
+
if (m.health) {
|
|
359
|
+
const pendingPrefix =
|
|
360
|
+
m.health.pendingWorkCount > 0
|
|
361
|
+
? `${formatCount(m.health.pendingWorkCount)} pending${sep}`
|
|
362
|
+
: "";
|
|
363
|
+
lines.push(` ${pendingPrefix}${healthStateLabel(m.health.state)}`);
|
|
364
|
+
}
|
|
137
365
|
}
|
|
138
366
|
|
|
367
|
+
// Fleet health warnings
|
|
368
|
+
const healthParts: string[] = [];
|
|
139
369
|
if (fleet.activeFaultCount > 0) {
|
|
140
|
-
|
|
370
|
+
healthParts.push(`${colors.yellow(String(fleet.activeFaultCount))} active faults`);
|
|
371
|
+
}
|
|
372
|
+
if (core.quarantinedInstanceCount > 0) {
|
|
373
|
+
healthParts.push(`${colors.yellow(String(core.quarantinedInstanceCount))} quarantined`);
|
|
374
|
+
}
|
|
375
|
+
if (core.engineErrorsCount > 0) {
|
|
376
|
+
healthParts.push(`${colors.yellow(formatCount(core.engineErrorsCount))} engine errors`);
|
|
377
|
+
}
|
|
378
|
+
if (core.tickOverrunsCount > 0) {
|
|
379
|
+
healthParts.push(`${colors.yellow(formatCount(core.tickOverrunsCount))} overruns`);
|
|
380
|
+
}
|
|
381
|
+
if (core.circuitBreakerTripsCount > 0) {
|
|
382
|
+
healthParts.push(`${colors.yellow(formatCount(core.circuitBreakerTripsCount))} breaker trips`);
|
|
383
|
+
}
|
|
384
|
+
if (healthParts.length > 0) {
|
|
385
|
+
lines.push(healthParts.join(sep));
|
|
141
386
|
}
|
|
142
387
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
388
|
+
// Fault breakdown
|
|
389
|
+
if (fleet.faultBreakdown.length > 0) {
|
|
390
|
+
const faultItems: TreeItem[] = fleet.faultBreakdown.map(
|
|
391
|
+
(f) => `${colors.yellow(String(f.count))} ${f.fault}`,
|
|
392
|
+
);
|
|
393
|
+
lines.push(formatTree(faultItems, { color: "yellow", prefix: " " }).trimEnd());
|
|
394
|
+
}
|
|
147
395
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
396
|
+
// Models & lifecycle
|
|
397
|
+
const treeItems: TreeItem[] = [];
|
|
398
|
+
if (fleet.modelBreakdown.length > 0) {
|
|
399
|
+
treeItems.push({
|
|
400
|
+
text: `${colors.cyan("Models")} ${colors.dim(`(${fleet.modelBreakdown.length})`)}`,
|
|
401
|
+
children: fleet.modelBreakdown.map((m) => `${colors.bold(String(m.count))} ${m.model}`),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const lifecycleEntries = Object.entries(fleet.lifecycleBreakdown).filter(
|
|
405
|
+
([, count]) => count > 0,
|
|
406
|
+
);
|
|
407
|
+
if (lifecycleEntries.length > 0) {
|
|
408
|
+
treeItems.push({
|
|
409
|
+
text: colors.cyan("Lifecycle"),
|
|
410
|
+
children: lifecycleEntries.map(([stage, count]) => `${colors.bold(String(count))} ${stage}`),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (treeItems.length > 0) {
|
|
414
|
+
lines.push(formatTree(treeItems, { color: "gray", prefix: " " }).trimEnd());
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Seed
|
|
418
|
+
if (input.seed !== undefined) {
|
|
419
|
+
const seedLine = input.seedExplicit
|
|
420
|
+
? `Seed: ${colors.bold(String(input.seed))}`
|
|
421
|
+
: `Seed: ${colors.bold(String(input.seed))} ${colors.gray("(add seed to config to reproduce)")}`;
|
|
422
|
+
lines.push(seedLine);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Top error contexts
|
|
426
|
+
if (input.errorContextCounts && input.errorContextCounts.size > 0) {
|
|
427
|
+
const topContexts = [...input.errorContextCounts.entries()]
|
|
428
|
+
.sort((a, b) => b[1] - a[1])
|
|
429
|
+
.slice(0, 5);
|
|
430
|
+
const errorItems: TreeItem[] = topContexts.map(
|
|
431
|
+
([context, count]) => `${context} ${colors.dim("\u00B7")} ${colors.bold(formatCount(count))}`,
|
|
432
|
+
);
|
|
433
|
+
lines.push(colors.cyan("Top errors"));
|
|
434
|
+
lines.push(formatTree(errorItems, { color: "red", prefix: " " }).trimEnd());
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return lines;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function buildJsonSummary(input: SummaryInput): Record<string, unknown> {
|
|
441
|
+
const { core, fleet, adapters } = input.metrics;
|
|
442
|
+
|
|
443
|
+
const adapterSummaries: Record<string, unknown>[] = [];
|
|
444
|
+
for (const adapter of adapters) {
|
|
445
|
+
const m = adapter.metrics;
|
|
446
|
+
const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
|
|
447
|
+
adapterSummaries.push({
|
|
448
|
+
name: adapter.name,
|
|
449
|
+
messagesPublished: m.messagesPublished,
|
|
450
|
+
errorsCount: m.errorsCount,
|
|
451
|
+
connected: m.connected,
|
|
452
|
+
...(avgThroughput !== null ? { avgMsgPerSecond: avgThroughput } : {}),
|
|
453
|
+
...(m.health
|
|
454
|
+
? { health: { state: m.health.state, pendingWorkCount: m.health.pendingWorkCount } }
|
|
455
|
+
: {}),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const summary: Record<string, unknown> = {
|
|
460
|
+
uptimeMs: core.uptimeMs,
|
|
461
|
+
tickCount: core.tickCount,
|
|
462
|
+
ticksPerSecond: Number(core.actualTicksPerSecond.toFixed(1)),
|
|
463
|
+
simulationTimeSeconds: Number(core.simulationTimeSeconds.toFixed(1)),
|
|
464
|
+
instanceCount: core.instanceCount,
|
|
465
|
+
memberValueCount: core.memberValueCount,
|
|
466
|
+
engineErrorsCount: core.engineErrorsCount,
|
|
467
|
+
activeFaultCount: fleet.activeFaultCount,
|
|
468
|
+
quarantinedInstanceCount: core.quarantinedInstanceCount,
|
|
469
|
+
tickOverrunsCount: core.tickOverrunsCount,
|
|
470
|
+
circuitBreakerTripsCount: core.circuitBreakerTripsCount,
|
|
471
|
+
lifecycleBreakdown: fleet.lifecycleBreakdown,
|
|
472
|
+
adapters: adapterSummaries,
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
if (input.seed !== undefined) {
|
|
476
|
+
summary["seed"] = input.seed;
|
|
477
|
+
summary["seedExplicit"] = input.seedExplicit ?? false;
|
|
154
478
|
}
|
|
155
479
|
|
|
480
|
+
if (input.errorContextCounts && input.errorContextCounts.size > 0) {
|
|
481
|
+
const topContexts = [...input.errorContextCounts.entries()]
|
|
482
|
+
.sort((a, b) => b[1] - a[1])
|
|
483
|
+
.slice(0, 5);
|
|
484
|
+
summary["topErrorContexts"] = Object.fromEntries(topContexts);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return summary;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function renderSummaryText(title: string, input: SummaryInput): string {
|
|
491
|
+
const lines = buildSummaryLines(input);
|
|
492
|
+
|
|
156
493
|
if (isPlain()) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
494
|
+
return [title, ...lines.map((line) => ` ${stripAnsi(line)}`), ""].join("\n");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return box(lines.join("\n"), {
|
|
498
|
+
title: ` ${title} `,
|
|
499
|
+
style: { borderColor: "gray", borderStyle: "rounded" },
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function printShutdownSummary(
|
|
504
|
+
metrics: EngineMetricsSnapshot,
|
|
505
|
+
seed: number,
|
|
506
|
+
seedExplicit: boolean,
|
|
507
|
+
errorContextCounts?: ReadonlyMap<string, number>,
|
|
508
|
+
writeFinal: (text: string) => Promise<void> = writeShutdownOutput,
|
|
509
|
+
): Promise<void> {
|
|
510
|
+
if (isSilent()) return;
|
|
511
|
+
|
|
512
|
+
if (isJsonLogFormat()) {
|
|
513
|
+
engineLog.info(
|
|
514
|
+
"Shutdown summary",
|
|
515
|
+
buildJsonSummary({ metrics, seed, seedExplicit, errorContextCounts }),
|
|
516
|
+
);
|
|
517
|
+
await flushOutputStreams();
|
|
162
518
|
return;
|
|
163
519
|
}
|
|
164
520
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
521
|
+
await writeFinal(
|
|
522
|
+
renderSummaryText("Shutdown Summary", {
|
|
523
|
+
metrics,
|
|
524
|
+
seed,
|
|
525
|
+
seedExplicit,
|
|
526
|
+
errorContextCounts,
|
|
169
527
|
}),
|
|
170
528
|
);
|
|
171
529
|
}
|
|
530
|
+
|
|
531
|
+
async function writeShutdownOutput(text: string): Promise<void> {
|
|
532
|
+
await writeToStream(process.stdout, text.endsWith("\n") ? text : `${text}\n`);
|
|
533
|
+
await flushOutputStreams();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function writeToStream(stream: NodeJS.WriteStream, text: string): Promise<void> {
|
|
537
|
+
await new Promise<void>((resolve, reject) => {
|
|
538
|
+
stream.write(text, (error?: Error | null) => {
|
|
539
|
+
if (error) {
|
|
540
|
+
reject(error);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
resolve();
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function flushOutputStreams(): Promise<void> {
|
|
549
|
+
await Promise.all([writeToStream(process.stdout, ""), writeToStream(process.stderr, "")]);
|
|
550
|
+
await new Promise<void>((resolve) => setTimeout(resolve, FINAL_OUTPUT_GRACE_MS));
|
|
551
|
+
}
|