feat: add typed qBittorrent scheduled polling

This commit is contained in:
Developer
2026-07-14 15:22:34 +00:00
parent eac9b5d33d
commit a9488af0b4
22 changed files with 2036 additions and 56 deletions
+15
View File
@@ -296,6 +296,21 @@ Multiple instances per service type are supported. Services are managed from the
These do not reference a service.
### Backend Scheduled Actions and qBittorrent Polling
- The backend should collect qBittorrent speed samples independently of browser or dashboard presence.
- Scheduled work should use a typed, explicitly registered action system; arbitrary widgets, SSH commands, and user-provided code must not be executable through the scheduler.
- The first scheduled action is qBittorrent speed sampling. The initial deployment assumes one scheduler-capable backend worker; multiple replicas must not silently duplicate polls.
- qBittorrent polling should be opt-out by default for enabled service instances and configurable per service with a 15-second default interval bounded to 5300 seconds.
- Sample retention should be configurable by duration and maximum rows, defaulting to 30 minutes and 1,200 rows, with duration bounded to 124 hours and the row cap enforced server-side.
- The scheduler should run immediately after startup with per-service staggering, use fixed-delay execution, prevent overlap/backlog, and reconcile configuration changes without a backend restart.
- Poll failures should remain enabled, be persisted, and retry with bounded exponential backoff. A successful scheduled or manual run should clear backoff.
- The qBittorrent widget-data endpoint must become read-only; only the scheduler may contact qBittorrent and append samples.
- The service UI should expose polling settings, current status, stale-data state, a manual `Run now` action, selectable chart windows, and paginated scheduled-action history.
- Scheduled-action runs should use dedicated generic records, retain at most 30 days or 1,000 runs per service/action, and never store secrets or raw credentials.
- Disabling a qBittorrent service pauses polling while retaining history; deleting the service purges its samples and scheduler history through the existing cascade-delete behavior.
- Persistent polling failures should be visible in the service UI and application metrics; a new notification channel is not required for the first release.
### Security
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
@@ -0,0 +1,156 @@
# Scheduled Actions Test Plan / QA Checklist
## Test objectives
Verify that qBittorrent speed collection is backend-owned, configurable per service, resilient to transient failures, observable, and safe when the frontend is not open.
## Backend unit tests
### Configuration
- [ ] Existing qBittorrent configs receive polling defaults without changing secrets.
- [ ] `polling_enabled` accepts booleans and defaults to enabled.
- [ ] Poll intervals below 5 seconds or above 300 seconds are rejected.
- [ ] Retention below 60 seconds or above 86,400 seconds is rejected.
- [ ] Sample caps below the minimum or above 1,200 are rejected.
- [ ] Credential-looking fields remain rejected from service config.
### Sample storage
- [ ] Samples remain isolated by `service_id`.
- [ ] Timestamp retention removes rows older than the configured window.
- [ ] Row-cap retention removes the oldest rows when the cap is exceeded.
- [ ] Both retention rules are applied together.
- [ ] Samples are ordered oldest to newest for chart responses.
- [ ] Service cascade deletion removes samples.
- [ ] Empty and missing databases initialize safely.
### Scheduler core
- [ ] Only registered action keys can execute.
- [ ] Disabled services do not run.
- [ ] Enabled services run immediately after startup with deterministic staggering.
- [ ] A normal run waits the configured interval after completion.
- [ ] A slow run cannot overlap itself.
- [ ] A slow run does not create queued backlog entries.
- [ ] Stop interrupts the wait and joins the worker within the configured timeout.
- [ ] A configuration change is applied on the next reconciliation cycle.
- [ ] Disabling a service allows an in-flight run to finish, then prevents new runs.
- [ ] Deleting a service removes its schedule state and history.
### Backoff and manual runs
- [ ] A failed run is persisted with safe error text and increments failure state.
- [ ] Retry delay increases exponentially and respects the configured cap.
- [ ] Backoff does not create duplicate queued runs.
- [ ] A successful scheduled run clears failures and backoff.
- [ ] A successful manual run clears failures and backoff.
- [ ] A failed manual run follows normal failure persistence and retry behavior.
- [ ] Manual runs do not alter the configured interval.
### qBittorrent action and widget separation
- [ ] The scheduled action calls qBittorrent and appends exactly one sample per successful poll.
- [ ] The qBittorrent client cache is reused correctly per service.
- [ ] Timeouts become failed runs without blocking the scheduler indefinitely.
- [ ] The `speed` widget adapter reads samples but does not call qBittorrent.
- [ ] Multiple widgets or browser tabs do not multiply samples.
- [ ] A failed latest poll still returns last-known samples with stale status.
## Backend API tests
- [ ] Scheduler status requires normal API authentication.
- [ ] Status returns effective configuration, last attempt, last success, failure count, backoff, and stale state.
- [ ] Missing service returns 404 or the projects established service error shape.
- [ ] Disabled service status is explicit and does not run an action.
- [ ] Run history is paginated and supports status/trigger filters.
- [ ] Run history is bounded by 30 days and 1,000 records per service/action.
- [ ] Manual-run endpoint returns a typed result and records the attempt.
- [ ] Samples endpoint accepts a display window and is read-only.
- [ ] Widget-data requests never cause a qBittorrent external call.
- [ ] Responses never expose usernames, passwords, API keys, headers, or raw payloads.
## Observability tests
- [ ] Run counters increment for success and failure.
- [ ] Duration metrics record completed attempts.
- [ ] Last-success gauges update only after successful sampling.
- [ ] Failure/stale gauges reset after recovery.
- [ ] Metric labels are bounded and contain no secrets or raw URLs.
- [ ] Structured logs include action/service/status context and sanitize errors.
## Frontend unit/component tests
- [ ] qBittorrent schedule fields render only for qBittorrent services.
- [ ] Invalid interval, retention, and cap values show validation feedback.
- [ ] Save preserves existing encrypted-secret behavior.
- [ ] Disabled polling clearly shows paused state.
- [ ] Status card renders healthy, running, backoff, stale, disabled, and never-run states.
- [ ] `Run now` shows pending state and disables duplicate clicks.
- [ ] Successful manual run refreshes status/history and clears backoff display.
- [ ] Failed manual run renders a safe error.
- [ ] Chart window selector requests samples without changing sampler settings.
- [ ] Stale warning appears while last-known speed data remains visible.
- [ ] Run history renders pagination, trigger, status, duration, timestamp, and error details.
- [ ] Empty history and no-data states are readable on mobile.
## Integration / lifespan tests
- [ ] Starting the FastAPI lifespan starts the scheduler exactly once.
- [ ] Repeated `start()` calls do not create duplicate workers.
- [ ] Lifespan shutdown stops the scheduler and does not leak a thread.
- [ ] A test app can override the scheduler/action registry cleanly.
- [ ] Existing mail queue and backup poller lifecycle behavior remains unchanged.
## Manual QA scenarios
### Headless collection
1. Configure an enabled qBittorrent service.
2. Start the backend without opening the frontend.
3. Wait for at least two intervals.
4. Query scheduler status and samples directly.
5. Confirm samples and successful run records exist.
### Duplicate prevention
1. Open the speed widget in multiple browser tabs.
2. Compare sample count growth to scheduler run count.
3. Confirm browser refreshes do not add samples or external qBittorrent calls.
### Outage and recovery
1. Make qBittorrent unreachable.
2. Confirm failures and increasing backoff appear in status/history.
3. Confirm last-known samples remain visible with a stale warning.
4. Restore qBittorrent.
5. Confirm the next successful scheduled or manual run clears backoff and stale state.
### Configuration reload
1. Change the interval and retention in the qBittorrent service editor.
2. Confirm the existing worker remains alive.
3. Confirm the new effective values appear after reconciliation.
4. Confirm pruning follows the new retention/cap.
### Service lifecycle
1. Disable a service and confirm no new runs are created while history remains.
2. Re-enable it and confirm polling resumes.
3. Delete it and confirm service-owned samples and run records are removed.
### Deployment constraint
1. Run the documented single-worker deployment.
2. Confirm one scheduler worker is active.
3. Verify the deployment documentation warns against multiple scheduler-capable replicas.
## Release gate
- [ ] Backend test suite passes.
- [ ] Frontend tests pass.
- [ ] Frontend lint passes with no new violations.
- [ ] Frontend build passes.
- [ ] No secret appears in logs, API responses, metrics, or scheduler run records.
- [ ] Project maps are patched for new files and validated.
- [ ] Requirements and runbook documentation match the shipped behavior.
@@ -0,0 +1,124 @@
# Typed Scheduler and qBittorrent Polling Implementation Plan
> This plan implements the approved design in `docs/superpowers/specs/2026-07-14-scheduled-actions-design.md`.
**Goal:** Move qBittorrent speed collection into a backend-owned typed scheduler while adding per-service controls, run history, stale-data status, and a manual run action.
**Scope:** qBittorrent speed polling only. The scheduler registry is extensible, but arbitrary widgets, SSH tasks, and other integrations remain out of scope.
## Delivery slices
### Slice 1 — Contracts and persistence
- [ ] Add validated qBittorrent config fields to `backend/src/media_library_viewer_api/integrations/qbittorrent.py`:
- `polling_enabled` default `true`;
- `poll_interval_seconds` default `15`, range `5..300`;
- `sample_retention_seconds` default `1800`, range `60..86400`;
- `sample_max_rows` default `1200`, range `60..1200`.
- [ ] Add migration/default normalization tests for existing qBittorrent records.
- [ ] Extend `QbittorrentSampleStore` to prune by retention timestamp and capped row count.
- [ ] Add a generic scheduler storage concern with run records, indexes, pruning, and cascade deletion by service ID.
- [ ] Add typed backend models for scheduler status, run records, samples, and manual-run responses.
**Acceptance:** Existing service records validate without edits; qBittorrent samples remain isolated by service; scheduler records cannot contain secrets; deletion removes service-owned samples and runs.
### Slice 2 — Typed scheduler core
- [ ] Create a scheduler action protocol and registry.
- [ ] Implement a single-worker, lifespan-managed scheduler coordinator with responsive stop behavior.
- [ ] Add qBittorrent speed sampling as the first registered action.
- [ ] Extract external polling from `QbittorrentWidgetSource` into a reusable sampler/action helper.
- [ ] Implement immediate startup execution with deterministic staggering.
- [ ] Implement fixed-delay, no-overlap execution and bounded exponential backoff.
- [ ] Reconcile enabled services/config changes on each cycle.
- [ ] Add safe structured logs and Prometheus metrics.
- [ ] Start/stop the scheduler in `main.py` alongside the existing mail queue and backup poller.
**Acceptance:** With no frontend open, enabled qBittorrent services append samples; one slow service cannot create overlapping runs or a backlog; shutdown joins the worker; a successful manual or scheduled run resets backoff.
### Slice 3 — Read-only APIs
- [ ] Add `backend/src/media_library_viewer_api/routers/scheduler.py`.
- [ ] Add status, paginated runs, manual-run, and read-only samples endpoints.
- [ ] Keep service configuration writes on the existing service-instance API.
- [ ] Change the qBittorrent speed widget adapter to read samples only.
- [ ] Add stale-data calculation and safe error truncation.
- [ ] Add API tests for disabled/missing services, stale data, pagination, manual runs, backoff, and authentication.
**Acceptance:** Opening or refreshing a speed widget never contacts qBittorrent and never appends a sample; API responses expose timestamps and status but no credentials.
### Slice 4 — Frontend controls and history
- [ ] Add scheduler TypeScript types, API functions, and React Query hooks.
- [ ] Add qBittorrent schedule controls to the existing schema-driven service editor.
- [ ] Add status/backoff/stale-data presentation and a `Run now` action.
- [ ] Add user-selectable chart windows.
- [ ] Add a paginated run-history table with safe error details.
- [ ] Keep UI refreshes separate from sampler cadence.
- [ ] Add frontend tests for validation, disabled state, stale warning, manual-run reset, chart-window selection, and run-history rendering.
**Acceptance:** Operators can configure, inspect, and manually trigger qBittorrent polling from the service surface without opening the dashboard; the chart remains useful during outages and identifies stale data.
### Slice 5 — Documentation and operational verification
- [ ] Update `docs/REQUIREMENTS.md` with scheduler requirements and the one-worker constraint.
- [ ] Update deployment/runbook documentation with scheduler startup, shutdown, and replica guidance.
- [ ] Add migration/recovery notes for sample and run-history retention.
- [ ] Run backend tests, frontend tests, lint, and build.
- [ ] Verify a headless collection scenario against a mocked qBittorrent service.
- [ ] Verify project-map artifacts after files are added.
## Suggested file map
### Backend
| File | Change |
| --- | --- |
| `backend/src/media_library_viewer_api/integrations/qbittorrent.py` | Schedule config schema and defaults |
| `backend/src/media_library_viewer_api/services/qbittorrent_store.py` | Duration/cap pruning and sample queries |
| `backend/src/media_library_viewer_api/services/service_data.py` | Register scheduler run concern |
| `backend/src/media_library_viewer_api/services/scheduler.py` | Worker lifecycle, reconciliation, timing, backoff |
| `backend/src/media_library_viewer_api/services/scheduler_actions.py` | Typed registry and qBittorrent action |
| `backend/src/media_library_viewer_api/services/scheduler_store.py` | Run-record persistence and pruning |
| `backend/src/media_library_viewer_api/models/scheduler.py` | Response/request models |
| `backend/src/media_library_viewer_api/routers/scheduler.py` | Status, history, samples, manual-run API |
| `backend/src/media_library_viewer_api/widgets/sources.py` | Make qBittorrent speed reads side-effect free |
| `backend/src/media_library_viewer_api/main.py` | Start/stop scheduler |
| `backend/tests/test_scheduler.py` | Scheduler lifecycle/timing/backoff tests |
| `backend/tests/test_scheduler_api.py` | Endpoint and auth tests |
| `backend/tests/test_service_data.py` | Migration/cascade coverage |
| `backend/tests/test_widgets.py` | Read-only qBittorrent widget coverage |
### Frontend
| File | Change |
| --- | --- |
| `frontend/src/types/scheduler.ts` | Scheduler status/run/sample types |
| `frontend/src/api/scheduler.ts` | Typed endpoint wrappers |
| `frontend/src/hooks/useScheduler.ts` | Queries and manual-run mutation |
| `frontend/src/pages/ServicesPage.tsx` | qBittorrent schedule controls/status surface |
| `frontend/src/pages/ServicePage.tsx` or qBittorrent service tab | Status, chart window, history surface |
| `frontend/src/widgets/QbittorrentSpeedWidget.tsx` | Read-only sample window and stale warning |
| `frontend/src/integrations/registry.ts` | Schedule metadata/config exposure if needed |
| `frontend/src/types/index.ts` | Shared exports |
## Risks and mitigations
- **Duplicate polling:** widget adapter becomes read-only; only scheduler action calls qBittorrent.
- **Multiple backend workers:** document and log the one-worker constraint; do not silently duplicate work.
- **Unbounded storage:** prune by both duration and row cap; test pruning under rapid polling.
- **Credential leakage:** reuse existing secret resolution and sanitize run errors/log fields.
- **Scheduler shutdown races:** use a stop event, per-action lock, and bounded joins; test lifespan shutdown.
- **Config changes during a run:** let the current run finish, then reconcile on the next cycle.
- **Stale but useful data:** return samples plus explicit stale status rather than blanking the chart.
## Verification commands
```bash
cd backend && PYTHONPATH=src pytest
cd frontend && npm test
cd frontend && npm run lint
cd frontend && npm run build
```
Do not begin implementation until the final module names, retry cap/jitter, and chart-window response shape are confirmed during the implementation pass.
@@ -0,0 +1,244 @@
# Typed Scheduled Actions and qBittorrent Polling Design
**Date:** 2026-07-14
**Status:** Proposed
## Overview
Manage currently collects qBittorrent speed samples as a side effect of a browser polling the widget-data endpoint. This design moves collection into a backend-owned typed scheduler so samples continue when no page is open, while keeping widget reads read-only.
The first scheduled action is qBittorrent speed polling. The scheduler is intentionally extensible but does not execute arbitrary widgets, SSH commands, or user-provided code.
## Goals
- Collect qBittorrent download/upload speed independently of browser presence.
- Configure polling per qBittorrent service instance.
- Preserve per-service SQLite isolation and existing cascade-delete behavior.
- Provide current status, stale-data state, run history, and a manual `Run now` action.
- Reuse the existing lifespan worker pattern and remain safe under the single-backend-worker deployment model.
- Expose metrics and structured logs without adding a new notification channel.
## Non-goals
- Distributed scheduling across replicas.
- External worker infrastructure or a task queue.
- Scheduling arbitrary saved SSH tasks.
- Moving every widget-backed integration to the scheduler in this release.
- A global scheduler administration page.
## Decisions
| Area | Decision |
| --- | --- |
| Architecture | Typed action registry with qBittorrent as the first action |
| Worker model | One lifespan-managed backend worker; deployment must run one scheduler-capable backend process |
| Activation | Enabled qBittorrent services poll by default; polling is opt-out |
| Defaults | 15-second interval; valid range 5300 seconds |
| Samples | 30-minute default history; valid range 124 hours; configurable lower sample cap with a hard maximum of 1,200 rows |
| Timing | Immediate first run with per-service startup staggering; fixed delay after completion |
| Concurrency | No overlapping runs and no queued missed ticks per service |
| Failure | Keep enabled, record failure, retry with bounded exponential backoff |
| Manual run | Supported; successful manual run clears backoff |
| Widget data | Read-only; scheduler is the only qBittorrent sampler |
| Run history | Dedicated generic scheduled-action run records; retain 30 days or 1,000 runs per service/action |
| Service lifecycle | Disable pauses and retains history; delete purges service-owned data through cascade deletion |
| UI | Controls and status/history live with each qBittorrent service |
| Alerts | UI and metrics only in this release |
## Current and target flow
### Current
```text
React Query interval
-> GET /api/widgets/instances/{id}/data
-> QbittorrentWidgetSource.fetch()
-> qBittorrent API
-> append speed sample
-> return chart data
```
### Target
```text
Backend lifespan
-> TypedScheduler
-> registered QbittorrentSpeedAction
-> qBittorrent API
-> QbittorrentSampleStore
-> SchedulerRunStore
React Query / service UI
-> scheduler status/history/sample endpoints
-> read-only SQLite queries
```
## Configuration model
The existing qBittorrent service config gains validated non-secret fields:
```json
{
"base_url": "https://qbit.example",
"timeout_seconds": 60,
"polling_enabled": true,
"poll_interval_seconds": 15,
"sample_retention_seconds": 1800,
"sample_max_rows": 1200
}
```
Suggested validation:
- `polling_enabled`: boolean, default `true` for backward compatibility.
- `poll_interval_seconds`: integer from 5 through 300, default 15.
- `sample_retention_seconds`: integer from 60 through 86,400, default 1,800.
- `sample_max_rows`: integer from 60 through 1,200, default 1,200. The upper bound is a server safety limit, not merely a UI hint.
- Secrets remain exclusively in the existing encrypted secret fields.
The service type metadata must expose these fields so the existing schema-driven service editor renders them. Existing qBittorrent records receive defaults through normalization rather than a destructive migration.
## Scheduler architecture
### Registry and contracts
Add a small scheduler service with explicit action registration:
```python
class ScheduledAction(Protocol):
action_key: str
async def run(self, service: ServiceRecord, context: ActionContext) -> ActionResult: ...
```
The registry maps an action key to its implementation and metadata. The first key is `qbittorrent.speed_sample`. The scheduler never evaluates arbitrary config as executable code.
A scheduler cycle should:
1. Read enabled service records.
2. Select services whose typed action is enabled.
3. Reconcile changed interval/enabled settings.
4. Run due actions serially per service.
5. Persist a run record and update in-memory status.
6. Wait using a stop event so shutdown is responsive.
A thread-based coordinator is appropriate for the first release because `BackupAlertPoller` already establishes the projects lifespan-managed worker pattern and qBittorrents client is blocking. The action may use the existing authenticated client cache, but the sampler should be extracted from the widget adapter so collection and presentation are not coupled.
### Timing and backoff
- First eligible service run starts immediately after startup, with a small deterministic stagger based on service ordering.
- Normal scheduling uses fixed delay: the next due time is calculated after the previous attempt completes.
- A per-service action lock prevents overlap.
- A failed run uses bounded exponential backoff, capped below the configured intervals operational maximum. Backoff must not enqueue missed runs.
- A successful scheduled or manual run resets consecutive failures and clears `backoff_until`.
- Config changes are observed during the next reconciliation cycle; an interval change affects the next due calculation.
- Disabling a service prevents new work and allows the current run to finish before the action becomes idle.
### Single-worker constraint
The initial design assumes one backend process owns scheduler execution. Running multiple Uvicorn workers or replicas would duplicate polls and run records. Startup logs and operational documentation must make this constraint explicit. A future distributed lease can be added without changing the action contract.
## Storage
### Speed samples
Extend `QbittorrentSampleStore` to prune by both:
- `service_id` and `ts >= now - sample_retention_seconds`;
- most recent `sample_max_rows`, bounded by 1,200.
The existing `qbittorrent_speed_samples` table remains the source for chart data. Its API should accept a requested display window and return ordered samples. Deleting a service must continue to cascade into this concern.
### Scheduled-action runs
Add a generic scheduler storage concern, separate from `service_task_runs`, with fields equivalent to:
| Field | Description |
| --- | --- |
| `id` | Run identifier |
| `service_id` | Owning service instance |
| `action_key` | Registered action key, e.g. `qbittorrent.speed_sample` |
| `trigger` | `schedule` or `manual` |
| `started_at` / `finished_at` | Attempt timing |
| `status` | `running`, `success`, `failure`, `backoff`, or `cancelled` |
| `duration_ms` | Elapsed time |
| `attempt` | Retry/backoff attempt number |
| `error` | Secret-safe error text, truncated |
| `created_at` | Record creation time |
Indexes should cover `(service_id, action_key, started_at DESC)` and `(status, started_at DESC)`. Prune records older than 30 days and enforce a maximum of 1,000 records per service/action.
No credentials, request headers, or raw qBittorrent payloads may be stored in run history.
## Backend API
Add a dedicated scheduler router. Exact response models should be typed and should not expose secrets.
| Endpoint | Purpose |
| --- | --- |
| `GET /api/scheduler/services/{service_id}/status` | Current action state, last attempt/success, stale state, failures, backoff, and effective config |
| `GET /api/scheduler/services/{service_id}/runs` | Paginated run history with status/trigger filters |
| `POST /api/scheduler/services/{service_id}/run` | Run the registered qBittorrent action immediately; return a run/status response |
| `GET /api/scheduler/services/{service_id}/samples` | Read-only speed samples for a selected display window |
The existing `PUT /api/services/instances/{id}` remains the write path for schedule configuration. The widget-data endpoint must stop calling qBittorrent for the `speed` kind; it should read samples through the same store/query helper used by the scheduler API.
Manual runs must use the same action registry and persistence path as scheduled runs. A successful manual run clears backoff; a failed manual run records the failure and applies the same bounded retry state.
## Stale-data semantics
The status response should include `last_success_at`, `last_error`, `consecutive_failures`, `backoff_until`, and `is_stale`. Suggested initial stale rule:
```text
is_stale = no successful run
OR now - last_success_at > max(2 * effective_interval, 60 seconds)
```
The speed widget should retain and render the last known samples with a warning containing the last-success time and current error. It should not replace useful history with an empty state solely because the latest poll failed.
## Frontend design
The existing schema-driven qBittorrent service editor should gain a scheduling section containing:
- polling enabled switch;
- interval field with bounds/error text;
- sample retention duration;
- maximum sample rows;
- effective next-run and last-success summary;
- `Run now` button;
- current failure/backoff message.
A qBittorrent service detail/editor surface should also contain:
- stale-data banner;
- user-selectable chart windows (for example 5m, 30m, 1h, all retained);
- speed chart sourced from read-only sample data;
- paginated run-history table with trigger, status, duration, timestamp, and safe error detail;
- loading, empty, disabled, and failed states.
Add typed API functions, React Query hooks, and types under the existing `frontend/src/api`, `frontend/src/hooks`, and `frontend/src/types` patterns. Poll status/history at a slower UI cadence than the sampler; the UI must not drive collection.
## Observability
Add secret-safe metrics using bounded labels:
- scheduled action runs total by action and status;
- scheduled action duration by action;
- last successful run timestamp by action/service;
- current consecutive failures or stale state by action/service.
Avoid labels containing URLs, usernames, API keys, raw errors, or unbounded widget IDs. Structured logs should include service ID, action key, trigger, status, duration, and request ID where available.
## Security and operational constraints
- Only registered action keys can execute.
- Service credentials are loaded through existing decryption helpers and are never returned or persisted in run records.
- Manual-run endpoints use existing JWT/API authentication.
- The scheduler must stop cleanly during lifespan shutdown and should not leave a new thread running after tests finish.
- The deployment documentation must state the one-worker scheduler constraint.
## Open implementation details
- Choose final module names and whether scheduler run storage belongs in a new service-data concern or a dedicated settings-store table.
- Define exact retry cap and jitter values.
- Decide whether the scheduler status endpoint returns one action or a list of registered actions.
- Finalize chart window/downsampling behavior for the 24-hour/1,200-row maximum.