feat(71): per-playout reshuffle + shuffle-state surfacing #387

Merged
timothy merged 10 commits from feat/71-reshuffle-playout into main 2026-07-17 00:19:04 +02:00
Owner

fixes #71

Summary

Adds a user-triggered per-playout Reshuffle action and surfaces the play-order seed.

Premise correction: the persistence half of #71 already exists upstream (Playout.Seed + owned CollectionEnumeratorState persist the shuffled order + position across rebuilds/restarts). The genuine gap was a per-playout, user-controlled reshuffle (a Classic channel's seed was otherwise frozen — reset-all only Refreshes Classic) plus visibility.

Changes

  • POST /api/v1/playouts/{id}/reshuffle (PlayoutController.Reshuffle) — guards 409 (build lock) → 404 → 422 (kind ∉ Classic/Block/Sequential/Scripted) → 202.
  • ReshufflePlayout command + handler — reseeds + clears via ErasePlayoutHistory (the one primitive that reseeds all four kinds) then enqueues BuildPlayout(id, Reset) to rebuild.
  • Seed surfacingint Seed on the playout list + detail DTOs; SPA shows it (visible confirmation the reshuffle worked) + a Reshuffle button.
  • Docs: blazor-route-parity, domain-model, decisions; regenerated OpenAPI + client.

Important note (found in review)

The initial implementation enqueued Reset alone, assuming it reseeds — but Reset only reseeds Classic (PlayoutBuilder.cs:301); Block/Sequential/Scripted rebuild deterministically. Caught by the independent adversarial review (Critical). Fixed by routing through ErasePlayoutHistory (reseeds all 4 kinds) — confirmed end-to-end.

Verification

  • Backend dotnet test 1638/1638, SPA 784/784, lint + npm run build + check:api clean.
  • Independent adversarial review (cold frontier; Codex quota-exhausted this window): lock-safety enumeration PASS; fix-commit re-review MERGEABLE.
  • Live-E2E (write-path handler): Block playout seed 453788318 → 1371155125 → 1124007100 across two reshuffles (round-tripped through GET); reshuffle 202; 404 nonexistent; 422 ExternalJson.

Design/plan: docs/superpowers/{specs,plans}/2026-07-16-reshuffle-playout*.md.

🤖 Generated with Claude Code

fixes #71 ## Summary Adds a user-triggered per-playout **Reshuffle** action and surfaces the play-order **seed**. **Premise correction:** the persistence half of #71 already exists upstream (`Playout.Seed` + owned `CollectionEnumeratorState` persist the shuffled order + position across rebuilds/restarts). The genuine gap was a *per-playout, user-controlled* reshuffle (a Classic channel's seed was otherwise frozen — `reset-all` only `Refresh`es Classic) plus visibility. ## Changes - **`POST /api/v1/playouts/{id}/reshuffle`** (`PlayoutController.Reshuffle`) — guards 409 (build lock) → 404 → 422 (kind ∉ Classic/Block/Sequential/Scripted) → 202. - **`ReshufflePlayout` command + handler** — reseeds + clears via `ErasePlayoutHistory` (the one primitive that reseeds **all four kinds**) then enqueues `BuildPlayout(id, Reset)` to rebuild. - **Seed surfacing** — `int Seed` on the playout list + detail DTOs; SPA shows it (visible confirmation the reshuffle worked) + a **Reshuffle** button. - Docs: blazor-route-parity, domain-model, decisions; regenerated OpenAPI + client. ## Important note (found in review) The initial implementation enqueued `Reset` alone, assuming it reseeds — but `Reset` only reseeds **Classic** (`PlayoutBuilder.cs:301`); Block/Sequential/Scripted rebuild deterministically. Caught by the independent adversarial review (Critical). Fixed by routing through `ErasePlayoutHistory` (reseeds all 4 kinds) — confirmed end-to-end. ## Verification - Backend `dotnet test` **1638/1638**, SPA **784/784**, lint + `npm run build` + `check:api` clean. - **Independent adversarial review** (cold frontier; Codex quota-exhausted this window): lock-safety enumeration PASS; fix-commit re-review **MERGEABLE**. - **Live-E2E** (write-path handler): Block playout seed `453788318 → 1371155125 → 1124007100` across two reshuffles (round-tripped through GET); reshuffle 202; 404 nonexistent; 422 ExternalJson. Design/plan: `docs/superpowers/{specs,plans}/2026-07-16-reshuffle-playout*.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 10 commits 2026-07-16 23:57:20 +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>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 3 of ersatztv#71 (reshuffle-playout). Appends Seed as the last
positional field on PlayoutNameViewModel, threading it through the
Mapper list projection, GetPlayoutByIdHandler detail projection, and
the four Update*PlayoutHandler VM constructions, then exposing it on
PlayoutListItemResponseModel/PlayoutResponseModel via the controller's
ToResponse/ToListItemResponse mappers.

