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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { mkdir, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { loadTemplate, resolveModelPath } from "@miravo/core";
|
|
4
|
+
import { MiravoError } from "@miravo/shared";
|
|
5
|
+
import type { Command } from "commander";
|
|
6
|
+
import { colors } from "consola/utils";
|
|
7
|
+
import { createCatalog, resolveTemplateIdentifier } from "../lib/template-locator.ts";
|
|
8
|
+
import { collectReferencedModels } from "../lib/template-utils.ts";
|
|
9
|
+
import { configLog, handleCommandError, writeOutput } from "../logger.ts";
|
|
10
|
+
|
|
11
|
+
export function registerForkCommand(program: Command): void {
|
|
12
|
+
const fork = program
|
|
13
|
+
.command("fork")
|
|
14
|
+
.description("Copy a model or template to your local registry for customization");
|
|
15
|
+
|
|
16
|
+
fork
|
|
17
|
+
.command("model <name>")
|
|
18
|
+
.description("Fork a model to ~/.miravo/registry/local/models/")
|
|
19
|
+
.option("--force", "Overwrite if already exists in local")
|
|
20
|
+
.action(async (name: string, options: { readonly force?: boolean }) => {
|
|
21
|
+
await forkModelAction(name, options);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
fork
|
|
25
|
+
.command("template <idNameOrPath>")
|
|
26
|
+
.description("Fork a template and its referenced models to ~/.miravo/registry/local/")
|
|
27
|
+
.option("--force", "Overwrite if already exists in local")
|
|
28
|
+
.option("--models-only", "Only fork referenced models, skip the template itself")
|
|
29
|
+
.action(
|
|
30
|
+
async (
|
|
31
|
+
idNameOrPath: string,
|
|
32
|
+
options: { readonly force?: boolean; readonly modelsOnly?: boolean },
|
|
33
|
+
) => {
|
|
34
|
+
await forkTemplateAction(idNameOrPath, options);
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Fork model
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async function forkModelAction(name: string, options: { readonly force?: boolean }): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
const catalog = createCatalog();
|
|
46
|
+
const resolvedPath = await catalog.resolveModel(name);
|
|
47
|
+
const localModelsDir = join(catalog.getLocalRegistryDir(), "models");
|
|
48
|
+
await mkdir(localModelsDir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
const targetPath = join(localModelsDir, basename(resolvedPath));
|
|
51
|
+
const copied = await atomicCopyFile({
|
|
52
|
+
sourcePath: resolvedPath,
|
|
53
|
+
targetPath,
|
|
54
|
+
label: `model "${name}"`,
|
|
55
|
+
force: options.force ?? false,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
writeOutput();
|
|
59
|
+
if (copied) {
|
|
60
|
+
writeOutput(` ${colors.bold("Forked to local:")}`);
|
|
61
|
+
writeOutput(` models/${basename(resolvedPath)}`);
|
|
62
|
+
}
|
|
63
|
+
writeOutput();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
handleCommandError(error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Fork template
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
async function forkTemplateAction(
|
|
74
|
+
idNameOrPath: string,
|
|
75
|
+
options: { readonly force?: boolean; readonly modelsOnly?: boolean },
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
const catalog = createCatalog();
|
|
79
|
+
const resolvedPath = await resolveTemplateIdentifier(idNameOrPath, catalog);
|
|
80
|
+
|
|
81
|
+
const localDir = catalog.getLocalRegistryDir();
|
|
82
|
+
const localModelsDir = join(localDir, "models");
|
|
83
|
+
const localTemplatesDir = join(localDir, "templates");
|
|
84
|
+
await mkdir(localModelsDir, { recursive: true });
|
|
85
|
+
await mkdir(localTemplatesDir, { recursive: true });
|
|
86
|
+
|
|
87
|
+
const forked: string[] = [];
|
|
88
|
+
const skipped: string[] = [];
|
|
89
|
+
|
|
90
|
+
// Fork the template itself (unless --models-only)
|
|
91
|
+
if (!options.modelsOnly) {
|
|
92
|
+
const targetPath = join(localTemplatesDir, basename(resolvedPath));
|
|
93
|
+
const copied = await atomicCopyFile({
|
|
94
|
+
sourcePath: resolvedPath,
|
|
95
|
+
targetPath,
|
|
96
|
+
label: `template "${idNameOrPath}"`,
|
|
97
|
+
force: options.force ?? false,
|
|
98
|
+
});
|
|
99
|
+
if (copied) {
|
|
100
|
+
forked.push(`templates/${basename(resolvedPath)}`);
|
|
101
|
+
} else {
|
|
102
|
+
skipped.push(`templates/${basename(resolvedPath)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Fork referenced models
|
|
107
|
+
const template = await loadTemplate(resolvedPath);
|
|
108
|
+
const inlineModels = new Set((template.models ?? []).map((m) => m.model.name));
|
|
109
|
+
const referencedModels = collectReferencedModels(template);
|
|
110
|
+
const searchPaths = catalog.getTemplateValidationSearchPaths(resolvedPath);
|
|
111
|
+
|
|
112
|
+
for (const modelName of referencedModels) {
|
|
113
|
+
if (inlineModels.has(modelName)) continue;
|
|
114
|
+
|
|
115
|
+
let sourceModelPath: string;
|
|
116
|
+
try {
|
|
117
|
+
sourceModelPath = await resolveModelPath(modelName, searchPaths);
|
|
118
|
+
} catch {
|
|
119
|
+
configLog.warn(`Skipped model "${modelName}": not found.`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const targetModelPath = join(localModelsDir, basename(sourceModelPath));
|
|
124
|
+
const copied = await atomicCopyFile({
|
|
125
|
+
sourcePath: sourceModelPath,
|
|
126
|
+
targetPath: targetModelPath,
|
|
127
|
+
label: `model "${modelName}"`,
|
|
128
|
+
force: options.force ?? false,
|
|
129
|
+
});
|
|
130
|
+
if (copied) {
|
|
131
|
+
forked.push(`models/${basename(sourceModelPath)}`);
|
|
132
|
+
} else {
|
|
133
|
+
skipped.push(`models/${basename(sourceModelPath)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
writeOutput();
|
|
138
|
+
if (forked.length > 0) {
|
|
139
|
+
writeOutput(` ${colors.bold("Forked to local:")}`);
|
|
140
|
+
for (const f of forked) {
|
|
141
|
+
writeOutput(` ${f}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (skipped.length > 0) {
|
|
145
|
+
for (const s of skipped) {
|
|
146
|
+
writeOutput(` ${s} ${colors.dim("(skipped — already in local)")}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (forked.length === 0 && skipped.length === 0) {
|
|
150
|
+
writeOutput(" Nothing to fork.");
|
|
151
|
+
}
|
|
152
|
+
writeOutput();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
handleCommandError(error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Helpers
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Copy a file using stage-to-temp-and-rename for atomic writes.
|
|
164
|
+
* Returns true if the file was copied, false if skipped (already exists and no --force).
|
|
165
|
+
*/
|
|
166
|
+
async function atomicCopyFile(options: {
|
|
167
|
+
readonly sourcePath: string;
|
|
168
|
+
readonly targetPath: string;
|
|
169
|
+
readonly label: string;
|
|
170
|
+
readonly force: boolean;
|
|
171
|
+
}): Promise<boolean> {
|
|
172
|
+
const { sourcePath, targetPath, label, force } = options;
|
|
173
|
+
|
|
174
|
+
if (resolve(sourcePath) === resolve(targetPath)) {
|
|
175
|
+
configLog.warn(`Skipped ${label}: source and destination are the same.`);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const targetExists = await Bun.file(targetPath).exists();
|
|
180
|
+
if (targetExists && !force) {
|
|
181
|
+
configLog.info(`Skipped ${label}: already exists in local. Use --force to overwrite.`);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const contents = await Bun.file(sourcePath).text();
|
|
186
|
+
const tmpPath = join(dirname(targetPath), `.${basename(targetPath)}.tmp`);
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await Bun.write(tmpPath, contents);
|
|
190
|
+
await rename(tmpPath, targetPath);
|
|
191
|
+
} catch {
|
|
192
|
+
// Clean up temp file on failure
|
|
193
|
+
try {
|
|
194
|
+
await unlink(tmpPath);
|
|
195
|
+
} catch {
|
|
196
|
+
// Ignore cleanup errors
|
|
197
|
+
}
|
|
198
|
+
throw new MiravoError(
|
|
199
|
+
`Failed to write ${label} to "${targetPath}".`,
|
|
200
|
+
`Ensure "${dirname(targetPath)}" is writable by the current user.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
@@ -13,7 +13,7 @@ describe("miravo inject", () => {
|
|
|
13
13
|
const optionNames = injectCmd?.options.map((o) => o.long);
|
|
14
14
|
expect(optionNames).toContain("--clear");
|
|
15
15
|
expect(optionNames).toContain("--name");
|
|
16
|
-
expect(optionNames).toContain("--
|
|
16
|
+
expect(optionNames).toContain("--admin-url");
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test("accepts instanceId and fault as positional arguments", async () => {
|
|
@@ -29,26 +29,24 @@ describe("miravo inject", () => {
|
|
|
29
29
|
expect(args?.[1]?.required).toBe(false);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test("sets exit code 1
|
|
32
|
+
test("sets exit code 1 with invalid admin URL", async () => {
|
|
33
33
|
const { program } = await findCliCommand("inject");
|
|
34
34
|
program.exitOverride();
|
|
35
35
|
|
|
36
36
|
try {
|
|
37
|
-
// Use a port that's very unlikely to have a broker running
|
|
38
37
|
await program.parseAsync([
|
|
39
38
|
"node",
|
|
40
39
|
"miravo",
|
|
41
40
|
"inject",
|
|
42
41
|
"pump-001",
|
|
43
42
|
"cavitation",
|
|
44
|
-
"--
|
|
45
|
-
"
|
|
43
|
+
"--admin-url",
|
|
44
|
+
"not-a-url",
|
|
46
45
|
]);
|
|
47
46
|
} catch {
|
|
48
|
-
// Expected
|
|
47
|
+
// Expected
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
// Should fail — either the template doesn't exist or the broker isn't running
|
|
52
50
|
expect(process.exitCode).toBe(1);
|
|
53
51
|
});
|
|
54
52
|
|
|
@@ -73,8 +71,7 @@ describe("miravo inject", () => {
|
|
|
73
71
|
// Expected
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
|
|
77
|
-
// The exact exit behavior depends on broker connection attempt
|
|
74
|
+
expect(process.exitCode).toBe(1);
|
|
78
75
|
} finally {
|
|
79
76
|
// Restore
|
|
80
77
|
Object.defineProperty(process.stdout, "isTTY", { value: origStdoutTTY, configurable: true });
|
package/src/commands/inject.ts
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import { confirm, intro, isCancel, outro, select } from "@clack/prompts";
|
|
2
|
-
import type {
|
|
3
|
-
import { CONTROL_CMD_TIMEOUT_MS,
|
|
2
|
+
import type { EngineCommand } from "@miravo/shared";
|
|
3
|
+
import { CONTROL_CMD_TIMEOUT_MS, MiravoError } from "@miravo/shared";
|
|
4
4
|
import type { Command } from "commander";
|
|
5
|
-
import {
|
|
5
|
+
import { executeAdminCommand, getAdminState, resolveAdminUrl } from "../lib/admin-client.ts";
|
|
6
6
|
import { parseDurationMs } from "../lib/parse.ts";
|
|
7
|
-
import { resolveTargetSimulation, type SimulationTopics } from "../lib/resolve-simulation.ts";
|
|
8
7
|
import { faultLog, handleCommandError } from "../logger.ts";
|
|
9
8
|
|
|
10
9
|
interface InjectOptions {
|
|
11
10
|
readonly clear?: boolean;
|
|
12
11
|
readonly name?: string;
|
|
13
|
-
readonly
|
|
12
|
+
readonly adminUrl?: string;
|
|
14
13
|
readonly timeout?: number;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Register the `miravo inject` command with the root Commander program.
|
|
19
|
-
* Triggers or clears a fault on a running twin instance via
|
|
18
|
+
* Triggers or clears a fault on a running twin instance via the admin HTTP server.
|
|
20
19
|
*/
|
|
21
20
|
export function registerInjectCommand(program: Command): void {
|
|
22
21
|
program
|
|
@@ -24,13 +23,10 @@ export function registerInjectCommand(program: Command): void {
|
|
|
24
23
|
.description("Inject or clear a fault on a running twin instance")
|
|
25
24
|
.option("--clear", "Clear the fault instead of triggering it")
|
|
26
25
|
.option("--name <name>", "Target simulation name")
|
|
27
|
-
.option(
|
|
28
|
-
"--template <path>",
|
|
29
|
-
"Path to .miravo.yaml for broker connection info (env: MIRAVO_TEMPLATE)",
|
|
30
|
-
)
|
|
26
|
+
.option("--admin-url <url>", "Miravo admin server URL (env: MIRAVO_ADMIN_URL)")
|
|
31
27
|
.option(
|
|
32
28
|
"--timeout <ms>",
|
|
33
|
-
"
|
|
29
|
+
"Admin request timeout in milliseconds (default: 5000)",
|
|
34
30
|
parseDurationMs,
|
|
35
31
|
)
|
|
36
32
|
.action(
|
|
@@ -57,65 +53,44 @@ async function injectAction(
|
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
try {
|
|
60
|
-
const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
|
|
61
56
|
const timeoutMs = options.timeout ?? CONTROL_CMD_TIMEOUT_MS;
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const target = await resolveTargetSimulation(client, {
|
|
66
|
-
timeoutMs,
|
|
67
|
-
brokerUrl: brokerInfo.url,
|
|
68
|
-
...(options.name !== undefined ? { name: options.name } : {}),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
let targetInstance = instanceId;
|
|
72
|
-
let targetFault = fault;
|
|
57
|
+
const adminUrl = resolveAdminUrl(options.adminUrl);
|
|
58
|
+
const stateResult = await getTargetState(adminUrl, timeoutMs, options.name);
|
|
73
59
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
77
|
-
faultLog.error(
|
|
78
|
-
"Interactive mode requires a terminal. Usage: miravo inject <instance> <fault>",
|
|
79
|
-
);
|
|
80
|
-
process.exitCode = 1;
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
60
|
+
let targetInstance = instanceId;
|
|
61
|
+
let targetFault = fault;
|
|
83
62
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
target.simulationName,
|
|
63
|
+
// Interactive mode: pick instance and fault via prompts
|
|
64
|
+
if (!targetInstance || !targetFault) {
|
|
65
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
66
|
+
faultLog.error(
|
|
67
|
+
"Interactive mode requires a terminal. Usage: miravo inject <instance> <fault>",
|
|
90
68
|
);
|
|
91
|
-
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
92
72
|
|
|
93
|
-
|
|
94
|
-
|
|
73
|
+
const picked = await pickInstanceAndFault(stateResult, options.clear ?? false);
|
|
74
|
+
if (!picked) return;
|
|
95
75
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
76
|
+
targetInstance = picked.instanceId;
|
|
77
|
+
targetFault = picked.fault;
|
|
78
|
+
}
|
|
99
79
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
: { type: "triggerFault", instanceId: targetInstance, fault: targetFault };
|
|
80
|
+
const command: EngineCommand = options.clear
|
|
81
|
+
? { type: "clearFault", instanceId: targetInstance, fault: targetFault }
|
|
82
|
+
: { type: "triggerFault", instanceId: targetInstance, fault: targetFault };
|
|
104
83
|
|
|
105
|
-
|
|
106
|
-
const result = await sendCommand(client, command, timeoutMs, target.topics);
|
|
84
|
+
const result = await executeAdminCommand(adminUrl, command, timeoutMs);
|
|
107
85
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
process.exitCode = 1;
|
|
86
|
+
if (result.success) {
|
|
87
|
+
faultLog.success(result.message);
|
|
88
|
+
} else {
|
|
89
|
+
faultLog.error(result.message);
|
|
90
|
+
if (result.error) {
|
|
91
|
+
faultLog.error(`Fix: ${result.error}`);
|
|
116
92
|
}
|
|
117
|
-
|
|
118
|
-
disconnectClient(client);
|
|
93
|
+
process.exitCode = 1;
|
|
119
94
|
}
|
|
120
95
|
} catch (error) {
|
|
121
96
|
handleCommandError(error, faultLog);
|
|
@@ -123,7 +98,7 @@ async function injectAction(
|
|
|
123
98
|
}
|
|
124
99
|
|
|
125
100
|
// ---------------------------------------------------------------------------
|
|
126
|
-
//
|
|
101
|
+
// Read state snapshot from admin server
|
|
127
102
|
// ---------------------------------------------------------------------------
|
|
128
103
|
|
|
129
104
|
interface StateSnapshot {
|
|
@@ -139,47 +114,19 @@ interface StateSnapshot {
|
|
|
139
114
|
}>;
|
|
140
115
|
}
|
|
141
116
|
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
brokerUrl: string,
|
|
117
|
+
async function getTargetState(
|
|
118
|
+
adminUrl: string,
|
|
145
119
|
timeoutMs: number,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const timer = setTimeout(() => {
|
|
157
|
-
cleanup();
|
|
158
|
-
faultLog.error(
|
|
159
|
-
`No running simulation named "${simulationName}" found on ${brokerUrl}. Start one with 'miravo start --name ${simulationName}'.`,
|
|
160
|
-
);
|
|
161
|
-
process.exitCode = 1;
|
|
162
|
-
resolve(null);
|
|
163
|
-
}, timeoutMs);
|
|
164
|
-
|
|
165
|
-
const handler = (topic: string, message: Buffer): void => {
|
|
166
|
-
if (topic !== topics.state) return;
|
|
167
|
-
try {
|
|
168
|
-
const parsed = EngineStateSnapshotSchema.safeParse(JSON.parse(message.toString()));
|
|
169
|
-
if (!parsed.success) {
|
|
170
|
-
// Skip malformed payloads — wait for a valid one or timeout.
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
cleanup();
|
|
174
|
-
resolve(parsed.data as StateSnapshot);
|
|
175
|
-
} catch {
|
|
176
|
-
// JSON parse error — skip and wait for valid data.
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
client.subscribe(topics.state, { qos: 0 });
|
|
181
|
-
client.on("message", handler);
|
|
182
|
-
});
|
|
120
|
+
expectedName?: string,
|
|
121
|
+
): Promise<StateSnapshot> {
|
|
122
|
+
const state = await getAdminState(adminUrl, timeoutMs);
|
|
123
|
+
if (expectedName && state.simulationName !== expectedName) {
|
|
124
|
+
throw new MiravoError(
|
|
125
|
+
`Connected to simulation "${state.simulationName}" instead of "${expectedName}".`,
|
|
126
|
+
"Pass --admin-url for the correct Miravo process, or omit --name to target the connected simulation.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return state as StateSnapshot;
|
|
183
130
|
}
|
|
184
131
|
|
|
185
132
|
// ---------------------------------------------------------------------------
|
|
@@ -255,60 +202,3 @@ async function pickInstanceAndFault(
|
|
|
255
202
|
|
|
256
203
|
return { instanceId: instanceChoice as string, fault: faultChoice as string };
|
|
257
204
|
}
|
|
258
|
-
|
|
259
|
-
// ---------------------------------------------------------------------------
|
|
260
|
-
// Send command and wait for result
|
|
261
|
-
// ---------------------------------------------------------------------------
|
|
262
|
-
|
|
263
|
-
function sendCommand(
|
|
264
|
-
client: import("mqtt").MqttClient,
|
|
265
|
-
command: EngineCommand,
|
|
266
|
-
timeoutMs: number,
|
|
267
|
-
topics: SimulationTopics,
|
|
268
|
-
): Promise<CommandResult> {
|
|
269
|
-
const commandWithCorrelation: EngineCommand = {
|
|
270
|
-
...command,
|
|
271
|
-
correlationId: crypto.randomUUID(),
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
return new Promise((resolve) => {
|
|
275
|
-
const cleanup = (): void => {
|
|
276
|
-
clearTimeout(timer);
|
|
277
|
-
client.off("message", handler);
|
|
278
|
-
client.unsubscribe(topics.cmdResult);
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const timer = setTimeout(() => {
|
|
282
|
-
cleanup();
|
|
283
|
-
resolve({
|
|
284
|
-
success: false,
|
|
285
|
-
command: command.type,
|
|
286
|
-
correlationId: commandWithCorrelation.correlationId,
|
|
287
|
-
message: "Command timed out.",
|
|
288
|
-
error: `No response within ${timeoutMs}ms. The simulation may not have a control plane enabled.`,
|
|
289
|
-
});
|
|
290
|
-
}, timeoutMs);
|
|
291
|
-
|
|
292
|
-
const handler = (topic: string, message: Buffer): void => {
|
|
293
|
-
if (topic !== topics.cmdResult) return;
|
|
294
|
-
try {
|
|
295
|
-
const result = JSON.parse(message.toString()) as CommandResult;
|
|
296
|
-
if (result.correlationId !== commandWithCorrelation.correlationId) return;
|
|
297
|
-
cleanup();
|
|
298
|
-
resolve(result);
|
|
299
|
-
} catch {
|
|
300
|
-
cleanup();
|
|
301
|
-
resolve({
|
|
302
|
-
success: false,
|
|
303
|
-
command: command.type,
|
|
304
|
-
correlationId: commandWithCorrelation.correlationId,
|
|
305
|
-
message: "Received invalid response from simulation.",
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
client.subscribe(topics.cmdResult, { qos: 0 });
|
|
311
|
-
client.on("message", handler);
|
|
312
|
-
client.publish(topics.cmd, JSON.stringify(commandWithCorrelation), { qos: 0 });
|
|
313
|
-
});
|
|
314
|
-
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { loadTwinModel } from "@miravo/core";
|
|
4
|
-
import type
|
|
4
|
+
import { type GeneratorConfig, TwinModelSchema } from "@miravo/shared";
|
|
5
5
|
import { stripAnsi } from "consola/utils";
|
|
6
6
|
import { configureLogger } from "../logger.ts";
|
|
7
7
|
import { formatGeneratorSummary, formatModelInspect } from "./models.ts";
|
|
8
8
|
|
|
9
9
|
configureLogger({ silent: true });
|
|
10
10
|
|
|
11
|
-
const modelsDir = resolve(import.meta.dir, "..", "..", "..", "
|
|
11
|
+
const modelsDir = resolve(import.meta.dir, "..", "..", "..", "content", "models");
|
|
12
12
|
|
|
13
13
|
beforeEach(() => {
|
|
14
14
|
process.exitCode = 0;
|
|
@@ -54,6 +54,16 @@ describe("formatGeneratorSummary", () => {
|
|
|
54
54
|
},
|
|
55
55
|
"health-dependent (2 stages)",
|
|
56
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
|
+
],
|
|
57
67
|
];
|
|
58
68
|
|
|
59
69
|
test.each(cases)("%s → %s", (_label, config, expected) => {
|
|
@@ -78,20 +88,34 @@ describe("formatModelInspect", () => {
|
|
|
78
88
|
expect(output).toContain("centrifugal-pump");
|
|
79
89
|
expect(output).toContain("Parameters:");
|
|
80
90
|
expect(output).toContain("rated_rpm");
|
|
81
|
-
expect(output).toContain("
|
|
91
|
+
expect(output).toContain("Members (29):");
|
|
92
|
+
expect(output).toContain("Methods (5):");
|
|
93
|
+
expect(output).toContain("availability_state");
|
|
82
94
|
expect(output).toContain("Lifecycle: healthy");
|
|
83
95
|
expect(output).toContain("\u2192");
|
|
84
96
|
expect(output).toContain("Faults:");
|
|
85
97
|
expect(output).toContain("Source: built-in");
|
|
86
98
|
});
|
|
87
99
|
|
|
88
|
-
test("model without lifecycle shows 'none'",
|
|
89
|
-
const model =
|
|
90
|
-
|
|
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");
|
|
91
115
|
const output = stripAnsi(raw);
|
|
92
116
|
|
|
93
|
-
expect(output).toContain("
|
|
94
|
-
expect(output).toContain("
|
|
117
|
+
expect(output).toContain("test-vessel");
|
|
118
|
+
expect(output).toContain("Methods: none");
|
|
95
119
|
expect(output).toContain("Lifecycle: none");
|
|
96
120
|
});
|
|
97
121
|
});
|