Files
manage/openspec/changes/archive/2026-07-09-service-storage-harness/verify-report.md
T
Developer 5cb5e79032 chore(service-storage-harness): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-service-storage-harness/
(history preserved via rename detection). 9 artifacts: proposal/spec/design/
tasks/apply-progress/verify-report/sync-report/archive-report + delta spec.
Canonical openspec/specs/service-storage/ remains. Native status engine
discrepancy (ambiguous change selection) disregarded per parent verification.
2026-07-09 09:47:05 +00:00

28 KiB
Raw Blame History

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:370pre-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.tsx4 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_viewerfile 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 pytest322 passed; ruff check src testsAll 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.

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.pyStorageConcern, ServiceDataHarness (lifecycle), get_service_data_harness().
  • services/qbittorrent_store.pyQBITTORRENT_CONCERN, QbittorrentSampleStore (append/window/prune).
  • clients/qbittorrent.pyQbittorrentClient (cookie login, 403 re-login, maindata).
  • integrations/qbittorrent.pyQbittorrentConfig/QbittorrentWidgetConfig/DEFINITION (3 widget kinds).

Backend (modified)

  • integrations/registry.pyqbittorrent registered in SERVICE_DEFINITIONS.
  • widgets/sources.pyQbittorrentWidgetSource (3 branches) + SERVICE_ADAPTERS["qbittorrent"].
  • services/media_index_impl.pyMEDIA_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.pyquery_media(jellyfin_service_id=...)index.query(service_id=...).
  • services/settings_store.pydelete_service calls harness cascade_delete (best-effort).
  • main.pylifespan 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.tsqbittorrent binding (3 widget kinds).
  • Tests: QbittorrentTotals/Active/SpeedWidget.test.tsx (new) + LineSeriesChart.test.tsx (new).