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.
Files changed (61) hide show
  1. package/README.md +116 -30
  2. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  3. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  4. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  5. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  6. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  7. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  8. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  9. package/console-dist/assets/index-BXtW73cF.css +2 -0
  10. package/console-dist/assets/index-ChSocZFW.js +119 -0
  11. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  12. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  13. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  14. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  15. package/console-dist/favicon.svg +20 -0
  16. package/console-dist/index.html +14 -0
  17. package/console-dist/miravo-logo.png +0 -0
  18. package/package.json +10 -7
  19. package/src/__tests__/integration/executable-methods-runner.ts +52 -45
  20. package/src/__tests__/integration/executable-methods.integration.ts +609 -47
  21. package/src/commands/adapter.ts +76 -0
  22. package/src/commands/cli-test-setup.ts +1 -1
  23. package/src/commands/fork.ts +14 -0
  24. package/src/commands/inject.test.ts +1 -1
  25. package/src/commands/inject.ts +16 -8
  26. package/src/commands/load.ts +167 -0
  27. package/src/commands/logs.ts +175 -0
  28. package/src/commands/models.test.ts +22 -1
  29. package/src/commands/models.ts +14 -0
  30. package/src/commands/pause.ts +67 -0
  31. package/src/commands/ps.ts +116 -0
  32. package/src/commands/remove.ts +38 -0
  33. package/src/commands/reset.ts +44 -0
  34. package/src/commands/rm.ts +97 -0
  35. package/src/commands/spawn.ts +45 -0
  36. package/src/commands/speed.ts +40 -0
  37. package/src/commands/start.test.ts +93 -0
  38. package/src/commands/start.ts +976 -192
  39. package/src/commands/status.test.ts +3 -1
  40. package/src/commands/status.ts +12 -6
  41. package/src/commands/stop.test.ts +84 -0
  42. package/src/commands/stop.ts +158 -0
  43. package/src/commands/templates.test.ts +1 -1
  44. package/src/commands/templates.ts +13 -0
  45. package/src/index.ts +49 -7
  46. package/src/lib/admin-client.test.ts +35 -0
  47. package/src/lib/admin-client.ts +13 -1
  48. package/src/lib/admin-server.test.ts +339 -53
  49. package/src/lib/admin-server.ts +413 -62
  50. package/src/lib/daemon-registry.test.ts +429 -0
  51. package/src/lib/daemon-registry.ts +345 -0
  52. package/src/lib/mqtt-client.ts +1 -1
  53. package/src/lib/parse.ts +18 -0
  54. package/src/lib/resolve-simulation.ts +1 -1
  55. package/src/lib/shutdown.test.ts +16 -4
  56. package/src/lib/shutdown.ts +3 -3
  57. package/src/lib/template-category.test.ts +1 -0
  58. package/src/lib/template-utils.ts +33 -2
  59. package/src/lib/ws-hub.ts +255 -0
  60. package/src/logger.test.ts +19 -19
  61. package/src/logger.ts +34 -6
