miravo 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -30
- package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
- package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
- package/console-dist/assets/index-BXtW73cF.css +2 -0
- package/console-dist/assets/index-ChSocZFW.js +119 -0
- package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
- package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/console-dist/favicon.svg +20 -0
- package/console-dist/index.html +14 -0
- package/console-dist/miravo-logo.png +0 -0
- package/package.json +10 -7
- package/src/__tests__/integration/executable-methods-runner.ts +52 -45
- package/src/__tests__/integration/executable-methods.integration.ts +609 -47
- package/src/commands/adapter.ts +76 -0
- package/src/commands/cli-test-setup.ts +1 -1
- package/src/commands/fork.ts +14 -0
- package/src/commands/inject.test.ts +1 -1
- package/src/commands/inject.ts +16 -8
- package/src/commands/load.ts +167 -0
- package/src/commands/logs.ts +175 -0
- package/src/commands/models.test.ts +22 -1
- package/src/commands/models.ts +14 -0
- package/src/commands/pause.ts +67 -0
- package/src/commands/ps.ts +116 -0
- package/src/commands/remove.ts +38 -0
- package/src/commands/reset.ts +44 -0
- package/src/commands/rm.ts +97 -0
- package/src/commands/spawn.ts +45 -0
- package/src/commands/speed.ts +40 -0
- package/src/commands/start.test.ts +93 -0
- package/src/commands/start.ts +976 -192
- package/src/commands/status.test.ts +3 -1
- package/src/commands/status.ts +12 -6
- package/src/commands/stop.test.ts +84 -0
- package/src/commands/stop.ts +158 -0
- package/src/commands/templates.test.ts +1 -1
- package/src/commands/templates.ts +13 -0
- package/src/index.ts +49 -7
- package/src/lib/admin-client.test.ts +35 -0
- package/src/lib/admin-client.ts +13 -1
- package/src/lib/admin-server.test.ts +339 -53
- package/src/lib/admin-server.ts +413 -62
- package/src/lib/daemon-registry.test.ts +429 -0
- package/src/lib/daemon-registry.ts +345 -0
- package/src/lib/mqtt-client.ts +1 -1
- package/src/lib/parse.ts +18 -0
- package/src/lib/resolve-simulation.ts +1 -1
- package/src/lib/shutdown.test.ts +16 -4
- package/src/lib/shutdown.ts +3 -3
- package/src/lib/template-category.test.ts +1 -0
- package/src/lib/template-utils.ts +33 -2
- package/src/lib/ws-hub.ts +255 -0
- package/src/logger.test.ts +19 -19
- package/src/logger.ts +34 -6
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon registry — tracks background simulation processes.
|
|
3
|
+
*
|
|
4
|
+
* Each background simulation gets a JSON entry at `~/.miravo/daemon/<name>.json`.
|
|
5
|
+
* Management commands (ps, stop, logs, rm) use this registry to discover and
|
|
6
|
+
* interact with running simulations via their admin HTTP server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir, open, readdir, rename, unlink } from "node:fs/promises";
|
|
10
|
+
import { resolveMiravoHome } from "@miravo/core";
|
|
11
|
+
import { MiravoError, SimulationNameSchema } from "@miravo/shared";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Schema (CLI-only — not in packages/shared/)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const DaemonAdapterEntrySchema = z.object({
|
|
19
|
+
port: z.number().int().positive(),
|
|
20
|
+
host: z.string().min(1),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const DaemonRegistryEntrySchema = z.object({
|
|
24
|
+
name: z.string().min(1),
|
|
25
|
+
pid: z.number().int().positive(),
|
|
26
|
+
adminPort: z.number().int().positive(),
|
|
27
|
+
adminHost: z.string().min(1),
|
|
28
|
+
adapters: z.record(z.string(), DaemonAdapterEntrySchema),
|
|
29
|
+
templatePath: z.string().min(1),
|
|
30
|
+
startedAt: z.string().min(1),
|
|
31
|
+
seed: z.number().finite(),
|
|
32
|
+
runId: z.string().min(1),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type DaemonAdapterEntry = z.infer<typeof DaemonAdapterEntrySchema>;
|
|
36
|
+
export type DaemonRegistryEntry = z.infer<typeof DaemonRegistryEntrySchema>;
|
|
37
|
+
|
|
38
|
+
export interface EntryIdentityResult {
|
|
39
|
+
readonly status: "dead" | "verified" | "mismatch" | "unreachable";
|
|
40
|
+
readonly ready: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Directory resolution
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** Returns the path to the daemon registry directory (`~/.miravo/daemon/`). */
|
|
48
|
+
export function daemonDir(): string {
|
|
49
|
+
return `${resolveMiravoHome()}/daemon`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate a simulation name to prevent path traversal.
|
|
54
|
+
* Reuses the shared SimulationNameSchema (lowercase alphanumeric + hyphens).
|
|
55
|
+
*/
|
|
56
|
+
function validateName(name: string): void {
|
|
57
|
+
const result = SimulationNameSchema.safeParse(name);
|
|
58
|
+
if (!result.success) {
|
|
59
|
+
throw new MiravoError(
|
|
60
|
+
`Invalid simulation name: "${name}".`,
|
|
61
|
+
"Use lowercase letters, numbers, and hyphens only (e.g., my-sim-1).",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function entryPath(name: string): string {
|
|
67
|
+
validateName(name);
|
|
68
|
+
return `${daemonDir()}/${name}.json`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// CRUD operations
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** Write a registry entry atomically (temp file + rename). */
|
|
76
|
+
export async function writeEntry(entry: DaemonRegistryEntry): Promise<void> {
|
|
77
|
+
const dir = daemonDir();
|
|
78
|
+
await mkdir(dir, { recursive: true });
|
|
79
|
+
const filePath = entryPath(entry.name);
|
|
80
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
81
|
+
await Bun.write(tmpPath, JSON.stringify(entry, null, 2));
|
|
82
|
+
await rename(tmpPath, filePath);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Acquire an exclusive lock for a simulation name.
|
|
87
|
+
* Prevents concurrent `start -d` from spawning duplicate daemons.
|
|
88
|
+
* Returns a release function; call it when done (even on error).
|
|
89
|
+
*
|
|
90
|
+
* Stale locks from crashed processes are detected via PID liveness and
|
|
91
|
+
* cleaned up automatically before retrying.
|
|
92
|
+
*
|
|
93
|
+
* @throws MiravoError if the name is already locked by a live process.
|
|
94
|
+
*/
|
|
95
|
+
export async function acquireNameLock(name: string): Promise<() => Promise<void>> {
|
|
96
|
+
validateName(name);
|
|
97
|
+
const dir = daemonDir();
|
|
98
|
+
await mkdir(dir, { recursive: true });
|
|
99
|
+
const lockPath = `${dir}/${name}.lock`;
|
|
100
|
+
|
|
101
|
+
const tryAcquire = async (): Promise<void> => {
|
|
102
|
+
try {
|
|
103
|
+
// O_CREAT | O_EXCL — fails if file already exists
|
|
104
|
+
const handle = await open(lockPath, "wx");
|
|
105
|
+
await handle.writeFile(String(process.pid));
|
|
106
|
+
await handle.close();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
109
|
+
// Check if the lock belongs to a live process or is stale (crashed).
|
|
110
|
+
try {
|
|
111
|
+
const content = await Bun.file(lockPath).text();
|
|
112
|
+
const pid = parseInt(content.trim(), 10);
|
|
113
|
+
if (!Number.isNaN(pid) && !isPidAlive(pid)) {
|
|
114
|
+
// Stale lock from a crashed process — remove and retry.
|
|
115
|
+
await unlink(lockPath);
|
|
116
|
+
return tryAcquire();
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Lock file disappeared between EEXIST and reading — retry.
|
|
120
|
+
return tryAcquire();
|
|
121
|
+
}
|
|
122
|
+
throw new MiravoError(
|
|
123
|
+
`Another 'miravo start -d' is already starting simulation "${name}".`,
|
|
124
|
+
"Wait for the other start to complete, or remove the lock with 'miravo rm'.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await tryAcquire();
|
|
132
|
+
|
|
133
|
+
return async () => {
|
|
134
|
+
try {
|
|
135
|
+
await unlink(lockPath);
|
|
136
|
+
} catch {
|
|
137
|
+
// Ignore — lock may have been cleaned up already
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Read and validate a registry entry. Returns null if missing or invalid. */
|
|
143
|
+
export async function readEntry(name: string): Promise<DaemonRegistryEntry | null> {
|
|
144
|
+
const filePath = entryPath(name);
|
|
145
|
+
const file = Bun.file(filePath);
|
|
146
|
+
if (!(await file.exists())) return null;
|
|
147
|
+
try {
|
|
148
|
+
const content = await file.text();
|
|
149
|
+
const parsed: unknown = JSON.parse(content);
|
|
150
|
+
const result = DaemonRegistryEntrySchema.safeParse(parsed);
|
|
151
|
+
return result.success ? result.data : null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Remove a registry entry. Safe if file does not exist. */
|
|
158
|
+
export async function removeEntry(name: string): Promise<void> {
|
|
159
|
+
try {
|
|
160
|
+
await unlink(entryPath(name));
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** List all valid registry entries. Invalid files are silently skipped. */
|
|
167
|
+
export async function listEntries(): Promise<readonly DaemonRegistryEntry[]> {
|
|
168
|
+
const dir = daemonDir();
|
|
169
|
+
let files: string[];
|
|
170
|
+
try {
|
|
171
|
+
files = await readdir(dir);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const entries: DaemonRegistryEntry[] = [];
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
if (!file.endsWith(".json") || file.endsWith(".tmp")) continue;
|
|
180
|
+
const name = file.slice(0, -5); // strip .json
|
|
181
|
+
const entry = await readEntry(name);
|
|
182
|
+
if (entry) entries.push(entry);
|
|
183
|
+
}
|
|
184
|
+
return entries;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Liveness checks
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Check if a PID is alive using signal 0. */
|
|
192
|
+
export function isPidAlive(pid: number): boolean {
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, 0);
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Verify that a registry entry corresponds to a live Miravo simulation.
|
|
203
|
+
*
|
|
204
|
+
* Two-step check:
|
|
205
|
+
* 1. PID alive (fast, avoids network I/O for dead processes)
|
|
206
|
+
* 2. RunId matches via GET /readyz (guards against PID reuse after reboot)
|
|
207
|
+
*/
|
|
208
|
+
export async function probeEntryIdentity(entry: DaemonRegistryEntry): Promise<EntryIdentityResult> {
|
|
209
|
+
if (!isPidAlive(entry.pid)) {
|
|
210
|
+
return { status: "dead", ready: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const url = `http://${entry.adminHost}:${entry.adminPort}/readyz`;
|
|
215
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
|
216
|
+
const bodyText = await response.text();
|
|
217
|
+
let body: unknown;
|
|
218
|
+
try {
|
|
219
|
+
body = JSON.parse(bodyText) as unknown;
|
|
220
|
+
} catch {
|
|
221
|
+
return { status: "mismatch", ready: false };
|
|
222
|
+
}
|
|
223
|
+
if (
|
|
224
|
+
typeof body !== "object" ||
|
|
225
|
+
body === null ||
|
|
226
|
+
!("runId" in body) ||
|
|
227
|
+
typeof body["runId"] !== "string"
|
|
228
|
+
) {
|
|
229
|
+
return { status: "mismatch", ready: false };
|
|
230
|
+
}
|
|
231
|
+
if (body["runId"] !== entry.runId) {
|
|
232
|
+
return { status: "mismatch", ready: false };
|
|
233
|
+
}
|
|
234
|
+
return { status: "verified", ready: response.ok };
|
|
235
|
+
} catch {
|
|
236
|
+
// Keep the registry entry when the admin server is temporarily unreachable,
|
|
237
|
+
// but do not treat the identity as safe for raw signal delivery.
|
|
238
|
+
return { status: "unreachable", ready: false };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function isEntryAlive(entry: DaemonRegistryEntry): Promise<boolean> {
|
|
243
|
+
const identity = await probeEntryIdentity(entry);
|
|
244
|
+
return identity.status === "verified" || identity.status === "unreachable";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function canSafelySignalEntry(entry: DaemonRegistryEntry): Promise<boolean> {
|
|
248
|
+
const identity = await probeEntryIdentity(entry);
|
|
249
|
+
return identity.status === "verified";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Remove all registry entries whose processes are no longer alive.
|
|
254
|
+
* Uses full identity verification (PID + runId) to handle PID reuse.
|
|
255
|
+
*/
|
|
256
|
+
export async function sweepStaleEntries(): Promise<void> {
|
|
257
|
+
const entries = await listEntries();
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
const identity = await probeEntryIdentity(entry);
|
|
260
|
+
if (identity.status === "dead" || identity.status === "mismatch") {
|
|
261
|
+
await removeEntry(entry.name);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Conflict detection
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Check for port conflicts against live registry entries.
|
|
272
|
+
*
|
|
273
|
+
* @param ports - Map of label → port to check (e.g., "admin" → 8080, "mqtt" → 1883)
|
|
274
|
+
* @param excludeName - Optional entry name to exclude (for re-checking own entry)
|
|
275
|
+
* @returns Array of human-readable conflict messages (empty = no conflicts)
|
|
276
|
+
*/
|
|
277
|
+
export async function checkPortConflicts(
|
|
278
|
+
ports: ReadonlyMap<string, number>,
|
|
279
|
+
excludeName?: string,
|
|
280
|
+
): Promise<readonly string[]> {
|
|
281
|
+
const entries = await listEntries();
|
|
282
|
+
const conflicts: string[] = [];
|
|
283
|
+
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
if (entry.name === excludeName) continue;
|
|
286
|
+
// Use PID check only (not full identity verification) for performance —
|
|
287
|
+
// port conflicts are about actual port usage, not Miravo identity.
|
|
288
|
+
if (!isPidAlive(entry.pid)) continue;
|
|
289
|
+
|
|
290
|
+
// Check admin port
|
|
291
|
+
for (const [label, port] of ports) {
|
|
292
|
+
if (port === entry.adminPort) {
|
|
293
|
+
conflicts.push(
|
|
294
|
+
`Port ${port} (${label}) is already in use by simulation "${entry.name}". Stop it first: miravo stop ${entry.name}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
// Check adapter ports
|
|
298
|
+
for (const [adapterName, adapter] of Object.entries(entry.adapters) as [
|
|
299
|
+
string,
|
|
300
|
+
DaemonAdapterEntry,
|
|
301
|
+
][]) {
|
|
302
|
+
if (port === adapter.port) {
|
|
303
|
+
conflicts.push(
|
|
304
|
+
`Port ${port} (${label}) conflicts with ${adapterName} port of simulation "${entry.name}". Stop it first: miravo stop ${entry.name}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return conflicts;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Check if a simulation name is already in use by a live entry.
|
|
316
|
+
* Removes stale entries as a side effect.
|
|
317
|
+
*
|
|
318
|
+
* @returns The alive entry if the name is taken, null otherwise.
|
|
319
|
+
*/
|
|
320
|
+
export async function checkNameConflict(name: string): Promise<DaemonRegistryEntry | null> {
|
|
321
|
+
const entry = await readEntry(name);
|
|
322
|
+
if (!entry) return null;
|
|
323
|
+
|
|
324
|
+
const identity = await probeEntryIdentity(entry);
|
|
325
|
+
if (identity.status === "verified" || identity.status === "unreachable") return entry;
|
|
326
|
+
|
|
327
|
+
if (identity.status === "dead" || identity.status === "mismatch") {
|
|
328
|
+
// Stale entry — clean it up
|
|
329
|
+
await removeEntry(name);
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Display helpers
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Map bind addresses to connectable addresses for display.
|
|
340
|
+
* `0.0.0.0` and `::` are replaced with `127.0.0.1`.
|
|
341
|
+
*/
|
|
342
|
+
export function displayHost(host: string): string {
|
|
343
|
+
if (host === "0.0.0.0" || host === "::") return "127.0.0.1";
|
|
344
|
+
return host;
|
|
345
|
+
}
|
package/src/lib/mqtt-client.ts
CHANGED
|
@@ -112,5 +112,5 @@ function disconnectClient(client: MqttClient): void {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
export { connectToBroker, connectWithTimeout, disconnectClient, resolveBrokerInfo };
|
|
116
115
|
export type { BrokerConnectionInfo };
|
|
116
|
+
export { connectToBroker, connectWithTimeout, disconnectClient, resolveBrokerInfo };
|
package/src/lib/parse.ts
CHANGED
|
@@ -67,3 +67,21 @@ export function parseDurationMs(value: string): number {
|
|
|
67
67
|
}
|
|
68
68
|
return n;
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/** Maximum allowed timeout in seconds (1 hour). */
|
|
72
|
+
const MAX_TIMEOUT_SECONDS = 3_600;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a string as a timeout in seconds and return milliseconds.
|
|
76
|
+
* Accepts positive numbers (integers or decimals). The result is rounded to
|
|
77
|
+
* the nearest millisecond.
|
|
78
|
+
*/
|
|
79
|
+
export function parseTimeoutSeconds(value: string): number {
|
|
80
|
+
const n = parsePositiveFloat(value);
|
|
81
|
+
if (n > MAX_TIMEOUT_SECONDS) {
|
|
82
|
+
throw new InvalidArgumentError(
|
|
83
|
+
`Expected a timeout in seconds (max ${MAX_TIMEOUT_SECONDS}), got "${value}".`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return Math.round(n * 1_000);
|
|
87
|
+
}
|
|
@@ -177,10 +177,10 @@ async function resolveTargetSimulation(
|
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
export { discoverRunningSimulations, resolveTargetSimulation };
|
|
181
180
|
export type {
|
|
182
181
|
ResolvedTargetSimulation,
|
|
183
182
|
ResolveTargetSimulationOptions,
|
|
184
183
|
SimulationResolverClient,
|
|
185
184
|
SimulationTopics,
|
|
186
185
|
};
|
|
186
|
+
export { discoverRunningSimulations, resolveTargetSimulation };
|
package/src/lib/shutdown.test.ts
CHANGED
|
@@ -21,11 +21,23 @@ const noopReporter = { log() {} };
|
|
|
21
21
|
function configureSummaryLogger(
|
|
22
22
|
options: { plain?: boolean; logFormat?: "pretty" | "json" } = {},
|
|
23
23
|
): void {
|
|
24
|
+
const previousNoColor = Bun.env["NO_COLOR"];
|
|
25
|
+
if (!(options.plain ?? false)) {
|
|
26
|
+
delete Bun.env["NO_COLOR"];
|
|
27
|
+
}
|
|
24
28
|
unlockLogger();
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
try {
|
|
30
|
+
configureLogger({
|
|
31
|
+
plain: options.plain ?? false,
|
|
32
|
+
logFormat: options.logFormat ?? "pretty",
|
|
33
|
+
});
|
|
34
|
+
} finally {
|
|
35
|
+
if (previousNoColor === undefined) {
|
|
36
|
+
delete Bun.env["NO_COLOR"];
|
|
37
|
+
} else {
|
|
38
|
+
Bun.env["NO_COLOR"] = previousNoColor;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
29
41
|
for (const l of [
|
|
30
42
|
logger,
|
|
31
43
|
engineLog,
|
package/src/lib/shutdown.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
|
|
|
2
2
|
import type { AdapterHealthSnapshot } from "@miravo/shared";
|
|
3
3
|
import type { TreeItem } from "consola/utils";
|
|
4
4
|
import { box, colors, formatTree, stripAnsi } from "consola/utils";
|
|
5
|
-
import { engineLog, isJsonLogFormat, isPlain,
|
|
5
|
+
import { engineLog, isJsonLogFormat, isPlain, isQuiet, mqttLog, opcuaLog } from "../logger.ts";
|
|
6
6
|
import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
|
|
7
7
|
|
|
8
8
|
const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
|
|
@@ -294,7 +294,7 @@ function formatAdapterLine(
|
|
|
294
294
|
* Print a compact periodic summary: one line for core stats, one per adapter.
|
|
295
295
|
*/
|
|
296
296
|
export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
|
|
297
|
-
if (
|
|
297
|
+
if (isQuiet()) return;
|
|
298
298
|
|
|
299
299
|
if (isJsonLogFormat()) {
|
|
300
300
|
engineLog.info("Periodic summary", buildJsonSummary({ metrics }));
|
|
@@ -507,7 +507,7 @@ async function printShutdownSummary(
|
|
|
507
507
|
errorContextCounts?: ReadonlyMap<string, number>,
|
|
508
508
|
writeFinal: (text: string) => Promise<void> = writeShutdownOutput,
|
|
509
509
|
): Promise<void> {
|
|
510
|
-
if (
|
|
510
|
+
if (isQuiet()) return;
|
|
511
511
|
|
|
512
512
|
if (isJsonLogFormat()) {
|
|
513
513
|
engineLog.info(
|
|
@@ -3,12 +3,43 @@ import type { MiravoTemplate } from "@miravo/shared";
|
|
|
3
3
|
/** Collect all unique model names referenced by a template's twin assignments. */
|
|
4
4
|
export function collectReferencedModels(template: MiravoTemplate): Set<string> {
|
|
5
5
|
const models = new Set<string>();
|
|
6
|
+
forEachTwin(template, (_area, _line, twin) => models.add(twin.model));
|
|
7
|
+
return models;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Build a UNS base path from template namespace, area, and line. */
|
|
11
|
+
export function buildUNSBasePath(
|
|
12
|
+
template: MiravoTemplate,
|
|
13
|
+
areaName: string,
|
|
14
|
+
lineName: string,
|
|
15
|
+
): string {
|
|
16
|
+
const segments: string[] = [];
|
|
17
|
+
const ns = template.namespace;
|
|
18
|
+
if (ns !== undefined) {
|
|
19
|
+
if ("enterprise" in ns) {
|
|
20
|
+
segments.push(ns.enterprise, ns.site);
|
|
21
|
+
} else if ("prefix" in ns) {
|
|
22
|
+
segments.push(ns.prefix);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
segments.push(areaName, lineName);
|
|
26
|
+
return segments.join("/");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Iterate all twin assignments across all areas and lines. */
|
|
30
|
+
export function forEachTwin(
|
|
31
|
+
template: MiravoTemplate,
|
|
32
|
+
callback: (
|
|
33
|
+
area: MiravoTemplate["areas"][number],
|
|
34
|
+
line: MiravoTemplate["areas"][number]["lines"][number],
|
|
35
|
+
twin: MiravoTemplate["areas"][number]["lines"][number]["twins"][number],
|
|
36
|
+
) => void,
|
|
37
|
+
): void {
|
|
6
38
|
for (const area of template.areas) {
|
|
7
39
|
for (const line of area.lines) {
|
|
8
40
|
for (const twin of line.twins) {
|
|
9
|
-
|
|
41
|
+
callback(area, line, twin);
|
|
10
42
|
}
|
|
11
43
|
}
|
|
12
44
|
}
|
|
13
|
-
return models;
|
|
14
45
|
}
|