miravo 0.5.0 → 0.6.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.
Files changed (69) hide show
  1. package/LICENSE +199 -0
  2. package/README.md +7 -70
  3. package/bin/miravo.js +84 -0
  4. package/package.json +20 -36
  5. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  6. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  7. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  8. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  9. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  10. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  11. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  12. package/console-dist/assets/index-C1oOHU1J.js +0 -119
  13. package/console-dist/assets/index-C3XMYq26.css +0 -2
  14. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  15. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  16. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  17. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  18. package/console-dist/favicon.svg +0 -37
  19. package/console-dist/index.html +0 -14
  20. package/console-dist/miravo-logo.png +0 -0
  21. package/src/__tests__/integration/executable-methods-runner.ts +0 -261
  22. package/src/__tests__/integration/executable-methods.integration.ts +0 -2256
  23. package/src/commands/adapter.ts +0 -76
  24. package/src/commands/cli-test-setup.ts +0 -91
  25. package/src/commands/fork.ts +0 -219
  26. package/src/commands/inject.test.ts +0 -57
  27. package/src/commands/inject.ts +0 -212
  28. package/src/commands/load.ts +0 -167
  29. package/src/commands/logs.ts +0 -175
  30. package/src/commands/models.test.ts +0 -162
  31. package/src/commands/models.ts +0 -338
  32. package/src/commands/pause.ts +0 -67
  33. package/src/commands/ps.ts +0 -116
  34. package/src/commands/remove.ts +0 -38
  35. package/src/commands/reset.ts +0 -44
  36. package/src/commands/rm.ts +0 -97
  37. package/src/commands/spawn.ts +0 -45
  38. package/src/commands/speed.ts +0 -40
  39. package/src/commands/start.test.ts +0 -481
  40. package/src/commands/start.ts +0 -2253
  41. package/src/commands/status.test.ts +0 -20
  42. package/src/commands/status.ts +0 -470
  43. package/src/commands/stop.test.ts +0 -84
  44. package/src/commands/stop.ts +0 -158
  45. package/src/commands/templates.test.ts +0 -58
  46. package/src/commands/templates.ts +0 -279
  47. package/src/index.ts +0 -158
  48. package/src/lib/admin-client.test.ts +0 -35
  49. package/src/lib/admin-client.ts +0 -226
  50. package/src/lib/admin-server.test.ts +0 -562
  51. package/src/lib/admin-server.ts +0 -658
  52. package/src/lib/daemon-registry.test.ts +0 -429
  53. package/src/lib/daemon-registry.ts +0 -345
  54. package/src/lib/mqtt-client.ts +0 -116
  55. package/src/lib/parse.test.ts +0 -123
  56. package/src/lib/parse.ts +0 -87
  57. package/src/lib/resolve-simulation.test.ts +0 -118
  58. package/src/lib/resolve-simulation.ts +0 -186
  59. package/src/lib/shutdown.test.ts +0 -380
  60. package/src/lib/shutdown.ts +0 -561
  61. package/src/lib/template-category.test.ts +0 -71
  62. package/src/lib/template-category.ts +0 -72
  63. package/src/lib/template-locator.ts +0 -49
  64. package/src/lib/template-utils.ts +0 -45
  65. package/src/lib/ws-hub.ts +0 -255
  66. package/src/logger.test.ts +0 -172
  67. package/src/logger.ts +0 -394
  68. package/src/output.test.ts +0 -64
  69. package/src/output.ts +0 -157
