mirrorneuron-cli 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,52 @@
1
+ import typer
2
+ import subprocess
3
+ import os
4
+ import time
5
+ from mn_cli.shared import console
6
+ from mn_cli.error_handler import handle_cli_error
7
+ from mn_cli.server_cmds import _start_server, kill_tree, BEAM_PID_FILE, API_PID_FILE, WEB_UI_PID_FILE
8
+
9
+ def start():
10
+ """Start MirrorNeuron services"""
11
+ _start_server()
12
+
13
+ def stop():
14
+ """Stop MirrorNeuron services"""
15
+ console.print("=> Stopping MirrorNeuron Services...")
16
+
17
+ console.print(" Stopping Core Service (Docker: mirror-neuron-core)...")
18
+ subprocess.run(["docker", "stop", "mirror-neuron-core"], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
19
+ subprocess.run(["docker", "rm", "mirror-neuron-core"], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
20
+
21
+ for pid_file, name in [
22
+ (WEB_UI_PID_FILE, "Web UI"),
23
+ (API_PID_FILE, "REST API"),
24
+ (BEAM_PID_FILE, "Legacy Core Service"),
25
+ ]:
26
+ if pid_file.exists():
27
+ try:
28
+ pid = int(pid_file.read_text().strip())
29
+ try:
30
+ os.kill(pid, 0)
31
+ console.print(f" Stopping {name} (PID: {pid})...")
32
+ kill_tree(pid)
33
+ time.sleep(1)
34
+ except OSError:
35
+ pass
36
+ except ValueError:
37
+ pass
38
+ pid_file.unlink()
39
+ console.print("=> [green]All services stopped.[/green]")
40
+
41
+ def join(ip: str):
42
+ """Join a MirrorNeuron cluster using the IP"""
43
+ _start_server(ip)
44
+
45
+ def leave(node_name: str):
46
+ """Remove a node from the cluster by its node name (e.g. mirror_neuron@192.168.4.173)"""
47
+ from mn_cli.shared import client, console
48
+ try:
49
+ status = client.remove_node(node_name)
50
+ console.print(f"[green]Successfully requested {node_name} to leave. Status: {status}[/green]")
51
+ except Exception as e:
52
+ handle_cli_error(e, console, 'leave')
mn_cli/libs/ui.py ADDED
@@ -0,0 +1,162 @@
1
+ from rich.console import Group
2
+ from rich.table import Table
3
+ from rich.panel import Panel
4
+ from rich.text import Text
5
+ from typing import Dict, Any, Optional
6
+
7
+ def generate_live_layout(job_id: str, data: Dict[str, Any]) -> Group:
8
+ summary = data.get("summary", {})
9
+ job = data.get("job", {})
10
+ agents = data.get("agents", [])
11
+
12
+ status = summary.get("status", "unknown")
13
+ color = "cyan"
14
+ if status == "completed":
15
+ color = "green"
16
+ elif status in ["failed", "cancelled"]:
17
+ color = "red"
18
+
19
+ last_event = summary.get("last_event", "N/A")
20
+
21
+ # Top panel: Job info (Executors removed)
22
+ spinner_str = "[cyan]⠋[/cyan]"
23
+ try:
24
+ import time
25
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
26
+ idx = int(time.time() * 12.5) % len(frames)
27
+ spinner_str = f"[cyan]{frames[idx]}[/cyan]"
28
+ except:
29
+ pass
30
+
31
+ info_text = (
32
+ f"[bold]Job ID:[/bold] {job_id}\n"
33
+ f"[bold]Name:[/bold] {job.get('job_name', 'N/A')} | [bold]Graph:[/bold] {job.get('graph_id', 'N/A')}\n"
34
+ f"[bold]Status:[/bold] [{color}]{status}[/{color}] | [bold]Live:[/bold] {summary.get('live?', False)}\n"
35
+ f"[bold]Nodes:[/bold] {len(summary.get('nodes', []))}\n"
36
+ f"[bold]Last Event:[/bold] {last_event} {spinner_str if status not in ['completed', 'failed', 'cancelled'] else ''}\n\n"
37
+ f"[dim]Press 'q' or Ctrl-C to exit[/dim]"
38
+ )
39
+ info_panel = Panel(info_text, title="Live Job Monitor", border_style=color)
40
+
41
+ # Agents Table
42
+ agent_table = Table(title="Agents (Top 20 by Processed Messages)", expand=True)
43
+ agent_table.add_column("Agent ID")
44
+ agent_table.add_column("Type")
45
+ agent_table.add_column("Status")
46
+ agent_table.add_column("Processed", justify="right")
47
+ agent_table.add_column("Mailbox", justify="right")
48
+
49
+ sorted_agents = sorted(agents, key=lambda x: x.get("processed_messages", 0), reverse=True)[:20]
50
+
51
+ for ag in sorted_agents:
52
+ ag_status = ag.get("status", "unknown")
53
+ st_color = "green" if ag_status in ["running", "completed"] else "yellow" if ag_status in ["ready", "busy", "queued"] else "red"
54
+
55
+ agent_table.add_row(
56
+ ag.get("agent_id", "N/A"),
57
+ ag.get("agent_type", "N/A"),
58
+ f"[{st_color}]{ag_status}[/{st_color}]",
59
+ str(ag.get("processed_messages", 0)),
60
+ str(ag.get("mailbox_depth", 0))
61
+ )
62
+
63
+ return Group(info_panel, agent_table)
64
+
65
+ def generate_summary_panel(job_id: str, status: str, log_dir) -> Panel:
66
+ status_text = "Unknown"
67
+ status_color = "yellow"
68
+ if status == "completed":
69
+ status_text = "Success"
70
+ status_color = "green"
71
+ elif status == "failed":
72
+ status_text = "Failed"
73
+ status_color = "red"
74
+ elif status == "cancelled":
75
+ status_text = "Cancelled"
76
+ status_color = "red"
77
+
78
+ log_file = log_dir / "events.log"
79
+
80
+ panel_text = (
81
+ f"[bold {status_color}]Job Status: {status_text}[/bold {status_color}]\n\n"
82
+ f"Job ID: {job_id}\n"
83
+ f"Outputs:\n"
84
+ f" Logs: {log_file}"
85
+ )
86
+ if (log_dir / "result.txt").exists():
87
+ panel_text += f"\n Result: {log_dir / 'result.txt'}"
88
+ if (log_dir / "result_stream.txt").exists():
89
+ panel_text += f"\n Stream: {log_dir / 'result_stream.txt'}"
90
+
91
+ return Panel(
92
+ panel_text,
93
+ title="Job Execution Summary",
94
+ border_style=status_color,
95
+ expand=False
96
+ )
97
+
98
+ def generate_run_submitted_panel(
99
+ *,
100
+ bundle_name: str,
101
+ job_id: str,
102
+ payload_count: int,
103
+ log_dir,
104
+ follow_seconds: float,
105
+ run_mode: str = "Batch",
106
+ blueprint_run_id: Optional[str] = None,
107
+ blueprint_revision: Optional[str] = None,
108
+ ) -> Panel:
109
+ table = Table.grid(padding=(0, 1))
110
+ table.add_column(style="bold")
111
+ table.add_column()
112
+ table.add_row("Bundle", bundle_name)
113
+ table.add_row("Job ID", f"[bold cyan]{job_id}[/bold cyan]")
114
+ if blueprint_run_id:
115
+ table.add_row("Blueprint Run ID", f"[bold green]{blueprint_run_id}[/bold green]")
116
+ if blueprint_revision:
117
+ table.add_row("Blueprint Revision", blueprint_revision[:12])
118
+ table.add_row("Type", run_mode)
119
+ table.add_row("Payloads", str(payload_count))
120
+ table.add_row("Logs", str(log_dir / "events.log"))
121
+ table.add_row("Snapshot", str(log_dir / "job_snapshot.json"))
122
+ table.add_row("Follow", f"{follow_seconds:g}s event tail, then detach")
123
+
124
+ return Panel(
125
+ table,
126
+ title="Job submitted successfully",
127
+ border_style="cyan",
128
+ expand=False,
129
+ )
130
+
131
+ def generate_detached_panel(job_id: str, log_dir, status: str, event_count: int) -> Panel:
132
+ status_color = (
133
+ "green"
134
+ if status == "completed"
135
+ else "red"
136
+ if status in {"failed", "cancelled"}
137
+ else "yellow"
138
+ )
139
+ status_label = status.replace("_", " ").title() if status else "Unknown"
140
+
141
+ table = Table.grid(padding=(0, 1))
142
+ table.add_column(style="bold")
143
+ table.add_column()
144
+ table.add_row("Status", f"[{status_color}]{status_label}[/{status_color}]")
145
+ table.add_row("Job ID", f"[bold cyan]{job_id}[/bold cyan]")
146
+ table.add_row("Events Logged", str(event_count))
147
+ table.add_row("Raw Events", str(log_dir / "events.log"))
148
+ table.add_row("Run Log", str(log_dir / "run.log"))
149
+ table.add_row("Monitor", f"mn monitor {job_id}")
150
+
151
+ message = Text()
152
+ if status in {"completed", "failed", "cancelled"}:
153
+ message.append("Final job state reached.", style=status_color)
154
+ else:
155
+ message.append("Detached while job is still scheduled/running.", style="yellow")
156
+
157
+ return Panel(
158
+ Group(message, table),
159
+ title="Run Detached",
160
+ border_style=status_color,
161
+ expand=False,
162
+ )
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from logging.handlers import RotatingFileHandler
6
+ from pathlib import Path
7
+
8
+
9
+ def configure_logging(
10
+ name: str,
11
+ log_path: Path,
12
+ *,
13
+ level: str | None = None,
14
+ ) -> logging.Logger:
15
+ logger = logging.getLogger(name)
16
+ logger.setLevel((level or os.getenv("MN_LOG_LEVEL", "INFO")).upper())
17
+ logger.propagate = False
18
+
19
+ if logger.handlers:
20
+ return logger
21
+
22
+ formatter = logging.Formatter(
23
+ "%(asctime)s %(levelname)s [%(name)s] %(message)s"
24
+ )
25
+
26
+ try:
27
+ log_path.parent.mkdir(parents=True, exist_ok=True)
28
+ handler: logging.Handler = RotatingFileHandler(
29
+ log_path,
30
+ maxBytes=int(os.getenv("MN_LOG_MAX_BYTES", "1048576")),
31
+ backupCount=int(os.getenv("MN_LOG_BACKUP_COUNT", "5")),
32
+ )
33
+ except OSError:
34
+ handler = logging.StreamHandler()
35
+
36
+ handler.setFormatter(formatter)
37
+ logger.addHandler(handler)
38
+ return logger
mn_cli/main.py ADDED
@@ -0,0 +1,35 @@
1
+ import typer
2
+ from mn_cli.libs import job_cmds, run_cmds, sys_cmds
3
+ from mn_cli.libs.blueprint_cmds import blueprint_app
4
+
5
+ app = typer.Typer(help="MirrorNeuron CLI")
6
+
7
+ # Run commands
8
+ app.command(name="validate")(run_cmds.validate)
9
+ app.command(name="run")(run_cmds.run)
10
+ app.command(name="monitor")(run_cmds.monitor)
11
+ app.command(name="result")(run_cmds.result)
12
+
13
+ # Job commands
14
+ app.command(name="submit")(job_cmds.submit)
15
+ app.command(name="status")(job_cmds.status)
16
+ app.command(name="list")(job_cmds.list_jobs)
17
+ app.command(name="clear")(job_cmds.clear)
18
+ app.command(name="cancel")(job_cmds.cancel)
19
+ app.command(name="pause")(job_cmds.pause)
20
+ app.command(name="resume")(job_cmds.resume)
21
+ app.command(name="nodes")(job_cmds.nodes)
22
+ app.command(name="metrics")(job_cmds.metrics)
23
+ app.command(name="dead-letters")(job_cmds.dead_letters)
24
+
25
+ # System commands
26
+ app.command(name="start")(sys_cmds.start)
27
+ app.command(name="stop")(sys_cmds.stop)
28
+ app.command(name="join")(sys_cmds.join)
29
+ app.command(name="leave")(sys_cmds.leave)
30
+
31
+ # Sub-apps
32
+ app.add_typer(blueprint_app, name="blueprint")
33
+
34
+ if __name__ == "__main__":
35
+ app()
mn_cli/server_cmds.py ADDED
@@ -0,0 +1,331 @@
1
+ import os
2
+ import signal
3
+ import subprocess
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from urllib.parse import urlparse
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from mn_cli.config import CliConfig
12
+ from mn_cli.logging_config import configure_logging
13
+
14
+ console = Console()
15
+ logger = configure_logging("mn-cli", CliConfig.from_env().log_path)
16
+
17
+ DIR = Path.home() / ".mirror_neuron"
18
+ PID_DIR = DIR / ".pids"
19
+ LOG_DIR = DIR / ".logs"
20
+ BEAM_PID_FILE = PID_DIR / "beam.pid"
21
+ API_PID_FILE = PID_DIR / "api.pid"
22
+ WEB_UI_PID_FILE = PID_DIR / "web-ui.pid"
23
+ BEAM_LOG = LOG_DIR / "beam.log"
24
+ API_LOG = LOG_DIR / "api.log"
25
+ WEB_UI_LOG = LOG_DIR / "web-ui.log"
26
+ VENV_DIR = Path.home() / ".local" / "share" / "mn_venv"
27
+ WEB_UI_DIRS = (
28
+ DIR / "web-ui-source",
29
+ Path(f"{DIR}_ui"),
30
+ )
31
+ WEB_UI_PORT = "5173"
32
+ DEFAULT_HOST = "localhost"
33
+
34
+ def _env_host(name: str, default: str = DEFAULT_HOST) -> str:
35
+ return os.getenv(name, default).strip() or default
36
+
37
+ def _core_host() -> str:
38
+ return _env_host("MN_CORE_HOST")
39
+
40
+ def _api_host() -> str:
41
+ return _env_host("MN_API_HOST")
42
+
43
+ def _redis_host() -> str:
44
+ return _env_host("MN_REDIS_HOST")
45
+
46
+ def _epmd_host() -> str:
47
+ return _env_host("MN_EPMD_HOST")
48
+
49
+ def _dist_host() -> str:
50
+ return _env_host("MN_DIST_HOST")
51
+
52
+ def _web_ui_host() -> str:
53
+ return _env_host("MN_WEB_UI_HOST")
54
+
55
+ def _docker_publish_host(host: str) -> str:
56
+ return "127.0.0.1" if host == "localhost" else host
57
+
58
+ def check_status(pid_file: Path) -> int:
59
+ if pid_file.exists():
60
+ try:
61
+ pid = int(pid_file.read_text().strip())
62
+ os.kill(pid, 0)
63
+ return 0 # Running
64
+ except (ValueError, OSError):
65
+ return 1 # Stale
66
+ return 2 # Not running
67
+
68
+ def kill_tree(parent_pid: int):
69
+ try:
70
+ os.kill(parent_pid, 0)
71
+ except OSError:
72
+ logger.debug("Process %s is not running", parent_pid)
73
+ return
74
+
75
+ try:
76
+ children = subprocess.check_output(['pgrep', '-P', str(parent_pid)], stderr=subprocess.DEVNULL)
77
+ for child_pid in children.decode().split():
78
+ if child_pid.strip():
79
+ kill_tree(int(child_pid.strip()))
80
+ except subprocess.CalledProcessError:
81
+ pass
82
+
83
+ try:
84
+ logger.info("Stopping process %s", parent_pid)
85
+ os.kill(parent_pid, signal.SIGTERM)
86
+ except OSError:
87
+ logger.exception("Failed to stop process %s", parent_pid)
88
+ pass
89
+
90
+ def find_web_ui_dir() -> Optional[Path]:
91
+ for web_ui_dir in WEB_UI_DIRS:
92
+ if (web_ui_dir / "package.json").exists() and (web_ui_dir / "node_modules").exists():
93
+ return web_ui_dir
94
+ return None
95
+
96
+ def _host_port_from_target(target: str, default_host: str, default_port: str) -> tuple[str, str]:
97
+ if "://" in target:
98
+ parsed = urlparse(target)
99
+ return parsed.hostname or default_host, str(parsed.port or default_port)
100
+
101
+ if target.startswith("[") and "]:" in target:
102
+ host, port = target.rsplit("]:", 1)
103
+ return host.lstrip("["), port
104
+
105
+ if ":" in target:
106
+ host, port = target.rsplit(":", 1)
107
+ return host or default_host, port or default_port
108
+
109
+ return target or default_host, default_port
110
+
111
+ def _redis_host_port(ip: Optional[str]) -> tuple[str, str]:
112
+ default_host = ip or _redis_host()
113
+ default_url = f"redis://{default_host}:6379/0"
114
+ return _host_port_from_target(
115
+ os.getenv("MN_REDIS_URL", default_url),
116
+ default_host,
117
+ "6379",
118
+ )
119
+
120
+ def _print_service_endpoints(ip: Optional[str], web_ui_available: bool):
121
+ core_host = _core_host()
122
+ grpc_host, grpc_port = _host_port_from_target(
123
+ os.getenv(
124
+ "MN_GRPC_TARGET",
125
+ os.getenv("MN_CORE_GRPC_TARGET", f"{core_host}:50051"),
126
+ ),
127
+ core_host,
128
+ os.getenv("MN_GRPC_PORT", "50051"),
129
+ )
130
+ api_host = _api_host()
131
+ api_port = os.getenv("MN_API_PORT", "4001")
132
+ redis_host, redis_port = _redis_host_port(ip)
133
+ epmd_host = _epmd_host()
134
+ dist_host = _dist_host()
135
+ dist_port = os.getenv("MN_DIST_PORT", "9000-9010" if os.uname().sysname == "Darwin" else "dynamic")
136
+ web_ui_host = _web_ui_host()
137
+
138
+ table = Table(title="Service endpoints", show_header=True, header_style="bold")
139
+ table.add_column("Service")
140
+ table.add_column("Host")
141
+ table.add_column("Port")
142
+ table.add_column("URL / target")
143
+
144
+ table.add_row("Core gRPC", grpc_host, grpc_port, f"{grpc_host}:{grpc_port}")
145
+ table.add_row("REST API", api_host, api_port, f"http://{api_host}:{api_port}/api/v1")
146
+ table.add_row("Redis", redis_host, redis_port, f"redis://{redis_host}:{redis_port}/0")
147
+ table.add_row("Erlang EPMD", epmd_host, "4369", f"{epmd_host}:4369")
148
+ table.add_row("Erlang dist", dist_host, dist_port, f"{dist_host}:{dist_port}")
149
+ if web_ui_available:
150
+ table.add_row("Web UI", web_ui_host, WEB_UI_PORT, f"http://{web_ui_host}:{WEB_UI_PORT}")
151
+
152
+ console.print(table)
153
+
154
+ def _start_web_ui_if_installed() -> bool:
155
+ web_ui_dir = find_web_ui_dir()
156
+ if not web_ui_dir:
157
+ return False
158
+
159
+ status = check_status(WEB_UI_PID_FILE)
160
+ if status == 0:
161
+ console.print("[yellow]=> Web UI is already running, skipping.[/yellow]")
162
+ return True
163
+ if status == 1:
164
+ WEB_UI_PID_FILE.unlink(missing_ok=True)
165
+
166
+ web_ui_host = _web_ui_host()
167
+ env = os.environ.copy()
168
+ env.setdefault("MN_WEB_UI_HOST", web_ui_host)
169
+ env.setdefault("MN_API_HOST", _api_host())
170
+ env.setdefault("MN_API_PORT", os.getenv("MN_API_PORT", "4001"))
171
+ console.print(f"=> Starting mn-web-ui (Vite on {web_ui_host}:5173)...")
172
+ with open(WEB_UI_LOG, "w") as out:
173
+ p_web = subprocess.Popen(
174
+ ["npm", "run", "dev", "--", "--host", web_ui_host],
175
+ cwd=web_ui_dir,
176
+ stdout=out,
177
+ stderr=subprocess.STDOUT,
178
+ env=env,
179
+ start_new_session=True
180
+ )
181
+ WEB_UI_PID_FILE.write_text(str(p_web.pid))
182
+ console.print(f" [green][Started][/green] Web UI (PID: {p_web.pid})")
183
+ return True
184
+
185
+ def _start_server(ip: str = None):
186
+ if check_status(API_PID_FILE) == 0:
187
+ console.print("[red]Error: MirrorNeuron API is already running.[/red]")
188
+ console.print("Use 'mn stop' to stop it first.")
189
+ raise typer.Exit(1)
190
+
191
+ try:
192
+ docker_status = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", "mirror-neuron-core"], capture_output=True, text=True)
193
+ if docker_status.stdout.strip() == "true":
194
+ console.print("[red]Error: MirrorNeuron Core (Docker) is already running.[/red]")
195
+ console.print("Use 'mn stop' to stop it first.")
196
+ raise typer.Exit(1)
197
+ except FileNotFoundError:
198
+ console.print("[red]Error: Docker is not installed or not in PATH.[/red]")
199
+ raise typer.Exit(1)
200
+
201
+ PID_DIR.mkdir(parents=True, exist_ok=True)
202
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
203
+
204
+ console.print("===========================================")
205
+ if ip:
206
+ console.print(f"Joining Cluster at {ip} in Detached Mode...")
207
+ else:
208
+ console.print("Starting Services in Detached Mode...")
209
+ console.print("===========================================")
210
+
211
+ env = os.environ.copy()
212
+ env.setdefault("MN_CORE_HOST", _core_host())
213
+ env.setdefault("MN_API_HOST", _api_host())
214
+ env.setdefault("MN_REDIS_HOST", _redis_host())
215
+ env.setdefault("MN_EPMD_HOST", _epmd_host())
216
+ env.setdefault("MN_DIST_HOST", _dist_host())
217
+ env.setdefault("MN_WEB_UI_HOST", _web_ui_host())
218
+ env.setdefault("MN_CORE_GRPC_TARGET", f"{env['MN_CORE_HOST']}:{os.getenv('MN_GRPC_PORT', '50051')}")
219
+ if ip:
220
+ env["MN_CLUSTER_NODES"] = ip
221
+
222
+ console.print("=> Starting MirrorNeuron Core Service (Docker)...")
223
+ logger.info("Starting MirrorNeuron Core Docker container")
224
+ subprocess.run(["docker", "rm", "-f", "mirror-neuron-core"], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
225
+
226
+ cmd = ["docker", "run", "-d", "--name", "mirror-neuron-core"]
227
+
228
+ # We want clustering to work, so we need to set the node name.
229
+ import socket
230
+ try:
231
+ local_ip = socket.gethostbyname(socket.gethostname())
232
+ except socket.gaierror:
233
+ local_ip = "127.0.0.1"
234
+ # As a fallback or override, you could prompt the user, but we'll try to guess it.
235
+ # To be safe for this specific test, we know local is 192.168.4.25 and remote is 192.168.4.173.
236
+ # Let's see if we can get the actual external IP:
237
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
238
+ try:
239
+ # doesn't even have to be reachable
240
+ s.connect(('10.255.255.255', 1))
241
+ local_ip = s.getsockname()[0]
242
+ except Exception:
243
+ local_ip = '127.0.0.1'
244
+ finally:
245
+ s.close()
246
+
247
+ cmd.extend(["-e", f"MN_NODE_NAME=mirror_neuron@{local_ip}"])
248
+
249
+ core_publish_host = _docker_publish_host(env["MN_CORE_HOST"])
250
+ epmd_publish_host = _docker_publish_host(env["MN_EPMD_HOST"])
251
+ dist_publish_host = _docker_publish_host(env["MN_DIST_HOST"])
252
+
253
+ system_name = os.uname().sysname
254
+
255
+ if system_name == "Darwin":
256
+ cmd.extend(["-p", f"{core_publish_host}:50051:50051", "-p", f"{epmd_publish_host}:4369:4369"])
257
+ # Publish the distribution ports too
258
+ for port in range(9000, 9011):
259
+ cmd.extend(["-p", f"{dist_publish_host}:{port}:{port}"])
260
+ cmd.extend(["-e", "MN_REDIS_URL=redis://host.docker.internal:6379/0"])
261
+ cmd.extend(["-e", "MN_EXECUTOR_MAX_CONCURRENCY=50"])
262
+ else:
263
+ cmd.extend(["--network", "host"])
264
+ cmd.extend(["-e", "MN_EXECUTOR_MAX_CONCURRENCY=50"])
265
+
266
+ if system_name == "Darwin":
267
+ cmd.extend(["-e", "MN_CORE_HOST=0.0.0.0"])
268
+ else:
269
+ cmd.extend(["-e", f"MN_CORE_HOST={env['MN_CORE_HOST']}"])
270
+ cmd.extend(["-e", f"MN_REDIS_HOST={env['MN_REDIS_HOST']}"])
271
+ cmd.extend(["-e", f"ERL_EPMD_ADDRESS={env['MN_EPMD_HOST']}"])
272
+
273
+ if ip:
274
+ cmd.extend(["-e", f"MN_CLUSTER_NODES=mirror_neuron@{ip}"])
275
+ # A node joining another should also point its redis to the main cluster leader if not specified
276
+ cmd.extend(["-e", f"MN_REDIS_URL=redis://{ip}:6379/0"])
277
+
278
+ for env_name in [
279
+ "SLACK_BOT_TOKEN",
280
+ "SLACK_DEFAULT_CHANNEL",
281
+ "SLACK_API_BASE_URL",
282
+ "MN_SLACK_BOT_TOKEN",
283
+ "MN_SLACK_DEFAULT_CHANNEL",
284
+ "MN_SLACK_API_BASE_URL",
285
+ ]:
286
+ if os.getenv(env_name):
287
+ cmd.extend(["-e", env_name])
288
+
289
+ cmd.append("mirror-neuron-core:latest")
290
+
291
+ try:
292
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL)
293
+ console.print(" [green][Started][/green] Core Service (Docker: mirror-neuron-core)")
294
+ except subprocess.CalledProcessError:
295
+ console.print("[red]Failed to start Core Service Docker container.[/red]")
296
+ raise typer.Exit(1)
297
+
298
+ console.print("=> Waiting for Elixir to boot...")
299
+ time.sleep(3)
300
+
301
+ api_bin = VENV_DIR / "bin" / "mn-api"
302
+ if api_bin.exists():
303
+ console.print("=> Starting mn-api (REST on port 4001)...")
304
+ with open(API_LOG, "w") as out:
305
+ p_api = subprocess.Popen(
306
+ [str(api_bin)],
307
+ stdout=out,
308
+ stderr=subprocess.STDOUT,
309
+ env=env,
310
+ start_new_session=True
311
+ )
312
+ API_PID_FILE.write_text(str(p_api.pid))
313
+ console.print(f" [green][Started][/green] REST API (PID: {p_api.pid})")
314
+ else:
315
+ console.print("[yellow]=> Warning: mn-api not found, skipping.[/yellow]")
316
+
317
+ web_ui_available = _start_web_ui_if_installed()
318
+
319
+ console.print("\n===========================================")
320
+ if ip:
321
+ console.print(f"MirrorNeuron is running and attempting to join cluster at {ip}!")
322
+ else:
323
+ console.print("MirrorNeuron is running in the background!")
324
+ _print_service_endpoints(ip, web_ui_available)
325
+ console.print("Logs are available at:")
326
+ console.print(f" Core: {BEAM_LOG}")
327
+ console.print(f" API: {API_LOG}")
328
+ if WEB_UI_LOG.exists():
329
+ console.print(f" Web: {WEB_UI_LOG}")
330
+ console.print("\nRun 'mn stop' to shut down the services.")
331
+ console.print("===========================================")
mn_cli/shared.py ADDED
@@ -0,0 +1,13 @@
1
+ from mn_sdk import Client
2
+ from rich.console import Console
3
+ from mn_cli.config import CliConfig
4
+ from mn_cli.logging_config import configure_logging
5
+
6
+ config = CliConfig.from_env()
7
+ logger = configure_logging("mn-cli", config.log_path)
8
+ console = Console(no_color=config.output_mode == "plain")
9
+ client = Client(
10
+ target=config.grpc_target,
11
+ timeout=config.grpc_timeout_seconds,
12
+ auth_token=config.grpc_auth_token,
13
+ )