Skip to content

Commit e579ca2

Browse files
authored
Merge pull request #41 from zachyzissou/codex/self-heal-cli-invocation
Self-heal CLI invocation and version probing
2 parents 8c98c15 + 6981202 commit e579ca2

File tree

2 files changed

+304
-29
lines changed

2 files changed

+304
-29
lines changed

app/server.py

Lines changed: 259 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,23 @@ def file_response(self, full_path: Path, *args: Any, **kwargs: Any) -> Starlette
123123
XML_PATH = DATA_DIR / "index.xml"
124124
STATE_PATH = DATA_DIR / "state.json"
125125

126+
# CLI invocation mode constants
127+
CLI_MODE_ROOT_FLAGS = "root_flags"
128+
CLI_MODE_RUN_FLAGS = "run_flags"
129+
CLI_MODE_ROOT_DEFAULTS = "root_defaults"
130+
CLI_MODE_RUN_DEFAULTS = "run_defaults"
131+
VALID_CLI_MODES = {
132+
CLI_MODE_ROOT_FLAGS,
133+
CLI_MODE_RUN_FLAGS,
134+
CLI_MODE_ROOT_DEFAULTS,
135+
CLI_MODE_RUN_DEFAULTS,
136+
}
137+
138+
# CLI version probing mode constants
139+
CLI_VERSION_MODE_SUBCOMMAND = "subcommand"
140+
CLI_VERSION_MODE_FLAG = "flag"
141+
VALID_CLI_VERSION_MODES = {CLI_VERSION_MODE_SUBCOMMAND, CLI_VERSION_MODE_FLAG}
142+
126143

127144
class CredentialsRotateRequest(BaseModel):
128145
"""Request body for rotating Xtreme credentials."""
@@ -598,6 +615,185 @@ def ensure_cli_exists() -> None:
598615
logger.info(f"CLI binary found and executable: {CLI_BIN}")
599616

600617

