From 9a251db23cd30b72426e9a8fb626e35f3e490dcf Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 9 Jul 2026 08:02:31 +0000 Subject: [PATCH] spec(service-storage-harness): add tasks (4 slices, each <=400 lines) S1 harness+store+client+integration; S2 widget adapter+3 FE widgets+ LineSeriesChart extract; S3 MediaIndex +service_id migration (fixes latent global-clear bug); S4 cascade-delete wiring. Each slice leaves pytest/npm build/npm lint green. Risk flags on LineSeriesChart extract + MediaIndex migration. --- .../changes/service-storage-harness/tasks.md | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 openspec/changes/service-storage-harness/tasks.md diff --git a/openspec/changes/service-storage-harness/tasks.md b/openspec/changes/service-storage-harness/tasks.md new file mode 100644 index 0000000..deb8fff --- /dev/null +++ b/openspec/changes/service-storage-harness/tasks.md @@ -0,0 +1,293 @@ +# SDD Tasks: Service Storage Harness (qBittorrent widgets + MediaIndex migration) + +**Change:** `service-storage-harness` +**Phase:** tasks +**Date:** 2026-07-09 + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | ~1,180–1,380 (sum of four implementation slices) | +| 400-line budget risk | High | +| Chained PRs recommended | Yes | +| Suggested split | PR 1: Harness + QbittorrentSampleStore + client + integration → PR 2: Widget adapter + LineSeriesChart extract + 3 FE widgets → PR 3: MediaIndex migration → PR 4: Cascade-delete wiring | +| Delivery strategy | auto-chain | +| Chain strategy | stacked-to-main | + +```text +Decision needed before apply: No +Chained PRs recommended: Yes +Chain strategy: stacked-to-main +400-line budget risk: High +``` + +> **Slice ordering rationale:** Slices 1–2 prove the harness and deliver all qBittorrent functionality independently of the MediaIndex migration. Slice 3 migrates the load-bearing MediaIndex feature. Slice 4 wires cascade-delete end-to-end. This ordering ensures the two biggest risks (new abstraction × load-bearing refactor) never fire in the same slice — the harness shape is settled in code before MediaIndex touches it. + +--- + +## Slice 1: ServiceDataHarness + QbittorrentSampleStore + QbittorrentClient + integration registration + +**Goal:** Build the lifecycle-only storage harness, prove it with the qBittorrent speed-sample store, add the qBittorrent HTTP client and integration registration. No widgets or frontend yet — this slice is backend-only and proves the harness contract with a real consumer. + +- [ ] **1.1 Create `ServiceDataHarness` lifecycle module** + - Files: `backend/src/media_library_viewer_api/services/service_data.py` (NEW) + - Lines: ~100 + - Dependencies: none + - Details: `StorageConcern` dataclass (`concern_key`, `db_filename`, `migrations: list[str]`, `tables: list[str]`, `service_id_column="service_id"`). `ServiceDataHarness` class with `register(concern)`, `db_path(concern_key)`, `connect(concern_key)` (WAL + busy_timeout 30s), `run_migrations()` (per-concern, per-statement try/except for "duplicate column name"), `cascade_delete(service_id)` (iterate concerns, PRAGMA-check column exists, DELETE WHERE col = ?). Module singleton `get_service_data_harness()` that lazy-inits with `base_dir = BACKEND_CACHE_DIR or ".cache/media_library_viewer"` and calls `run_migrations()`. NO generic value table, NO generic CRUD. + +- [ ] **1.2 Create `QbittorrentSampleStore` with concern registration** + - Files: `backend/src/media_library_viewer_api/services/qbittorrent_store.py` (NEW) + - Lines: ~80 + - Dependencies: 1.1 + - Details: Define `QBITTORRENT_CONCERN` (`db_filename="qbittorrent.db"`, migration creates `qbittorrent_speed_samples(service_id, ts, dl_speed, up_speed)` + index on `(service_id, ts)`, `tables=["qbittorrent_speed_samples"]`). `MAX_SAMPLES = 120`. `QbittorrentSampleStore` class: `append(service_id, ts, dl_speed, up_speed)` (INSERT + prune keeping most recent MAX_SAMPLES for this service_id), `window(service_id, since_ts=None)` (SELECT ordered ASC). Register `QBITTORRENT_CONCERN` with harness on import. + +- [ ] **1.3 Create `QbittorrentClient` HTTP client** + - Files: `backend/src/media_library_viewer_api/clients/qbittorrent.py` (NEW) + - Lines: ~90 + - Dependencies: none + - Details: `QbittorrentClient(base_url, username, password, timeout=10)` modeled on `JellyfinClient`'s session pattern. `_login()` POSTs to `/auth/login` with `Referer` header, expects `"Ok."` response, stores SID cookie. `_get(path, **params)` auto-logins on first call, re-logins on 403. `maindata()` calls `/sync/maindata` returning `{server_state: {...}, torrents: {hash: {...}}}`. Base URL normalization: append `/api/v2` if not present. Timeout + `requests.RequestException` handling consistent with existing clients. + +- [ ] **1.4 Create qBittorrent integration definition** + - Files: `backend/src/media_library_viewer_api/integrations/qbittorrent.py` (NEW) + - Lines: ~55 + - Dependencies: 1.1 (for `ServiceConfigBase`, `WidgetConfigBase`, `SecretField`, `ServiceDefinition`, `widget_kind` from `base.py`) + - Details: `QbittorrentConfig(ServiceConfigBase)` with `base_url: ServiceBaseUrl`, `timeout_seconds: int = 10`. `QbittorrentWidgetConfig(WidgetConfigBase)` empty (all three widget kinds derive from service connection). `DEFINITION = ServiceDefinition(service_type="qbittorrent", ...)` with `secret_fields=[username (required), password (required)]` and three `widget_kinds`: `totals` (30s refresh), `active` (15s), `speed` (5s). Model on `integrations/prometheus.py`. + +- [ ] **1.5 Register qBittorrent in backend integration registry** + - Files: `backend/src/media_library_viewer_api/integrations/registry.py` (MODIFY) + - Lines: ~5 + - Dependencies: 1.4 + - Details: Import `DEFINITION as QBITTORRENT` from `integrations/qbittorrent.py`; add `SERVICE_DEFINITIONS["qbittorrent"] = QBITTORRENT`. Verify `list_service_types()` now includes `qbittorrent`. + +- [ ] **1.6 Initialize harness in application lifespan** + - Files: `backend/src/media_library_viewer_api/main.py` (MODIFY, lifespan function ~line 41) + - Lines: ~5 + - Dependencies: 1.1, 1.2 + - Details: In `lifespan()` after `get_settings_store().ensure_defaults()`, add a try/except block calling `get_service_data_harness()` (triggers lazy init + migration run). Log on failure (same pattern as the settings seed block at lines 51–54). Import is local inside lifespan to avoid circular import risk. + +- [ ] **1.7 Create backend tests for harness + store** + - Files: `backend/tests/test_service_data.py` (NEW) + - Lines: ~110 + - Dependencies: 1.1, 1.2 + - Details: Test `ServiceDataHarness`: register concern → `run_migrations()` creates table; `cascade_delete(service_id)` removes rows by service_id but leaves other services' rows; migration idempotency (re-run doesn't crash). Test `QbittorrentSampleStore`: `append` + prune (append 130 samples, assert only 120 remain), `window` returns ordered samples, two services don't cross-contaminate. Use `tmp_path` for base_dir isolation. + +- [ ] **1.8 Create backend tests for qBittorrent client** + - Files: `backend/tests/test_qbittorrent_client.py` (NEW) + - Lines: ~85 + - Dependencies: 1.3 + - Details: Test login flow (mock `requests.Session.post` → `"Ok."` + cookie), cookie reuse on subsequent calls, 403 → re-login flow (first GET returns 403, second returns 200 after re-login), `maindata()` parsing, base URL normalization (appends `/api/v2`), login failure (response ≠ "Ok." raises RuntimeError), timeout handling. Use `unittest.mock.patch` on the session. + +- [ ] **1.9 Verify Slice 1 (backend only)** + - Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_service_data.py tests/test_qbittorrent_client.py -v` + - Verify: all new tests pass; existing tests unaffected. + +**Slice 1 total:** ~310–390 changed lines. Backend-only; no frontend changes. + +--- + +## Slice 2: Widget source adapter + LineSeriesChart extraction + frontend widgets + registry binding + +**Goal:** Wire qBittorrent data into the widget system, extract a shared chart renderer, and build the three frontend widgets. This slice touches `PrometheusChartWidget` (extraction) — its existing tests MUST stay green. + +- [ ] **2.1 Add `QbittorrentWidgetSource` adapter to `widgets/sources.py`** + - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (MODIFY) + - Lines: ~75 + - Dependencies: Slice 1 (1.3, 1.2) + - Details: Add `QbittorrentWidgetSource` implementing `WidgetSource.fetch(service, widget_kind, config)`. Resolves client inline from `ServiceRecord` config+secrets (like `PrometheusWidgetSource`). Three branches: + - `totals`: `len(torrents)` + `by_state` dict breakdown. Returns `{total, by_state}`. + - `active`: filter `state in {"downloading","uploading"}`. Returns `{torrents: [{name, state, size, progress, dl_speed, up_speed}]}`. + - `speed`: append sample to `QbittorrentSampleStore(service.id, ts, dl, up)`, read `window(service.id)`, return `{series: [{label:"download", points:[{t: ts*1000, v: dl}]}, {label:"upload", ...}]}` (timestamps ×1000 for JS epoch). + All branches wrap in try/except → `{"error": ...}`. Add `"qbittorrent": QbittorrentWidgetSource()` to `SERVICE_ADAPTERS`. + +- [ ] **2.2 Add backend tests for qBittorrent widget adapter** + - Files: `backend/tests/test_widgets.py` (EXTEND) + - Lines: ~90 + - Dependencies: 2.1 + - Details: Test `QbittorrentWidgetSource` with mocked `QbittorrentClient.maindata()`: totals counts all torrents + breaks down by state; active filters to DL/UL only (assert queued/stalled excluded); speed appends sample and returns `{series}` shape with two labeled series; missing service → `{"error": ...}`; timeout → `{"error": ...}`. Use `tmp_path` harness override for the store. + +- [ ] **2.3 Extract shared `LineSeriesChart` component from `PrometheusChartWidget`** + - Files: `frontend/src/components/LineSeriesChart.tsx` (NEW), `frontend/src/widgets/PrometheusChartWidget.tsx` (MODIFY) + - Lines: ~75 (new file) + ~20 (PrometheusChartWidget thin wrapper) + - Dependencies: none (refactor of existing code) + - Details: **RISK ITEM (a).** Extract `mergeSeries`, `formatTime`, `CHART_COLORS`, `ChartSeries`/`SeriesPoint` types, and the `++++++` JSX from `PrometheusChartWidget.tsx` into `components/LineSeriesChart.tsx`. Props: `{ series: ChartSeries[], height?: number }`. `PrometheusChartWidget` becomes: `useWidgetData` → ``. Export `ChartSeries` type from `LineSeriesChart` for reuse. **PrometheusChartWidget's existing tests MUST stay green — the extraction is mechanical (move + import), not a rewrite.** + +- [ ] **2.4 Create `QbittorrentTotalsWidget` component** + - Files: `frontend/src/widgets/QbittorrentTotalsWidget.tsx` (NEW) + - Lines: ~60 + - Dependencies: 2.3 + - Details: Renders `{total, by_state}` from `useWidgetData`. Uses `SectionCard` + prominent total count + state breakdown badges (e.g. `downloading: 3`, `uploading: 1`, `paused: 5`). Loading skeleton + error alert + empty state. Model on `BackupsWidget`/`MetricCard` patterns. + +- [ ] **2.5 Create `QbittorrentActiveTorrentsWidget` component** + - Files: `frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx` (NEW) + - Lines: ~80 + - Dependencies: 2.3 + - Details: Renders `{torrents: [...]}` from `useWidgetData`. Uses `SectionCard` + compact table/list (manual `Table` rows, NOT DataTable — keep it simple for ≤10 active torrents). Columns: name, state badge, progress bar, dl/up speed. Scrollable container for overflow. Loading + error + empty ("No active torrents") states. Reuses `humanSize`-style formatting for speeds (bytes/s → MB/s). + +- [ ] **2.6 Create `QbittorrentSpeedWidget` component** + - Files: `frontend/src/widgets/QbittorrentSpeedWidget.tsx` (NEW) + - Lines: ~40 + - Dependencies: 2.3 + - Details: Renders `{series}` from `useWidgetData` — IDENTICAL pattern to `PrometheusChartWidget`. Thin wrapper: `useWidgetData` → ``. Loading skeleton + error alert + empty state ("No speed data yet"). Refresh interval from registry binding (5s). + +- [ ] **2.7 Add qBittorrent binding to frontend registry** + - Files: `frontend/src/integrations/registry.ts` (MODIFY) + - Lines: ~40 + - Dependencies: 2.4, 2.5, 2.6 + - Details: Import the three new widget components. Add `qbittorrent` entry to `SERVICE_REGISTRY` with three widget kinds: `totals` (30s, `QbittorrentTotalsWidget`), `active` (15s, `QbittorrentActiveTorrentsWidget`), `speed` (5s, `QbittorrentSpeedWidget`). All with empty config schemas (`{type:"object", properties:{}, required:[]}`). Update barrel `widgets/index.ts` with the three new exports. + +- [ ] **2.8 Create frontend tests for new widgets + LineSeriesChart** + - Files: `frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx` (NEW), `frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx` (NEW), `frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx` (NEW), `frontend/src/components/__tests__/LineSeriesChart.test.tsx` (NEW) + - Lines: ~110 (4 files × ~25-30 lines) + - Dependencies: 2.3, 2.4, 2.5, 2.6 + - Details: Each widget test: loading skeleton (`data-slot="skeleton"`), error alert (destructive variant), rendered data case. `LineSeriesChart.test.tsx`: renders lines from series data, empty state (no series). Model on existing `PrometheusChartWidget.test.tsx` mock pattern (`vi.mocked(useWidgetData).mockReturnValue(...)`). **Verify `PrometheusChartWidget.test.tsx` still passes after extraction.** + +- [ ] **2.9 Verify Slice 2 (full stack)** + - Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_widgets.py -v` + - Run: `cd frontend && npm run lint && npm run build && npx vitest run src/widgets/__tests__/Qbittorrent src/widgets/__tests__/PrometheusChart src/components/__tests__/LineSeriesChart` + - Verify: PrometheusChartWidget tests GREEN (extraction not a regression); qBit widget tests GREEN; build + lint GREEN. + +**Slice 2 total:** ~350–400 changed lines. + +--- + +## Slice 3: MediaIndex migration onto harness (load-bearing refactor) + +**Goal:** Register MediaIndex as a harness concern, add `service_id` scoping to the schema, fix the latent global-clear bug in `replace_items`, and thread `service_id` through query/build/worker. All existing MediaIndex tests MUST pass unchanged. + +- [ ] **3.1 Define `MEDIA_INDEX_CONCERN` and register with harness** + - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY — add concern constant near top), `backend/src/media_library_viewer_api/services/service_data.py` (MODIFY — register concern in `get_service_data_harness`) + - Lines: ~25 + - Dependencies: Slice 1 (1.1) + - Details: `MEDIA_INDEX_CONCERN = StorageConcern(concern_key="media_index", db_filename="media_index.sqlite", migrations=["ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''"], tables=["media_items"])`. Register in harness singleton alongside `QBITTORRENT_CONCERN`. The `db_filename` MUST match the existing `DEFAULT_INDEX_PATH` leaf name (`media_index.sqlite`). The harness `base_dir` (`.cache/media_library_viewer`) is the same parent — no file move. + +- [ ] **3.2 Add `service_id` column to `init_schema` CREATE TABLE** + - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `init_schema` ~line 107) + - Lines: ~3 + - Dependencies: 3.1 + - Details: Add `service_id TEXT NOT NULL DEFAULT ''` as the last column in the `CREATE TABLE IF NOT EXISTS media_items` statement. New installs get the column from `init_schema`; existing DBs get it from the harness ALTER migration. Both paths converge on the same schema. + +- [ ] **3.3 Scope `replace_items` by `service_id` (FIXES latent global-clear bug)** + - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `replace_items` ~line 166) + - Lines: ~20 + - Dependencies: 3.2 + - Details: **RISK ITEM (b).** Add `service_id: str = ""` parameter. Add `"service_id"` to the columns list. Change `DELETE FROM media_items` → `DELETE FROM media_items WHERE service_id = ?` with `(service_id,)`. Change the `executemany` rows to prepend `service_id` to each row's values. This FIXES the latent bug where building for one Jellyfin wipes another's rows. Existing tests call `replace_items(rows)` with no `service_id` → defaults to `""` → deletes `WHERE service_id = ''` → backward-compatible (existing test data has `service_id=''` from backfill). + +- [ ] **3.4 Scope `query` by `service_id`** + - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `query` ~line 279) + - Lines: ~10 + - Dependencies: 3.2 + - Details: Add `service_id: str = ""` parameter. After existing WHERE clause building, add: `if service_id: where.append("service_id = ?"); params.append(service_id)`. When `service_id=""` (empty), skip the filter → shows all rows (backward-compatible). This means existing tests (which pass no `service_id`) see all rows unchanged. + +- [ ] **3.5 Thread `service_id` through `build_media_index` → `replace_items`** + - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `build_media_index` ~line 331) + - Lines: ~5 + - Dependencies: 3.3 + - Details: Add `service_id: str = ""` parameter to `build_media_index(...)`. Change the `count = index.replace_items(normalized_rows)` call (~line 458) to `count = index.replace_items(normalized_rows, service_id=service_id)`. + +- [ ] **3.6 Pass `service_id` from worker to `build_media_index`** + - Files: `backend/src/media_library_viewer_api/workers/media_index_worker.py` (MODIFY, `run_build` ~line 144, `build_media_index` call ~line 168) + - Lines: ~3 + - Dependencies: 3.5 + - Details: The worker's `run_build(final_index_path, staging_index_path, service_id="")` ALREADY receives `service_id` from argparse (`--service-id`). Change the `build_media_index(...)` call (~line 168) to pass `service_id=service_id`. + +- [ ] **3.7 Add `jellyfin_service_id` to `query_media` and pass to `query`** + - Files: `backend/src/media_library_viewer_api/routers/media.py` (MODIFY, `query_media` ~line 276) + - Lines: ~5 + - Dependencies: 3.4 + - Details: `query_media` does NOT currently have a `jellyfin_service_id` param (unlike `post_build_index` which does). Add `jellyfin_service_id: str | None = None` to the signature (after `offset`). Pass `service_id=jellyfin_service_id or ""` to the `index.query(...)` call (~line 310). Existing callers that don't send the param → `None` → `""` → all rows (backward-compatible). + +- [ ] **3.8 Add backend tests for scoped MediaIndex operations** + - Files: `backend/tests/test_media_index.py` (EXTEND) + - Lines: ~45 + - Dependencies: 3.3, 3.4 + - Details: Add tests for the NEW scoped behavior: `replace_items(rows, service_id="svc-a")` then `replace_items(other_rows, service_id="svc-b")` — assert svc-a rows survive svc-b's replace (proves the bug fix). `query(service_id="svc-a")` returns only svc-a rows. `query(service_id="")` returns all rows. `query()` (no arg) returns all rows. **Existing MediaIndex tests MUST pass unchanged** — verify they don't break by running the full file. + +- [ ] **3.9 Verify Slice 3 (backend, load-bearing)** + - Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_media_index.py -v` + - Run: `cd backend && PYTHONPATH=src python -m pytest -q` (full suite — no regressions) + - Verify: ALL existing MediaIndex tests pass unchanged; new scoped tests pass; full suite green. + +**Slice 3 total:** ~200–260 changed lines. Backend-only; Media page UX unchanged. + +--- + +## Slice 4: Cascade-delete wiring end-to-end + integration test + +**Goal:** Hook `ServiceDataHarness.cascade_delete` into `SettingsStore.delete_service` so removing a service wipes its owned data across all harness-managed tables. End-to-end integration test. + +- [ ] **4.1 Wire harness cascade-delete into `delete_service`** + - Files: `backend/src/media_library_viewer_api/services/settings_store.py` (MODIFY, `delete_service` ~line 1742) + - Lines: ~10 + - Dependencies: Slice 1 (1.1), Slice 3 (3.1) + - Details: After the existing `DELETE FROM services WHERE id = ?` (line 1754), add a try/except block: `from media_library_viewer_api.services.service_data import get_service_data_harness; get_service_data_harness().cascade_delete(service_id)`. Log on failure (`logger.exception(...)`) — cascade is best-effort (service row is already deleted; data cleanup must not block deletion). Local import to avoid circular dependency. + +- [ ] **4.2 Add integration test for cascade-delete** + - Files: `backend/tests/test_services.py` (EXTEND) + - Lines: ~45 + - Dependencies: 4.1 + - Details: Test end-to-end cascade: (1) create a qBittorrent service, append speed samples for it, delete the service → assert samples gone from `qbittorrent.db`. (2) Create two Jellyfin services, build media for both with distinct `service_id`s, delete one → assert only that service's media rows removed, other survives. Use `tmp_path` for both settings DB and harness base_dir. Verify `delete_service` doesn't raise even if harness fails (best-effort guard). + +- [ ] **4.3 Verify Slice 4 (full suite)** + - Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest -q` + - Run: `cd frontend && npm run lint && npm run build` + - Verify: full backend suite green (293+ tests + new); frontend unaffected (no FE changes in this slice). + +**Slice 4 total:** ~80–110 changed lines. Backend-only. + +--- + +## Integration and acceptance verification + +- [ ] **5.1 Full backend test run** + - Run: `cd backend && PYTHONPATH=src python -m pytest -q` + - Verify: all existing tests pass + all new tests (harness, store, client, widget adapter, scoped MediaIndex, cascade-delete) pass. + +- [ ] **5.2 Full frontend build + lint** + - Run: `cd frontend && npm run lint && npm run build` + - Verify: no TypeScript errors; no new lint failures; PrometheusChartWidget tests still green after LineSeriesChart extraction. + +- [ ] **5.3 Verify qBittorrent end-to-end (manual or integration)** + - Verify: a qBittorrent service can be registered; three widget kinds are bindable; totals shows item count; active shows DL/UL torrents; speed shows a live recharts line chart. + +- [ ] **5.4 Verify MediaIndex backward-compat** + - Verify: existing Media page works identically (no `service_id` → sees all rows); building for a specific Jellyfin service scopes correctly. + +- [ ] **5.5 Verify cascade-delete** + - Verify: deleting a service removes its qBit samples + media items; deleting one of multiple Jellyfin services removes only its rows. + +--- + +## Total estimate + +| Slice | Changed lines | Risk | +|-------|---------------|------| +| Slice 1: Harness + store + client + integration | ~310–390 | Low (new code, no existing behavior touched) | +| Slice 2: Widget adapter + LineSeriesChart + FE widgets | ~350–400 | Medium (touches PrometheusChartWidget) | +| Slice 3: MediaIndex migration | ~200–260 | **High** (load-bearing; must fix replace_items bug + keep tests green) | +| Slice 4: Cascade-delete wiring | ~80–110 | Low (small, well-isolated) | +| Integration tests | ~45 | Low | +| **Total** | **~985–1,205** | | + +--- + +## Guard lines + +```text +Decision needed before apply: No +Chained PRs recommended: Yes +Chain strategy: stacked-to-main +400-line budget risk: High +``` + +--- + +## Risk callouts (for reviewer + apply agents) + +| # | Risk | Slice | Mitigation | +|---|------|-------|------------| +| **R1** | `LineSeriesChart` extraction breaks `PrometheusChartWidget` tests | S2 (2.3, 2.8) | Extraction is mechanical (move JSX + helpers, pass series as prop). S2 exit gate explicitly verifies `PrometheusChartWidget.test.tsx` still passes. | +| **R2** | MediaIndex migration breaks existing tests or introduces a regression | S3 (3.3, 3.8) | `service_id` defaults to `""`; empty-string queries skip the filter → all rows visible. `replace_items` bug fix changes `DELETE FROM media_items` → `DELETE WHERE service_id = ?` with `""` default → existing tests (which use `""`) are unaffected. S3 exit gate runs full backend suite. | +| **R3** | ALTER TABLE fails on fresh installs where `init_schema` already created the column | S3 (3.1) | `run_migrations` catches "duplicate column name" per-statement (designed in Slice 1). | +| **R4** | Harness lazy-init hides migration failures on startup | S1 (1.6) | `run_migrations` runs on first access in lifespan; failures raise (only "duplicate column name" is swallowed). | +| **R5** | Circular import between `settings_store` and `service_data` | S4 (4.1) | Cascade-delete uses local import inside `delete_service` method, not module-level. |