mirrorneuron-cli 1.1.0__tar.gz → 1.1.2__tar.gz

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 (39) hide show
  1. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/.github/workflows/ci.yml +1 -1
  2. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/.github/workflows/release.yml +3 -3
  3. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/PKG-INFO +5 -4
  4. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/README.md +3 -2
  5. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mirrorneuron_cli.egg-info/PKG-INFO +5 -4
  6. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/error_handler.py +1 -2
  7. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/libs/job_cmds.py +68 -3
  8. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/libs/run_cmds.py +2 -5
  9. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/libs/sys_cmds.py +0 -1
  10. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/libs/ui.py +1 -1
  11. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/main.py +1 -0
  12. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/update_cmds.py +3 -1
  13. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/pyproject.toml +1 -1
  14. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/tests/test_blueprint_cmds.py +0 -3
  15. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/tests/test_job_cmds.py +64 -2
  16. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/tests/test_run_cmds.py +7 -8
  17. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/tests/test_server_cmds.py +1 -4
  18. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/tests/test_sys_cmds.py +1 -4
  19. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/.gitignore +0 -0
  20. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/LICENSE +0 -0
  21. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/RELEASE.md +0 -0
  22. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mirrorneuron_cli.egg-info/SOURCES.txt +0 -0
  23. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mirrorneuron_cli.egg-info/dependency_links.txt +0 -0
  24. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mirrorneuron_cli.egg-info/entry_points.txt +0 -0
  25. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mirrorneuron_cli.egg-info/requires.txt +0 -0
  26. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mirrorneuron_cli.egg-info/top_level.txt +0 -0
  27. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/__init__.py +0 -0
  28. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/config.py +0 -0
  29. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/libs/__init__.py +0 -0
  30. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/libs/blueprint_cmds.py +0 -0
  31. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/logging_config.py +0 -0
  32. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/server_cmds.py +0 -0
  33. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/mn_cli/shared.py +0 -0
  34. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/scripts/check-release-artifacts.sh +0 -0
  35. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/scripts/make-release-zip.sh +0 -0
  36. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/scripts/validate-version-tag.sh +0 -0
  37. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/setup.cfg +0 -0
  38. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/tests/conftest.py +0 -0
  39. {mirrorneuron_cli-1.1.0 → mirrorneuron_cli-1.1.2}/tests/test_update_cmds.py +0 -0
@@ -102,7 +102,7 @@ jobs:
102
102
  if [[ "$GITHUB_REPOSITORY" == "MirrorNeuronLab/mn-system-tests" ]]; then
103
103
  python -m pip install pytest pytest-cov requests
104
104
  python -m pip install "mirrorneuron-python-sdk @ git+https://github.com/MirrorNeuronLab/mn-python-sdk.git"
105
- python -m pip install "mn-cli @ git+https://github.com/MirrorNeuronLab/mn-cli.git"
105
+ python -m pip install "mirrorneuron-cli @ git+https://github.com/MirrorNeuronLab/mn-cli.git"
106
106
  elif [[ -f requirements.txt && "${{ steps.detect.outputs.has_python_package }}" != "true" ]]; then
107
107
  python -m pip install -r requirements.txt
108
108
  fi
@@ -128,7 +128,7 @@ jobs:
128
128
  if [[ "$GITHUB_REPOSITORY" == "MirrorNeuronLab/mn-system-tests" ]]; then
129
129
  python -m pip install pytest pytest-cov requests
130
130
  python -m pip install "mirrorneuron-python-sdk @ git+https://github.com/MirrorNeuronLab/mn-python-sdk.git"
131
- python -m pip install "mn-cli @ git+https://github.com/MirrorNeuronLab/mn-cli.git"
131
+ python -m pip install "mirrorneuron-cli @ git+https://github.com/MirrorNeuronLab/mn-cli.git"
132
132
  elif [[ -f requirements.txt && "${{ steps.detect.outputs.has_python_package }}" != "true" ]]; then
