40a7ac80d3
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).
354 lines
28 KiB
Markdown
354 lines
28 KiB
Markdown
# 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.1–1.9, §2.1–2.9, §3.1–3.9, §4.1–4.3, §5.1–5.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 "~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 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.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-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,
|
||
~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::lifespan` calls
|
||
`get_service_data_harness()` (which lazy-registers both concerns and runs
|
||
migrations) inside a try/except after `ensure_defaults()` (lines 56–60). 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.1–5.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.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` — `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).
|