Files
manage/openspec/changes/service-storage-harness/verify-report.md
T
Developer 40a7ac80d3 spec(service-storage-harness): verify + reconcile tracking
Write apply-progress.md, tick all 35 tasks, add verify-report.md (28/28
SS-101..128 PASS). Gates green: 322 pytest, ruff clean, FE build+lint 0
errors, PrometheusChartWidget extraction 4/4 non-regressive. No blocking
code findings. Process note: slice 2 was +644 lines over 400-line budget
(additive, no scope creep; retrospectively extract+widgets could split).
2026-07-09 09:30:52 +00:00

354 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Verify Report — service-storage-harness
> Phase: **verify** · Change: `service-storage-harness` · Repo: `/home/user/manage`
> FRESH-CONTEXT adversarial read-only verification of the change against
> `proposal.md`, `spec.md`, `design.md`, and `tasks.md`. **No source edits.**
> This verify report is the only file written.
**Head commit verified:** `c9404f0` (`spec(service-storage-harness): add spec`).
Four implementation slices are committed underneath it:
- `e7bd0af` slice 1 — harness + qbit store + client + integration
- `1fb12b8` slice 2 — qbit widgets + LineSeriesChart extract *(brief cited `8b0e7ea`,
an earlier amend state; the actual landed commit is `1fb12b8`. Content matches
the spec/design/tasks; informational, not a defect.)*
- `c87f398` slice 3 — migrate MediaIndex onto harness (scoped replace_items, +service_id)
- `75c949a` slice 4 — cascade-delete wiring + integration test
---
## 0. Executive summary / verdict
**VERDICT: PASS — implementation complete and green; archive BLOCKED on a
task-hygiene / missing-`apply-progress` issue (reconcilable without code).**
Every functional requirement **SS-101 … SS-128** was checked against source and
**passes**. The `ServiceDataHarness` is genuinely lifecycle-only (provisioning,
idempotent migrations, `service_id` cascade-delete) with **no** generic value
table or CRUD. The qBittorrent stack (store + cookie-reusing client + 3-branch
adapter + 3 frontend widgets) is wired consistently in **both** registries with
matching kinds/names/refresh intervals. The **load-bearing** MediaIndex migration
(SS-119..SS-124) is correct: `replace_items` is scoped `WHERE service_id = ?`
(fixing the latent global-clear bug), with a real regression test proving
service A's rows survive service B's rebuild; existing MediaIndex tests pass
unchanged via the `service_id=""` default. The `LineSeriesChart` extraction is
non-regressive (PrometheusChartWidget tests stay green; it becomes a thin
wrapper). Cascade-delete is wired into `delete_service` (best-effort try/except)
and initialized in `lifespan`, with an end-to-end integration test covering
**both** concerns with multi-instance preservation. All four gates are green:
backend `pytest` (**322 passed**), `ruff` (**clean**), frontend
`npm run build` (**exit 0**), `npm run lint` (**0 errors**).
Findings:
- **[CRITICAL — archive blocker, NOT a code defect]** **All 30
implementation/verification task checkboxes** remain unchecked in `tasks.md`
(§1.11.9, §2.12.9, §3.13.9, §4.14.3, §5.15.5), and **`apply-progress.md`
does not exist** to reconcile them. The underlying work *is* done and verified
complete against source; the blocker is that the task tracker was never updated
and no apply-progress artifact was produced. Reconciliation = tick the boxes +
write `apply-progress.md` (no code change). See §4.
- **[WARNING — review workload]** Slice 2 (`1fb12b8`) lands **~644 non-test
source lines** (widget adapter + `LineSeriesChart` extraction + 3 FE widgets),
above the 400-line budget and above the slice-2 forecast of "~350400". The
boundary is exactly the qBit-widget feature (no unrelated files, no scope
creep), and the forecast itself rated slice 2 "Medium"; but no `size:exception`
was recorded. Recommend recording the slice-2 actual in the archive summary.
**Non-blocking.** See §6.
- **[INFO]** `LineSeriesChart.test.tsx` is smoke-only (asserts `container.firstChild`
is not null; does not assert recharts `<Line>` SVGs rendered). Same weak-render
pattern noted in the Change A report. The PrometheusChartWidget rendered case
is title-only. Non-blocking coverage note.
- **[INFO]** `QbittorrentSampleStore.append` prunes via
`ts NOT IN (SELECT ts … LIMIT 120)`; in the degenerate case of two samples
sharing an identical `ts`, the keep-set could exceed `MAX_SAMPLES`. At a 5 s
poll the probability is effectively nil; the per-service cap invariant holds in
all realistic operation. Non-blocking.
- **[INFO]** Stale generated `.pi-map.md` files predate these new files
(`service_data.py`, `qbittorrent_store.py`, `clients/qbittorrent.py`,
`Qbittorrent*.tsx`, `LineSeriesChart.tsx`, etc.). Not deliverable source;
regenerate via `project_map_patch`/`project_map_validate`.
---
## 1. Structured status & actionContext findings
The native `gentle-pi.sdd-status` reports `changeName: null` /
`blockedReasons: ["Change selection is ambiguous: mobile-responsive-parity,
service-storage-harness, services-as-hub-ia"]` because the engine auto-detected
three active changes. This verify task was **explicitly assigned**
`service-storage-harness`; the ambiguity is a parent-resolution artifact and does
not block this phase.
- `artifactStore: openspec`; change root
`openspec/changes/service-storage-harness/`.
- Artifacts present: `proposal.md`, `design.md`, `spec.md`, `tasks.md`.
- **`apply-progress.md`: MISSING** (confirmed: directory contains only the four
planning docs). This is the root cause of the §4 archive blocker.
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/manage`,
`allowedEditRoots: ["/home/user/manage"]`, `warnings: []`. Implementation
ownership and all target files are provably inside the authoritative workspace. ✓
## 2. Gate results (actual output, run at `c9404f0`)
| Gate | Command | Result | Evidence |
|------|---------|--------|----------|
| Backend tests | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** | **322 passed, 2 warnings** in 39.36s. Includes new `test_service_data.py` (9), `test_qbittorrent_client.py` (12), qBit adapter tests (6) in `test_widgets.py`, scoped MediaIndex regression (`test_replace_scoped_by_service_id_preserves_other_services`), and cascade integration (`test_cascade_delete_removes_harness_data_across_concerns`). |
| Backend lint | `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | **PASS** | `All checks passed!` |
| Frontend build | `cd frontend && npm run build` (`tsc -b` + `vite build`) | **PASS** exit 0 | `✓ built in 1.22s`; 2543 modules transformed. Non-fatal `>500 kB` chunk-size warning (pre-existing, orthogonal). |
| Frontend lint | `cd frontend && npm run lint` (`eslint .`) | **PASS** exit 0 | `0 errors, 1 warning`. The warning is `react-hooks/exhaustive-deps` in `WidgetConfigDialog.tsx:370`**pre-existing, untouched by this change** (no slice modified that file). |
| Extraction non-regression | `cd frontend && npx vitest run src/widgets/__tests__/PrometheusChartWidget.test.tsx` | **PASS** | **4 passed** (SS-118: LineSeriesChart extraction did not regress PrometheusChartWidget). |
| New FE widget + chart tests | `npx vitest run src/widgets/__tests__/ src/components/__tests__/LineSeriesChart.test.tsx` | **PASS** | **8 files, 31 tests passed** (3 qBit widgets + PrometheusChart/Gauge/Mean + LineSeriesChart). |
## 3. Spec coverage (SS-101 … SS-128)
| SS | Requirement | Verdict | Evidence |
|----|-------------|---------|----------|
| SS-101 | Harness is lifecycle-only | **PASS** | `service_data.py::ServiceDataHarness` exposes only `register`/`db_path`/`connect`/`run_migrations`/`cascade_delete`. No generic value table, no generic CRUD — each integration keeps bespoke stores (`append/window`, `replace_items/query`). Module docstring states the contract explicitly. |
| SS-102 | Concern registration dataclass | **PASS** | `StorageConcern` frozen dataclass carries `concern_key`, `db_filename`, `migrations: list[str]`, `tables: list[str]`, `service_id_column="service_id"`. `register()` stores by `concern_key`. |
| SS-103 | Idempotent migrations | **PASS** | `run_migrations` splits each migration on `;` and executes per-statement; catches `sqlite3.OperationalError` for "duplicate column name" **and** "no such table", logging at debug and continuing. Test `test_cascade_delete_*` + harness tests re-init without raising. |
| SS-104 | Cascade-delete across concerns | **PASS** | `cascade_delete(service_id)` iterates **every** registered concern, PRAGMA-checks the column exists, `DELETE FROM <table> WHERE <col> = ?`. Both `QBITTORRENT_CONCERN` and `MEDIA_INDEX_CONCERN` are registered in `get_service_data_harness()`, so it covers `qbittorrent_speed_samples` + `media_items`. Skips concern DBs that don't exist (no crash). |
| SS-105 | Sample store schema | **PASS** | `qbittorrent_store.py` migration creates `qbittorrent_speed_samples(service_id, ts, dl_speed, up_speed)` + index `idx_qbit_samples_service_ts ON (service_id, ts)`, in `qbittorrent.db` (per-concern topology via `db_filename`). |
| SS-106 | append/window/prune | **PASS** | `append(service_id, ts, dl, up)` INSERTs then prunes to `MAX_SAMPLES=120` (DELETE `ts NOT IN (top-120 for this service_id)`). `window(service_id, since_ts=None)` SELECTs ordered ASC. Test appends 130 → asserts 120 remain; two services don't cross-contaminate. |
| SS-107 | Store registered as concern | **PASS** | `QBITTORRENT_CONCERN` registered in `get_service_data_harness()` alongside the media concern. |
| SS-108 | Cookie login | **PASS** | `QbittorrentClient._login()` POSTs `/auth/login` (with `Referer` header), expects `"Ok."`, stores SID cookie in the shared `requests.Session`. Credentials resolved from `ServiceRecord.secrets` (Fernet-encrypted at rest) in the adapter. Test `test_login_posts_credentials`. |
| SS-109 | 403 re-login | **PASS** | `_get` retries once after 403: sets `_logged_in=False`, re-`_login()`, re-GETs. Test `test_403_triggers_re_login` asserts `session.get` called twice + `session.post` once. |
| SS-110 | maindata fetch | **PASS** | `maindata()``_get("/sync/maindata")` returns the dict (`server_state` + `torrents`). Timeout/connection/non-2xx propagate as exceptions (`raise_for_status`); the adapter catches them (SS-115). Test `test_maindata_returns_full_payload`. |
| SS-111 | Three widget kinds dispatched | **PASS** | `QbittorrentWidgetSource.fetch` resolves `QbittorrentClient` inline from `ServiceRecord` config+secrets, dispatches on `widget_kind ∈ {totals, active, speed}`. Registered in `SERVICE_ADAPTERS["qbittorrent"]`. |
| SS-112 | totals = item count | **PASS** | `totals``{"total": len(torrents), "by_state": {...}}`. Count of currently-listed torrents, **not** cumulative bytes. Test `test_qbittorrent_totals_counts_all_torrents`. |
| SS-113 | active = DL/UL filter | **PASS** | `active` keeps only `state in {"downloading","uploading"}``{"torrents": [...]}`. Test `test_qbittorrent_active_filters_dl_ul_only` asserts queued/stalled excluded. |
| SS-114 | speed appends + returns series | **PASS** | `speed` reads `dl_info_speed`/`up_info_speed`, `store.append(service.id, ts, dl, up)`, returns `{"series": [{label:"download", points:[...]}, {label:"upload", points:[...]}]}` from `.window()` with `t = ts * 1000` (JS ms). Test `test_qbittorrent_speed_appends_and_returns_series` asserts labels + last download value. |
| SS-115 | Errors degrade gracefully | **PASS** | `fetch` body wrapped in `try/except`: `asyncio.TimeoutError → {"error": "...timed out"}`, generic `Exception → {"error": "...failed: ..."}`. Missing service / missing creds return `{"error":...}` before any network call. Tests cover missing-service, missing-credentials, timeout. Never raises. |
| SS-116 | Three FE widget components + binding | **PASS** | `QbittorrentTotalsWidget`, `QbittorrentActiveTorrentsWidget`, `QbittorrentSpeedWidget` exist under `frontend/src/widgets/`; `registry.ts` binds them to `qbittorrent` with kinds `totals`/`active`/`speed`. |
| SS-117 | Speed widget reuses shared renderer | **PASS** | `QbittorrentSpeedWidget``<LineSeriesChart series={series} height={220} />`. No new charting code, no new charting dependency. |
| SS-118 | LineSeriesChart extraction non-regressive | **PASS** | `LineSeriesChart.tsx` owns `mergeSeries`/`formatTime`/`CHART_COLORS`/`ChartSeries`/recharts JSX; `PrometheusChartWidget.tsx` is a thin wrapper (`useWidgetData``<LineSeriesChart series={series} />`). `PrometheusChartWidget.test.tsx`**4 passed**. |
| SS-119 | service_id column added (idempotent) | **PASS** | `MEDIA_INDEX_CONCERN.migrations = ["ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''"]`; `init_schema` CREATE TABLE also includes it. `db_filename="media_index.sqlite"` == `DEFAULT_INDEX_PATH.name`; harness `base_dir` == `.cache/media_library_viewer`**file location unchanged**. |
| SS-120 | Existing rows backfilled | **PASS** | Column `DEFAULT ''` backfills all pre-existing rows to `service_id=""` (preserving visibility). |
| SS-121 | replace_items scoped (bug fix) | **PASS** | `replace_items(rows, service_id="")` does `DELETE FROM media_items WHERE service_id = ?` (NOT global). **Regression test `test_replace_scoped_by_service_id_preserves_other_services`** inserts svc-a (2 rows), replaces svc-b (1 row), asserts `item_count==3` (svc-a survives), and scoped queries return only each service's rows. Latent global-clear bug confirmed fixed. |
| SS-122 | query backward-compatible | **PASS** | `query(..., service_id="")`: non-empty appends `WHERE service_id = ?`; empty (default) skips the filter → all rows. Existing `test_media_index.py` / `test_api.py` callers pass no `service_id` and run **unchanged** (322 passed). |
| SS-123 | Worker threads service_id | **PASS** | `run_build(..., service_id="")` receives `--service-id` from argparse and passes `service_id=service_id` into `build_media_index(...)`, which calls `index.replace_items(normalized_rows, service_id=service_id)` (line 488). Newly-built rows stamped with the real service_id. |
| SS-124 | MediaIndex registered as concern | **PASS** | `MEDIA_INDEX_CONCERN` (tables=`["media_items"]`) registered in `get_service_data_harness()`, so `media_items` participates in cascade-delete. |
| SS-125 | delete_service triggers cascade | **PASS** | `settings_store.delete_service` (after `DELETE FROM services WHERE id=?`) does `try: get_service_data_harness().cascade_delete(service_id); except Exception: logger.exception(...)`**best-effort, local import** to avoid circular dependency. A cascade failure does not crash deletion. |
| SS-126 | End-to-end cascade across both concerns | **PASS** | `test_cascade_delete_removes_harness_data_across_concerns`: qBit A+B samples appended, delete A → A gone, B survives; media items A+B built with distinct service_ids, delete A → A rows gone (`total_a_after==0`), B survives (`total_b_after==1`). Both concerns + multi-instance preservation proven. |
| SS-127 | Backend tests + lint green | **PASS** | `pytest`**322 passed**; `ruff check src tests``All checks passed!`. New tests present for harness lifecycle, qBit store, qBit client (login/403/cookie-reuse/maindata), qBit adapter (3 branches + errors), scoped MediaIndex regression, cascade-delete integration. |
| SS-128 | Frontend build + lint green | **PASS** | `npm run build` exit 0; `npm run lint` 0 errors (1 pre-existing warning). New widget tests cover **loading/error/rendered** states (Totals includes an explicit `isLoading:true` skeleton case — improving on Change A's SC-125 gap). PrometheusChartWidget tests green (SS-118). |
**Functional spec coverage: 28/28 fully PASS.**
---
## 4. Task completion status — ⚠ archive blocker (reconcilable)
`tasks.md` checkbox state (via `grep -nE '^\s*- \[' tasks.md`):
- **Slice 1 (§1.11.9): all 9 `[ ]` — UNCHECKED**
- **Slice 2 (§2.12.9): all 9 `[ ]` — UNCHECKED**
- **Slice 3 (§3.13.9): all 9 `[ ]` — UNCHECKED**
- **Slice 4 (§4.14.3): all 3 `[ ]` — UNCHECKED**
- **Integration (§5.15.5): all 5 `[ ]` — UNCHECKED**
Total: **0 checked, 30 unchecked**. **`apply-progress.md` does not exist.**
**This is a CRITICAL completeness issue for the archive gate per the verify
contract.** However — as with Change A — **all 30 items are verifiably DONE
against source**. Selected reconciliation (full mapping available on request):
| Unchecked task | Actual state (verified) |
|----------------|-------------------------|
| 1.1 `ServiceDataHarness` module | **done** (`service_data.py` exists; lifecycle-only) |
| 1.2 `QbittorrentSampleStore` | **done** (`qbittorrent_store.py`; `QBITTORRENT_CONCERN` registered) |
| 1.3 `QbittorrentClient` | **done** (`clients/qbittorrent.py`; login/403/maindata) |
| 1.4/1.5 integration def + registry | **done** (`integrations/qbittorrent.py` + `registry.py` entry) |
| 1.6 harness init in lifespan | **done** (`main.py:56-60` try/except) |
| 1.7/1.8 backend tests | **done** (`test_service_data.py`=9, `test_qbittorrent_client.py`=12) |
| 2.1 adapter | **done** (`QbittorrentWidgetSource` + `SERVICE_ADAPTERS`) |
| 2.3 LineSeriesChart extract | **done** (`components/LineSeriesChart.tsx`; PrometheusChartWidget thin wrapper) |
| 2.42.6/2.7 3 FE widgets + binding | **done** (`QbittorrentTotals/Active/SpeedWidget` + registry.ts) |
| 2.8 FE tests | **done** (3 widget tests + LineSeriesChart test) |
| 3.13.7 MediaIndex migration | **done** (concern + column + scoped replace_items + scoped query + worker/router threading) |
| 3.8 scoped MediaIndex tests | **done** (`test_replace_scoped_by_service_id_preserves_other_services`) |
| 4.1/4.2 cascade wiring + integration test | **done** (`delete_service` cascade + `test_cascade_delete_removes_harness_data_across_concerns`) |
| 5.15.5 full-suite gates | **done** (322 passed / ruff clean / build+lint exit 0 / extraction non-regression) |
The unchecked boxes are **stale** (work performed, tracker not updated), and **no
`apply-progress.md` exists** to serve as the stale-checkbox reconciliation record
the contract permits. Resolution is a **documentation-only** step: tick
§1.11.9, §2.12.9, §3.13.9, §4.14.3, §5.15.5, and author `apply-progress.md`
documenting the four landed slices. **No code change is required.**
> Per the verify contract, an unchecked implementation-task line is an archive
> blocker until reconciled. Because the implementation is verified complete, this
> blocks **archive** but does **not** block `sdd-sync` of the green code.
## 5. TDD compliance & assertion-quality assessment
Strict-TDD was **not** declared active for this change in `openspec/config.yaml` /
parent prompt / (absent) `apply-progress.md`, so the formal TDD-cycle-evidence
check is **not applicable**. Assertion quality was audited adversarially.
**Backend assertions — GENUINELY BEHAVIORAL (good).** Spot-checked:
- `test_media_index.py::test_replace_scoped_by_service_id_preserves_other_services`:
inserts two services' rows, replaces the second, asserts the **count** (3, not
1 — proves the global-clear bug is fixed) and the **id sets** per scoped query
(`{"a1","a2"}`, `{"b1"}`). No tautology; directly locks in SS-121.
- `test_qbittorrent_client.py`: `test_cookie_reuse_does_not_re_login` asserts
`session.post.assert_not_called()` after login (proves no re-auth per request);
`test_403_triggers_re_login` asserts `get` call_count==2 + `post` called once.
- `test_widgets.py` qBit adapters: totals asserts `total` + `by_state` mapping;
active asserts DL/UL kept and queued/stalled excluded; speed asserts series
labels (`["download","upload"]`) + last download value.
- `test_services.py::test_cascade_delete_removes_harness_data_across_concerns`:
asserts qBit A window `== []` after delete while B `len==1`; media A
`total_a_after==0` while B `total_b_after==1`. Real cross-concern, multi-instance.
**Frontend assertions — adequate, two minor notes.**
- qBit widget tests render the **actual value text** (e.g. `"4"`, state badges
`"downloading: 1"`) and the error alert — meaningful. The Totals test includes
an explicit **loading** skeleton case (`isLoading:true``[data-slot="skeleton"]`),
which closes the gap Change A's SC-125 left open. Good.
- `LineSeriesChart.test.tsx` is **smoke-only** (asserts `container.firstChild`
non-null; does not assert the recharts `<Line>` series rendered). Acceptable as
a "renders without crashing" guard but does not prove lines drew. Non-blocking.
No ghost loops, no type-only assertions, no implementation-detail CSS assertions,
no tautologies found.
## 6. Review-workload / PR-boundary findings
Per-slice changed lines (numstat; "source" excludes tests/docs):
| Commit | Slice | Source Δ | Total Δ | Over 400? | Verdict |
|--------|-------|----------|---------|-----------|---------|
| `e7bd0af` | 1 harness+store+client+integration | +400 / -0 | +685 / -1 | at budget (source) | **OK** |
| `1fb12b8` | 2 adapter+LineSeriesChart+3 FE widgets | **+644 / -81** | +831 / -81 | **over** | **WARNING** — additive feature code+tests; boundary is exactly qBit widgets (see below) |
| `c87f398` | 3 MediaIndex migration | +46 / -7 | +93 / -7 | under | **OK** |
| `75c949a` | 4 cascade-delete wiring | +13 / -3 | +99 / -22 | under | **OK** |
The `tasks.md` Review Workload Forecast (`stacked-to-main`, 4 slices,
~9851,205 total) was followed: S1→S2→S3→S4, each independently green. **Slice 2's
+644 source insertions exceed the 400-line budget** and the slice's own
"~350400" forecast (which under-counted the 3 components + LineSeriesChart
extraction + their tests). No `size:exception` was recorded. This is a
**forecast-vs-actual variance on an additive slice**, not scope creep — the
boundary is exactly the qBit-widget feature and no unrelated files were touched.
Recommend recording the slice-2 actual in the archive summary. **Non-blocking.**
Scope was honored: no backend API/type-contract widening beyond the planned
`jellyfin_service_id` query param (defaulted, backward-compatible); MediaIndex
file location unchanged; no coupling to the unrelated `mobile-responsive-parity`
or `services-as-hub-ia` changes (this change builds/tests green independently).
## 7. Adversarial checks
- **Harness initialized at startup?** **Yes.** `main.py::lifespan` calls
`get_service_data_harness()` (which lazy-registers both concerns and runs
migrations) inside a try/except after `ensure_defaults()` (lines 5660). So
`cascade_delete` is operational at runtime, not just in tests.
- **qBit client cookie reuse (no re-auth per request)?** **Correct.** `_get`
only calls `_login()` when `not self._logged_in`; the SID cookie persists in
the `requests.Session`. `test_cookie_reuse_does_not_re_login` locks this in.
- **Speed timestamp ×1000 vs LineSeriesChart expectation?** **Consistent.**
Adapter: `{"t": s["ts"] * 1000, ...}` (store keeps unix seconds; ×1000 → JS ms).
`LineSeriesChart.formatTime(ms)` = `new Date(ms).toLocaleTimeString()`. Same
`{t:ms, v}` contract Prometheus's `normalize_prometheus_matrix` produces, so
both chart consumers are interchangeable.
- **Dead code / unregistered concerns?** **None found.** Both
`QBITTORRENT_CONCERN` and `MEDIA_INDEX_CONCERN` register in
`get_service_data_harness()`. `SERVICE_ADAPTERS` has `qbittorrent`;
`SERVICE_DEFINITIONS` has `qbittorrent`; frontend `SERVICE_REGISTRY` has
`qbittorrent`. `ruff` (catches unused imports) is clean; `tsc`/`eslint` clean.
(`authentik` is backend-only by design — pre-existing, unrelated.)
- **qBit widget kinds declared consistently in BOTH registries?** **Yes.** Backend
`integrations/qbittorrent.py` declares `totals`(30s)/`active`(15s)/`speed`(5s);
frontend `registry.ts` declares the same three kinds with **identical** names,
descriptions, and refresh intervals. Empty config schemas match (`{type:"object",
properties:{}, required:[]}` ↔ empty `model_cls`).
- **MediaIndex backward-compat real?** **Yes.** Existing `test_media_index.py`
calls `replace_items(rows)` / `.query(...)` with no `service_id`; they default
to `""` → delete `WHERE service_id=''` / no filter → all rows. Full suite (322)
green, including `test_api.py::TestMediaIndexApi`.
## 8. Residual risks / non-blocking findings
1. **[CRITICAL-process] 30 unchecked tasks + missing `apply-progress.md`** (§4) — archive blocker; reconciliation is doc-only.
2. **[WARNING] Slice 2 over budget** (§6) — +644 source lines vs 400-line budget; additive feature slice, no scope creep; no `size:exception` recorded.
3. **[INFO] `LineSeriesChart.test.tsx` smoke-only** (§5) — asserts mount, not rendered lines.
4. **[INFO] Prune `NOT IN (… LIMIT 120)` edge case** — duplicate `ts` values could in theory keep >120 rows; negligible at 5 s poll.
5. **[INFO] Stale generated `.pi-map.md`** files predate the new modules; not deliverable source. Regenerate.
6. **[INFO] Slice-2 commit hash drift** — brief cited `8b0e7ea`; actual is `1fb12b8`. Content matches; informational.
7. **[INFO] Uncommitted `MediaTab.tsx`** cosmetic reformat + untracked `.pi-tmp/*` (orthogonal to this change). **No files are staged** (`git diff --cached` empty).
8. **[INFO] Chunk-size build warning** (~1.1 MB JS) — non-fatal, pre-existing, orthogonal.
9. **No browser/visual smoke** performed (out of scope); the qBit speed recharts line is only structurally tested.
## 9. Exact blockers
- **BLOCKER (archive only, doc-reconcilable):** 30 unchecked
implementation/verification tasks (§1.15.5) and absent `apply-progress.md`.
Implementation is verified complete; resolution = tick boxes + write
`apply-progress.md`.
No code-level blockers. All functional requirements SS-101…SS-128 pass. All four
gates green. **Code is ready for `sdd-sync`; archive requires the
checkbox/apply-progress reconciliation.**
## 10. Recommended next phase
**`sdd-sync`** (code PASS). Concurrently/after: author `apply-progress.md`
documenting the four landed slices, and tick §1.11.9, §2.12.9, §3.13.9,
§4.14.3, §5.15.5 in `tasks.md` to clear the archive blocker. Optionally
strengthen `LineSeriesChart.test.tsx` to assert rendered `<Line>` elements, and
regenerate the stale `.pi-map.md`.
---
### Appendix A — Verification commands run (at `c9404f0`)
```
cd backend && PYTHONPATH=src python3 -m pytest -q → 322 passed (2 warnings)
cd backend && PYTHONPATH=src python3 -m ruff check src tests → All checks passed!
cd backend && PYTHONPATH=src python3 -m pytest tests/test_service_data.py tests/test_qbittorrent_client.py -q
→ 40 passed
cd frontend && npm run build → exit 0 (✓ built; >500kB warning pre-existing)
cd frontend && npm run lint → exit 0 (0 errors, 1 pre-existing warning)
cd frontend && npx vitest run src/widgets/__tests__/PrometheusChartWidget.test.tsx
→ 4 passed (SS-118 non-regression)
cd frontend && npx vitest run src/widgets/__tests__/ src/components/__tests__/LineSeriesChart.test.tsx
→ 8 files, 31 tests passed
grep -nE '^\s*- \[' openspec/changes/service-storage-harness/tasks.md → 30 unchecked (all tasks)
ls openspec/changes/service-storage-harness/apply-progress.md → ENOENT (missing)
git diff --cached --name-only → empty (no staged files)
```
### Appendix B — Files substantively changed
**Backend (new)**
- `services/service_data.py``StorageConcern`, `ServiceDataHarness` (lifecycle), `get_service_data_harness()`.
- `services/qbittorrent_store.py``QBITTORRENT_CONCERN`, `QbittorrentSampleStore` (append/window/prune).
- `clients/qbittorrent.py``QbittorrentClient` (cookie login, 403 re-login, maindata).
- `integrations/qbittorrent.py``QbittorrentConfig`/`QbittorrentWidgetConfig`/`DEFINITION` (3 widget kinds).
**Backend (modified)**
- `integrations/registry.py``qbittorrent` registered in `SERVICE_DEFINITIONS`.
- `widgets/sources.py``QbittorrentWidgetSource` (3 branches) + `SERVICE_ADAPTERS["qbittorrent"]`.
- `services/media_index_impl.py``MEDIA_INDEX_CONCERN`, `+service_id` column, scoped `replace_items`/`query`, `build_media_index(service_id=...)`.
- `workers/media_index_worker.py` — threads `--service-id` into `build_media_index`.
- `routers/media.py``query_media(jellyfin_service_id=...)``index.query(service_id=...)`.
- `services/settings_store.py``delete_service` calls harness `cascade_delete` (best-effort).
- `main.py``lifespan` initializes `get_service_data_harness()`.
**Backend tests (new/extended)**
- `tests/test_service_data.py` (new, 9), `tests/test_qbittorrent_client.py` (new, 12).
- `tests/test_media_index.py` (+scoped regression), `tests/test_widgets.py` (+6 qBit adapter tests), `tests/test_services.py` (+cascade integration).
**Frontend (new/modified)**
- `components/LineSeriesChart.tsx` (new, extracted), `widgets/PrometheusChartWidget.tsx` (thin wrapper).
- `widgets/QbittorrentTotalsWidget.tsx`, `QbittorrentActiveTorrentsWidget.tsx`, `QbittorrentSpeedWidget.tsx` (new).
- `integrations/registry.ts``qbittorrent` binding (3 widget kinds).
- Tests: `QbittorrentTotals/Active/SpeedWidget.test.tsx` (new) + `LineSeriesChart.test.tsx` (new).