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:
@@ -113,7 +113,7 @@ class SettingsStore:
|
||||
task_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
default_machine_id TEXT NOT NULL,
|
||||
default_service_id TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -121,28 +121,14 @@ class SettingsStore:
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS saved_task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
task_name TEXT NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
machine_name TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
stdout_tail TEXT NOT NULL,
|
||||
stderr_tail TEXT NOT NULL,
|
||||
error TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
# saved_tasks.default_machine_id → default_service_id (saved tasks now
|
||||
# target ssh_tasks service instances). Migrate existing columns.
|
||||
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
|
||||
if "default_service_id" not in saved_tasks_cols and "default_machine_id" in saved_tasks_cols:
|
||||
conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id")
|
||||
# Run history for saved tasks now lives in service_task_runs; the
|
||||
# legacy machine-based table is dropped.
|
||||
conn.execute("DROP TABLE IF EXISTS saved_task_runs")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dashboard_shortcuts (
|
||||
@@ -714,7 +700,7 @@ class SettingsStore:
|
||||
"task_type": row["task_type"],
|
||||
"content": row["content"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"default_machine_id": row["default_machine_id"],
|
||||
"default_service_id": row["default_service_id"],
|
||||
"notes": row["notes"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
@@ -731,10 +717,10 @@ class SettingsStore:
|
||||
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or ""
|
||||
)
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
default_machine_id = str(
|
||||
payload.get("default_machine_id")
|
||||
if payload.get("default_machine_id") is not None
|
||||
else (current or {}).get("default_machine_id", "") or ""
|
||||
default_service_id = str(
|
||||
payload.get("default_service_id")
|
||||
if payload.get("default_service_id") is not None
|
||||
else (current or {}).get("default_service_id", "") or ""
|
||||
).strip()
|
||||
notes = str(
|
||||
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
||||
@@ -745,7 +731,7 @@ class SettingsStore:
|
||||
"task_type": task_type,
|
||||
"content": content,
|
||||
"enabled": enabled,
|
||||
"default_machine_id": default_machine_id,
|
||||
"default_service_id": default_service_id,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
@@ -773,7 +759,7 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_tasks (
|
||||
id, name, task_type, content, enabled, default_machine_id,
|
||||
id, name, task_type, content, enabled, default_service_id,
|
||||
notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -782,7 +768,7 @@ class SettingsStore:
|
||||
task_type = excluded.task_type,
|
||||
content = excluded.content,
|
||||
enabled = excluded.enabled,
|
||||
default_machine_id = excluded.default_machine_id,
|
||||
default_service_id = excluded.default_service_id,
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
@@ -792,7 +778,7 @@ class SettingsStore:
|
||||
task["task_type"],
|
||||
task["content"],
|
||||
1 if task["enabled"] else 0,
|
||||
task["default_machine_id"],
|
||||
task["default_service_id"],
|
||||
task["notes"],
|
||||
created_at,
|
||||
now,
|
||||
@@ -805,58 +791,6 @@ class SettingsStore:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,))
|
||||
|
||||
def list_task_runs(self, task_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM saved_task_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 50))),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def record_task_run(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
status: str,
|
||||
*,
|
||||
machine_id: str,
|
||||
machine_name: str,
|
||||
task_type: str,
|
||||
duration_ms: int,
|
||||
request_id: str = "",
|
||||
stdout_tail: str = "",
|
||||
stderr_tail: str = "",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
self.init_schema()
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_task_runs (
|
||||
id, task_id, task_name, machine_id, machine_name, task_type,
|
||||
status, created_at, duration_ms, request_id, stdout_tail,
|
||||
stderr_tail, error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
uuid.uuid4().hex,
|
||||
str(task.get("id") or ""),
|
||||
str(task.get("name") or ""),
|
||||
machine_id,
|
||||
machine_name,
|
||||
task_type,
|
||||
status,
|
||||
now,
|
||||
duration_ms,
|
||||
request_id,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
error,
|
||||
),
|
||||
)
|
||||
|
||||
def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
target = json.loads(row["target_json"] or "{}")
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user