618+
async def _run_cmd_capture(
619+
cmd: list[str], *, cwd: str | None = None, timeout: int = 30
620+
) -> tuple[int, str, str]:
621+
"""Run a command and return returncode/stdout/stderr text."""
622+
proc = await asyncio.create_subprocess_exec(
623+
*cmd,
624+
cwd=cwd,
625+
stdout=asyncio.subprocess.PIPE,
626+
stderr=asyncio.subprocess.PIPE,
627+
)
628+
629+
try:
630+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
631+
except TimeoutError:
632+
proc.terminate()
633+
try:
634+
await asyncio.wait_for(proc.wait(), timeout=5)
635+
except TimeoutError:
636+
proc.kill()
637+
await proc.wait()
638+
raise RuntimeError(f"Command timed out after {timeout}s") from None
639+
640+
return (
641+
proc.returncode,
642+
stdout.decode(errors="ignore"),
643+
stderr.decode(errors="ignore"),
644+
)
645+
646+
647+
def _normalize_cli_mode(value: Any) -> str | None:
648+
"""Normalize a persisted/runtime CLI mode value."""
649+
if not isinstance(value, str):
650+
return None
651+
normalized = value.strip().lower()
652+
return normalized if normalized in VALID_CLI_MODES else None
653+
654+
655+
def _normalize_cli_version_mode(value: Any) -> str | None:
656+
"""Normalize a persisted/runtime CLI version probe mode value."""
657+
if not isinstance(value, str):
658+
return None
659+
normalized = value.strip().lower()
660+
return normalized if normalized in VALID_CLI_VERSION_MODES else None
661+
662+
663+
def _normalize_optional_bool(value: Any) -> bool | None:
664+
"""Normalize mixed-type bool configuration values."""
665+
if isinstance(value, bool):
666+
return value
667+
if isinstance(value, str):
668+
lowered = value.strip().lower()
669+
if lowered in {"1", "true", "yes", "on"}:
670+
return True
671+
if lowered in {"0", "false", "no", "off"}:
672+
return False
673+
return None
674+
675+
676+
def _cli_attempt_catalog() -> dict[str, tuple[list[str], str | None]]:
677+
"""Return all supported command invocation patterns."""
678+
return {
679+
CLI_MODE_ROOT_FLAGS: (
680+
[str(CLI_BIN), "-m", str(M3U_PATH), "-x", str(XML_PATH)],
681+
None,
682+
),
683+
CLI_MODE_RUN_FLAGS: (
684+
[str(CLI_BIN), "run", "-m", str(M3U_PATH), "-x", str(XML_PATH)],
685+
None,
686+
),
687+
CLI_MODE_ROOT_DEFAULTS: ([str(CLI_BIN)], str(DATA_DIR)),
688+
CLI_MODE_RUN_DEFAULTS: ([str(CLI_BIN), "run"], str(DATA_DIR)),
689+
}
690+
691+
692+
def _ordered_cli_attempt_modes(
693+
preferred_mode: str | None,
694+
supports_run_subcommand: bool | None,
695+
) -> list[str]:
696+
"""Return invocation modes ordered by compatibility confidence."""
697+
if supports_run_subcommand is True:
698+
modes = [
699+
CLI_MODE_RUN_FLAGS,
700+
CLI_MODE_RUN_DEFAULTS,
701+
CLI_MODE_ROOT_FLAGS,
702+
CLI_MODE_ROOT_DEFAULTS,
703+
]
704+
elif supports_run_subcommand is False:
705+
modes = [
706+
CLI_MODE_ROOT_FLAGS,
707+
CLI_MODE_ROOT_DEFAULTS,
708+
CLI_MODE_RUN_FLAGS,
709+
CLI_MODE_RUN_DEFAULTS,
710+
]
711+
else:
712+
modes = [
713+
CLI_MODE_ROOT_FLAGS,
714+
CLI_MODE_RUN_FLAGS,
715+
CLI_MODE_ROOT_DEFAULTS,
716+
CLI_MODE_RUN_DEFAULTS,
717+
]
718+
719+
normalized_preferred = _normalize_cli_mode(preferred_mode)
720+
if normalized_preferred and normalized_preferred in modes:
721+
modes.remove(normalized_preferred)
722+
modes.insert(0, normalized_preferred)
723+
return modes
724+
725+
726+
def _ordered_cli_version_probe_modes(preferred_mode: str | None) -> list[str]:
727+
"""Return version probe modes, preferring the last known working mode."""
728+
modes = [CLI_VERSION_MODE_SUBCOMMAND, CLI_VERSION_MODE_FLAG]
729+
normalized_preferred = _normalize_cli_version_mode(preferred_mode)
730+
if normalized_preferred and normalized_preferred in modes:
731+
modes.remove(normalized_preferred)
732+
modes.insert(0, normalized_preferred)
733+
return modes
734+
735+
736+
def _get_cached_cli_attempt_mode(state: dict[str, Any]) -> str | None:
737+
"""Read cached CLI invocation mode from runtime or persisted state."""
738+
runtime_mode = _normalize_cli_mode(getattr(app.state, "cli_last_success_mode", None))
739+
if runtime_mode:
740+
return runtime_mode
741+
persisted_mode = _normalize_cli_mode(state.get("cli_last_success_mode"))
742+
if persisted_mode:
743+
app.state.cli_last_success_mode = persisted_mode
744+
return persisted_mode
745+
746+
747+
def _get_cached_cli_version_mode(state: dict[str, Any]) -> str | None:
748+
"""Read cached CLI version probe mode from runtime or persisted state."""
749+
runtime_mode = _normalize_cli_version_mode(getattr(app.state, "cli_version_mode", None))
750+
if runtime_mode:
751+
return runtime_mode
752+
persisted_mode = _normalize_cli_version_mode(state.get("cli_version_mode"))
753+
if persisted_mode:
754+
app.state.cli_version_mode = persisted_mode
755+
return persisted_mode
756+
757+
758+
async def _detect_cli_supports_run_subcommand(state: dict[str, Any]) -> bool | None:
759+
"""Detect whether the CLI expects the `run` subcommand."""
760+
runtime_value = _normalize_optional_bool(
761+
getattr(app.state, "cli_supports_run_subcommand", None)
762+
)
763+
if runtime_value is not None:
764+
return runtime_value
765+
766+
persisted_value = _normalize_optional_bool(state.get("cli_supports_run_subcommand"))
767+
if persisted_value is not None:
768+
app.state.cli_supports_run_subcommand = persisted_value
769+
return persisted_value
770+
771+
try:
772+
return_code, stdout, stderr = await _run_cmd_capture(
773+
[str(CLI_BIN), "run", "--help"],
774+
timeout=8,
775+
)
776+
except Exception as exc:
777+
logger.debug("CLI run-subcommand probe failed: %s", exc)
778+
return None
779+
780+
combined_output = f"{stdout}\n{stderr}".lower()
781+
if return_code == 0:
782+
app.state.cli_supports_run_subcommand = True
783+
return True
784+
785+
if (
786+
'unknown command "run"' in combined_output
787+
or "unknown command: run" in combined_output
788+
or "is not a command" in combined_output
789+
):
790+
app.state.cli_supports_run_subcommand = False
791+
return False
792+
793+
logger.debug("CLI run-subcommand probe was inconclusive (rc=%s)", return_code)
794+
return None
795+
796+
601797
async def generate_files() -> dict[str, Any]:
602798
"""
603799
Generate M3U and XMLTV files using the toonamiaftermath-cli.
@@ -619,19 +815,21 @@ async def generate_files() -> dict[str, Any]:
619815
WEB_DIR,
620816
os.environ.get("CRON_SCHEDULE", CRON_SCHEDULE),
621817
)
622-
623-
# Try multiple invocation strategies to support different CLI versions
624-
attempts: list[tuple[list[str], str | None]] = [
625-
([str(CLI_BIN), "-m", str(M3U_PATH), "-x", str(XML_PATH)], None),
626-
([str(CLI_BIN), "run", "-m", str(M3U_PATH), "-x", str(XML_PATH)], None),
627-
([str(CLI_BIN)], str(DATA_DIR)), # defaults to index.* in cwd
628-
# some versions require subcommand
629-
([str(CLI_BIN), "run"], str(DATA_DIR)),
630-
]
818+
state = read_state()
819+
preferred_mode = _get_cached_cli_attempt_mode(state)
820+
supports_run_subcommand = await _detect_cli_supports_run_subcommand(state)
821+
attempt_catalog = _cli_attempt_catalog()
822+
attempt_modes = _ordered_cli_attempt_modes(preferred_mode, supports_run_subcommand)
823+
logger.info(
824+
"CLI invocation order resolved: %s",
825+
", ".join(attempt_modes),
826+
)
631827

632828
success = False
633-
for attempt_num, (cmd, cwd) in enumerate(attempts, 1):
634-
logger.info(f"Attempt {attempt_num}/{len(attempts)}: {' '.join(cmd)}")
829+
successful_mode: str | None = None
830+
for attempt_num, mode in enumerate(attempt_modes, 1):
831+
cmd, cwd = attempt_catalog[mode]
832+
logger.info(f"Attempt {attempt_num}/{len(attempt_modes)}: {' '.join(cmd)}")
635833
attempt_started_at = time.time()
636834

637835
try:
@@ -646,7 +844,7 @@ async def run_cli(cmd_args: list[str] = cmd, cmd_cwd: str | None = cwd):
646844
run_cli,
647845
max_retries=2, # Fewer retries per command variant
648846
delay=0.5,
649-
operation_name=f"CLI execution: {cmd[0]}",
847+
operation_name=f"CLI execution ({mode}): {cmd[0]}",
650848
)
651849

652850
except Exception as e:
@@ -662,6 +860,8 @@ async def run_cli(cmd_args: list[str] = cmd, cmd_cwd: str | None = cwd):
662860
# Check if files were generated successfully
663861
success = _verify_generated_files(generated_after=attempt_started_at)
664862
if success:
863+
successful_mode = mode
864+
app.state.cli_last_success_mode = mode
665865
break
666866
logger.warning(
667867
"Artifact validation failed after successful CLI exit; "
@@ -671,8 +871,14 @@ async def run_cli(cmd_args: list[str] = cmd, cmd_cwd: str | None = cwd):
671871
if not success:
672872
raise RuntimeError("Failed to generate M3U/XML files after multiple attempts")
673873

874+
inferred_supports_run = supports_run_subcommand
875+
if inferred_supports_run is None and successful_mode:
876+
inferred_supports_run = successful_mode in {
877+
CLI_MODE_RUN_FLAGS,
878+
CLI_MODE_RUN_DEFAULTS,
879+
}
880+
674881
# Update state
675-
state = read_state()
676882
state.update(
677883
{
678884
"last_update": datetime.now(UTC).isoformat(),
@@ -681,6 +887,12 @@ async def run_cli(cmd_args: list[str] = cmd, cmd_cwd: str | None = cwd):
681887
"last_failure_at": None,
682888
"last_failure_context": None,
683889
"consecutive_failures": 0,
890+
"cli_last_success_mode": successful_mode or preferred_mode,
891+
"cli_supports_run_subcommand": inferred_supports_run,
892+
"cli_version_mode": _normalize_cli_version_mode(
893+
getattr(app.state, "cli_version_mode", None)
894+
)
895+
or _get_cached_cli_version_mode(state),
684896
}
685897
)
686898
write_state(state)
@@ -774,24 +986,42 @@ async def get_cli_version() -> str | None:
774986
Returns:
775987
Optional[str]: Version string if available, None otherwise
776988
"""
777-
try:
778-
cmd = [str(CLI_BIN), "--version"]
779-
proc = await asyncio.create_subprocess_exec(
780-
*cmd,
781-
stdout=asyncio.subprocess.PIPE,
782-
stderr=asyncio.subprocess.PIPE,
783-
)
784-
out, err = await proc.communicate()
989+
state = read_state()
990+
preferred_mode = _get_cached_cli_version_mode(state)
991+
probe_modes = _ordered_cli_version_probe_modes(preferred_mode)
992+
probe_commands: dict[str, list[str]] = {
993+
CLI_VERSION_MODE_SUBCOMMAND: [str(CLI_BIN), "version"],
994+
CLI_VERSION_MODE_FLAG: [str(CLI_BIN), "--version"],
995+
}
785996

