diff --git a/src/ralphkit/local.py b/src/ralphkit/local.py index 6c58d6a..3e2a5a6 100644 --- a/src/ralphkit/local.py +++ b/src/ralphkit/local.py @@ -7,6 +7,7 @@ from ralphkit.tmux import ( build_submission_metadata, build_job_script, + current_version, job_path_local, parse_session_list, log_path_local, @@ -35,7 +36,14 @@ def submit_local( _check_tmux() ralph_cmd = f"ralphkit {subcommand} " + shlex.join(ralph_args) - script = build_job_script(job_id, ralph_cmd, working_dir, isolation=isolation) + script = build_job_script( + job_id, + ralph_cmd, + working_dir, + isolation=isolation, + package_spec="local-cli", + caller_version=current_version(), + ) script_file = script_path_local(job_id) meta_file = meta_path_local(job_id) job_dir = job_path_local(job_id) @@ -52,6 +60,8 @@ def submit_local( working_dir=working_dir, isolation=isolation, scratch_dir=str(job_dir), + package_spec="local-cli", + caller_version=current_version(), ) | { "submitted_at": datetime.now().isoformat(), diff --git a/src/ralphkit/remote.py b/src/ralphkit/remote.py index 3cfb304..a5dd85d 100644 --- a/src/ralphkit/remote.py +++ b/src/ralphkit/remote.py @@ -9,6 +9,7 @@ TMUX_LIST_FORMAT, build_submission_metadata, build_job_script, + current_version, parse_session_list, ) @@ -54,6 +55,22 @@ def _is_prerelease(version: str) -> bool: return bool(re.search(r"(a|b|rc|dev|alpha|beta)\d*", version)) +def _resolve_ralph_version(ralph_version: str | None) -> str | None: + if ralph_version in (None, "", "latest"): + return None + if ralph_version == "current": + resolved = current_version() + if not resolved: + raise SystemExit("Could not determine the current ralphkit version.") + return resolved + return ralph_version + + +def _package_spec(ralph_version: str | None) -> str: + resolved = _resolve_ralph_version(ralph_version) + return f"ralphkit=={resolved}" if resolved else "ralphkit@latest" + + def _ralph_cmd( ralph_args: list[str], ralph_version: str | None = None, @@ -61,9 +78,10 @@ def _ralph_cmd( subcommand: str, ) -> str: """Build the uvx ralphkit command string.""" - pkg = f"ralphkit=={ralph_version}" if ralph_version else "ralphkit@latest" + resolved_version = _resolve_ralph_version(ralph_version) + pkg = f"ralphkit=={resolved_version}" if resolved_version else "ralphkit@latest" parts = ["uvx", "--refresh", "--from", shlex.quote(pkg)] - if ralph_version and _is_prerelease(ralph_version): + if resolved_version and _is_prerelease(resolved_version): parts += ["--prerelease", "allow"] parts += ["ralphkit", subcommand] return " ".join(parts) + " " + shlex.join(ralph_args) @@ -81,6 +99,9 @@ def submit_job( plan_content: str | None = None, ) -> None: """Submit a ralphkit job to a remote host via SSH + tmux.""" + caller_version = current_version() + package_spec = _package_spec(ralph_version) + # Pre-flight: tmux available? result = _ssh_run(host, "command -v tmux", check=False, login_shell=True) if result.returncode != 0: @@ -134,6 +155,8 @@ def submit_job( working_dir=working_dir, isolation=isolation, scratch_dir=f"{remote_home}/.local/share/ralphkit/jobs/{job_id}", + package_spec=package_spec, + caller_version=caller_version, ) | { "submitted_at": datetime.now().isoformat(), @@ -156,6 +179,8 @@ def submit_job( ralph_cmd, working_dir=working_dir, isolation=isolation, + package_spec=package_spec, + caller_version=caller_version, ) # Upload script via ssh stdin pipe diff --git a/src/ralphkit/runner.py b/src/ralphkit/runner.py index f4cef20..d676cc0 100644 --- a/src/ralphkit/runner.py +++ b/src/ralphkit/runner.py @@ -230,8 +230,17 @@ def run_claude( try: parsed = json.loads(stdout_data) - except (json.JSONDecodeError, TypeError): - return None + except (json.JSONDecodeError, TypeError) as e: + raise ClaudeRunError( + "claude exited successfully but did not emit valid JSON.", + kind="invalid_json_output", + elapsed_s=elapsed_s, + timeout_seconds=timeout_seconds, + idle_timeout_seconds=idle_timeout_seconds, + stdout_tail=_tail_text(stdout_data), + stderr_tail=_tail_text(stderr_data), + transcript_path=transcript_path, + ) from e if isinstance(parsed, dict) and transcript_path: parsed["_ralphkit_transcript_path"] = transcript_path return parsed diff --git a/src/ralphkit/tmux.py b/src/ralphkit/tmux.py index 242a0be..9e51224 100644 --- a/src/ralphkit/tmux.py +++ b/src/ralphkit/tmux.py @@ -26,6 +26,15 @@ def meta_path_local(job_id: str) -> Path: return LOGS_DIR_LOCAL / f"{job_id}.meta.json" +def current_version() -> str | None: + """Best-effort version for the currently running ralphkit CLI.""" + try: + from ralphkit._version import version + except Exception: + return None + return version or None + + def job_path_local(job_id: str) -> Path: """Local Python path to a job scratch directory.""" return JOBS_DIR_LOCAL / job_id @@ -60,6 +69,8 @@ def build_submission_metadata( working_dir: str | None, isolation: str | None, scratch_dir: str, + package_spec: str | None = None, + caller_version: str | None = None, ) -> dict: return { "job_id": job_id, @@ -75,6 +86,8 @@ def build_submission_metadata( "idle_timeout_seconds": _arg_int_value(ralph_args, "--idle-timeout-seconds"), "cleanup_on_error": _arg_value(ralph_args, "--cleanup-on-error"), "resume_run": _arg_value(ralph_args, "--resume-run"), + "package_spec": package_spec, + "caller_version": caller_version, } @@ -83,6 +96,9 @@ def build_job_script( ralph_cmd: str, working_dir: str | None = None, isolation: str | None = None, + *, + package_spec: str | None = None, + caller_version: str | None = None, ) -> str: """Generate a bash script for a ralphkit job.""" lines = [ @@ -120,9 +136,22 @@ def build_job_script( 'export RALPHKIT_WORKING_DIR="$ORIG_DIR"', 'cd "$ORIG_DIR" || exit 1', ] + lines.append( + 'echo "[ralphkit] started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LOG_FILE"' + ) + if package_spec: + lines.append( + f'echo {shlex.quote(f"[ralphkit] package={package_spec}")} >> "$LOG_FILE"' + ) + if caller_version: + lines.append( + f'echo {shlex.quote(f"[ralphkit] caller_version={caller_version}")} >> "$LOG_FILE"' + ) lines += [ + 'echo "[ralphkit] scratch_dir=$JOB_DIR" >> "$LOG_FILE"', + 'echo "[ralphkit] working_dir=$PWD" >> "$LOG_FILE"', "", - f'{ralph_cmd} 2>&1 | tee "$LOG_FILE"', + f'{ralph_cmd} 2>&1 | tee -a "$LOG_FILE"', "RC=${PIPESTATUS[0]}", 'echo "[ralphkit] exit=$RC" >> "$LOG_FILE"', "exit $RC", diff --git a/tests/test_remote.py b/tests/test_remote.py index 2f24d5b..4f92551 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -31,6 +31,13 @@ def test_ralph_cmd_with_version(): assert cmd == "uvx --refresh --from ralphkit==0.5.0 ralphkit fix 'do stuff'" +@patch("ralphkit.remote.current_version", return_value="0.1.7") +def test_ralph_cmd_with_current_version(mock_version): + cmd = _ralph_cmd(["do stuff"], ralph_version="current", subcommand="fix") + assert cmd == "uvx --refresh --from ralphkit==0.1.7 ralphkit fix 'do stuff'" + mock_version.assert_called_once() + + def test_ralph_cmd_auto_detects_prerelease(): cmd = _ralph_cmd(["do stuff"], ralph_version="0.6.0a1", subcommand="build") assert ( @@ -44,6 +51,11 @@ def test_ralph_cmd_no_prerelease_for_stable(): assert cmd == "uvx --refresh --from ralphkit==0.6.0 ralphkit build 'do stuff'" +def test_ralph_cmd_with_latest_alias(): + cmd = _ralph_cmd(["do stuff"], ralph_version="latest", subcommand="build") + assert cmd == "uvx --refresh --from ralphkit@latest ralphkit build 'do stuff'" + + def test_ralph_cmd_with_subcommand(): cmd = _ralph_cmd(["task.md", "--force"], subcommand="build") assert cmd == "uvx --refresh --from ralphkit@latest ralphkit build task.md --force" @@ -97,9 +109,12 @@ def test_submit_job_full_flow(mock_run): assert meta["subcommand"] == "build" assert meta["isolation"] == "shared" assert meta["scratch_dir"].endswith("/.local/share/ralphkit/jobs/rk-abc123") + assert meta["package_spec"] == "ralphkit@latest" + assert meta["caller_version"] assert "submitted_at" in meta assert "mkdir -p" not in calls[4][0][0][4] assert "cat >" in calls[4][0][0][4] + assert "[ralphkit] package=ralphkit@latest" in calls[4][1]["input"] # Launch tmux assert "tmux new-session" in calls[5][0][0][4] assert "remain-on-exit" in calls[5][0][0][4] @@ -141,9 +156,11 @@ def test_submit_job_with_ralph_version(mock_run): # calls[3]=metadata upload, calls[4]=script upload meta = json.loads(calls[3][1]["input"]) assert meta["subcommand"] == "build" + assert meta["package_spec"] == "ralphkit==0.5.0" upload_call = calls[4] script_content = upload_call[1]["input"] assert "uvx --refresh --from ralphkit==0.5.0 ralphkit" in script_content + assert "[ralphkit] package=ralphkit==0.5.0" in script_content @patch("ralphkit.remote.subprocess.run") diff --git a/tests/test_runner.py b/tests/test_runner.py index 6fd22ce..28da7c4 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -65,9 +65,15 @@ def test_run_claude_success_invokes_popen_with_expected_options( @patch.object(runner, "_latest_transcript", return_value=(None, None)) @patch.object(runner.subprocess, "Popen") -def test_run_claude_returns_none_for_invalid_json(mock_popen, mock_latest): +def test_run_claude_raises_invalid_json_error(mock_popen, mock_latest): mock_popen.return_value = _proc(stdout="not json") - assert run_claude("p", "m", "s") is None + + with pytest.raises(ClaudeRunError, match="did not emit valid JSON") as exc_info: + run_claude("p", "m", "s") + + error = exc_info.value + assert error.kind == "invalid_json_output" + assert error.stdout_tail == "not json" mock_latest.assert_called_once() diff --git a/tests/test_tmux.py b/tests/test_tmux.py index 6dce759..61be7e3 100644 --- a/tests/test_tmux.py +++ b/tests/test_tmux.py @@ -5,9 +5,11 @@ def test_build_job_script_basic_output(): script = build_job_script("rk-test-0307-120000-abcd", "ralph run pipe.yml") assert script.startswith("#!/usr/bin/env bash\n") assert "set -uo pipefail" in script - assert 'tee "$LOG_FILE"' in script + assert 'tee -a "$LOG_FILE"' in script assert "RC=${PIPESTATUS[0]}" in script assert 'LOG_FILE="$LOG_DIR/rk-test-0307-120000-abcd.log"' in script + assert 'echo "[ralphkit] started_at=' in script + assert 'echo "[ralphkit] working_dir=$PWD"' in script def test_build_job_script_with_working_dir(): @@ -34,6 +36,17 @@ def test_build_job_script_enables_agent_teams(): assert "export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1" in script +def test_build_job_script_logs_package_metadata(): + script = build_job_script( + "rk-test-0307-120000-abcd", + "ralph run pipe.yml", + package_spec="ralphkit==0.1.7", + caller_version="0.1.7", + ) + assert "[ralphkit] package=ralphkit==0.1.7" in script + assert "[ralphkit] caller_version=0.1.7" in script + + def test_parse_session_list_empty_string(): assert parse_session_list("") == []