miravo 0.2.3 → 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 +974 -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,76 @@
|
|
|
1
|
+
import type { EngineCommand } from "@miravo/shared";
|
|
2
|
+
import { CONTROL_CMD_TIMEOUT_MS } from "@miravo/shared";
|
|
3
|
+
import type { Command } from "commander";
|
|
4
|
+
import { executeAdminCommand, resolveAdminUrl } from "../lib/admin-client.ts";
|
|
5
|
+
import { parsePositiveInt, parseTimeoutSeconds } from "../lib/parse.ts";
|
|
6
|
+
import { handleCommandError, handleCommandResult } from "../logger.ts";
|
|
7
|
+
|
|
8
|
+
interface AdapterOptions {
|
|
9
|
+
readonly port?: number;
|
|
10
|
+
readonly host?: string;
|
|
11
|
+
readonly json?: boolean;
|
|
12
|
+
readonly adminUrl?: string;
|
|
13
|
+
readonly timeout?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerAdapterCommand(program: Command): void {
|
|
17
|
+
const adapter = program.command("adapter").description("Manage protocol adapters (MQTT, OPC UA)");
|
|
18
|
+
|
|
19
|
+
adapter
|
|
20
|
+
.command("enable <name>")
|
|
21
|
+
.description("Enable a protocol adapter (mqtt, opcua)")
|
|
22
|
+
.option("-p, --port <number>", "Override adapter port", parsePositiveInt)
|
|
23
|
+
.option("--host <host>", "Override adapter bind host")
|
|
24
|
+
.option("--json", "Machine-readable JSON output")
|
|
25
|
+
.option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
|
|
26
|
+
.option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
|
|
27
|
+
.addHelpText(
|
|
28
|
+
"after",
|
|
29
|
+
`
|
|
30
|
+
Examples:
|
|
31
|
+
$ miravo adapter enable mqtt Enable MQTT adapter
|
|
32
|
+
$ miravo adapter enable opcua -p 4841 Enable OPC UA on custom port`,
|
|
33
|
+
)
|
|
34
|
+
.action(async (name: string, options: AdapterOptions) => {
|
|
35
|
+
try {
|
|
36
|
+
const adminUrl = resolveAdminUrl(options.adminUrl);
|
|
37
|
+
const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
|
|
38
|
+
const config: Record<string, unknown> = {};
|
|
39
|
+
if (options.port !== undefined) config["port"] = options.port;
|
|
40
|
+
if (options.host !== undefined) config["host"] = options.host;
|
|
41
|
+
const command: EngineCommand = {
|
|
42
|
+
type: "enableAdapter",
|
|
43
|
+
adapter: name,
|
|
44
|
+
...(Object.keys(config).length > 0 ? { config } : {}),
|
|
45
|
+
};
|
|
46
|
+
const result = await executeAdminCommand(adminUrl, command, timeoutMs);
|
|
47
|
+
handleCommandResult(result, options.json);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
handleCommandError(error);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
adapter
|
|
54
|
+
.command("disable <name>")
|
|
55
|
+
.description("Disable a running protocol adapter (mqtt, opcua)")
|
|
56
|
+
.option("--json", "Machine-readable JSON output")
|
|
57
|
+
.option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
|
|
58
|
+
.option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
|
|
59
|
+
.addHelpText(
|
|
60
|
+
"after",
|
|
61
|
+
`
|
|
62
|
+
Examples:
|
|
63
|
+
$ miravo adapter disable mqtt Disable MQTT adapter`,
|
|
64
|
+
)
|
|
65
|
+
.action(async (name: string, options: AdapterOptions) => {
|
|
66
|
+
try {
|
|
67
|
+
const adminUrl = resolveAdminUrl(options.adminUrl);
|
|
68
|
+
const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
|
|
69
|
+
const command: EngineCommand = { type: "disableAdapter", adapter: name };
|
|
70
|
+
const result = await executeAdminCommand(adminUrl, command, timeoutMs);
|
|
71
|
+
handleCommandResult(result, options.json);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
handleCommandError(error);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -26,7 +26,7 @@ const allLoggers = [logger, engineLog, mqttLog, configLog, lifecycleLog, faultLo
|
|
|
26
26
|
|
|
27
27
|
export function muteAllLoggers(): void {
|
|
28
28
|
unlockLogger();
|
|
29
|
-
configureLogger({
|
|
29
|
+
configureLogger({ quiet: true });
|
|
30
30
|
for (const l of allLoggers) {
|
|
31
31
|
l.level = -999;
|
|
32
32
|
l.setReporters([noopReporter]);
|
package/src/commands/fork.ts
CHANGED
|
@@ -17,6 +17,13 @@ export function registerForkCommand(program: Command): void {
|
|
|
17
17
|
.command("model <name>")
|
|
18
18
|
.description("Fork a model to ~/.miravo/registry/local/models/")
|
|
19
19
|
.option("--force", "Overwrite if already exists in local")
|
|
20
|
+
.addHelpText(
|
|
21
|
+
"after",
|
|
22
|
+
`
|
|
23
|
+
Examples:
|
|
24
|
+
$ miravo fork model centrifugal-pump Fork a model for customization
|
|
25
|
+
$ miravo fork model centrifugal-pump --force Overwrite existing fork`,
|
|
26
|
+
)
|
|
20
27
|
.action(async (name: string, options: { readonly force?: boolean }) => {
|
|
21
28
|
await forkModelAction(name, options);
|
|
22
29
|
});
|
|
@@ -26,6 +33,13 @@ export function registerForkCommand(program: Command): void {
|
|
|
26
33
|
.description("Fork a template and its referenced models to ~/.miravo/registry/local/")
|
|
27
34
|
.option("--force", "Overwrite if already exists in local")
|
|
28
35
|
.option("--models-only", "Only fork referenced models, skip the template itself")
|
|
36
|
+
.addHelpText(
|
|
37
|
+
"after",
|
|
38
|
+
`
|
|
39
|
+
Examples:
|
|
40
|
+
$ miravo fork template water-treatment Fork template + referenced models
|
|
41
|
+
$ miravo fork template water-treatment --models-only Fork only models`,
|
|
42
|
+
)
|
|
29
43
|
.action(
|
|
30
44
|
async (
|
|
31
45
|
idNameOrPath: string,
|
|
@@ -8,7 +8,7 @@ describe("miravo inject", () => {
|
|
|
8
8
|
test("command is registered with correct name and options", async () => {
|
|
9
9
|
const { cmd: injectCmd } = await findCliCommand("inject");
|
|
10
10
|
expect(injectCmd).toBeDefined();
|
|
11
|
-
expect(injectCmd?.description()).toBe("
|
|
11
|
+
expect(injectCmd?.description()).toBe("Trigger or clear a fault on a running instance");
|
|
12
12
|
|
|
13
13
|
const optionNames = injectCmd?.options.map((o) => o.long);
|
|
14
14
|
expect(optionNames).toContain("--clear");
|
package/src/commands/inject.ts
CHANGED
|
@@ -3,11 +3,12 @@ import type { EngineCommand } from "@miravo/shared";
|
|
|
3
3
|
import { CONTROL_CMD_TIMEOUT_MS, MiravoError } from "@miravo/shared";
|
|
4
4
|
import type { Command } from "commander";
|
|
5
5
|
import { executeAdminCommand, getAdminState, resolveAdminUrl } from "../lib/admin-client.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { parseTimeoutSeconds } from "../lib/parse.ts";
|
|
7
7
|
import { faultLog, handleCommandError } from "../logger.ts";
|
|
8
8
|
|
|
9
9
|
interface InjectOptions {
|
|
10
10
|
readonly clear?: boolean;
|
|
11
|
+
readonly json?: boolean;
|
|
11
12
|
readonly name?: string;
|
|
12
13
|
readonly adminUrl?: string;
|
|
13
14
|
readonly timeout?: number;
|
|
@@ -20,14 +21,19 @@ interface InjectOptions {
|
|
|
20
21
|
export function registerInjectCommand(program: Command): void {
|
|
21
22
|
program
|
|
22
23
|
.command("inject [instanceId] [fault]")
|
|
23
|
-
.description("
|
|
24
|
+
.description("Trigger or clear a fault on a running instance")
|
|
24
25
|
.option("--clear", "Clear the fault instead of triggering it")
|
|
26
|
+
.option("--json", "Machine-readable JSON output")
|
|
25
27
|
.option("--name <name>", "Target simulation name")
|
|
26
28
|
.option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
|
|
27
|
-
.option(
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
29
|
+
.option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
|
|
30
|
+
.addHelpText(
|
|
31
|
+
"after",
|
|
32
|
+
`
|
|
33
|
+
Examples:
|
|
34
|
+
$ miravo inject Interactive mode (pick instance + fault)
|
|
35
|
+
$ miravo inject pump-001 cavitation Trigger fault directly
|
|
36
|
+
$ miravo inject pump-001 cavitation --clear Clear an active fault`,
|
|
31
37
|
)
|
|
32
38
|
.action(
|
|
33
39
|
async (instanceId: string | undefined, fault: string | undefined, options: InjectOptions) => {
|
|
@@ -83,15 +89,17 @@ async function injectAction(
|
|
|
83
89
|
|
|
84
90
|
const result = await executeAdminCommand(adminUrl, command, timeoutMs);
|
|
85
91
|
|
|
86
|
-
if (
|
|
92
|
+
if (options.json) {
|
|
93
|
+
console.log(JSON.stringify(result, null, 2));
|
|
94
|
+
} else if (result.success) {
|
|
87
95
|
faultLog.success(result.message);
|
|
88
96
|
} else {
|
|
89
97
|
faultLog.error(result.message);
|
|
90
98
|
if (result.error) {
|
|
91
99
|
faultLog.error(`Fix: ${result.error}`);
|
|
92
100
|
}
|
|
93
|
-
process.exitCode = 1;
|
|
94
101
|
}
|
|
102
|
+
if (!result.success) process.exitCode = 1;
|
|
95
103
|
} catch (error) {
|
|
96
104
|
handleCommandError(error, faultLog);
|
|
97
105
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { isCancel, select } from "@clack/prompts";
|
|
3
|
+
import type { ContentCatalog } from "@miravo/core";
|
|
4
|
+
import { discoverTemplates } from "@miravo/core";
|
|
5
|
+
import type { DiscoveredTemplate, EngineCommand } from "@miravo/shared";
|
|
6
|
+
import { CONTROL_CMD_TIMEOUT_MS, ConfigNotFoundError, MiravoError } from "@miravo/shared";
|
|
7
|
+
import type { Command } from "commander";
|
|
8
|
+
import { executeAdminCommand, resolveAdminUrl } from "../lib/admin-client.ts";
|
|
9
|
+
import { parsePositiveInt, parseTimeoutSeconds } from "../lib/parse.ts";
|
|
10
|
+
import { isStressBenchmarkTemplate } from "../lib/template-category.ts";
|
|
11
|
+
import {
|
|
12
|
+
createCatalog,
|
|
13
|
+
discoverPreferredLocalTemplates,
|
|
14
|
+
resolveTemplateIdentifier,
|
|
15
|
+
} from "../lib/template-locator.ts";
|
|
16
|
+
import { configLog, handleCommandError, handleCommandResult } from "../logger.ts";
|
|
17
|
+
|
|
18
|
+
interface LoadOptions {
|
|
19
|
+
readonly template?: string;
|
|
20
|
+
readonly count?: number;
|
|
21
|
+
readonly json?: boolean;
|
|
22
|
+
readonly adminUrl?: string;
|
|
23
|
+
readonly timeout?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerLoadCommand(program: Command): void {
|
|
27
|
+
program
|
|
28
|
+
.command("load")
|
|
29
|
+
.description("Add instances from a template to the running simulation")
|
|
30
|
+
.option(
|
|
31
|
+
"-t, --template <id|name|path,...>",
|
|
32
|
+
"Template id, name, path, or comma-separated list (env: MIRAVO_TEMPLATE)",
|
|
33
|
+
)
|
|
34
|
+
.option(
|
|
35
|
+
"-c, --count <n>",
|
|
36
|
+
"Number of template copies to load per template (default: 1)",
|
|
37
|
+
parsePositiveInt,
|
|
38
|
+
)
|
|
39
|
+
.option("--json", "Machine-readable JSON output")
|
|
40
|
+
.option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
|
|
41
|
+
.option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
|
|
42
|
+
.addHelpText(
|
|
43
|
+
"after",
|
|
44
|
+
`
|
|
45
|
+
Examples:
|
|
46
|
+
$ miravo load -t smart-factory Load built-in template by name
|
|
47
|
+
$ miravo load -t ./extra.miravo.yaml Load template from file
|
|
48
|
+
$ miravo load -t smart-factory,water-treatment Load two templates at once
|
|
49
|
+
$ miravo load -t smart-factory -c 3 Load 3 copies of a template
|
|
50
|
+
$ miravo load Auto-discover and load`,
|
|
51
|
+
)
|
|
52
|
+
.action(async (options: LoadOptions) => {
|
|
53
|
+
try {
|
|
54
|
+
const catalog = createCatalog();
|
|
55
|
+
const templateIdentifier = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
|
|
56
|
+
|
|
57
|
+
let templatePaths: string[];
|
|
58
|
+
if (templateIdentifier !== undefined) {
|
|
59
|
+
const identifiers = templateIdentifier
|
|
60
|
+
.split(",")
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
templatePaths = await Promise.all(
|
|
64
|
+
identifiers.map((id) => resolveTemplateIdentifier(id, catalog)),
|
|
65
|
+
);
|
|
66
|
+
} else {
|
|
67
|
+
const path = await resolveLoadTemplatePath(catalog);
|
|
68
|
+
templatePaths = [path];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const adminUrl = resolveAdminUrl(options.adminUrl);
|
|
72
|
+
const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
|
|
73
|
+
|
|
74
|
+
for (const templatePath of templatePaths) {
|
|
75
|
+
const command: EngineCommand = {
|
|
76
|
+
type: "loadTemplate",
|
|
77
|
+
templatePath,
|
|
78
|
+
count: options.count ?? 1,
|
|
79
|
+
};
|
|
80
|
+
const result = await executeAdminCommand(adminUrl, command, timeoutMs);
|
|
81
|
+
handleCommandResult(result, options.json);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
handleCommandError(error);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveLoadTemplatePath(catalog: ContentCatalog): Promise<string> {
|
|
90
|
+
// 1. Auto-discover local templates (CWD + local registry)
|
|
91
|
+
const discovered = await discoverPreferredLocalTemplates(catalog);
|
|
92
|
+
if (discovered.length === 1) {
|
|
93
|
+
const first = discovered[0];
|
|
94
|
+
if (first !== undefined) {
|
|
95
|
+
configLog.info(`Found template: ${first.name} (${first.path})`);
|
|
96
|
+
return first.path;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (discovered.length > 1) {
|
|
100
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
101
|
+
const names = discovered.map((t) => t.name).join(", ");
|
|
102
|
+
throw new MiravoError(
|
|
103
|
+
`Found ${discovered.length} templates: ${names}.`,
|
|
104
|
+
"Use '--template <name|path>' to specify which template to use.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const first = discovered[0];
|
|
108
|
+
if (first !== undefined) {
|
|
109
|
+
configLog.warn(
|
|
110
|
+
`Found ${discovered.length} templates: ${discovered.map((t) => t.name).join(", ")}. Using ${first.name}. Use '--template <name|path>' to specify.`,
|
|
111
|
+
);
|
|
112
|
+
return first.path;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Fall back to built-in templates
|
|
117
|
+
const builtinTemplateDir = catalog.getBuiltinTemplatesDir();
|
|
118
|
+
let builtinTemplates: readonly DiscoveredTemplate[] = [];
|
|
119
|
+
try {
|
|
120
|
+
const all = await discoverTemplates(builtinTemplateDir);
|
|
121
|
+
builtinTemplates = all.filter((t) => !isStressBenchmarkTemplate(t.id));
|
|
122
|
+
} catch {
|
|
123
|
+
// Builtin dir may not exist in edge cases
|
|
124
|
+
}
|
|
125
|
+
if (builtinTemplates.length > 0) {
|
|
126
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
127
|
+
return pickBuiltinTemplate(builtinTemplates);
|
|
128
|
+
}
|
|
129
|
+
const selected =
|
|
130
|
+
builtinTemplates.find((t) => basename(t.path) === "smart-factory.miravo.yaml") ??
|
|
131
|
+
builtinTemplates[0];
|
|
132
|
+
if (selected !== undefined) {
|
|
133
|
+
configLog.info(`No template specified. Using built-in "${selected.name}".`);
|
|
134
|
+
configLog.info(
|
|
135
|
+
"Run 'miravo templates list' to see all options, or use --template <name|path>.",
|
|
136
|
+
);
|
|
137
|
+
return selected.path;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Nothing available
|
|
142
|
+
throw new ConfigNotFoundError(
|
|
143
|
+
"No template found. Use 'miravo templates list' to find built-ins or pass 'miravo load --template <name|path>'.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function pickBuiltinTemplate(templates: readonly DiscoveredTemplate[]): Promise<string> {
|
|
148
|
+
configLog.info("No template found in current directory.\n");
|
|
149
|
+
|
|
150
|
+
const choice = await select({
|
|
151
|
+
message: "Choose a built-in template to load:",
|
|
152
|
+
options: templates.map((t) => ({
|
|
153
|
+
value: t.path,
|
|
154
|
+
label: t.name,
|
|
155
|
+
hint: t.description ?? t.id,
|
|
156
|
+
})),
|
|
157
|
+
maxItems: 9,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (isCancel(choice)) {
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
configLog.info("Tip: use --template <id> to skip this prompt next time.");
|
|
165
|
+
|
|
166
|
+
return choice as string;
|
|
167
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { WSMessage } from "@miravo/shared";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { colors } from "consola/utils";
|
|
4
|
+
import { isPidAlive, readEntry } from "../lib/daemon-registry.ts";
|
|
5
|
+
import { parseNonNegativeInt } from "../lib/parse.ts";
|
|
6
|
+
import { handleCommandError, writeOutput } from "../logger.ts";
|
|
7
|
+
|
|
8
|
+
interface LogsOptions {
|
|
9
|
+
readonly tail?: number;
|
|
10
|
+
readonly channels?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerLogsCommand(program: Command): void {
|
|
14
|
+
program
|
|
15
|
+
.command("logs <name>")
|
|
16
|
+
.description("Stream domain events from a background simulation")
|
|
17
|
+
.option("--tail <n>", "Fetch last N events before streaming", parseNonNegativeInt)
|
|
18
|
+
.option(
|
|
19
|
+
"--channels <list>",
|
|
20
|
+
"Comma-separated channels: faults,lifecycle,instances,adapters,engine (default: all except tick)",
|
|
21
|
+
)
|
|
22
|
+
.addHelpText(
|
|
23
|
+
"after",
|
|
24
|
+
`
|
|
25
|
+
Examples:
|
|
26
|
+
$ miravo logs my-sim Stream live events
|
|
27
|
+
$ miravo logs my-sim --tail 50 Show last 50 events first
|
|
28
|
+
$ miravo logs my-sim --channels faults Only fault events`,
|
|
29
|
+
)
|
|
30
|
+
.action(async (name: string, options: LogsOptions) => {
|
|
31
|
+
try {
|
|
32
|
+
await logsAction(name, options);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
handleCommandError(error);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function logsAction(name: string, options: LogsOptions): Promise<void> {
|
|
40
|
+
const entry = await readEntry(name);
|
|
41
|
+
if (!entry) {
|
|
42
|
+
writeOutput(`Simulation "${name}" not found. Use 'miravo ps' to list running simulations.`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!isPidAlive(entry.pid)) {
|
|
48
|
+
writeOutput(`Simulation "${name}" is not running.`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const adminUrl = `http://${entry.adminHost}:${entry.adminPort}`;
|
|
54
|
+
|
|
55
|
+
// Fetch historical events if --tail is set
|
|
56
|
+
if (options.tail !== undefined && options.tail > 0) {
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(`${adminUrl}/events?limit=${options.tail}`, {
|
|
59
|
+
signal: AbortSignal.timeout(5_000),
|
|
60
|
+
});
|
|
61
|
+
if (response.ok) {
|
|
62
|
+
const result = (await response.json()) as { events: readonly Record<string, unknown>[] };
|
|
63
|
+
for (const event of result.events) {
|
|
64
|
+
const formatted = formatLoggedEvent(event);
|
|
65
|
+
if (formatted) writeOutput(formatted);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
writeOutput("Warning: could not fetch historical events.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Connect WebSocket for live streaming
|
|
74
|
+
const wsUrl = `ws://${entry.adminHost}:${entry.adminPort}/ws`;
|
|
75
|
+
|
|
76
|
+
const selectedChannels = options.channels
|
|
77
|
+
? options.channels.split(",").map((c) => c.trim())
|
|
78
|
+
: ["faults", "lifecycle", "instances", "engine", "adapters"];
|
|
79
|
+
|
|
80
|
+
const ws = new WebSocket(wsUrl);
|
|
81
|
+
let connected = false;
|
|
82
|
+
let userInitiatedClose = false;
|
|
83
|
+
|
|
84
|
+
const cleanup = (): void => {
|
|
85
|
+
userInitiatedClose = true;
|
|
86
|
+
ws.close();
|
|
87
|
+
process.exit(0);
|
|
88
|
+
};
|
|
89
|
+
process.once("SIGINT", cleanup);
|
|
90
|
+
process.once("SIGTERM", cleanup);
|
|
91
|
+
|
|
92
|
+
ws.onopen = () => {
|
|
93
|
+
connected = true;
|
|
94
|
+
ws.send(JSON.stringify({ type: "subscribe", channels: selectedChannels }));
|
|
95
|
+
writeOutput(
|
|
96
|
+
`${colors.dim("Connected to")} ${name} ${colors.dim(`(channels: ${selectedChannels.join(", ")})`)}`,
|
|
97
|
+
);
|
|
98
|
+
writeOutput(colors.dim("Waiting for events... (Ctrl+C to stop)"));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
ws.onmessage = (event) => {
|
|
102
|
+
try {
|
|
103
|
+
const msg = JSON.parse(String(event.data)) as WSMessage;
|
|
104
|
+
const formatted = formatWSEvent(msg);
|
|
105
|
+
if (formatted) writeOutput(formatted);
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore parse errors
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
ws.onerror = () => {
|
|
112
|
+
if (!connected) {
|
|
113
|
+
writeOutput(`Failed to connect to simulation "${name}". Is it still running?`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
writeOutput(`Connection lost to simulation "${name}".`);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Keep alive until WebSocket closes
|
|
120
|
+
await new Promise<void>((resolve) => {
|
|
121
|
+
ws.onclose = () => resolve();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!connected || !userInitiatedClose) {
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Format a WebSocket message for terminal output. Returns null for tick events. */
|
|
130
|
+
export function formatWSEvent(msg: WSMessage): string | null {
|
|
131
|
+
switch (msg.type) {
|
|
132
|
+
case "fault:triggered":
|
|
133
|
+
return `${colors.red("[fault:triggered]")} ${msg.instanceId} · ${msg.fault}`;
|
|
134
|
+
case "fault:cleared":
|
|
135
|
+
return `${colors.green("[fault:cleared]")} ${msg.instanceId} · ${msg.fault}`;
|
|
136
|
+
case "lifecycle:changed":
|
|
137
|
+
return `${colors.blue("[lifecycle:changed]")} ${msg.instanceId} · ${msg.from} → ${msg.to}`;
|
|
138
|
+
case "instance:created":
|
|
139
|
+
return `${colors.cyan("[instance:created]")} ${msg.id} · ${msg.assetType}`;
|
|
140
|
+
case "instance:removed":
|
|
141
|
+
return `${colors.yellow("[instance:removed]")} ${msg.id}`;
|
|
142
|
+
case "engine:state":
|
|
143
|
+
return `${colors.magenta("[engine:state]")} ${msg.from} → ${msg.to}`;
|
|
144
|
+
case "adapter:enabled":
|
|
145
|
+
return `${colors.cyan("[adapter:enabled]")} ${msg.name}`;
|
|
146
|
+
case "adapter:disabled":
|
|
147
|
+
return `${colors.yellow("[adapter:disabled]")} ${msg.name}`;
|
|
148
|
+
case "tick":
|
|
149
|
+
case "snapshot":
|
|
150
|
+
case "resync-required":
|
|
151
|
+
return null;
|
|
152
|
+
default:
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatLoggedEvent(event: Record<string, unknown>): string | null {
|
|
158
|
+
const type = event["type"];
|
|
159
|
+
if (typeof type !== "string") return null;
|
|
160
|
+
|
|
161
|
+
switch (type) {
|
|
162
|
+
case "fault:triggered":
|
|
163
|
+
return `${colors.red("[fault:triggered]")} ${event["instanceId"]} · ${event["fault"]}`;
|
|
164
|
+
case "fault:cleared":
|
|
165
|
+
return `${colors.green("[fault:cleared]")} ${event["instanceId"]} · ${event["fault"]}`;
|
|
166
|
+
case "lifecycle:changed":
|
|
167
|
+
return `${colors.blue("[lifecycle:changed]")} ${event["instanceId"]} · ${event["from"]} → ${event["to"]}`;
|
|
168
|
+
case "instance:created":
|
|
169
|
+
return `${colors.cyan("[instance:created]")} ${event["instanceId"]} · ${event["assetType"]}`;
|
|
170
|
+
case "instance:removed":
|
|
171
|
+
return `${colors.yellow("[instance:removed]")} ${event["instanceId"]}`;
|
|
172
|
+
default:
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -6,7 +6,7 @@ import { stripAnsi } from "consola/utils";
|
|
|
6
6
|
import { configureLogger } from "../logger.ts";
|
|
7
7
|
import { formatGeneratorSummary, formatModelInspect } from "./models.ts";
|
|
8
8
|
|
|
9
|
-
configureLogger({
|
|
9
|
+
configureLogger({ quiet: true });
|
|
10
10
|
|
|
11
11
|
const modelsDir = resolve(import.meta.dir, "..", "..", "..", "content", "models");
|
|
12
12
|
|
|
@@ -138,4 +138,25 @@ describe("miravo models", () => {
|
|
|
138
138
|
|
|
139
139
|
expect(process.exitCode).toBe(1);
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
test("models list --dir with nonexistent path sets exit code 1", async () => {
|
|
143
|
+
const { createProgram } = await import("../index.ts");
|
|
144
|
+
const program = createProgram();
|
|
145
|
+
program.exitOverride();
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await program.parseAsync([
|
|
149
|
+
"node",
|
|
150
|
+
"miravo",
|
|
151
|
+
"models",
|
|
152
|
+
"list",
|
|
153
|
+
"--dir",
|
|
154
|
+
"/definitely-nonexistent-miravo-test-path",
|
|
155
|
+
]);
|
|
156
|
+
} catch {
|
|
157
|
+
// Commander may throw on exitOverride
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
expect(process.exitCode).toBe(1);
|
|
161
|
+
});
|
|
141
162
|
});
|
package/src/commands/models.ts
CHANGED
|
@@ -20,6 +20,12 @@ export function registerModelsCommand(program: Command): void {
|
|
|
20
20
|
.command("list")
|
|
21
21
|
.description("Discover and list available models")
|
|
22
22
|
.option("--dir <path>", "Additional directory to search for models")
|
|
23
|
+
.addHelpText(
|
|
24
|
+
"after",
|
|
25
|
+
`
|
|
26
|
+
Examples:
|
|
27
|
+
$ miravo models list List all discoverable models`,
|
|
28
|
+
)
|
|
23
29
|
.action(async (options: { readonly dir?: string }) => {
|
|
24
30
|
await modelsListAction(options);
|
|
25
31
|
});
|
|
@@ -28,6 +34,13 @@ export function registerModelsCommand(program: Command): void {
|
|
|
28
34
|
.command("inspect <name>")
|
|
29
35
|
.description("Show detailed information about a model")
|
|
30
36
|
.option("--raw", "Dump the raw YAML file to stdout")
|
|
37
|
+
.addHelpText(
|
|
38
|
+
"after",
|
|
39
|
+
`
|
|
40
|
+
Examples:
|
|
41
|
+
$ miravo models inspect centrifugal-pump Show model details
|
|
42
|
+
$ miravo models inspect centrifugal-pump --raw Dump raw YAML`,
|
|
43
|
+
)
|
|
31
44
|
.action(async (name: string, options: { readonly raw?: boolean }) => {
|
|
32
45
|
await modelsInspectAction(name, options);
|
|
33
46
|
});
|
|
@@ -68,6 +81,7 @@ async function modelsListAction(options: { readonly dir?: string }): Promise<voi
|
|
|
68
81
|
const resolvedDir = resolve(options.dir);
|
|
69
82
|
if (!existsSync(resolvedDir)) {
|
|
70
83
|
configLog.error(`Directory not found: ${resolvedDir}`);
|
|
84
|
+
process.exitCode = 1;
|
|
71
85
|
return;
|
|
72
86
|
}
|
|
73
87
|
const { discoverModels } = await import("@miravo/core");
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EngineCommand } from "@miravo/shared";
|
|
2
|
+
import { CONTROL_CMD_TIMEOUT_MS } from "@miravo/shared";
|
|
3
|
+
import type { Command } from "commander";
|
|
4
|
+
import { executeAdminCommand, resolveAdminUrl } from "../lib/admin-client.ts";
|
|
5
|
+
import { parseTimeoutSeconds } from "../lib/parse.ts";
|
|
6
|
+
import { handleCommandError, handleCommandResult } from "../logger.ts";
|
|
7
|
+
|
|
8
|
+
interface PauseResumeOptions {
|
|
9
|
+
readonly json?: boolean;
|
|
10
|
+
readonly adminUrl?: string;
|
|
11
|
+
readonly timeout?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerPauseCommand(program: Command): void {
|
|
15
|
+
program
|
|
16
|
+
.command("pause")
|
|
17
|
+
.description("Pause the running simulation")
|
|
18
|
+
.option("--json", "Machine-readable JSON output")
|
|
19
|
+
.option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
|
|
20
|
+
.option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
|
|
21
|
+
.addHelpText(
|
|
22
|
+
"after",
|
|
23
|
+
`
|
|
24
|
+
Examples:
|
|
25
|
+
$ miravo pause Pause the simulation`,
|
|
26
|
+
)
|
|
27
|
+
.action(async (options: PauseResumeOptions) => {
|
|
28
|
+
try {
|
|
29
|
+
await sendSimpleCommand("pause", options);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
handleCommandError(error);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerResumeCommand(program: Command): void {
|
|
37
|
+
program
|
|
38
|
+
.command("resume")
|
|
39
|
+
.description("Resume a paused simulation")
|
|
40
|
+
.option("--json", "Machine-readable JSON output")
|
|
41
|
+
.option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
|
|
42
|
+
.option("--timeout <seconds>", "Timeout in seconds (default: 5)", parseTimeoutSeconds)
|
|
43
|
+
.addHelpText(
|
|
44
|
+
"after",
|
|
45
|
+
`
|
|
46
|
+
Examples:
|
|
47
|
+
$ miravo resume Resume a paused simulation`,
|
|
48
|
+
)
|
|
49
|
+
.action(async (options: PauseResumeOptions) => {
|
|
50
|
+
try {
|
|
51
|
+
await sendSimpleCommand("resume", options);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
handleCommandError(error);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function sendSimpleCommand(
|
|
59
|
+
type: "pause" | "resume",
|
|
60
|
+
options: PauseResumeOptions,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const adminUrl = resolveAdminUrl(options.adminUrl);
|
|
63
|
+
const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
|
|
64
|
+
const command: EngineCommand = { type };
|
|
65
|
+
const result = await executeAdminCommand(adminUrl, command, timeoutMs);
|
|
66
|
+
handleCommandResult(result, options.json);
|
|
67
|
+
}
|