From 261a99446b5bdc5b3f2c4fef043b4c250138d455 Mon Sep 17 00:00:00 2001 From: Bin Le Date: Sun, 9 Aug 2026 11:45:45 -0400 Subject: [PATCH] feat: add --json --samples N for bounded one-shot NDJSON --- CHANGELOG.md | 15 +++++++++++++-- actop/actop.py | 19 +++++++++++++++++++ pyproject.toml | 2 +- tests/test_export.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad9b0d..2f3185c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ This project follows a Keep a Changelog-style format and uses version tags for r ## [Unreleased] +## [1.6.5] - 2026-08-09 + +### Added +- **`--json --samples N` — bounded NDJSON emission for agent/script one-shots.** + `--json` previously streamed until interrupted, forcing an agent or script to + kill the process after reading its record. `--samples N` (with `--json`) now + emits exactly `N` snapshot records then exits 0; `--samples 0` (default) + keeps the streaming behavior. The first record already carries real deltas + (the `Monitor` primes the baseline at construction), so `--json --samples 1` + is a clean single-snapshot tool call. + ## [1.6.4] - 2026-08-09 ### Fixed @@ -492,7 +503,7 @@ Reading-plane audit remediation (`docs/TODO-reading-plane-audit-2026-07-29.md` (`capture-tui-diagram`, `record-tui-gif`). ### Chore -- Add the `record-tui-gif` Claude Code skill (`.claude/skills/record-tui-gif/`): +- Add the `record-tui-gif` Claude Code skill (`.agents/skills/record-tui-gif/`): reproducible hero-GIF recording via `vhs` driven by a live GPU workload, so the capture can be refreshed after any TUI/layout change. @@ -726,7 +737,7 @@ Reading-plane audit remediation (`docs/TODO-reading-plane-audit-2026-07-29.md` New must-have items (fan RPM via SMC, net/disk I/O via native ctypes) and a deferred low-priority item (menu bar mode, explicitly after first market-promo push per `docs/RUNBOOK-launch-and-growth.md`). -- `.claude/skills/run-actop`: documents driving the TUI via tmux send-keys/ +- `.agents/skills/run-actop`: documents driving the TUI via tmux send-keys/ capture-pane for manual verification (Homebrew binary and local `.venv` dev build), including the sampler-init ready marker and how to confirm live updates vs. a static frame. diff --git a/actop/actop.py b/actop/actop.py index 1541af7..559aeb4 100644 --- a/actop/actop.py +++ b/actop/actop.py @@ -137,6 +137,14 @@ def build_parser(): metavar="PORT", help="Serve Prometheus metrics on http://0.0.0.0:PORT/metrics (no TUI)", ) + parser.add_argument( + "--samples", + type=_validate_samples, + default=0, + metavar="N", + help="With --json: emit N snapshot records then exit (0 = stream " + "indefinitely until interrupted)", + ) return parser @@ -186,6 +194,16 @@ def _validate_sustain_samples(value): return samples +def _validate_samples(value): + try: + samples = int(value) + except (TypeError, ValueError) as error: + raise argparse.ArgumentTypeError("samples must be an integer") from error + if samples < 0: + raise argparse.ArgumentTypeError("samples must be >= 0") + return samples + + def _validate_port(value): try: port = int(value) @@ -236,6 +254,7 @@ def _run_export(args): export.run_json_stream( interval_s, subsamples, + max_samples=max(0, int(getattr(args, "samples", 0) or 0)), include_processes=include_processes, proc_filter=proc_filter, ) diff --git a/pyproject.toml b/pyproject.toml index b9b2a6b..cc634f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "actop" -version = "1.6.4" +version = "1.6.5" description = "Apple Silicon (M1–M4) power, GPU, ANE & memory-bandwidth monitor — sudoless TUI + Python API for profiling local LLM / MLX / CoreML inference" readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_export.py b/tests/test_export.py index e79bd5f..ac3d479 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -398,6 +398,38 @@ def test_json_stream_proc_filter_implies_show_processes(): ) +@pytest.mark.local +def test_json_samples_limits_records_and_exits_zero(): + # Agent-facing contract: `--json --samples N` must emit exactly N NDJSON + # records and exit 0 on its own — an agent's one-shot tool call must not + # hang (infinite stream) nor report failure (130) on natural completion. + # Without the flag wired through _run_export the process streams forever + # and this test's timeout trips; with max_samples>0 run_json_stream breaks + # the loop and _run_export returns 0. + process = subprocess.run( + [ + sys.executable, + "-m", + "actop.actop", + "--json", + "--samples", + "2", + "--interval", + "1", + ], + capture_output=True, + text=True, + timeout=20, + ) + + lines = [ln for ln in process.stdout.splitlines() if ln.strip()] + assert process.returncode == 0, process.stderr + assert len(lines) == 2, f"expected exactly 2 records, got {len(lines)}" + record = json.loads(lines[0]) + assert "cpu_watts" in record + assert "p_cores" in record + + @pytest.mark.local def test_serve_prometheus_includes_process_gauges_with_show_processes(): import urllib.request