786-
if proc.returncode == 0:
787-
version = out.decode().strip()
788-
return version if version else None
789-
logger.warning(f"CLI version check failed with code {proc.returncode}: {err.decode()}")
790-
return None
997+
last_failure_detail: str | None = None
791998

792-
except Exception as e:
793-
logger.warning(f"Failed to get CLI version: {e}")
794-
return None
999+
for mode in probe_modes:
1000+
cmd = probe_commands[mode]
1001+
try:
1002+
return_code, stdout, stderr = await _run_cmd_capture(cmd, timeout=10)
1003+
except Exception as exc:
1004+
last_failure_detail = f"{' '.join(cmd)} raised: {exc}"
1005+
continue
1006+
1007+
if return_code == 0:
1008+
output = (stdout or stderr).strip()
1009+
if output:
1010+
app.state.cli_version_mode = mode
1011+
first_line = output.splitlines()[0].strip()
1012+
return first_line if first_line else output
1013+
app.state.cli_version_mode = mode
1014+
return None
1015+
1016+
error_preview = (stderr or stdout).strip().splitlines()
1017+
if error_preview:
1018+
last_failure_detail = f"{' '.join(cmd)} -> {error_preview[0][:180]}"
1019+
else:
1020+
last_failure_detail = f"{' '.join(cmd)} -> exit code {return_code}"
1021+
1022+
if last_failure_detail:
1023+
logger.warning("CLI version check failed: %s", last_failure_detail)
1024+
return None
7951025

7961026

7971027
def _parse_extinf(line: str) -> tuple[str | None, str | None, str | None]:

0 commit comments

Comments
 (0)