# 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" `<Select>` lists `ssh_tasks` service instances
  (via `useServiceInstances("ssh_tasks")`), not machines.
- Run dialog: "Run on" `<Select>` 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.
