feat(415): per-channel fault detection — server-derived health object + Problems filter #581

Merged
timothy merged 11 commits from feat/415-channel-health into main 2026-07-23 22:45:20 +02:00
Owner

Closes #415 (deferred from #72 scope item b; sibling #414 already merged as #575).

What

Adds a server-derived health object to ChannelResponseModel (list) and ChannelDetailResponseModel (detail), plus a single "Problems" rollup filter with per-fault badges in the SPA. Supersedes #72's api.channel-health-signal (raw-PlayoutCount-only) decision → new api.channel-health-object.

"health": { "status": "Problems", "faults": ["EmptyUpcoming","BrokenSource"], "playoutCount": 2, "brokenSourceItemCount": 7 }

How — built-timeline detection (Approach A)

Every fault is derived from what the scheduler has already materialized, not from per-kind config:

  • Playout.BuildStatus → NeverBuilt / BuildFailed
  • upcoming PlayoutItem (Finish >= now) → EmptyUpcoming; PlayoutItem → MediaItem.State ∈ {FileNotFound, Unavailable} → BrokenSource (exact per-channel attribution, no media→collection→channel join)
  • PlayoutCount == 0 → NoPlayout

Because the built timeline is the same shape for all 5 PlayoutScheduleKind values, coverage is correct by construction (the #71 "shared primitive covers all variants" trap can't bite). Assessable gate: on-demand channels suppress the absence signals (NeverBuilt/EmptyUpcoming) — keyed to the owning channel's mode so mirrors of on-demand sources don't false-flag — while presence signals (BuildFailed/BrokenSource) always apply. Rollup: any fault ⇒ Problems; assessable-clean ⇒ Healthy; else Unknown (never a false verdict for an idle on-demand playout).

Server owns the rollup so the SPA and MCP read one authoritative verdict. Cost: one bounded GROUP BY PlayoutId aggregate query (no N+1), read-time (MediaItem.State flips on scan, not build).

Done-when (#415)

  • Per-channel "empty schedule" signal correct for all 5 PlayoutScheduleKind values (unbuilt/on-demand → Unknown, never Healthy)
  • Broken-source attribution implemented (built-timeline seam; decision recorded)
  • Cost measured — single aggregate query, no N+1 (integration test + live-E2E)
  • SPA filter renamed to "Problems"; willNeverPlay → hasProblems, comment updated
  • docs/decisions.md (supersede) + domain-model.md + api-conventions.md + spa-conventions.md updated
  • Adversarial review passed

Verification

  • 1867/1867 ErsatzTV.Tests green (incl. per-PlayoutScheduleKind guard, real-SQLite repo aggregate test, mirror-of-on-demand regression tests); 24 SPA Vitest green.
  • Build clean (0 errors), BOM clean, dotnet format verify clean, check:api clean, decisions_validate OK.
  • Live-E2E: health serializes on list + detail DTOs; no-N+1 confirmed on a running instance.
  • 6 per-task reviews + a frontier whole-branch review (caught 1 real mirror-of-on-demand bug, fixed + re-reviewed clean).
  • Rebased onto current origin/main; generated artifacts byte-identical to a fresh generation.

🤖 Generated with Claude Code

Closes #415 (deferred from #72 scope item b; sibling #414 already merged as #575). ## What Adds a server-derived `health` object to `ChannelResponseModel` (list) and `ChannelDetailResponseModel` (detail), plus a single **"Problems"** rollup filter with per-fault badges in the SPA. Supersedes #72's `api.channel-health-signal` (raw-`PlayoutCount`-only) decision → new `api.channel-health-object`. ```jsonc "health": { "status": "Problems", "faults": ["EmptyUpcoming","BrokenSource"], "playoutCount": 2, "brokenSourceItemCount": 7 } ``` ## How — built-timeline detection (Approach A) Every fault is derived from what the scheduler has already **materialized**, not from per-kind config: - `Playout.BuildStatus` → `NeverBuilt` / `BuildFailed` - upcoming `PlayoutItem` (`Finish >= now`) → `EmptyUpcoming`; `PlayoutItem → MediaItem.State ∈ {FileNotFound, Unavailable}` → `BrokenSource` (exact per-channel attribution, no media→collection→channel join) - `PlayoutCount == 0` → `NoPlayout` Because the built timeline is the same shape for all 5 `PlayoutScheduleKind` values, coverage is correct **by construction** (the #71 "shared primitive covers all variants" trap can't bite). **Assessable gate:** on-demand channels suppress the *absence* signals (`NeverBuilt`/`EmptyUpcoming`) — keyed to the *owning* channel's mode so mirrors of on-demand sources don't false-flag — while *presence* signals (`BuildFailed`/`BrokenSource`) always apply. Rollup: any fault ⇒ `Problems`; assessable-clean ⇒ `Healthy`; else `Unknown` (never a false verdict for an idle on-demand playout). Server owns the rollup so the SPA **and** MCP read one authoritative verdict. Cost: one bounded `GROUP BY PlayoutId` aggregate query (no N+1), read-time (`MediaItem.State` flips on scan, not build). ## Done-when (#415) - [x] Per-channel "empty schedule" signal correct for all 5 `PlayoutScheduleKind` values (unbuilt/on-demand → `Unknown`, never `Healthy`) - [x] Broken-source attribution implemented (built-timeline seam; decision recorded) - [x] Cost measured — single aggregate query, no N+1 (integration test + live-E2E) - [x] SPA filter renamed to "Problems"; `willNeverPlay` → `hasProblems`, comment updated - [x] `docs/decisions.md` (supersede) + `domain-model.md` + `api-conventions.md` + `spa-conventions.md` updated - [x] Adversarial review passed ## Verification - **1867/1867** `ErsatzTV.Tests` green (incl. per-`PlayoutScheduleKind` guard, real-SQLite repo aggregate test, mirror-of-on-demand regression tests); **24** SPA Vitest green. - Build clean (0 errors), BOM clean, `dotnet format` verify clean, `check:api` clean, `decisions_validate` OK. - Live-E2E: `health` serializes on list + detail DTOs; no-N+1 confirmed on a running instance. - 6 per-task reviews + a frontier whole-branch review (caught 1 real mirror-of-on-demand bug, fixed + re-reviewed clean). - Rebased onto current `origin/main`; generated artifacts byte-identical to a fresh generation. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 10 commits 2026-07-23 22:11:59 +02:00
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread the Task 2 upcoming-item aggregate through Mapper.ProjectToResponseModel
and ProjectToDetailResponseModel, add a trailing Health field to
ChannelResponseModel/ChannelDetailResponseModel, and update both API handlers
to fetch contributing-playout ids, call GetPlayoutUpcomingHealth, and pass the
result into the mapper. Regenerate the OpenAPI spec + fixture DTOs affected by
the additive field.
fix(415): key on-demand suppression to owning channel (mirror-of-on-demand) + review cleanups
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
6c90e7caf7
Author
Owner

Review-verdict: MERGEABLE @ 6c90e7caf7

Independent frontier whole-branch review completed; its one substantive finding (mirror-of-on-demand false Problems — suppression must key off the owning channel's mode) was fixed and re-reviewed clean, along with 4 minor cleanups. The rebase onto current origin/main changed only the docs/decisions.md conflict resolution (kept both main's #434/#435 records and this PR's supersession) and regenerated artifacts (byte-identical to a fresh generation); all reviewed C# is unchanged. Local gate green on this head: 1867/1867 ErsatzTV.Tests, build 0 errors, BOM clean, dotnet format verify clean, check:api clean, decisions_validate OK. Awaiting CI (runs 1048/1049) before merge.

Review-verdict: **MERGEABLE** @ 6c90e7caf7a116686a3c371eadc4a73f898aa975 Independent frontier whole-branch review completed; its one substantive finding (mirror-of-on-demand false `Problems` — suppression must key off the *owning* channel's mode) was fixed and re-reviewed clean, along with 4 minor cleanups. The rebase onto current `origin/main` changed only the `docs/decisions.md` conflict resolution (kept both main's #434/#435 records and this PR's supersession) and regenerated artifacts (byte-identical to a fresh generation); all reviewed C# is unchanged. Local gate green on this head: 1867/1867 `ErsatzTV.Tests`, build 0 errors, BOM clean, `dotnet format` verify clean, `check:api` clean, `decisions_validate` OK. Awaiting CI (runs 1048/1049) before merge.
timothy added 1 commit 2026-07-23 22:18:47 +02:00
fix(415): keep archived api.channel-health-signal rationale verbatim
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 15s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m54s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 27s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m47s
4f028120d3
The supersession appended a 'Why superseded' note to the archived #72 record, which
the diff-mode decisions validator (CI 'decisions lifecycle' job) flags as a rationale-prose
change requiring [decisions-edit]. The archive's own rule is rationale-kept-verbatim, and the
'why' is already carried by the superseded-by link, the Mechanics forwarding pointer, the
archive header, and the successor record api.channel-health-object. Drop the note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Review-verdict: MERGEABLE @ 4f02812

(Note: an earlier CI run's decisions-lifecycle job failed on 6c90e7ca — reproduced locally in diff mode, so not the known flake — and was fixed in 4f028120 by keeping the archived rationale verbatim. All checks now pass on this head.)

Review-verdict: MERGEABLE @ 4f02812 (Note: an earlier CI run's decisions-lifecycle job failed on 6c90e7ca — reproduced locally in diff mode, so not the known flake — and was fixed in 4f028120 by keeping the archived rationale verbatim. All checks now pass on this head.)
Author
Owner

Review-verdict: MERGEABLE @ 4f02812

(Plain-format re-post of the verdict on the current head — the prior comment used markdown bold on the token. All findings resolved: the frontier whole-branch review's one substantive bug (mirror-of-on-demand false Problems) fixed + re-reviewed clean; the diff-mode decisions-validator failure fixed in 4f028120. Local gate green: 1867/1867 tests, build clean, BOM clean, dotnet format clean, check:api clean, decisions-validate OK (diff mode), catalog up to date.)

Review-verdict: MERGEABLE @ 4f02812 (Plain-format re-post of the verdict on the current head — the prior comment used markdown bold on the token. All findings resolved: the frontier whole-branch review's one substantive bug (mirror-of-on-demand false Problems) fixed + re-reviewed clean; the diff-mode decisions-validator failure fixed in 4f028120. Local gate green: 1867/1867 tests, build clean, BOM clean, dotnet format clean, check:api clean, decisions-validate OK (diff mode), catalog up to date.)
Author
Owner

Review-verdict: MERGEABLE @ 4f02812

Review-verdict: MERGEABLE @ 4f02812
Author
Owner

Review-verdict: MERGEABLE @ 4f028120d3

Review-verdict: MERGEABLE @ 4f028120d33287ade98a7f35095f31786dd14505
timothy merged commit 65c0e09179 into main 2026-07-23 22:45:20 +02:00
Sign in to join this conversation.