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
+6
View File
@@ -32,6 +32,12 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
### **BREAKING** ### **BREAKING**
- Saved Actions (server tasks) now target `ssh_tasks` service instances instead
of monitoring machines. The `default_machine_id` field on saved tasks was
replaced with `default_service_id`; the legacy `saved_task_runs` table was
dropped and run history now lives in `service_task_runs`. Re-create SSH task
runner services on the Services page and re-link saved actions after
upgrading.
- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one - **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
with: with:
@@ -3,18 +3,15 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import shlex
import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,7 +24,7 @@ class TaskInput(BaseModel):
task_type: str = Field(default="shell", description="shell or python") task_type: str = Field(default="shell", description="shell or python")
content: str = Field(default="") content: str = Field(default="")
enabled: bool = True enabled: bool = True
default_machine_id: str = "" default_service_id: str = ""
notes: str = "" notes: str = ""
@@ -35,61 +32,31 @@ class RunTaskRequest(BaseModel):
task_id: str task_id: str
def _machine_label(machine: dict[str, Any] | None) -> str: def _service_label(service: dict[str, Any] | None) -> str:
if not machine: if not service:
return "" return ""
return str(machine.get("name") or machine.get("host") or machine.get("id") or "") return str(service.get("name") or service.get("id") or "")
def _resolve_machine_for_task( def _resolve_service_for_task(
store: SettingsStore, store: SettingsStore,
task: dict[str, Any], task: dict[str, Any],
machine_id: str | None, service_id: str | None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
if machine_id: if service_id:
return store.get_machine_config(machine_id) or store.get_machine(machine_id) return store.get_service(service_id)
default_machine_id = str(task.get("default_machine_id") or "").strip() default_service_id = str(task.get("default_service_id") or "").strip()
if default_machine_id: if default_service_id:
return store.get_machine_config(default_machine_id) or store.get_machine(default_machine_id) return store.get_service(default_service_id)
machines = [machine for machine in store.list_machines() if machine.get("enabled")] services = [svc for svc in store.list_services("ssh_tasks") if svc.get("enabled")]
return machines[0] if machines else None return services[0] if services else None
def _client_for_machine(store: SettingsStore, machine: dict[str, Any]): def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
mode = str(machine.get("mode") or "local").lower() """Build a ServiceRecord from a raw settings_store service row."""
if mode == "local": from media_library_viewer_api.services.settings_store import get_settings_store
return LocalCommandClient()
host = str(machine.get("host") or "").strip() return build_service_record(get_settings_store(), service_row)
username = str(machine.get("username") or "").strip()
if not host or not username:
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
settings = get_settings()
private_key = str(machine.get("ssh_private_key") or "")
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
ssh_key_id = str(machine.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 private_key)
passphrase = str(ssh_key.get("passphrase") or passphrase)
key_filename = ""
if machine.get("key_directory") and machine.get("key_name"):
key_filename = f"{machine.get('key_directory')}/{machine.get('key_name')}"
return RemoteSSHClient(
host=host,
username=username,
port=int(machine.get("port") or 22),
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=passphrase or None,
password=str(machine.get("password") or "") or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
)
@router.get("") @router.get("")
@@ -125,14 +92,14 @@ def list_task_runs(
) -> dict[str, Any]: ) -> dict[str, Any]:
if not store.get_task(task_id): if not store.get_task(task_id):
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
runs = store.list_task_runs(task_id, limit=limit) runs = store.list_service_task_runs(task_id=task_id, limit=limit)
return {"items": runs, "total": len(runs)} return {"items": runs, "total": len(runs)}
@router.post("/run") @router.post("/run")
def run_task( def run_task(
request: RunTaskRequest, request: RunTaskRequest,
machine_id: str | None = Query(default=None), service_id: str | None = Query(default=None),
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
task = store.get_task(request.task_id) task = store.get_task(request.task_id)
@@ -141,68 +108,22 @@ def run_task(
if not task.get("enabled", True): if not task.get("enabled", True):
raise HTTPException(status_code=400, detail="Task is disabled") raise HTTPException(status_code=400, detail="Task is disabled")
machine = _resolve_machine_for_task(store, task, machine_id) service_row = _resolve_service_for_task(store, task, service_id)
if not machine: if not service_row:
raise HTTPException(status_code=400, detail="No machine is available for this action") raise HTTPException(status_code=400, detail="No SSH task service is available for this action")
if not service_row.get("enabled", True):
raise HTTPException(status_code=400, detail="Selected SSH task service is disabled")
task_type = str(task.get("task_type") or "shell").lower() service = _service_row_to_record(service_row)
command = str(task.get("content") or "") result = run_saved_task(store, task, service)
if task_type == "python":
command = f"python3 -c {shlex.quote(command)}"
elif task_type != "shell":
raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}")
client = _client_for_machine(store, machine) return {
start = time.perf_counter() "task_id": task["id"],
machine_name = _machine_label(machine) "task_name": task["name"],
try: "service_id": service.id,
result = client.run(command, timeout=1200) "service_name": _service_label(service_row),
stdout = result.stdout or "" "task_type": task.get("task_type", "shell"),
stderr = result.stderr or "" "exit_status": result.exit_status,
status_text = "success" if result.exit_status == 0 else "error" "stdout": result.stdout,
store.record_task_run( "stderr": result.stderr,
task, }
status_text,
machine_id=str(machine.get("id") or ""),
machine_name=machine_name,
task_type=task_type,
duration_ms=int((time.perf_counter() - start) * 1000),
stdout_tail=stdout[-4000:],
stderr_tail=stderr[-4000:],
error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
)
return {
"task_id": task["id"],
"task_name": task["name"],
"machine_id": str(machine.get("id") or ""),
"machine_name": machine_name,
"task_type": task_type,
"exit_status": result.exit_status,
"stdout": stdout,
"stderr": stderr,
}
except Exception as exc:
duration_ms = int((time.perf_counter() - start) * 1000)
error_text = str(exc)
store.record_task_run(
task,
"error",
machine_id=str(machine.get("id") or ""),
machine_name=machine_name,
task_type=task_type,
duration_ms=duration_ms,
stdout_tail="",
stderr_tail=error_text[-4000:],
error=error_text,
)
logger.exception("Task execution failed task_id=%s", task["id"])
return {
"task_id": task["id"],
"task_name": task["name"],
"machine_id": str(machine.get("id") or ""),
"machine_name": machine_name,
"task_type": task_type,
"exit_status": 1,
"stdout": "",
"stderr": error_text,
}
@@ -113,7 +113,7 @@ class SettingsStore:
task_type TEXT NOT NULL, task_type TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
enabled INTEGER NOT NULL, enabled INTEGER NOT NULL,
default_machine_id TEXT NOT NULL, default_service_id TEXT NOT NULL,
notes TEXT NOT NULL, notes TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_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 INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
conn.execute( # saved_tasks.default_machine_id → default_service_id (saved tasks now
""" # target ssh_tasks service instances). Migrate existing columns.
CREATE TABLE IF NOT EXISTS saved_task_runs ( saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
id TEXT PRIMARY KEY, if "default_service_id" not in saved_tasks_cols and "default_machine_id" in saved_tasks_cols:
task_id TEXT NOT NULL, conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id")
task_name TEXT NOT NULL, # Run history for saved tasks now lives in service_task_runs; the
machine_id TEXT NOT NULL, # legacy machine-based table is dropped.
machine_name TEXT NOT NULL, conn.execute("DROP TABLE IF EXISTS saved_task_runs")
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)"
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS dashboard_shortcuts ( CREATE TABLE IF NOT EXISTS dashboard_shortcuts (
@@ -714,7 +700,7 @@ class SettingsStore:
"task_type": row["task_type"], "task_type": row["task_type"],
"content": row["content"], "content": row["content"],
"enabled": bool(row["enabled"]), "enabled": bool(row["enabled"]),
"default_machine_id": row["default_machine_id"], "default_service_id": row["default_service_id"],
"notes": row["notes"], "notes": row["notes"],
"created_at": row["created_at"], "created_at": row["created_at"],
"updated_at": row["updated_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 "" 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))) enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
default_machine_id = str( default_service_id = str(
payload.get("default_machine_id") payload.get("default_service_id")
if payload.get("default_machine_id") is not None if payload.get("default_service_id") is not None
else (current or {}).get("default_machine_id", "") or "" else (current or {}).get("default_service_id", "") or ""
).strip() ).strip()
notes = str( notes = str(
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "" 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, "task_type": task_type,
"content": content, "content": content,
"enabled": enabled, "enabled": enabled,
"default_machine_id": default_machine_id, "default_service_id": default_service_id,
"notes": notes, "notes": notes,
} }
@@ -773,7 +759,7 @@ class SettingsStore:
conn.execute( conn.execute(
""" """
INSERT INTO saved_tasks ( 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 notes, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -782,7 +768,7 @@ class SettingsStore:
task_type = excluded.task_type, task_type = excluded.task_type,
content = excluded.content, content = excluded.content,
enabled = excluded.enabled, enabled = excluded.enabled,
default_machine_id = excluded.default_machine_id, default_service_id = excluded.default_service_id,
notes = excluded.notes, notes = excluded.notes,
updated_at = excluded.updated_at updated_at = excluded.updated_at
""", """,
@@ -792,7 +778,7 @@ class SettingsStore:
task["task_type"], task["task_type"],
task["content"], task["content"],
1 if task["enabled"] else 0, 1 if task["enabled"] else 0,
task["default_machine_id"], task["default_service_id"],
task["notes"], task["notes"],
created_at, created_at,
now, now,
@@ -805,58 +791,6 @@ class SettingsStore:
with self.connect() as conn: with self.connect() as conn:
conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,)) 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]: def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
target = json.loads(row["target_json"] or "{}") target = json.loads(row["target_json"] or "{}")
return { 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,
}
)
@@ -12,21 +12,18 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import shlex
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Protocol from typing import Any, Protocol
import requests import requests
from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.domain.dashboard import ( from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows, _map_sessions_to_activity_rows,
build_backup_dashboard_summary, build_backup_dashboard_summary,
) )
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
from media_library_viewer_api.services.task_runner import run_saved_task
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -200,51 +197,17 @@ class SshTaskWidgetSource:
if not task.get("enabled", True): if not task.get("enabled", True):
return {"error": "Task is disabled"} return {"error": "Task is disabled"}
client = _build_ssh_client(store, service)
timeout = int(service.config.get("timeout_seconds") or 30) timeout = int(service.config.get("timeout_seconds") or 30)
task_type = str(task.get("task_type") or "shell").lower()
command = str(task.get("content") or "")
if task_type == "python":
command = f"python3 -c {shlex.quote(command)}"
elif task_type != "shell":
return {"error": f"Unknown task type: {task_type}"}
start = time.perf_counter()
result = await asyncio.wait_for( result = await asyncio.wait_for(
asyncio.to_thread(client.run, command, timeout), asyncio.to_thread(run_saved_task, store, task, service),
timeout=timeout, timeout=timeout,
) )
duration_ms = int((time.perf_counter() - start) * 1000) return {"exit_status": result.exit_status, "stdout": result.stdout, "stderr": result.stderr}
stdout = result.stdout or ""
stderr = result.stderr or ""
store.record_service_task_run(
{
"task_id": task_id,
"service_id": service.id,
"status": "success" if result.exit_status == 0 else "failure",
"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 {"exit_status": result.exit_status, "stdout": stdout, "stderr": stderr}
except asyncio.TimeoutError: except asyncio.TimeoutError:
_record_timeout(service, config, timeout) _record_timeout(service, config, timeout)
return {"error": "Widget data fetch timed out"} return {"error": "Widget data fetch timed out"}
except Exception as exc: except Exception as exc:
logger.exception("ssh_task adapter failed") logger.exception("ssh_task adapter failed")
store = get_settings_store()
store.record_service_task_run(
{
"task_id": str(config.get("task_id") or ""),
"service_id": service.id if service else "",
"status": "error",
"duration_ms": 0,
"error": str(exc)[:1000],
}
)
return {"error": f"SSH task failed: {exc}"} return {"error": f"SSH task failed: {exc}"}
@@ -264,37 +227,6 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
logger.exception("failed to record ssh task timeout") logger.exception("failed to record ssh task timeout")
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),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Registries # Registries
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+3 -2
View File
@@ -359,7 +359,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
"task_type": "shell", "task_type": "shell",
"content": "echo hi", "content": "echo hi",
"enabled": True, "enabled": True,
"default_machine_id": "", "default_service_id": "",
} }
) )
service = store.upsert_service( service = store.upsert_service(
@@ -369,6 +369,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="") fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
fake_client = SimpleNamespace(run=lambda *a, **k: fake_result) fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)
from media_library_viewer_api.services.task_runner import build_ssh_client
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource() adapter = SshTaskWidgetSource()
@@ -377,7 +378,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
) )
with ( with (
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store), patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
patch("media_library_viewer_api.widgets.sources._build_ssh_client", return_value=fake_client), patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=fake_client),
): ):
result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]}) result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})
+2 -2
View File
@@ -249,8 +249,8 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
- Provide an Actions tab for predefined server tasks that users can save and run later. - Provide an Actions tab for predefined server tasks that users can save and run later.
- The initial task types should support shell commands and Python scripts, while keeping the design flexible for future task types. - The initial task types should support shell commands and Python scripts, while keeping the design flexible for future task types.
- Avoid arbitrary free-form command execution for ad-hoc execution; tasks should be stored records with an explicit name, type, content, enabled flag, default machine, and notes. - Avoid arbitrary free-form command execution for ad-hoc execution; tasks should be stored records with an explicit name, type, content, enabled flag, default SSH task service, and notes.
- Support running tasks against either the local API host or a configured SSH machine using the same machine registry used by Monitoring. - Support running tasks against `ssh_tasks` service instances only; local execution on the API host is no longer supported.
- Command/script content should be executed through the existing safe process helpers and shell-quoted where applicable. - Command/script content should be executed through the existing safe process helpers and shell-quoted where applicable.
- Future destructive actions should require explicit confirmations or dry-run style safeguards. - Future destructive actions should require explicit confirmations or dry-run style safeguards.
- Job templates should remain centralized in `jobs.py` for future extension. - Job templates should remain centralized in `jobs.py` for future extension.
+5 -5
View File
@@ -245,19 +245,19 @@ export const saveTask = (task: SavedTaskInput) =>
}); });
export const deleteTask = (taskId: string) => export const deleteTask = (taskId: string) =>
del<{ status: string }>(`/api/tasks/${encodeURIComponent(taskId)}`); del<{ status: string }>(`/api/tasks/${encodeURIComponent(taskId)}`);
export const runTask = (taskId: string, machineId?: string) => export const runTask = (taskId: string, serviceId?: string) =>
post<{ post<{
task_id: string; task_id: string;
task_name: string; task_name: string;
machine_id: string; service_id: string;
machine_name: string; service_name: string;
task_type: string; task_type: string;
exit_status: number; exit_status: number;
stdout: string; stdout: string;
stderr: string; stderr: string;
}>( }>(
machineId serviceId
? `/api/tasks/run?machine_id=${encodeURIComponent(machineId)}` ? `/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`
: "/api/tasks/run", : "/api/tasks/run",
{ task_id: taskId }, { task_id: taskId },
); );
+3 -3
View File
@@ -113,11 +113,11 @@ export function useRunTask() {
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
taskId, taskId,
machineId, serviceId,
}: { }: {
taskId: string; taskId: string;
machineId?: string; serviceId?: string;
}) => runTask(taskId, machineId), }) => runTask(taskId, serviceId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] }); queryClient.invalidateQueries({ queryKey: ["tasks"] });
}, },
+43 -36
View File
@@ -1,14 +1,14 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { MonitoringMachine, SavedTask, SavedTaskInput } from "../types"; import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
import { import {
useDeleteTask, useDeleteTask,
useMonitoringSettings,
useRunTask, useRunTask,
useSaveTask, useSaveTask,
useTaskRuns, useTaskRuns,
useTasks, useTasks,
} from "../hooks/useSettings"; } from "../hooks/useSettings";
import { useServiceInstances } from "../hooks/useServices";
import { DialogFooter } from "../components/DialogFooter"; import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton"; import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
@@ -75,7 +75,7 @@ function emptyTask(): SavedTaskInput {
task_type: "shell", task_type: "shell",
content: "", content: "",
enabled: true, enabled: true,
default_machine_id: "", default_service_id: "",
notes: "", notes: "",
}; };
} }
@@ -87,7 +87,7 @@ function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
a.task_type === b.task_type && a.task_type === b.task_type &&
a.content === b.content && a.content === b.content &&
a.enabled === b.enabled && a.enabled === b.enabled &&
a.default_machine_id === b.default_machine_id && a.default_service_id === b.default_service_id &&
a.notes === b.notes a.notes === b.notes
); );
} }
@@ -99,22 +99,22 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
task_type: task.task_type, task_type: task.task_type,
content: task.content, content: task.content,
enabled: task.enabled, enabled: task.enabled,
default_machine_id: task.default_machine_id, default_service_id: task.default_service_id,
notes: task.notes, notes: task.notes,
}; };
} }
function TaskEditor({ function TaskEditor({
task, task,
machines, services,
onChange, onChange,
}: { }: {
task: SavedTaskInput; task: SavedTaskInput;
machines: MonitoringMachine[]; services: ServiceInstance[];
onChange: (task: SavedTaskInput) => void; onChange: (task: SavedTaskInput) => void;
}) { }) {
const selectedMachine = machines.find( const selectedService = services.find(
(machine) => machine.id === task.default_machine_id, (service) => service.id === task.default_service_id,
); );
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -124,8 +124,8 @@ function TaskEditor({
</p> </p>
<Badge variant="outline">{task.task_type}</Badge> <Badge variant="outline">{task.task_type}</Badge>
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge> <Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
{selectedMachine && ( {selectedService && (
<Badge variant="outline">{`default: ${selectedMachine.name}`}</Badge> <Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
)} )}
</div> </div>
@@ -160,13 +160,13 @@ function TaskEditor({
</FormField> </FormField>
</div> </div>
<div className="min-w-[220px] flex-1"> <div className="min-w-[220px] flex-1">
<FormField label="Default machine"> <FormField label="Default SSH task service">
<Select <Select
value={task.default_machine_id || NONE} value={task.default_service_id || NONE}
onValueChange={(value) => onValueChange={(value) =>
onChange({ onChange({
...task, ...task,
default_machine_id: value === NONE ? "" : value, default_service_id: value === NONE ? "" : value,
}) })
} }
> >
@@ -175,9 +175,9 @@ function TaskEditor({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NONE}>None</SelectItem> <SelectItem value={NONE}>None</SelectItem>
{machines.map((machine) => ( {services.map((service) => (
<SelectItem key={machine.id} value={machine.id}> <SelectItem key={service.id} value={service.id}>
{machine.name} {service.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -216,7 +216,7 @@ function TaskDialog({
open, open,
task, task,
baseline, baseline,
machines, services,
onClose, onClose,
onChange, onChange,
onSave, onSave,
@@ -225,7 +225,7 @@ function TaskDialog({
open: boolean; open: boolean;
task: SavedTaskInput; task: SavedTaskInput;
baseline: SavedTaskInput; baseline: SavedTaskInput;
machines: MonitoringMachine[]; services: ServiceInstance[];
onClose: () => void; onClose: () => void;
onChange: (task: SavedTaskInput) => void; onChange: (task: SavedTaskInput) => void;
onSave: () => void; onSave: () => void;
@@ -254,9 +254,10 @@ function TaskDialog({
<DialogDescription> <DialogDescription>
Save a reusable server task. Shell commands run via{" "} Save a reusable server task. Shell commands run via{" "}
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>. <code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
Runs execute on the selected SSH task service instance.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<TaskEditor task={task} machines={machines} onChange={onChange} /> <TaskEditor task={task} services={services} onChange={onChange} />
<DialogFooter <DialogFooter
onCancel={requestClose} onCancel={requestClose}
cancelLabel="Cancel" cancelLabel="Cancel"
@@ -277,7 +278,7 @@ function TaskDialog({
} }
export function Actions() { export function Actions() {
const { data: machines = [] } = useMonitoringSettings(); const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
const { data: tasks = [] } = useTasks(); const { data: tasks = [] } = useTasks();
const saveTask = useSaveTask(); const saveTask = useSaveTask();
const deleteTask = useDeleteTask(); const deleteTask = useDeleteTask();
@@ -287,7 +288,7 @@ export function Actions() {
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>( const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
emptyTask(), emptyTask(),
); );
const [runMachineId, setRunMachineId] = useState(""); const [runServiceId, setRunServiceId] = useState("");
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const selectedTask = useMemo( const selectedTask = useMemo(
@@ -306,7 +307,7 @@ export function Actions() {
const initial = emptyTask(); const initial = emptyTask();
setDraft(initial); setDraft(initial);
setDraftBaseline(initial); setDraftBaseline(initial);
setRunMachineId(machines[0]?.id || ""); setRunServiceId(sshServices[0]?.id || "");
setEditOpen(true); setEditOpen(true);
}; };
@@ -320,7 +321,7 @@ export function Actions() {
task_type: saved.task_type, task_type: saved.task_type,
content: saved.content, content: saved.content,
enabled: saved.enabled, enabled: saved.enabled,
default_machine_id: saved.default_machine_id, default_service_id: saved.default_service_id,
notes: saved.notes, notes: saved.notes,
}; };
setDraft(nextDraft); setDraft(nextDraft);
@@ -418,11 +419,11 @@ export function Actions() {
Edit Edit
</Button> </Button>
<Button <Button
disabled={runTask.isPending || !runMachineId} disabled={runTask.isPending || !runServiceId}
onClick={async () => { onClick={async () => {
await runTask.mutateAsync({ await runTask.mutateAsync({
taskId: editingTask.id, taskId: editingTask.id,
machineId: runMachineId, serviceId: runServiceId,
}); });
}} }}
> >
@@ -432,18 +433,25 @@ export function Actions() {
} }
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<FormField label="Run on machine"> <FormField
label="Run on SSH task service"
htmlFor="run-service-id"
>
<Select <Select
value={runMachineId} value={runServiceId}
onValueChange={(value) => setRunMachineId(value)} onValueChange={(value) => setRunServiceId(value)}
> >
<SelectTrigger className="min-w-[240px]" size="sm"> <SelectTrigger
<SelectValue placeholder="Select machine" /> id="run-service-id"
className="min-w-[240px]"
size="sm"
>
<SelectValue placeholder="Select service" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{machines.map((machine) => ( {sshServices.map((service) => (
<SelectItem key={machine.id} value={machine.id}> <SelectItem key={service.id} value={service.id}>
{machine.name} {service.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -462,7 +470,6 @@ export function Actions() {
<div className="flex flex-row flex-wrap items-center gap-2"> <div className="flex flex-row flex-wrap items-center gap-2">
<Badge variant="outline">{run.status}</Badge> <Badge variant="outline">{run.status}</Badge>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{run.machine_name} ·{" "}
{new Date(run.created_at * 1000).toLocaleString()} {new Date(run.created_at * 1000).toLocaleString()}
</p> </p>
</div> </div>
@@ -529,7 +536,7 @@ export function Actions() {
open={editOpen} open={editOpen}
task={draft} task={draft}
baseline={draftBaseline} baseline={draftBaseline}
machines={machines} services={sshServices}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
onChange={setDraft} onChange={setDraft}
onSave={saveDraft} onSave={saveDraft}
+40 -28
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { Actions } from "../Actions"; import { Actions } from "../Actions";
import type { MonitoringMachine, SavedTask } from "../../types"; import type { SavedTask, ServiceInstance } from "../../types";
const saveTaskMutate = vi.fn().mockResolvedValue({ const saveTaskMutate = vi.fn().mockResolvedValue({
id: "t1", id: "t1",
@@ -10,17 +10,16 @@ const saveTaskMutate = vi.fn().mockResolvedValue({
task_type: "shell", task_type: "shell",
content: "", content: "",
enabled: true, enabled: true,
default_machine_id: "", default_service_id: "",
notes: "", notes: "",
}); });
const deleteTaskMutate = vi.fn(); const deleteTaskMutate = vi.fn();
const runTaskMutate = vi.fn().mockResolvedValue({}); const runTaskMutate = vi.fn().mockResolvedValue({});
let machines: MonitoringMachine[] = []; let sshServices: ServiceInstance[] = [];
let tasks: SavedTask[] = []; let tasks: SavedTask[] = [];
vi.mock("../../hooks/useSettings", () => ({ vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
useTasks: () => ({ data: tasks }), useTasks: () => ({ data: tasks }),
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }), useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
useDeleteTask: () => ({ mutate: deleteTaskMutate }), useDeleteTask: () => ({ mutate: deleteTaskMutate }),
@@ -28,29 +27,22 @@ vi.mock("../../hooks/useSettings", () => ({
useTaskRuns: () => ({ data: { items: [] } }), useTaskRuns: () => ({ data: { items: [] } }),
})); }));
function machine( vi.mock("../../hooks/useServices", () => ({
overrides: Partial<MonitoringMachine> = {}, useServiceInstances: () => ({ data: sshServices }),
): MonitoringMachine { }));
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
return { return {
id: "m1", id: "s1",
name: "This machine", service_type: "ssh_tasks",
mode: "local", name: "Box",
config: { host: "box", username: "u" },
secrets_set: {},
enabled: true, enabled: true,
services: ["monitoring", "files"], created_at: 0,
host: "", updated_at: 0,
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
media_root: "",
path_prefix: "",
notes: "",
...overrides, ...overrides,
} as MonitoringMachine; } as ServiceInstance;
} }
function task(overrides: Partial<SavedTask> = {}): SavedTask { function task(overrides: Partial<SavedTask> = {}): SavedTask {
@@ -60,7 +52,7 @@ function task(overrides: Partial<SavedTask> = {}): SavedTask {
task_type: "shell", task_type: "shell",
content: "systemctl restart foo", content: "systemctl restart foo",
enabled: true, enabled: true,
default_machine_id: "", default_service_id: "",
notes: "", notes: "",
created_at: 0, created_at: 0,
updated_at: 0, updated_at: 0,
@@ -72,7 +64,7 @@ beforeEach(() => {
saveTaskMutate.mockClear(); saveTaskMutate.mockClear();
deleteTaskMutate.mockClear(); deleteTaskMutate.mockClear();
runTaskMutate.mockClear(); runTaskMutate.mockClear();
machines = []; sshServices = [];
tasks = []; tasks = [];
}); });
@@ -97,10 +89,11 @@ describe("Actions", () => {
const saved = saveTaskMutate.mock.calls[0][0]; const saved = saveTaskMutate.mock.calls[0][0];
expect(saved.name).toBe("Restart svc"); expect(saved.name).toBe("Restart svc");
expect(saved.task_type).toBe("shell"); expect(saved.task_type).toBe("shell");
expect(saved.default_service_id).toBe("");
}); });
it("disables the Run button until a run machine is selected", async () => { it("disables the Run button until a run service is selected", async () => {
machines = [machine()]; sshServices = [sshService()];
tasks = [task()]; tasks = [task()];
render(<Actions />); render(<Actions />);
@@ -110,4 +103,23 @@ describe("Actions", () => {
const runButton = screen.getByRole("button", { name: "Run action" }); const runButton = screen.getByRole("button", { name: "Run action" });
expect(runButton).toBeDisabled(); expect(runButton).toBeDisabled();
}); });
it("runs a task on the selected SSH task service", async () => {
sshServices = [sshService()];
tasks = [task()];
render(<Actions />);
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
await userEvent.click(
screen.getByRole("combobox", { name: "Run on SSH task service" }),
);
await userEvent.click(screen.getByRole("option", { name: "Box" }));
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
expect(runTaskMutate).toHaveBeenCalledTimes(1);
expect(runTaskMutate).toHaveBeenCalledWith({
taskId: "t1",
serviceId: "s1",
});
});
}); });
+23
View File
@@ -23,3 +23,26 @@ globalThis.ResizeObserver =
// Radix popper also probes `requestAnimationFrame`; jsdom provides it, but some // Radix popper also probes `requestAnimationFrame`; jsdom provides it, but some
// primitives defer layout reads through rAF that never flush in jsdom. Keep the // primitives defer layout reads through rAF that never flush in jsdom. Keep the
// default rAF; this guard is intentionally minimal. // default rAF; this guard is intentionally minimal.
// Radix Select uses pointer capture APIs that jsdom does not implement.
// Stub them on HTMLElement so opening/closing selects in tests does not throw.
if (typeof window !== "undefined" && window.HTMLElement) {
const proto = window.HTMLElement.prototype;
if (!proto.hasPointerCapture) {
proto.hasPointerCapture = () => false;
}
if (!proto.setPointerCapture) {
proto.setPointerCapture = () => {};
}
if (!proto.releasePointerCapture) {
proto.releasePointerCapture = () => {};
}
}
// Radix Select also calls scrollIntoView on items when opening; jsdom lacks it.
if (typeof window !== "undefined" && window.Element) {
const proto = window.Element.prototype;
if (!proto.scrollIntoView) {
proto.scrollIntoView = () => {};
}
}
+5 -46
View File
@@ -126,7 +126,7 @@ export interface SavedTask {
task_type: "shell" | "python"; task_type: "shell" | "python";
content: string; content: string;
enabled: boolean; enabled: boolean;
default_machine_id: string; default_service_id: string;
notes: string; notes: string;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
@@ -138,21 +138,18 @@ export interface SavedTaskInput {
task_type: "shell" | "python"; task_type: "shell" | "python";
content: string; content: string;
enabled: boolean; enabled: boolean;
default_machine_id: string; default_service_id: string;
notes: string; notes: string;
} }
export interface SavedTaskRun { export interface SavedTaskRun {
id: string; id: string;
task_id: string; task_id: string;
task_name: string; service_id: string;
machine_id: string; status: "success" | "failure" | "error" | "timeout" | string;
machine_name: string; exit_status: number | null;
task_type: "shell" | "python";
status: string;
created_at: number; created_at: number;
duration_ms: number; duration_ms: number;
request_id: string;
stdout_tail: string; stdout_tail: string;
stderr_tail: string; stderr_tail: string;
error: string; error: string;
@@ -198,44 +195,6 @@ export interface MonitoringMachineInput {
notes: string; notes: string;
} }
export interface SavedTask {
id: string;
name: string;
task_type: "shell" | "python";
content: string;
enabled: boolean;
default_machine_id: string;
notes: string;
created_at: number;
updated_at: number;
}
export interface SavedTaskInput {
id?: string | null;
name: string;
task_type: "shell" | "python";
content: string;
enabled: boolean;
default_machine_id: string;
notes: string;
}
export interface SavedTaskRun {
id: string;
task_id: string;
task_name: string;
machine_id: string;
machine_name: string;
task_type: "shell" | "python";
status: string;
created_at: number;
duration_ms: number;
request_id: string;
stdout_tail: string;
stderr_tail: string;
error: string;
}
export interface ResetLocalDatabaseInput { export interface ResetLocalDatabaseInput {
confirm_phrase: string; confirm_phrase: string;
acknowledge_settings_loss: boolean; acknowledge_settings_loss: boolean;
@@ -16,47 +16,47 @@
**Goal:** One execution path; tasks target ssh_tasks services; one history table. **Goal:** One execution path; tasks target ssh_tasks services; one history table.
- [ ] **1.1 Add shared `run_saved_task` helper** - [x] **1.1 Add shared `run_saved_task` helper**
- Files: `backend/src/media_library_viewer_api/services/task_runner.py` (new) - Files: `backend/src/media_library_viewer_api/services/task_runner.py` (new)
- Lines: ~90 - Lines: ~90
- Details: `run_saved_task(store, task, service, *, request_id)` builds the SSH - Details: `run_saved_task(store, task, service, *, request_id)` builds the SSH
client from the service record (promote `_build_ssh_client`), renders the client from the service record (promote `_build_ssh_client`), renders the
command, runs with the service timeout, appends a `service_task_runs` row, command, runs with the service timeout, appends a `service_task_runs` row,
returns a `TaskRunResult`. returns a `TaskRunResult`.
- [ ] **1.2 Rename saved_tasks column** - [x] **1.2 Rename saved_tasks column**
- Files: `services/settings_store.py` (modify) - Files: `services/settings_store.py` (modify)
- Lines: ~20 - Lines: ~20
- Details: `default_machine_id``default_service_id` (ALTER TABLE RENAME - Details: `default_machine_id``default_service_id` (ALTER TABLE RENAME
COLUMN on startup; update `_row_to_task`, `_normalize_task_payload`, COLUMN on startup; update `_row_to_task`, `_normalize_task_payload`,
`upsert_task`). `upsert_task`).
- [ ] **1.3 Drop saved_task_runs** - [x] **1.3 Drop saved_task_runs**
- Files: `services/settings_store.py` (modify) - Files: `services/settings_store.py` (modify)
- Lines: ~-60 - Lines: ~-60
- Details: `DROP TABLE IF EXISTS saved_task_runs`; remove `record_task_run` - Details: `DROP TABLE IF EXISTS saved_task_runs`; remove `record_task_run`
and `list_task_runs` (task flavor). and `list_task_runs` (task flavor).
- [ ] **1.4 Rewire tasks router** - [x] **1.4 Rewire tasks router**
- Files: `routers/tasks.py` (modify) - Files: `routers/tasks.py` (modify)
- Lines: ~70 - Lines: ~70
- Details: `TaskInput.default_service_id`; `run_task` takes `service_id` - Details: `TaskInput.default_service_id`; `run_task` takes `service_id`
(override), resolves an ssh_tasks service, calls `run_saved_task`; (override), resolves an ssh_tasks service, calls `run_saved_task`;
`/api/tasks/{id}/runs` reads `service_task_runs`. Remove `/api/tasks/{id}/runs` reads `service_task_runs`. Remove
`_resolve_machine_for_task` and `_client_for_machine`. `_resolve_machine_for_task` and `_client_for_machine`.
- [ ] **1.5 Widget delegates to shared helper** - [x] **1.5 Widget delegates to shared helper**
- Files: `widgets/sources.py` (modify) - Files: `widgets/sources.py` (modify)
- Lines: ~-40 - Lines: ~-40
- Details: `SshTaskWidgetSource.fetch` calls `run_saved_task` instead of its - Details: `SshTaskWidgetSource.fetch` calls `run_saved_task` instead of its
inline run+log block. inline run+log block.
- [ ] **1.6 Add `list_service_task_runs` by task (if not present)** - [x] **1.6 Add `list_service_task_runs` by task (if not present)**
- Files: `services/settings_store.py` (modify) - Files: `services/settings_store.py` (modify)
- Lines: ~10 - Lines: ~10
- Details: Confirm `list_service_task_runs(task_id=...)` covers the tasks - Details: Confirm `list_service_task_runs(task_id=...)` covers the tasks
router needs. router needs.
- [ ] **1.7 Update backend tests** - [x] **1.7 Update backend tests**
- Files: `backend/tests/test_jobs.py`, `test_api.py` (modify) - Files: `backend/tests/test_jobs.py`, `test_api.py` (modify)
- Lines: ~60 - Lines: ~60
- Details: Update task-run tests to the service model; cover override + - Details: Update task-run tests to the service model; cover override +
default + disabled-service paths. default + disabled-service paths.
- [ ] **1.8 Verify** - [x] **1.8 Verify**
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest` - Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
**Slice 1 total:** ~250 changed lines. **Slice 1 total:** ~250 changed lines.
@@ -65,39 +65,39 @@
**Goal:** Actions page targets ssh_tasks services; reads service_task_runs. **Goal:** Actions page targets ssh_tasks services; reads service_task_runs.
- [ ] **2.1 Update types** - [x] **2.1 Update types**
- Files: `frontend/src/types/index.ts` (modify) - Files: `frontend/src/types/index.ts` (modify)
- Lines: ~15 - Lines: ~15
- Details: `SavedTask` / `SavedTaskInput` `default_service_id`; - Details: `SavedTask` / `SavedTaskInput` `default_service_id`;
`SavedTaskRun` aligned to `service_task_runs`. `SavedTaskRun` aligned to `service_task_runs`.
- [ ] **2.2 Update API client** - [x] **2.2 Update API client**
- Files: `frontend/src/api/client.ts` (modify) - Files: `frontend/src/api/client.ts` (modify)
- Lines: ~10 - Lines: ~10
- Details: `runTask(taskId, serviceId?)` sends `service_id`. - Details: `runTask(taskId, serviceId?)` sends `service_id`.
- [ ] **2.3 Rewire Actions page** - [x] **2.3 Rewire Actions page**
- Files: `frontend/src/pages/Actions.tsx` (modify) - Files: `frontend/src/pages/Actions.tsx` (modify)
- Lines: ~120 - Lines: ~120
- Details: Task editor "Default service" select lists ssh_tasks services via - Details: Task editor "Default service" select lists ssh_tasks services via
`useServiceInstances("ssh_tasks")`; run dialog "Run on" selects an instance; `useServiceInstances("ssh_tasks")`; run dialog "Run on" selects an instance;
run history reads `service_task_runs`. Remove `useMonitoringSettings`. run history reads `service_task_runs`. Remove `useMonitoringSettings`.
- [ ] **2.4 Update Actions tests** - [x] **2.4 Update Actions tests**
- Files: `frontend/src/pages/__tests__/Actions.test.tsx` (modify) - Files: `frontend/src/pages/__tests__/Actions.test.tsx` (modify)
- Lines: ~30 - Lines: ~30
- Details: Mock `useServiceInstances`; update fixtures. - Details: Mock `useServiceInstances`; update fixtures.
- [ ] **2.5 Docs + changelog** - [x] **2.5 Docs + changelog**
- Files: `docs/REQUIREMENTS.md`, `CHANGELOG.md` (modify) - Files: `docs/REQUIREMENTS.md`, `CHANGELOG.md` (modify)
- Lines: ~30 - Lines: ~30
- Details: Saved-actions section: tasks target ssh_tasks services; local mode - Details: Saved-actions section: tasks target ssh_tasks services; local mode
dropped; breaking-upgrade note. dropped; breaking-upgrade note.
- [ ] **2.6 Verify** - [x] **2.6 Verify**
- Run: `cd frontend && npm run lint && npm run build && npm run test` - Run: `cd frontend && npm run lint && npm run build && npm run test`
**Slice 2 total:** ~200 changed lines. **Slice 2 total:** ~200 changed lines.
## Integration and acceptance ## Integration and acceptance
- [ ] **3.1 Backend full test run**`PYTHONPATH=src pytest`, all green. - [x] **3.1 Backend full test run**`PYTHONPATH=src pytest`, all green.
- [ ] **3.2 Frontend full build/lint/test**. - [x] **3.2 Frontend full build/lint/test**.
- [ ] **3.3 Manual dev-stack check**: - [ ] **3.3 Manual dev-stack check**:
- Create an ssh_tasks service; create a task with that default; run from - Create an ssh_tasks service; create a task with that default; run from
Actions; see the run in both the Actions history and the service page. Actions; see the run in both the Actions history and the service page.