133
133
  python -m pip install -r requirements.txt
134
134
  fi
@@ -345,7 +345,7 @@ jobs:
345
345
  run: |
346
346
  set -euo pipefail
347
347
 
348
- if gh release view "$TAG_NAME" >/dev/null 2>&1; then
348
+ if gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
349
349
  echo "Release $TAG_NAME already exists; refusing to overwrite it." >&2
350
350
  exit 1
351
351
  fi
@@ -356,7 +356,7 @@ jobs:
356
356
  exit 1
357
357
  fi
358
358
 
359
- release_args=(release create "$TAG_NAME")
359
+ release_args=(release create "$TAG_NAME" --repo "$GITHUB_REPOSITORY")
360
360
  release_args+=("${assets[@]}")
361
361
  release_args+=(--title "$TAG_NAME" --verify-tag --generate-notes)
362
362
 
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
7
- Requires-Python: >=3.9
7
+ Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: mirrorneuron-python-sdk
@@ -31,7 +31,7 @@ The CLI submits workflow bundles, monitors jobs, manages the local runtime servi
31
31
 
32
32
  | Area | Tooling |
33
33
  | --- | --- |
34
- | Runtime | Python 3.9+ |
34
+ | Runtime | Python 3.11+ |
35
35
  | CLI framework | Typer |
36
36
  | Terminal rendering | Rich |
37
37
  | Core client | `mirrorneuron-python-sdk` |
@@ -39,7 +39,7 @@ The CLI submits workflow bundles, monitors jobs, manages the local runtime servi
39
39
 
40
40
  ## Prerequisites
41
41
 
42
- - Python 3.9 or newer.
42
+ - Python 3.11 or newer.
43
43
  - A MirrorNeuron core reachable over gRPC.
44
44
  - Docker for the default local core and Redis workflow.
45
45
  - Optional: the released-package installer from `mn-deploy`, which installs and wires the CLI automatically.
@@ -91,6 +91,7 @@ Submit and inspect a workflow:
91
91
  mn validate ./bundle
92
92
  mn run ./bundle
93
93
  mn list
94
+ mn unfinished
94
95
  mn status <job_id>
95
96
  mn monitor <job_id>
96
97
  ```
@@ -17,7 +17,7 @@ The CLI submits workflow bundles, monitors jobs, manages the local runtime servi
17
17
 
18
18
  | Area | Tooling |
19
19
  | --- | --- |
20
- | Runtime | Python 3.9+ |
20
+ | Runtime | Python 3.11+ |
21
21
  | CLI framework | Typer |
22
22
  | Terminal rendering | Rich |
23
23
  | Core client | `mirrorneuron-python-sdk` |
@@ -25,7 +25,7 @@ The CLI submits workflow bundles, monitors jobs, manages the local runtime servi
25
25
 
26
26
  ## Prerequisites
27
27
 
28
- - Python 3.9 or newer.
28
+ - Python 3.11 or newer.
29
29
  - A MirrorNeuron core reachable over gRPC.
30
30
  - Docker for the default local core and Redis workflow.
31
31
  - Optional: the released-package installer from `mn-deploy`, which installs and wires the CLI automatically.
@@ -77,6 +77,7 @@ Submit and inspect a workflow:
77
77
  mn validate ./bundle
78
78
  mn run ./bundle
79
79
  mn list
80
+ mn unfinished
80
81
  mn status <job_id>
81
82
  mn monitor <job_id>
82
83
  ```
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
7
- Requires-Python: >=3.9
7
+ Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: mirrorneuron-python-sdk
@@ -31,7 +31,7 @@ The CLI submits workflow bundles, monitors jobs, manages the local runtime servi
31
31
 
32
32
  | Area | Tooling |
33
33
  | --- | --- |
34
- | Runtime | Python 3.9+ |
34
+ | Runtime | Python 3.11+ |
35
35
  | CLI framework | Typer |
36
36
  | Terminal rendering | Rich |
37
37
  | Core client | `mirrorneuron-python-sdk` |