ChannelControllerTests.MakePlayout was an additional construction site
not listed in the task brief (positional-record break); updated to
append 0 for Seed alongside the brief's ErsatzTV.Tests changes.

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>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(71): reseed all playout kinds on reshuffle (erase-history + rebuild); docs + minors
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 4m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m24s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 6m58s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 11m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16m11s
81684411bd
Reset only reseeds Playout.Seed for Classic playouts (PlayoutBuilder); Block/
Sequential/Scripted rebuild deterministically from the existing seed, so
reshuffle was a silent no-op for 3 of the 4 supported kinds (C1). Fix:
ReshufflePlayoutHandler now sends ErasePlayoutHistory (reseed + clear anchors/
history, the only primitive that reseeds all four kinds) before enqueueing
BuildPlayout(Reset).

Also: correct docs/decisions.md's false "Reset already reseeds..." claim,
fix the SPA reshuffle test mock to return 202 (matches the real endpoint),
and gate the seed-help text to the resettable kinds (was showing even for
ExternalJson/None where no Reshuffle button exists).

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

Independent adversarial review

Cross-model (Codex) was quota-exhausted this window, so review ran cold-context on the frontier model (fresh context per pass), scoped review-only.

Whole-branch review @ 6a4d81f8 — lock cross-release/double-release safety enumerated the full BuildPlayout producer/consumer graph + all lock acquire/release sites: PASS (the new handler touches no locks). But found Critical C1: BuildPlayout(Reset) only reseeds Playout.Seed for Classic; Block/Sequential/Scripted rebuilt deterministically → reshuffle was a no-op for 3 of 4 kinds. → BLOCKED.

Fix @ 81684411 — reshuffle now routes through ErasePlayoutHistory (reseeds + clears derived enumerator anchors for all 4 kinds) then rebuilds. Fix-commit re-review traced each builder's seed derivation and confirmed the reseed genuinely produces a new order for every kind; ordering (erase commits before build enqueued), IMediator injection, and the non-vacuous regression test all verified.

Live-E2E (write-path requirement): Block playout (the previously-broken kind) seed 453788318 → 1371155125 → 1124007100 across two reshuffles, round-tripped through GET; POST /reshuffle 202; nonexistent → 404; ExternalJson → 422.

Review-verdict: MERGEABLE @ 81684411

## Independent adversarial review Cross-model (Codex) was quota-exhausted this window, so review ran cold-context on the frontier model (fresh context per pass), scoped review-only. **Whole-branch review @ 6a4d81f8** — lock cross-release/double-release safety enumerated the full `BuildPlayout` producer/consumer graph + all lock acquire/release sites: **PASS** (the new handler touches no locks). But found **Critical C1**: `BuildPlayout(Reset)` only reseeds `Playout.Seed` for Classic; Block/Sequential/Scripted rebuilt deterministically → reshuffle was a no-op for 3 of 4 kinds. → BLOCKED. **Fix @ 81684411** — reshuffle now routes through `ErasePlayoutHistory` (reseeds + clears derived enumerator anchors for all 4 kinds) then rebuilds. **Fix-commit re-review** traced each builder's seed derivation and confirmed the reseed genuinely produces a new order for every kind; ordering (erase commits before build enqueued), `IMediator` injection, and the non-vacuous regression test all verified. **Live-E2E** (write-path requirement): Block playout (the previously-broken kind) seed `453788318 → 1371155125 → 1124007100` across two reshuffles, round-tripped through GET; `POST /reshuffle` 202; nonexistent → 404; ExternalJson → 422. Review-verdict: MERGEABLE @ 81684411
timothy merged commit 7ef7ef25e8 into main 2026-07-17 00:19:04 +02:00
timothy deleted branch feat/71-reshuffle-playout 2026-07-17 00:19:04 +02:00
Sign in to join this conversation.