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.
28 KiB
Verify Report — service-storage-harness
Phase: verify · Change:
service-storage-harness· Repo:/home/user/manageFRESH-CONTEXT adversarial read-only verification of the change againstproposal.md,spec.md,design.md, andtasks.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:
e7bd0afslice 1 — harness + qbit store + client + integration1fb12b8slice 2 — qbit widgets + LineSeriesChart extract (brief cited8b0e7ea, an earlier amend state; the actual landed commit is1fb12b8. Content matches the spec/design/tasks; informational, not a defect.)c87f398slice 3 — migrate MediaIndex onto harness (scoped replace_items, +service_id)75c949aslice 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.1–1.9, §2.1–2.9, §3.1–3.9, §4.1–4.3, §5.1–5.5), andapply-progress.mddoes 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 + writeapply-progress.md(no code change). See §4. - [WARNING — review workload] Slice 2 (
1fb12b8) lands ~644 non-test source lines (widget adapter +LineSeriesChartextraction + 3 FE widgets), above the 400-line budget and above the slice-2 forecast of "~350–400". The boundary is exactly the qBit-widget feature (no unrelated files, no scope creep), and the forecast itself rated slice 2 "Medium"; but nosize:exceptionwas recorded. Recommend recording the slice-2 actual in the archive summary. Non-blocking. See §6. - [INFO]
LineSeriesChart.test.tsxis smoke-only (assertscontainer.firstChildis 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.appendprunes viats NOT IN (SELECT ts … LIMIT 120); in the degenerate case of two samples sharing an identicalts, the keep-set could exceedMAX_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.mdfiles predate these new files (service_data.py,qbittorrent_store.py,clients/qbittorrent.py,Qbittorrent*.tsx,LineSeriesChart.tsx, etc.). Not deliverable source; regenerate viaproject_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 rootopenspec/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.1–1.9): all 9
[ ]— UNCHECKED - Slice 2 (§2.1–2.9): all 9
[ ]— UNCHECKED - Slice 3 (§3.1–3.9): all 9
[ ]— UNCHECKED - Slice 4 (§4.1–4.3): all 3
[ ]— UNCHECKED - Integration (§5.1–5.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.4–2.6/2.7 3 FE widgets + binding | done (QbittorrentTotals/Active/SpeedWidget + registry.ts) |
| 2.8 FE tests | done (3 widget tests + LineSeriesChart test) |
| 3.1–3.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.1–5.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.1–1.9, §2.1–2.9, §3.1–3.9, §4.1–4.3, §5.1–5.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-syncof 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_loginassertssession.post.assert_not_called()after login (proves no re-auth per request);test_403_triggers_re_loginassertsgetcall_count==2 +postcalled once.test_widgets.pyqBit adapters: totals assertstotal+by_statemapping; 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 Blen==1; media Atotal_a_after==0while Btotal_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.tsxis smoke-only (assertscontainer.firstChildnon-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,
~985–1,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
"~350–400" 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::lifespancallsget_service_data_harness()(which lazy-registers both concerns and runs migrations) inside a try/except afterensure_defaults()(lines 56–60). Socascade_deleteis operational at runtime, not just in tests. - qBit client cookie reuse (no re-auth per request)? Correct.
_getonly calls_login()whennot self._logged_in; the SID cookie persists in therequests.Session.test_cookie_reuse_does_not_re_loginlocks 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'snormalize_prometheus_matrixproduces, so both chart consumers are interchangeable. - Dead code / unregistered concerns? None found. Both
QBITTORRENT_CONCERNandMEDIA_INDEX_CONCERNregister inget_service_data_harness().SERVICE_ADAPTERShasqbittorrent;SERVICE_DEFINITIONShasqbittorrent; frontendSERVICE_REGISTRYhasqbittorrent.ruff(catches unused imports) is clean;tsc/eslintclean. (authentikis backend-only by design — pre-existing, unrelated.) - qBit widget kinds declared consistently in BOTH registries? Yes. Backend
integrations/qbittorrent.pydeclarestotals(30s)/active(15s)/speed(5s); frontendregistry.tsdeclares the same three kinds with identical names, descriptions, and refresh intervals. Empty config schemas match ({type:"object", properties:{}, required:[]}↔ emptymodel_cls). - MediaIndex backward-compat real? Yes. Existing
test_media_index.pycallsreplace_items(rows)/.query(...)with noservice_id; they default to""→ deleteWHERE service_id=''/ no filter → all rows. Full suite (322) green, includingtest_api.py::TestMediaIndexApi.
8. Residual risks / non-blocking findings
- [CRITICAL-process] 30 unchecked tasks + missing
apply-progress.md(§4) — archive blocker; reconciliation is doc-only. - [WARNING] Slice 2 over budget (§6) — +644 source lines vs 400-line budget; additive feature slice, no scope creep; no
size:exceptionrecorded. - [INFO]
LineSeriesChart.test.tsxsmoke-only (§5) — asserts mount, not rendered lines. - [INFO] Prune
NOT IN (… LIMIT 120)edge case — duplicatetsvalues could in theory keep >120 rows; negligible at 5 s poll. - [INFO] Stale generated
.pi-map.mdfiles predate the new modules; not deliverable source. Regenerate. - [INFO] Slice-2 commit hash drift — brief cited
8b0e7ea; actual is1fb12b8. Content matches; informational. - [INFO] Uncommitted
MediaTab.tsxcosmetic reformat + untracked.pi-tmp/*(orthogonal to this change). No files are staged (git diff --cachedempty). - [INFO] Chunk-size build warning (~1.1 MB JS) — non-fatal, pre-existing, orthogonal.
- 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.1–5.5) and absent
apply-progress.md. Implementation is verified complete; resolution = tick boxes + writeapply-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.1–1.9, §2.1–2.9, §3.1–3.9,
§4.1–4.3, §5.1–5.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—qbittorrentregistered inSERVICE_DEFINITIONS.widgets/sources.py—QbittorrentWidgetSource(3 branches) +SERVICE_ADAPTERS["qbittorrent"].services/media_index_impl.py—MEDIA_INDEX_CONCERN,+service_idcolumn, scopedreplace_items/query,build_media_index(service_id=...).workers/media_index_worker.py— threads--service-idintobuild_media_index.routers/media.py—query_media(jellyfin_service_id=...)→index.query(service_id=...).services/settings_store.py—delete_servicecalls harnesscascade_delete(best-effort).main.py—lifespaninitializesget_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—qbittorrentbinding (3 widget kinds).- Tests:
QbittorrentTotals/Active/SpeedWidget.test.tsx(new) +LineSeriesChart.test.tsx(new).