# Design: Unify Saved Tasks on SSH Services **Change:** `unify-tasks-on-services` **Phase:** design **Date:** 2026-06-19 ## 1. Architecture overview ``` ┌─────────────────────────────────────┐ │ saved_tasks (global, reusable) │ │ default_service_id → ssh_tasks │ └─────────────────────────────────────┘ │ │ Actions page │ │ SSH task widget ▼ ▼ ┌─────────────────────────────────────┐ │ run_saved_task(store, task, svc) │ ← shared helper │ build client → run → log │ └─────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ service_task_runs (one history) │ └─────────────────────────────────────┘ ``` Both the Actions runner and the SSH task widget call one shared helper, so there is a single execution path and a single history table. ## 2. Shared execution helper New: `backend/src/media_library_viewer_api/services/task_runner.py` ```python from dataclasses import dataclass from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.widgets.sources import ServiceRecord, _build_ssh_client @dataclass class TaskRunResult: exit_status: int stdout: str stderr: str duration_ms: int status: str # "success" | "failure" | "timeout" | "error" error: str def run_saved_task( store: SettingsStore, task: dict, service: ServiceRecord, *, request_id: str = "", ) -> TaskRunResult: """Run a saved task on an ssh_tasks service instance and log it. Builds the SSH client from the service record, renders the command (shell or python3 -c), runs it with the service's timeout, appends a service_task_runs row, and returns the result. """ ... ``` - The widget adapter (`SshTaskWidgetSource.fetch`) is refactored to call `run_saved_task`, removing its inline copy. - `routers/tasks.py` `run_task` calls `run_saved_task` instead of `_client_for_machine` + `record_task_run`. - `_build_ssh_client` (currently private in `widgets/sources.py`) is promoted to the helper module or a shared location so both callers use it. ## 3. Data model changes ### 3.1 `saved_tasks` ```sql -- default_machine_id replaced by default_service_id ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id; ``` In SQLite (3.25+) `RENAME COLUMN` is supported. The column still stores an id, now pointing at `services.id` (an `ssh_tasks` instance) instead of a machine. ### 3.2 `saved_task_runs` dropped ```sql DROP TABLE IF EXISTS saved_task_runs; ``` All history lives in `service_task_runs` (added in the service-registry change). The `record_task_run` / `list_task_runs` methods on `SettingsStore` are removed. ## 4. Backend API ### `routers/tasks.py` | Method | Path | Change | |--------|------|--------| | GET | `/api/tasks` | Unchanged (task now carries `default_service_id`). | | POST | `/api/tasks` | `TaskInput.default_service_id` replaces `default_machine_id`. | | PUT | `/api/tasks/{id}` | Same field rename. | | DELETE | `/api/tasks/{id}` | Unchanged. | | GET | `/api/tasks/{id}/runs` | Reads `service_task_runs` (filtered by `task_id`). | | POST | `/api/tasks/run?service_id=...` | `service_id` replaces `machine_id`; resolves an `ssh_tasks` service (override) or the task's `default_service_id`; calls `run_saved_task`. | `_resolve_machine_for_task` and `_client_for_machine` are removed (replaced by service resolution + the shared helper). ### Resolution + validation - `run_task`: load the task; if `service_id` query param is given, use it (override), else use `task.default_service_id`; load the `ssh_tasks` service record; build a `ServiceRecord` (decrypt secrets); call `run_saved_task`. - 400 if the task is disabled; 400 if no service resolves; 404 if the task or service is missing. ## 5. Frontend ### 5.1 Types `SavedTask` / `SavedTaskInput` / `SavedTaskRun` (`frontend/src/types/index.ts`): - `default_machine_id` → `default_service_id`. - `SavedTaskRun` fields align with `service_task_runs` (`service_id`, `exit_status`, `stdout_tail`, …). ### 5.2 API client + hooks - `runTask(taskId, serviceId?)` sends `service_id`. - `fetchSavedTaskRuns(taskId)` reads `/api/tasks/{id}/runs` (now `service_task_runs`-backed). ### 5.3 Actions page - Task editor: "Default service" `` lists `ssh_tasks` instances (override). - Run history: reads the task's `service_task_runs`. - `useMonitoringSettings` removed from the Actions page (no longer needed). ## 6. Migration and breaking changes - **DB:** `saved_tasks.default_machine_id` renamed to `default_service_id` (existing values become stale references to machine ids; inert — the user re-points). `saved_task_runs` dropped. - **Local execution removed.** Deployments relying on local tasks must use an `ssh_tasks` service (e.g. pointing at localhost with a key). - **Changelog + README** note the breaking change. ## 7. File-level plan ### Create (backend) - `services/task_runner.py` — `run_saved_task` shared helper. ### Modify (backend) - `services/settings_store.py` — rename column; drop `saved_task_runs` + `record_task_run` / `list_task_runs` (task-run flavor). - `routers/tasks.py` — service resolution; call `run_saved_task`; `service_id` param; read `service_task_runs`. - `widgets/sources.py` — `SshTaskWidgetSource.fetch` delegates to `run_saved_task`. ### Modify (frontend) - `types/index.ts` — field rename + `SavedTaskRun` alignment. - `api/client.ts` — `runTask` sends `service_id`. - `pages/Actions.tsx` — service selectors + history source. ## 8. Slice boundaries 1. **Backend** — `run_saved_task` helper; saved_tasks column rename; tasks router rewired; widget delegates; `saved_task_runs` dropped; tests. 2. **Frontend** — types + API + Actions page rewire; tests. Estimated ~600–800 changed lines across two PRs.