@@ -1,167 +0,0 @@
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
- }
@@ -1,175 +0,0 @@
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
- }
@@ -1,162 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { resolve } from "node:path";
3
- import { loadTwinModel } from "@miravo/core";
4
- import { type GeneratorConfig, TwinModelSchema } from "@miravo/shared";
5
- import { stripAnsi } from "consola/utils";
6
- import { configureLogger } from "../logger.ts";
7
- import { formatGeneratorSummary, formatModelInspect } from "./models.ts";
8
-
9
- configureLogger({ quiet: true });
10
-
11
- const modelsDir = resolve(import.meta.dir, "..", "..", "..", "content", "models");
12
-
13
- beforeEach(() => {
14
- process.exitCode = 0;
15
- });
16
-
17
- afterEach(() => {
18
- process.exitCode = 0;
19
- });
20
-
21
- // ---------------------------------------------------------------------------
22
- // formatGeneratorSummary
23
- // ---------------------------------------------------------------------------
24
-
25
- describe("formatGeneratorSummary", () => {
26
- const cases: [string, GeneratorConfig, string][] = [
27
- ["sine", { type: "sine", center: 1500, amplitude: 50, period: 60 }, "sine (center: 1500)"],
28
- ["noise", { type: "noise", center: 3.5, stddev: 0.5 }, "noise (center: 3.5)"],
29
- [
30
- "correlated",
31
- { type: "correlated", source: "rpm", factor: 0.007, offset: 2 },
32
- "correlated (source: rpm)",
33
- ],
34
- ["random-walk", { type: "random-walk", min: 0, max: 360, step: 5 }, "random-walk (0..360)"],
35
- ["step", { type: "step", values: [100, 75, 50, 75, 100], interval: 300 }, "step (5 values)"],
36
- [
37
- "state-machine",
38
- {
39
- type: "state-machine",
40
- states: ["idle", "running", "cooldown"],
41
- initial: "idle",
42
- transitions: [{ from: "idle", to: "running" }],
43
- },
44
- "state-machine (3 states)",
45
- ],
46
- [
47
- "health-dependent",
48
- {
49
- type: "health-dependent",
50
- stages: {
51
- healthy: { type: "noise", center: 1.5, stddev: 0.3 },
52
- degraded: { type: "noise", center: 3.5, stddev: 0.8 },
53
- },
54
- },
55
- "health-dependent (2 stages)",
56
- ],
57
- [
58
- "gate",
59
- {
60
- type: "gate",
61
- enabled_member: "run_command",
62
- input: { member: "speed_command" },
63
- disabled_value: 0,
64
- },
65
- "gate (enabled: run_command)",
66
- ],
67
- ];
68
-
69
- test.each(cases)("%s → %s", (_label, config, expected) => {
70
- expect(formatGeneratorSummary(config)).toBe(expected);
71
- });
72
- });
73
-
74
- // ---------------------------------------------------------------------------
75
- // formatModelInspect
76
- // ---------------------------------------------------------------------------
77
-
78
- describe("formatModelInspect", () => {
79
- test("model with parameters, lifecycle, and faults shows all sections", async () => {
80
- const model = await loadTwinModel(resolve(modelsDir, "centrifugal-pump.twin.yaml"));
81
- const raw = formatModelInspect(
82
- model,
83
- resolve(modelsDir, "centrifugal-pump.twin.yaml"),
84
- "built-in",
85
- );
86
- const output = stripAnsi(raw);
87
-
88
- expect(output).toContain("centrifugal-pump");
89
- expect(output).toContain("Parameters:");
90
- expect(output).toContain("rated_rpm");
91
- expect(output).toContain("Members (30):");
92
- expect(output).toContain("Methods (5):");
93
- expect(output).toContain("availability_state");
94
- expect(output).toContain("Lifecycle: healthy");
95
- expect(output).toContain("\u2192");
96
- expect(output).toContain("Faults:");
97
- expect(output).toContain("Source: built-in");
98
- });
99
-
100
- test("model without lifecycle shows 'none'", () => {
101
- const model = TwinModelSchema.parse({
102
- schema_version: "2",
103
- model: { name: "test-vessel", description: "Minimal vessel" },
104
- members: {
105
- level: {
106
- kind: "variable",
107
- data_type: "Double",
108
- access: "read",
109
- unit: "%",
110
- generator: { type: "noise", center: 65, stddev: 1.5 },
111
- },
112
- },
113
- });
114
- const raw = formatModelInspect(model, "/tmp/test-vessel.twin.yaml", "test");
115
- const output = stripAnsi(raw);
116
-
117
- expect(output).toContain("test-vessel");
118
- expect(output).toContain("Methods: none");
119
- expect(output).toContain("Lifecycle: none");
120
- });
121
- });
122
-
123
- // ---------------------------------------------------------------------------
124
- // CLI integration
125
- // ---------------------------------------------------------------------------
126
-
127
- describe("miravo models", () => {
128
- test("models inspect nonexistent model sets exit code 1", async () => {
129
- const { createProgram } = await import("../index.ts");
130
- const program = createProgram();
131
- program.exitOverride();
132
-
133
- try {
134
- await program.parseAsync(["node", "miravo", "models", "inspect", "nonexistent-model"]);
135
- } catch {
136
- // Commander may throw on exitOverride
137
- }
138
-
139
- expect(process.exitCode).toBe(1);
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
- });
162
- });