"""Shared runner for saved tasks over Remote machine services. Both the Actions page (``routers/tasks.py``) and the SSH task widget (``widgets/sources.py``) run saved tasks against ``remote_machine`` service instances. This module is the single execution path: build the client from the service record, render the command, run it with the service timeout, append a ``service_task_runs`` row, and return the result. There is intentionally no local execution mode — tasks are SSH-only. """ from __future__ import annotations import logging import shlex import time from dataclasses import dataclass from typing import TYPE_CHECKING, Any from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.config import get_settings from media_library_viewer_api.services.settings_store import SettingsStore if TYPE_CHECKING: from media_library_viewer_api.widgets.sources import ServiceRecord logger = logging.getLogger(__name__) @dataclass class TaskRunResult: """Outcome of a single saved-task run.""" exit_status: int stdout: str stderr: str duration_ms: int status: str # "success" | "failure" | "error" error: str = "" def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient: """Build an SSH client from an remote_machine service instance + referenced key.""" config = service.config host = str(config.get("host") or "").strip() username = str(config.get("username") or "").strip() if not host or not username: raise ValueError("Remote machine service is missing host or username") settings = get_settings() private_key = "" key_passphrase = "" ssh_key_id = str(config.get("ssh_key_id") or "").strip() if ssh_key_id: ssh_key = store.get_ssh_key(ssh_key_id) if ssh_key: private_key = str(ssh_key.get("private_key") or "") key_passphrase = str(ssh_key.get("passphrase") or "") # Service-level passphrase secret takes precedence. key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase return RemoteSSHClient( host=host, username=username, port=int(config.get("port") or 22), private_key=private_key or None, private_key_passphrase=key_passphrase or None, password=str(service.secrets.get("password") or "") or None, known_hosts_path=str(settings.ssh_known_hosts_file), timeout=int(config.get("timeout_seconds") or 30), ) def _render_command(task: dict[str, Any]) -> str: """Render a saved task into a shell command (shell or python3 -c).""" task_type = str(task.get("task_type") or "shell").lower() command = str(task.get("content") or "") if task_type == "python": return f"python3 -c {shlex.quote(command)}" if task_type == "shell": return command raise ValueError(f"Unknown task type: {task_type}") def run_saved_task( store: SettingsStore, task: dict[str, Any], service: "ServiceRecord", *, timeout: int | None = None, ) -> TaskRunResult: """Run a saved task on an remote_machine service instance and log the run. The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run is recorded in ``service_task_runs`` regardless of outcome (success, failure, error). Raises ``ValueError`` for an unsupported task type or an incomplete service config (propagated from ``build_ssh_client`` / ``_render_command``). """ timeout = int(timeout if timeout is not None else service.config.get("timeout_seconds") or 30) client = build_ssh_client(store, service) command = _render_command(task) start = time.perf_counter() try: result = client.run(command, timeout=timeout) except Exception as exc: duration_ms = int((time.perf_counter() - start) * 1000) _record(store, task, service, "error", duration_ms=duration_ms, error=str(exc)[:1000]) logger.exception("saved task run failed task_id=%s", task.get("id")) return TaskRunResult( exit_status=1, stdout="", stderr=str(exc), duration_ms=duration_ms, status="error", error=str(exc), ) duration_ms = int((time.perf_counter() - start) * 1000) stdout = result.stdout or "" stderr = result.stderr or "" status = "success" if result.exit_status == 0 else "failure" _record( store, task, service, status, exit_status=result.exit_status, duration_ms=duration_ms, stdout_tail=stdout, stderr_tail=stderr, error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"), ) return TaskRunResult( exit_status=result.exit_status, stdout=stdout, stderr=stderr, duration_ms=duration_ms, status=status, ) def _record( store: SettingsStore, task: dict[str, Any], service: "ServiceRecord", status: str, *, exit_status: int | None = None, duration_ms: int = 0, stdout_tail: str = "", stderr_tail: str = "", error: str = "", ) -> None: """Append a service_task_runs row for a saved-task run.""" store.record_service_task_run( { "task_id": str(task.get("id") or ""), "service_id": service.id, "status": status, "exit_status": exit_status, "duration_ms": duration_ms, "stdout_tail": stdout_tail, "stderr_tail": stderr_tail, "error": error, } )