@@ -0,0 +1,255 @@
1
+ import type { EventBus, MiravoEvents } from "@miravo/core";
2
+ import { subscribeToDomainEvents } from "@miravo/core";
3
+ import type { EngineRunState, WSMessage } from "@miravo/shared";
4
+ import type { ServerWebSocket } from "bun";
5
+
6
+ /** Channels a client can subscribe to. */
7
+ type Channel = "tick" | "faults" | "lifecycle" | "instances" | "engine" | "adapters";
8
+
9
+ /** All channels enabled by default. */
10
+ const ALL_CHANNELS: ReadonlySet<Channel> = new Set([
11
+ "tick",
12
+ "faults",
13
+ "lifecycle",
14
+ "instances",
15
+ "engine",
16
+ "adapters",
17
+ ]);
18
+
19
+ function isChannel(value: string): value is Channel {
20
+ return (ALL_CHANNELS as ReadonlySet<string>).has(value);
21
+ }
22
+
23
+ /** Per-connection state stored via Bun's WebSocket data field. */
24
+ interface WSClientData {
25
+ channels: Set<Channel>;
26
+ }
27
+
28
+ interface WSHubOptions {
29
+ readonly bus: EventBus;
30
+ readonly getEngineState: () => EngineRunState;
31
+ readonly getTickInfo: () => { tick: number; simulationTime: number; instanceCount: number };
32
+ readonly maxConnections?: number;
33
+ readonly backpressureThreshold?: number;
34
+ }
35
+
36
+ const DEFAULT_MAX_CONNECTIONS = 10;
37
+ const DEFAULT_BACKPRESSURE_THRESHOLD = 1024 * 64; // 64KB send buffer
38
+
39
+ /**
40
+ * WebSocket hub that fans out engine events to connected clients.
41
+ * Read-only: clients can only subscribe to channels, not send commands.
42
+ */
43
+ class WSHub {
44
+ readonly #clients = new Set<ServerWebSocket<WSClientData>>();
45
+ readonly #maxConnections: number;
46
+ readonly #backpressureThreshold: number;
47
+ readonly #getEngineState: () => EngineRunState;
48
+ readonly #getTickInfo: () => { tick: number; simulationTime: number; instanceCount: number };
49
+ readonly #bus: EventBus;
50
+ readonly #tickHandler: (evt: MiravoEvents["tick:complete"]) => void;
51
+ readonly #adapterEnabledHandler: (evt: MiravoEvents["adapter:enabled"]) => void;
52
+ readonly #adapterDisabledHandler: (evt: MiravoEvents["adapter:disabled"]) => void;
53
+ readonly #unsubscribeDomain: () => void;
54
+ #revision = 0;
55
+
56
+ constructor(options: WSHubOptions) {
57
+ this.#maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
58
+ this.#backpressureThreshold = options.backpressureThreshold ?? DEFAULT_BACKPRESSURE_THRESHOLD;
59
+ this.#getEngineState = options.getEngineState;
60
+ this.#getTickInfo = options.getTickInfo;
61
+ this.#bus = options.bus;
62
+
63
+ // Tick events are on their own channel (highest frequency)
64
+ this.#tickHandler = (evt) => {
65
+ this.#broadcast("tick", {
66
+ type: "tick",
67
+ revision: this.#nextRevision(),
68
+ tick: evt.tick,
69
+ simulationTime: evt.simulationTime,
70
+ delta: evt.delta.memberChanged,
71
+ instanceCount: evt.graph.assets.size,
72
+ });
73
+ };
74
+ this.#bus.on("tick:complete", this.#tickHandler);
75
+
76
+ // Adapter lifecycle events
77
+ this.#adapterEnabledHandler = (evt) => {
78
+ this.#broadcast("adapters", {
79
+ type: "adapter:enabled",
80
+ revision: this.#nextRevision(),
81
+ name: evt.name,
82
+ endpoint: evt.endpoint,
83
+ });
84
+ };
85
+ this.#adapterDisabledHandler = (evt) => {
86
+ this.#broadcast("adapters", {
87
+ type: "adapter:disabled",
88
+ revision: this.#nextRevision(),
89
+ name: evt.name,
90
+ });
91
+ };
92
+ this.#bus.on("adapter:enabled", this.#adapterEnabledHandler);
93
+ this.#bus.on("adapter:disabled", this.#adapterDisabledHandler);
94
+
95
+ // Domain events use the shared subscriber (stays in sync with EventLog)
96
+ this.#unsubscribeDomain = subscribeToDomainEvents(options.bus, {
97
+ "fault:triggered": (evt) => {
98
+ this.#broadcast("faults", {
99
+ type: "fault:triggered",
100
+ revision: this.#nextRevision(),
101
+ instanceId: evt.instanceId,
102
+ fault: evt.fault,
103
+ effects: evt.effects,
104
+ });
105
+ },
106
+ "fault:cleared": (evt) => {
107
+ this.#broadcast("faults", {
108
+ type: "fault:cleared",
109
+ revision: this.#nextRevision(),
110
+ instanceId: evt.instanceId,
111
+ fault: evt.fault,
112
+ });
113
+ },
114
+ "lifecycle:changed": (evt) => {
115
+ this.#broadcast("lifecycle", {
116
+ type: "lifecycle:changed",
117
+ revision: this.#nextRevision(),
118
+ instanceId: evt.instanceId,
119
+ from: evt.from,
120
+ to: evt.to,
121
+ operatingHours: evt.operatingHours,
122
+ });
123
+ },
124
+ "instance:created": (evt) => {
125
+ this.#broadcast("instances", {
126
+ type: "instance:created",
127
+ revision: this.#nextRevision(),
128
+ id: evt.id,
129
+ assetType: evt.assetType,
130
+ browsePath: evt.browsePath,
131
+ });
132
+ },
133
+ "instance:removed": (evt) => {
134
+ this.#broadcast("instances", {
135
+ type: "instance:removed",
136
+ revision: this.#nextRevision(),
137
+ id: evt.id,
138
+ });
139
+ },
140
+ "engine:state-changed": (evt) => {
141
+ this.#broadcast("engine", {
142
+ type: "engine:state",
143
+ revision: this.#nextRevision(),
144
+ from: evt.from,
145
+ to: evt.to,
146
+ });
147
+ },
148
+ });
149
+ }
150
+
151
+ #nextRevision(): number {
152
+ return ++this.#revision;
153
+ }
154
+
155
+ #broadcast(channel: Channel, message: WSMessage): void {
156
+ const json = JSON.stringify(message);
157
+ for (const ws of this.#clients) {
158
+ if (!ws.data.channels.has(channel)) continue;
159
+
160
+ // Backpressure: close congested connections to prevent unbounded buffer growth.
161
+ // The client should reconnect and will receive a fresh snapshot.
162
+ if (ws.getBufferedAmount() > this.#backpressureThreshold) {
163
+ try {
164
+ ws.close(4008, "backpressure");
165
+ } catch {
166
+ // Client may have already disconnected
167
+ }
168
+ this.#clients.delete(ws);
169
+ continue;
170
+ }
171
+
172
+ try {
173
+ ws.send(json);
174
+ } catch {
175
+ // Client disconnected — will be cleaned up in onClose
176
+ }
177
+ }
178
+ }
179
+
180
+ /** Called when a new WebSocket connection opens. */
181
+ onOpen(ws: ServerWebSocket<WSClientData>): void {
182
+ if (this.#clients.size >= this.#maxConnections) {
183
+ ws.close(1013, "Max connections exceeded");
184
+ return;
185
+ }
186
+
187
+ this.#clients.add(ws);
188
+
189
+ // Send initial snapshot
190
+ const tickInfo = this.#getTickInfo();
191
+ const snapshot: WSMessage = {
192
+ type: "snapshot",
193
+ revision: this.#nextRevision(),
194
+ state: this.#getEngineState(),
195
+ tick: tickInfo.tick,
196
+ simulationTime: tickInfo.simulationTime,
197
+ instanceCount: tickInfo.instanceCount,
198
+ };
199
+ ws.send(JSON.stringify(snapshot));
200
+ }
201
+
202
+ /** Called when a WebSocket message is received from a client. */
203
+ onMessage(ws: ServerWebSocket<WSClientData>, message: string | Buffer): void {
204
+ const text = typeof message === "string" ? message : message.toString();
205
+ let parsed: unknown;
206
+ try {
207
+ parsed = JSON.parse(text);
208
+ } catch {
209
+ return; // Ignore invalid JSON
210
+ }
211
+
212
+ if (typeof parsed !== "object" || parsed === null) return;
213
+ const msg = parsed as Record<string, unknown>;
214
+
215
+ if (msg["type"] === "subscribe" && Array.isArray(msg["channels"])) {
216
+ const newChannels = new Set<Channel>();
217
+ for (const ch of msg["channels"]) {
218
+ if (typeof ch === "string" && isChannel(ch)) {
219
+ newChannels.add(ch);
220
+ }
221
+ }
222
+ ws.data.channels = newChannels;
223
+ }
224
+ }
225
+
226
+ /** Called when a WebSocket connection closes. */
227
+ onClose(ws: ServerWebSocket<WSClientData>): void {
228
+ this.#clients.delete(ws);
229
+ }
230
+
231
+ /** Number of currently connected clients. */
232
+ get connectionCount(): number {
233
+ return this.#clients.size;
234
+ }
235
+
236
+ /** Close all connections and unsubscribe from bus events. */
237
+ close(): void {
238
+ this.#bus.off("tick:complete", this.#tickHandler);
239
+ this.#bus.off("adapter:enabled", this.#adapterEnabledHandler);
240
+ this.#bus.off("adapter:disabled", this.#adapterDisabledHandler);
241
+ this.#unsubscribeDomain();
242
+
243
+ for (const ws of this.#clients) {
244
+ try {
245
+ ws.close(1001, "Server shutting down");
246
+ } catch {
247
+ // Ignore
248
+ }
249
+ }
250
+ this.#clients.clear();
251
+ }
252
+ }
253
+
254
+ export type { Channel, WSClientData, WSHubOptions };
255
+ export { ALL_CHANNELS, WSHub };
@@ -5,7 +5,7 @@ import {
5
5
  engineLog,
6
6
  isJsonLogFormat,
7
7
  isPlain,
8
- isSilent,
8
+ isQuiet,
9
9
  lockLogger,
10
10
  logger,
11
11
  parseLogFormat,
@@ -19,8 +19,8 @@ beforeEach(() => {
19
19
  });
20
20
 
21
21
  afterEach(() => {
22
- // Restore silent + locked for other test files
23
- configureLogger({ silent: true });
22
+ // Restore quiet + locked for other test files
23
+ configureLogger({ quiet: true });
24
24
  lockLogger();
25
25
  });
26
26
 
@@ -41,35 +41,35 @@ async function captureConsoleLogs<T>(
41
41
  }
42
42
 
43
43
  describe("configureLogger", () => {
44
- test("--silent trumps --verbose", () => {
45
- configureLogger({ verbose: true, silent: true });
44
+ test("--quiet trumps --verbose", () => {
45
+ configureLogger({ verbose: true, quiet: true });
46
46
  expect(logger.level).toBe(0);
47
- expect(isSilent()).toBe(true);
47
+ expect(isQuiet()).toBe(true);
48
48
  });
49
49
 
50
- test("--silent sets output suppressed", () => {
51
- configureLogger({ silent: true });
50
+ test("--quiet sets output suppressed", () => {
51
+ configureLogger({ quiet: true });
52
52
  expect(logger.level).toBe(0);
53
- expect(isSilent()).toBe(true);
53
+ expect(isQuiet()).toBe(true);
54
54
  });
55
55
 
56
56
  test("--verbose sets debug level", () => {
57
57
  configureLogger({ verbose: true });
58
58
  expect(logger.level).toBe(4);
59
- expect(isSilent()).toBe(false);
59
+ expect(isQuiet()).toBe(false);
60
60
  });
61
61
 
62
62
  test("default sets info level", () => {
63
63
  configureLogger({});
64
64
  expect(logger.level).toBe(3);
65
- expect(isSilent()).toBe(false);
65
+ expect(isQuiet()).toBe(false);
66
66
  });
67
67
 
68
- test("--verbose after --silent restores output", () => {
69
- configureLogger({ silent: true });
70
- expect(isSilent()).toBe(true);
68
+ test("--verbose after --quiet restores output", () => {
69
+ configureLogger({ quiet: true });
70
+ expect(isQuiet()).toBe(true);
71
71
  configureLogger({ verbose: true });
72
- expect(isSilent()).toBe(false);
72
+ expect(isQuiet()).toBe(false);
73
73
  });
74
74
 
75
75
  test("--plain enables plain output mode", () => {
@@ -148,18 +148,18 @@ describe("configureLogger", () => {
148
148
  expect(calls[0]).toEqual(["red"]);
149
149
  });
150
150
 
151
- test("writeOutput is suppressed by --silent even in plain mode", async () => {
151
+ test("writeOutput is suppressed by --quiet even in plain mode", async () => {
152
152
  const { calls } = await captureConsoleLogs(() => {
153
- configureLogger({ silent: true, plain: true });
153
+ configureLogger({ quiet: true, plain: true });
154
154
  writeOutput("hidden");
155
155
  });
156
156
 
157
157
  expect(calls.length).toBe(0);
158
158
  });
159
159
 
160
- test("--silent still emits error logs", async () => {
160
+ test("--quiet still emits error logs", async () => {
161
161
  const { calls } = await captureConsoleLogs(() => {
162
- configureLogger({ silent: true, logFormat: "json" });
162
+ configureLogger({ quiet: true, logFormat: "json" });
163
163
  engineLog.error("still visible");
164
164
  });
165
165
 
package/src/logger.ts CHANGED
@@ -195,7 +195,7 @@ function applyLogReporter(format: LogFormat): void {
195
195
  /** Options for configuring log verbosity from CLI flags. */
196
196
  export interface LoggerOptions {
197
197
  readonly verbose?: boolean | undefined;
198
- readonly silent?: boolean | undefined;
198
+ readonly quiet?: boolean | undefined;
199
199
  readonly plain?: boolean | undefined;
200
200
  readonly logFormat?: LogFormat | undefined;
201
201
  readonly logLevel?: LogLevel | undefined;
@@ -239,7 +239,7 @@ export function clearRuntimeLogContext(): void {
239
239
 
240
240
  /**
241
241
  * Set the global log level based on CLI flags.
242
- * Priority: --silent > --log-level > --verbose > default (info).
242
+ * Priority: --quiet > --log-level > --verbose > default (info).
243
243
  *
244
244
  * Consola levels: 0 = errors/fatals, 2 = warn, 3 = info, 4 = debug.
245
245
  */
@@ -249,8 +249,13 @@ export function configureLogger(opts: LoggerOptions): void {
249
249
  applyLogReporter(_logFormat);
250
250
  _plainOutput = opts.plain ?? false;
251
251
 
252
+ // NO_COLOR convention: disable colors when set to any non-empty value
253
+ if (!_plainOutput && Bun.env["NO_COLOR"] !== undefined && Bun.env["NO_COLOR"] !== "") {
254
+ _plainOutput = true;
255
+ }
256
+
252
257
  let level: number;
253
- if (opts.silent) {
258
+ if (opts.quiet) {
254
259
  level = 0;
255
260
  _outputSuppressed = true;
256
261
  } else if (opts.logLevel) {
@@ -275,7 +280,7 @@ export function isJsonLogFormat(): boolean {
275
280
  }
276
281
 
277
282
  /**
278
- * Write informational output to stdout, respecting --silent.
283
+ * Write informational output to stdout, respecting --quiet.
279
284
  * Use this for primary formatted output (tables, boxes, status renders).
280
285
  * Do NOT use for machine-readable output (--json, --raw) — those should
281
286
  * always go through `process.stdout.write()` or `console.log()` directly.
@@ -305,8 +310,8 @@ export function writeOutput(...args: unknown[]): void {
305
310
  console.log(...args);
306
311
  }
307
312
 
308
- /** Returns true when --silent is active. */
309
- export function isSilent(): boolean {
313
+ /** Returns true when --quiet is active. */
314
+ export function isQuiet(): boolean {
310
315
  return _outputSuppressed;
311
316
  }
312
317
 
@@ -347,3 +352,26 @@ export function handleCommandError(error: unknown, log: ConsolaInstance = config
347
352
  throw error;
348
353
  }
349
354
  }
355
+
356
+ interface CommandResultLike {
357
+ readonly success: boolean;
358
+ readonly message: string;
359
+ readonly error?: string | undefined;
360
+ }
361
+
362
+ /**
363
+ * Handle the result of an admin command, respecting --json output.
364
+ * Writes success messages to stdout and errors to the control log.
365
+ * Sets process.exitCode = 1 on failure.
366
+ */
367
+ export function handleCommandResult(result: CommandResultLike, json?: boolean): void {
368
+ if (json) {
369
+ console.log(JSON.stringify(result, null, 2));
370
+ } else if (result.success) {
371
+ writeOutput(result.message);
372
+ } else {
373
+ controlLog.error(result.message);
374
+ if (result.error) controlLog.error(`Fix: ${result.error}`);
375
+ }
376
+ if (!result.success) process.exitCode = 1;
377
+ }