Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/ralphkit/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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(),
Expand Down
29 changes: 27 additions & 2 deletions src/ralphkit/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
TMUX_LIST_FORMAT,
build_submission_metadata,
build_job_script,
current_version,
parse_session_list,
)

Expand Down Expand Up @@ -54,16 +55,33 @@ 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,
*,
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)
Expand All @@ -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:
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down
13 changes: 11 additions & 2 deletions src/ralphkit/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 30 additions & 1 deletion src/ralphkit/tmux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}


Expand All @@ -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 = [
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions tests/test_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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")
Expand Down
10 changes: 8 additions & 2 deletions tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down
15 changes: 14 additions & 1 deletion tests/test_tmux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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("") == []

Expand Down
Loading