@@ -39,7 +39,7 @@ The CLI submits workflow bundles, monitors jobs, manages the local runtime servi
39
39
 
40
40
  ## Prerequisites
41
41
 
42
- - Python 3.9 or newer.
42
+ - Python 3.11 or newer.
43
43
  - A MirrorNeuron core reachable over gRPC.
44
44
  - Docker for the default local core and Redis workflow.
45
45
  - Optional: the released-package installer from `mn-deploy`, which installs and wires the CLI automatically.
@@ -91,6 +91,7 @@ Submit and inspect a workflow:
91
91
  mn validate ./bundle
92
92
  mn run ./bundle
93
93
  mn list
94
+ mn unfinished
94
95
  mn status <job_id>
95
96
  mn monitor <job_id>
96
97
  ```
@@ -1,4 +1,3 @@
1
- import sys
2
1
  import grpc
3
2
  from rich.console import Console
4
3
  from mn_cli.config import CliConfig
@@ -38,7 +37,7 @@ def handle_cli_error(e: Exception, console: Console, context: str = ""):
38
37
  elif code == grpc.StatusCode.INTERNAL and "not found" in str(details).lower():
39
38
  console.print(f"[red]Error: Cannot find the job by ID. ({details})[/red]")
40
39
  elif code == grpc.StatusCode.INTERNAL and "terminal state" in str(details).lower():
41
- console.print(f"[red]Error: Job is already in a terminal state and cannot be modified.[/red]")
40
+ console.print("[red]Error: Job is already in a terminal state and cannot be modified.[/red]")
42
41
  elif code == grpc.StatusCode.RESOURCE_EXHAUSTED:
43
42
  console.print("[yellow]Runtime is under CPU/GPU/memory pressure and is not accepting new jobs.[/yellow]")
44
43
  console.print(f"[dim]{details}[/dim]")
@@ -34,16 +34,18 @@ def list_jobs(running_only: bool = typer.Option(False, "--running-only", help="O
34
34
  jobs_json = client.list_jobs()
35
35
  data = json.loads(jobs_json)
36
36
 
37
- table = Table("Job ID", "Graph ID", "Status", "Submitted At")
37
+ table = recovery_table("Submitted At")
38
38
  for job in data.get("data", []):
39
39
  status = job.get("status", "N/A")
40
- if running_only and status not in ["running", "pending", "scheduled", "validated", "paused"]:
40
+ live_statuses = ["running", "pending", "scheduled", "validated", "paused"]
41
+ if running_only and status not in live_statuses:
41
42
  continue
42
-
43
+
43
44
  table.add_row(
44
45
  job.get("job_id", "N/A"),
45
46
  job.get("graph_id", "N/A"),
46
47
  status,
48
+ recovery_label(job),
47
49
  job.get("submitted_at", "N/A"),
48
50
  )
49
51
  console.print(table)
@@ -60,6 +62,7 @@ def clear():
60
62
  except Exception as e:
61
63
  handle_cli_error(e, console, 'clear')
62
64
 
65
+
63
66
  def cancel(job_id: str):
64
67
  """Cancel a running job"""
65
68
  try:
@@ -87,6 +90,68 @@ def resume(job_id: str):
87
90
  handle_cli_error(e, console, 'resume')
88
91
 
89
92
 
93
+ def unfinished():
94
+ """List unfinished jobs that may need recovery or manual resume"""
95
+ try:
96
+ jobs_json = client.list_jobs(include_terminal=False)
97
+ data = json.loads(jobs_json)
98
+ jobs = data.get("data", [])
99
+
100
+ if not jobs:
101
+ console.print("[green]No unfinished jobs.[/green]")
102
+ return
103
+
104
+ table = recovery_table("Updated At", include_review=True)
105
+ for job in jobs:
106
+ table.add_row(
107
+ job.get("job_id", "N/A"),
108
+ job.get("graph_id", "N/A"),
109
+ job.get("status", "N/A"),
110
+ recovery_label(job),
111
+ "yes" if recovery_requires_review(job) else "no",
112
+ job.get("updated_at") or job.get("submitted_at", "N/A"),
113
+ )
114
+
115
+ console.print(table)
116
+ for job in jobs:
117
+ review = "yes" if recovery_requires_review(job) else "no"
118
+ console.print(
119
+ f"{job.get('job_id', 'N/A')} recovery={recovery_label(job)} review={review}"
120
+ )
121
+ console.print(
122
+ "Use [bold]mn status <job_id>[/bold] to inspect and "
123
+ "[bold]mn resume <job_id>[/bold] to continue a paused run."
124
+ )
125
+ except Exception as e:
126
+ handle_cli_error(e, console, 'list_jobs')
127
+
128
+
129
+ def recovery_label(job: dict) -> str:
130
+ recovery = job.get("recovery") or {}
131
+ return (
132
+ job.get("recovery_status")
133
+ or recovery.get("status")
134
+ or "normal"
135
+ )
136
+
137
+
138
+ def recovery_requires_review(job: dict) -> bool:
139
+ recovery = job.get("recovery") or {}
140
+ return bool(job.get("recovery_requires_review") or recovery.get("requires_review"))
141
+
142
+
143
+ def recovery_table(time_column: str, include_review: bool = False) -> Table:
144
+ table = Table()
145
+ table.add_column("Job ID", no_wrap=True)
146
+ table.add_column("Graph ID", no_wrap=True)
147
+ table.add_column("Status", no_wrap=True)
148
+ table.add_column("Recovery", no_wrap=True)
149
+ if include_review:
150
+ table.add_column("Review", no_wrap=True)
151
+ table.add_column(time_column, no_wrap=True)
152
+ return table
153
+
154
+
90
155
  def nodes():
91
156
  """Get system summary and nodes"""
92
157
  try:
@@ -224,7 +224,6 @@ def _stream_and_format_events(
224
224
  )
225
225
 
226
226
  status_text = "Unknown / Detached"
227
- status_color = "yellow"
228
227
  msg_count = 0
229
228
 
230
229
  try:
@@ -284,12 +283,10 @@ def _stream_and_format_events(
284
283
  description="[green]Completed successfully.",
285
284
  )
286
285
  status_text = "Success"
287
- status_color = "green"
288
286
  break
289
287
  elif event_type == "job_failed":
290
288
  progress.update(job_task, description="[red]Job failed.")
291
289
  status_text = "Failed"
292
- status_color = "red"
293
290
  break
294
291
  else:
295
292
  progress.update(
@@ -706,7 +703,7 @@ def _live_monitor(job_id: str):
706
703
  view = MonitorView()
707
704
 
708
705
  try:
709
- with Live(view, refresh_per_second=12, console=console) as live:
706
+ with Live(view, refresh_per_second=12, console=console):
710
707
  while True:
711
708
  try:
712
709
  job_json = client.get_job(job_id)
@@ -771,7 +768,7 @@ def result(job_id: str):
771
768
  if res_file.exists():
772
769
  console.print(f"[green]Final result saved to: {res_file}[/green]")
773
770
  else:
774
- console.print(f"[yellow]No final result found (job might not be completed).[/yellow]")
771
+ console.print("[yellow]No final result found (job might not be completed).[/yellow]")
775
772
 
776
773
  if stream_file.exists():
777
774
  console.print(f"[green]Stream results saved to: {stream_file}[/green]")
@@ -1,4 +1,3 @@
1
- import typer
2
1
  import subprocess
3
2
  import os
4
3
  import time
@@ -25,7 +25,7 @@ def generate_live_layout(job_id: str, data: Dict[str, Any]) -> Group:
25
25
  frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
26
26
  idx = int(time.time() * 12.5) % len(frames)
27
27
  spinner_str = f"[cyan]{frames[idx]}[/cyan]"
28
- except:
28
+ except Exception:
29
29
  pass
30
30
 
31
31
  info_text = (
@@ -24,6 +24,7 @@ app.command(name="clear")(job_cmds.clear)
24
24
  app.command(name="cancel")(job_cmds.cancel)
25
25
  app.command(name="pause")(job_cmds.pause)
26
26
  app.command(name="resume")(job_cmds.resume)
27
+ app.command(name="unfinished")(job_cmds.unfinished)
27
28
  app.command(name="nodes")(job_cmds.nodes)
28
29
  app.command(name="metrics")(job_cmds.metrics)
29
30
  app.command(name="dead-letters")(job_cmds.dead_letters)
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import os
3
5
  import subprocess
@@ -13,7 +15,7 @@ from typing import Optional
13
15
  import typer
14
16
  from rich.console import Console
15
17
 
16
- from mn_cli.server_cmds import DIR, VENV_DIR, WEB_UI_DIRS, _start_server
18
+ from mn_cli.server_cmds import DIR, WEB_UI_DIRS, _start_server
17
19
 
18
20
  console = Console()
19
21
 
@@ -3,7 +3,7 @@ name = "mirrorneuron-cli"
3
3
  dynamic = ["version"]
4
4
  description = "MirrorNeuron CLI"
5
5
  readme = "README.md"
6
- requires-python = ">=3.9"
6
+ requires-python = ">=3.11"
7
7
  license = "MIT"
8
8
  classifiers = [
9
9
  "Programming Language :: Python :: 3",
@@ -1,11 +1,8 @@
1
1
  import pytest
2
2
  import importlib.util
3
3
  import json
4
- import subprocess
5
4
  from typer.testing import CliRunner
6
5
  from mn_cli.main import app
7
- import os
8
- from pathlib import Path
9
6
 
10
7
  runner = CliRunner()
11
8
  requires_blueprint_support = pytest.mark.skipif(
@@ -1,4 +1,3 @@
1
- import pytest
2
1
  from typer.testing import CliRunner
3
2
  import json
4
3
 
@@ -47,11 +46,12 @@ def test_status_error(mocker):
47
46
  def test_list_jobs_success(mocker):
48
47
  mock_list = mocker.patch(
49
48
  'mn_cli.libs.job_cmds.client.list_jobs',
50
- return_value=json.dumps({"data": [{"job_id": "job-1", "graph_id": "g-1", "status": "completed", "submitted_at": "2023-10-01"}]})
49
+ return_value=json.dumps({"data": [{"job_id": "job-1", "graph_id": "g-1", "status": "completed", "submitted_at": "2023-10-01", "recovery_status": "normal"}]})
51
50
  )
52
51
  result = runner.invoke(app, ["list"])
53
52
  assert result.exit_code == 0
54
53
  assert "job-1" in result.stdout
54
+ assert "normal" in result.stdout
55
55
  mock_list.assert_called_once()
56
56
 
57
57
  def test_list_jobs_running_only_success(mocker):
@@ -126,6 +126,68 @@ def test_resume_error(mocker):
126
126
  assert result.exit_code == 0
127
127
  assert "Error resuming job: Fail" in result.stdout
128
128
 
129
+
130
+ def test_unfinished_jobs_shows_recovery_review_state(mocker):
131
+ mock_list = mocker.patch(
132
+ 'mn_cli.libs.job_cmds.client.list_jobs',
133
+ return_value=json.dumps({"data": [
134
+ {
135
+ "job_id": "job-review",
136
+ "graph_id": "g-review",
137
+ "status": "paused",
138
+ "updated_at": "2026-05-05T00:00:00Z",
139
+ "recovery_status": "paused_for_review",
140
+ "recovery_requires_review": True,
141
+ }
142
+ ]})
143
+ )
144
+
145
+ result = runner.invoke(app, ["unfinished"])
146
+
147
+ assert result.exit_code == 0
148
+ assert "job-review" in result.stdout
149
+ assert "paused_for_review" in result.stdout
150
+ assert "yes" in result.stdout
151
+ assert "mn resume <job_id>" in result.stdout
152
+ mock_list.assert_called_once_with(include_terminal=False)
153
+
154
+
155
+ def test_unfinished_jobs_empty(mocker):
156
+ mocker.patch(
157
+ 'mn_cli.libs.job_cmds.client.list_jobs',
158
+ return_value=json.dumps({"data": []})
159
+ )
160
+
161
+ result = runner.invoke(app, ["unfinished"])
162
+
163
+ assert result.exit_code == 0
164
+ assert "No unfinished jobs" in result.stdout
165
+
166
+
167
+ def test_unfinished_jobs_accepts_nested_recovery_summary(mocker):
168
+ mocker.patch(
169
+ 'mn_cli.libs.job_cmds.client.list_jobs',
170
+ return_value=json.dumps({"data": [
171
+ {
172
+ "job_id": "job-nested",
173
+ "graph_id": "g-nested",
174
+ "status": "paused",
175
+ "submitted_at": "2026-05-05T00:00:00Z",
176
+ "recovery": {
177
+ "status": "paused_for_review",
178
+ "requires_review": True,
179
+ },
180
+ }
181
+ ]})
182
+ )
183
+
184
+ result = runner.invoke(app, ["unfinished"])
185
+
186
+ assert result.exit_code == 0
187
+ assert "job-nested" in result.stdout
188
+ assert "paused_for_review" in result.stdout
189
+ assert "review=yes" in result.stdout
190
+
129
191
  def test_nodes_success(mocker):
130
192
  mock_nodes = mocker.patch('mn_cli.libs.job_cmds.client.get_system_summary', return_value='{"nodes": ["node1"]}')
131
193
  result = runner.invoke(app, ["nodes"])
@@ -1,4 +1,3 @@
1
- import pytest
2
1
  import json
3
2
  import logging
4
3
  import re
@@ -287,7 +286,7 @@ def test_job_log_writer_extracts_web_ui_url_once():
287
286
  assert writer.record_web_ui_url(event) is None
288
287
 
289
288
  def test_run_error_submitting(mocker, tmp_path):
290
- mock_submit = mocker.patch('mn_cli.libs.run_cmds.client.submit_job', side_effect=Exception("API failure"))
289
+ mocker.patch('mn_cli.libs.run_cmds.client.submit_job', side_effect=Exception("API failure"))
291
290
 
292
291
  bundle_dir = tmp_path / "run_bundle"
293
292
  bundle_dir.mkdir()
@@ -300,8 +299,8 @@ def test_run_error_submitting(mocker, tmp_path):
300
299
  assert "Error running bundle: API failure" in result.stdout
301
300
 
302
301
  def test_run_keyboard_interrupt(mocker, tmp_path):
303
- mock_submit = mocker.patch('mn_cli.libs.run_cmds.client.submit_job', return_value="job-123")
304
- mock_stream = mocker.patch('mn_cli.libs.run_cmds.client.stream_events', side_effect=KeyboardInterrupt)
302
+ mocker.patch('mn_cli.libs.run_cmds.client.submit_job', return_value="job-123")
303
+ mocker.patch('mn_cli.libs.run_cmds.client.stream_events', side_effect=KeyboardInterrupt)
305
304
 
306
305
  bundle_dir = tmp_path / "run_bundle"
307
306
  bundle_dir.mkdir()
@@ -343,7 +342,7 @@ def test_monitor_error(mocker):
343
342
  assert result.exit_code == 0
344
343
  assert "Error fetching job: Network fail" in result.stdout
345
344
  def test_result_success(mocker, tmp_path):
346
- mock_get = mocker.patch('mn_cli.libs.run_cmds.client.get_job', return_value=json.dumps({
345
+ mocker.patch('mn_cli.libs.run_cmds.client.get_job', return_value=json.dumps({
347
346
  "job": {"status": "completed", "result": {"test": "result"}},
348
347
  "recent_events": []
349
348
  }))
@@ -378,7 +377,7 @@ def test_result_error(mocker):
378
377
  assert "Error fetching results: DB Error" in result.stdout
379
378
 
380
379
  def test_stream_bad_json(mocker, tmp_path):
381
- mock_stream = mocker.patch('mn_cli.libs.run_cmds.client.stream_events', return_value=[
380
+ mocker.patch('mn_cli.libs.run_cmds.client.stream_events', return_value=[
382
381
  "invalid json format",
383
382
  json.dumps({"type": "job_failed"})
384
383
  ])
@@ -415,7 +414,7 @@ def test_stream_all_events(mocker, tmp_path):
415
414
  json.dumps({"type": "custom_progressive", "payload": {"foo": "progressive"}}),
416
415
  json.dumps({"type": "job_completed", "result": {"foo": "bar"}})
417
416
  ]
418
- mock_stream = mocker.patch('mn_cli.libs.run_cmds.client.stream_events', return_value=events)
417
+ mocker.patch('mn_cli.libs.run_cmds.client.stream_events', return_value=events)
419
418
  mocker.patch('mn_cli.libs.run_cmds.client.submit_job', return_value="job-123")
420
419
 
421
420
  bundle_dir = tmp_path / "run_bundle"
@@ -430,7 +429,7 @@ def test_stream_all_events(mocker, tmp_path):
430
429
  assert "result_stream.txt" in result.stdout
431
430
 
432
431
  def test_stream_keyboard_interrupt(mocker, tmp_path):
433
- mock_stream = mocker.patch('mn_cli.libs.run_cmds.client.stream_events', side_effect=KeyboardInterrupt)
432
+ mocker.patch('mn_cli.libs.run_cmds.client.stream_events', side_effect=KeyboardInterrupt)
434
433
  mocker.patch('mn_cli.libs.run_cmds.client.submit_job', return_value="job-123")
435
434
 
436
435
  bundle_dir = tmp_path / "run_bundle"
@@ -1,9 +1,6 @@
1
1
  import pytest
2
- import os
3
- import signal
4
2
  import subprocess
5
3
  from io import StringIO
6
- from pathlib import Path
7
4
  from rich.console import Console
8
5
  from mn_cli.server_cmds import check_status, kill_tree, _start_server, find_web_ui_dir, _start_web_ui_if_installed, _print_service_endpoints
9
6
  import typer
@@ -34,7 +31,7 @@ def test_kill_tree(mocker):
34
31
  mock_kill = mocker.patch('mn_cli.server_cmds.os.kill')
35
32
 
36
33
  # Mock pgrep to return children
37
- mock_check_output = mocker.patch('mn_cli.server_cmds.subprocess.check_output', side_effect=[
34
+ mocker.patch('mn_cli.server_cmds.subprocess.check_output', side_effect=[
38
35
  b" 1235 \n 1236 \n", # First call for parent 1234
39
36
  subprocess.CalledProcessError(1, "pgrep"), # Second call for child 1235
40
37
  subprocess.CalledProcessError(1, "pgrep") # Third call for child 1236
@@ -1,8 +1,5 @@
1
- import pytest
2
1
  from typer.testing import CliRunner
3
2
  from mn_cli.main import app
4
- import os
5
- from pathlib import Path
6
3
 
7
4
  runner = CliRunner()
8
5
 
@@ -28,7 +25,7 @@ def test_leave_success(mocker):
28
25
 
29
26
  def test_leave_error(mocker):
30
27
  import mn_cli.shared
31
- mock_remove = mocker.patch.object(mn_cli.shared.client, 'remove_node', side_effect=Exception("Timeout"))
28
+ mocker.patch.object(mn_cli.shared.client, 'remove_node', side_effect=Exception("Timeout"))
32
29
  result = runner.invoke(app, ["leave", "mirror_neuron@1.2.3.4"])
33
30
  assert result.exit_code == 0
34
31
  assert "Error removing node: Timeout" in result.stdout