# Apply Progress: Service Storage Harness

**Change:** `service-storage-harness`
**Phase:** apply-progress
**Date:** 2026-07-09
**Status:** complete — all 35 tasks done, all gates green, verified (see `verify-report.md`)

## Slices delivered

Four slices, each its own commit, each leaving `pytest` / `npm run build` / `npm run lint` / `ruff` green.

### Slice 1 — Harness + qBit store + client + integration (commit `e7bd0af`, amended)

- `services/service_data.py` — `ServiceDataHarness`: lifecycle-only (concern registration via dataclass, idempotent `run_migrations` catching "duplicate column name" per-statement, `cascade_delete(service_id)` iterating owned tables). No generic data ops (SS-101..104).
- `services/qbittorrent_store.py` — `QbittorrentSampleStore`: `qbittorrent_speed_samples(service_id, ts, dl_speed, up_speed)` + index in dedicated `qbittorrent.db`; `append/window/prune`, MAX_SAMPLES=120; registered as a harness concern (SS-105..107).
- `clients/qbittorrent.py` — `QbittorrentClient`: cookie login via `/api/v2/auth/login`, 403 re-login+retry, `maindata()` via `/api/v2/sync/maindata` (SS-108..110).
- `integrations/qbittorrent.py` + registry entry — config (base_url, timeout_seconds) + secret (username, password) schema; 3 widget kinds declared.
- Initialized in `main.py` lifespan.
- Tests: `test_service_data.py` (harness lifecycle), `test_qbittorrent_store.py`, `test_qbittorrent_client.py`.

### Slice 2 — qBit widgets + LineSeriesChart extract (commit `8b0e7ea`, amended)

- `QbittorrentWidgetSource` in `widgets/sources.py` — 3 branches: `totals` (item count from maindata), `active` (filter state ∈ {downloading, uploading}), `speed` (append sample + return `{series}` from `.window()`, ts×1000 for JS ms). Errors → `{error}`. Registered in `SERVICE_ADAPTERS` (SS-111..115).
- `frontend/src/components/LineSeriesChart.tsx` — shared recharts renderer extracted from `PrometheusChartWidget` (~40 lines, props `{series, height?}`); `PrometheusChartWidget` becomes a thin wrapper. **Extraction non-regressive: 4 PrometheusChartWidget tests stay green** (SS-118).
- `QbittorrentTotalsWidget` (count), `QbittorrentActiveTorrentsWidget` (list), `QbittorrentSpeedWidget` (uses LineSeriesChart). Registry binding + barrel + tests (SS-116..117).

### Slice 3 — MediaIndex migration (commit `c87f398`) — LOAD-BEARING

- Idempotent harness migration: `ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''`. `media_index.db` file location UNCHANGED. Existing rows backfill to `service_id=''` via DEFAULT (SS-119, SS-120, SS-124).
- **Scoped `replace_items`** — `DELETE FROM media_items WHERE service_id = ?` replaces the prior global `DELETE FROM media_items`. **FIXES the latent global-clear bug** where rebuilding for one Jellyfin wiped another's rows. `service_id` stamped into inserted rows. Regression test `test_replace_scoped_by_service_id_preserves_other_services` proves svc-A survives svc-B's rebuild (SS-121).
- `query(service_id="")` shows all rows (backward-compat); `query(service_id="X")` scopes. **Existing MediaIndex + API tests pass unchanged** (62 passed) (SS-122).
- Worker threads the real `service_id` (already plumbed via `--service-id`) into `replace_items` so new rows are stamped (SS-123).

### Slice 4 — Cascade-delete wiring (commit `75c949a`)

- `settings_store.delete_service` calls `ServiceDataHarness.cascade_delete(service_id)` after existing cleanup, best-effort try/except (failure logs, doesn't crash the delete) (SS-125).
- Integration test proves end-to-end cascade across BOTH concerns (qBit samples + media items) with multi-instance preservation (SS-126).

## Deviations from tasks.md

- **Slice 2 over the 400-line review budget** (verify-report flagged +644 source lines). The slice is additive (3 new widgets + extraction + tests), no scope creep, but the per-slice budget from `openspec/config.yaml` was exceeded. Retrospectively this could have been split (extraction in one slice, qBit widgets in another). No code defect; recorded here as a process note for future slicing. The verify agent flagged it WARNING, not blocking.

## Final gate results

| Gate | Result |
|---|---|
| `backend && PYTHONPATH=src python3 -m pytest -q` | **322 passed**, 2 warnings (pre-existing pythonjsonlogger DeprecationWarning) |
| `backend && PYTHONPATH=src python3 -m ruff check src tests` | **All checks passed** |
| `frontend && npm run build` | **exit 0** (pre-existing chunk-size warning) |
| `frontend && npm run lint` | **0 errors**, 1 pre-existing warning (`WidgetConfigDialog.tsx`, untouched) |
| `frontend && npx vitest run PrometheusChartWidget.test.tsx` | **4 passed** (extraction non-regression confirmed) |

## Verification

See `verify-report.md` — adversarial fresh-context review: **28/28 PASS**. No blocking code findings. Archive blocker is doc-only (this file + the ticked tasks.md clear it).
