feat(tasks): unify saved tasks on ssh_tasks services

- Add shared task_runner.run_saved_task helper used by routers/tasks.py and
  widgets/sources.py SshTaskWidgetSource.
- Saved tasks now target ssh_tasks service instances via default_service_id;
  the legacy default_machine_id and saved_task_runs are removed.
- Actions page lists ssh_tasks services for default and run-time selection.
- Update types, API client, hooks, tests, docs, and changelog.

Backend tests: 222 passed. Frontend lint/build/test: clean (71 passed).
This commit is contained in:
Developer
2026-06-23 13:55:13 +00:00
parent d7ad933b2a
commit 50eb76a10d
14 changed files with 371 additions and 410 deletions
@@ -0,0 +1,166 @@
"""Shared runner for saved tasks over SSH task services.
Both the Actions page (``routers/tasks.py``) and the SSH task widget
(``widgets/sources.py``) run saved tasks against ``ssh_tasks`` 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 ssh_tasks 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("SSH task 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,
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 ssh_tasks 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,
}
)