Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
928784ba48 | ||
|
|
44d9e47e1c | ||
|
|
1ce5743bc1 | ||
|
|
04d3eb2dbc | ||
|
|
c05fdd3be1 | ||
|
|
86fe69d7fb | ||
|
|
9713bae498 | ||
|
|
dc130f2526 | ||
|
|
0ef053a209 | ||
|
|
12e5c3d26f | ||
|
|
92bf8b083d | ||
|
|
d9eb307d71 | ||
|
|
dfed9a393b | ||
|
|
5e5da9dbd1 | ||
|
|
c21d227132 | ||
|
|
5db0836d41 | ||
|
|
c0d3dab190 | ||
|
|
b4ac46fce1 | ||
|
|
b432892d35 | ||
|
|
f355a4a96b |
@@ -5,19 +5,55 @@ description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Je
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
|
||||
Web UI: internal only (`http://localhost:8409` via SSH)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
|
||||
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
|
||||
Host: **jazz (192.168.1.29)**. Prod container `ersatztv` port **8409**; test `ersatztv-test` port
|
||||
**8410** (tracks `:latest` via Komodo auto-update, daily 03:00 — a same-day validation needs the
|
||||
manual pull below).
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` (owned by root — use `sudo sqlite3`)
|
||||
Image: **our fork**, `192.168.1.95:3000/timothy/ersatztv` (`:prod` / `:latest`). Upstream
|
||||
`ghcr.io/ersatztv/ersatztv` was archived at v26.3.0 and is NOT what runs here.
|
||||
|
||||
## Architecture
|
||||
|
||||
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
|
||||
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
|
||||
- **POST endpoints**: library scan, playout reset, show scan
|
||||
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
|
||||
**This section described upstream v26.3.0 and was wrong for the fork — corrected 2026-07-21.**
|
||||
|
||||
## REST API
|
||||
- The **Blazor UI is gone** (#91 phase b). The only UI is the ChicoryTV React SPA at `/app`; legacy
|
||||
routes 302 there.
|
||||
- There **is** a full versioned REST API under **`/api/v1`**, write paths included — channels,
|
||||
collections, schedules, playouts and media sources have CRUD. **Do not hand-edit SQLite for
|
||||
something the API can do.** The DB-scripting recipes below survive only for gaps with no endpoint.
|
||||
- Controllers stay thin and delegate to MediatR handlers; the SPA talks to `/api/v1` only.
|
||||
- Authoritative endpoint list: `docs/endpoint-index.md` (generated) + `docs/api-conventions.md`.
|
||||
Prefer those over any list in this file — a hand-maintained copy drifts.
|
||||
|
||||
## REST API access (auth-gated — read before curling)
|
||||
|
||||
Calls need **`X-Api-Key`** (machine clients) or a browser session. An unauthenticated call returns a
|
||||
401 JSON body that is easy to mistake for real data — see the silent-401 trap in Gotchas.
|
||||
|
||||
The key file is **root-owned `0600`**, so `cat` as `timothy` fails *silently* and yields an empty
|
||||
header. Read it with `sudo`, inline, so the value is never printed:
|
||||
|
||||
```bash
|
||||
# prod (8409); test is identical with .../ersatztv-test/api.key and port 8410
|
||||
ssh timothy@192.168.1.29 'K=$(sudo -n cat /home/timothy/downloadswarm/ersatztv/api.key); \
|
||||
curl -s -H "X-Api-Key: $K" http://localhost:8409/api/v1/channels'
|
||||
```
|
||||
|
||||
Settings live under `/api/v1/settings/*` — `settings/ffmpeg` (`workAheadSegmenterLimit`,
|
||||
`qsvExtraHardwareFrames`) and `settings/logging` (`streamingMinimumLogLevel`). Note the order: it is
|
||||
`settings/ffmpeg`, **not** `ffmpeg/settings`.
|
||||
|
||||
Refresh test to the newest `:latest` without waiting for 03:00 — scope it to the service, since a
|
||||
bare `up -d` would recreate everything else in the compose project:
|
||||
|
||||
```bash
|
||||
D=/etc/komodo/stacks/ersatztv/docker/jazz/stacks/ersatztv
|
||||
docker compose -f $D/compose.yaml pull ersatztv-test
|
||||
docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
|
||||
```
|
||||
|
||||
The unversioned `/api/*` endpoints below predate the `/api/v1` surface — verify one against
|
||||
`docs/endpoint-index.md` before relying on it.
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
@@ -148,13 +184,25 @@ After creating: `POST /api/channels/{number}/playout/reset`
|
||||
## Gotchas
|
||||
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- **The api.key file is root-owned too, and an unsudo'd read fails SILENTLY.** `cat` returns nothing,
|
||||
the header goes out empty, and the 401 body parses as a dict — so a naive script reports "0
|
||||
channels" rather than an auth error. If a query returns a suspiciously empty result, check auth
|
||||
before believing it. (Cost a wrong reading on 2026-07-21.)
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- No REST API for channel/collection/schedule CRUD — DB scripting only
|
||||
- ~~No REST API for channel/collection/schedule CRUD~~ — **false since the fork's `/api/v1`**; use the
|
||||
API, not DB scripting, wherever an endpoint exists
|
||||
- **A container's OCI labels lie about what is running** — they are inherited from the linuxserver
|
||||
base image (they claimed `2026-06-27` on an image built minutes earlier). To prove which build is
|
||||
live, compare `docker inspect <c> --format '{{.Image}}'` to the registry's `Docker-Content-Digest`
|
||||
for that tag
|
||||
- **Container log lines carry a LOCAL-time bracket (`[18:48:13 DBG]`) while `docker logs -t` emits
|
||||
UTC**, so `--since` windows silently mis-slice. For before/after measurements capture by line
|
||||
offset instead (`wc -l` before, `tail -n +N` after)
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
|
||||
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
|
||||
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
|
||||
- `/api/health` predates the Blazor removal; verify the API with an authenticated `/api/v1/channels` instead
|
||||
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
|
||||
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
|
||||
@@ -6,6 +6,10 @@ name: Build ErsatzTV Image
|
||||
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
|
||||
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
|
||||
#
|
||||
# The PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard) live in the sibling
|
||||
# .gitea/workflows/pr-checks.yml (`on: pull_request`). They were split out of this file so they
|
||||
# are not dispatched-and-killed on a tag/main push (ersatztv#535 — see that file's header).
|
||||
#
|
||||
# Runner + registry provisioned in server-management#172. The Gitea registry is
|
||||
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
|
||||
# inherit the host daemon's insecure-registries setting).
|
||||
@@ -642,119 +646,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#390): the CI toolchain image pin in this file must name the image that
|
||||
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
|
||||
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
|
||||
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
|
||||
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
|
||||
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
|
||||
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
|
||||
#
|
||||
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
|
||||
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
|
||||
# git+grep -> keep it off the build runners.
|
||||
ci-image-pin:
|
||||
name: CI image pin matches docker/ci
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# need real history: `git log -- <path>` on a shallow clone can't find the last
|
||||
# commit that touched the image sources
|
||||
fetch-depth: 0
|
||||
- name: Verify the pin matches the last-published image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
|
||||
# only builds on pushes touching these paths — so the published image is named by the last
|
||||
# commit to touch them.
|
||||
#
|
||||
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
||||
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
|
||||
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
|
||||
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
|
||||
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
|
||||
echo "Image sources last changed in: ${expected}"
|
||||
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
|
||||
if [ "${#pins[@]}" -ne 1 ]; then
|
||||
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
|
||||
exit 1
|
||||
fi
|
||||
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
|
||||
if [ -z "$pin_full" ]; then
|
||||
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$pin_full" != "$expected" ]; then
|
||||
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
|
||||
exit 1
|
||||
fi
|
||||
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
|
||||
|
||||
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
|
||||
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
|
||||
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
|
||||
# cache-save issues seen on the relocated runner (server-management#570).
|
||||
docs-reminder:
|
||||
name: Docs update reminder
|
||||
runs-on: small # seconds-long git diff; keep it off the build runners
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Warn when a screen/route change skips the parity doc
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
|
||||
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
||||
screen_or_route=no
|
||||
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
||||
screen_or_route=yes
|
||||
fi
|
||||
parity=no
|
||||
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
|
||||
parity=yes
|
||||
fi
|
||||
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
|
||||
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
|
||||
else
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
||||
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
||||
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
|
||||
# vanishing from the active set without an archive copy) and that the generated active catalog
|
||||
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
|
||||
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
|
||||
decisions-guard:
|
||||
name: decisions lifecycle
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Validate decision lifecycle
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
||||
- name: Active catalog in sync
|
||||
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
||||
- name: Kickoff guard
|
||||
run: bash scripts/check-kickoff-guard.sh
|
||||
|
||||
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
|
||||
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
|
||||
# surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
name: PR Gates
|
||||
|
||||
# Fast, git-only PR gates split out of docker-build.yml into a dedicated `on: pull_request`
|
||||
# workflow (ersatztv#535) so they are NEVER created on a tag/main push.
|
||||
#
|
||||
# WHY THIS FILE EXISTS. These three checks are pure `checkout + git diff` gates: they carry no
|
||||
# `container:`, run on the `small` lane (git-only, 1 GiB; server-management#639), and are PR-only.
|
||||
# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags —
|
||||
# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because
|
||||
# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the
|
||||
# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase
|
||||
# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened
|
||||
# the tag's overall commit status even though the release built, scanned, and deployed fine
|
||||
# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical
|
||||
# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens
|
||||
# in the dispatch window before any step or `if:`-skip runs.
|
||||
#
|
||||
# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow
|
||||
# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole
|
||||
# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders
|
||||
# (they also encode "these steps need a PR base_ref"; harmless given the trigger).
|
||||
#
|
||||
# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of
|
||||
# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/
|
||||
# api-docs/format) that remain there. None of these three are required checks — branch protection
|
||||
# requires only `Build & test (.NET)` and `EF migration integrity` — so relocating them (which
|
||||
# changes their status-context prefix from "Build ErsatzTV Image / …" to "PR Gates / …") does not
|
||||
# affect merges. See docs/ci-cd.md -> "PR gates workflow".
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but
|
||||
# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel.
|
||||
concurrency:
|
||||
group: ersatztv-pr-gates-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that
|
||||
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
|
||||
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
|
||||
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
|
||||
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
|
||||
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
|
||||
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
|
||||
#
|
||||
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
|
||||
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
|
||||
# git+grep -> keep it off the build runners.
|
||||
ci-image-pin:
|
||||
name: CI image pin matches docker/ci
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# need real history: `git log -- <path>` on a shallow clone can't find the last
|
||||
# commit that touched the image sources
|
||||
fetch-depth: 0
|
||||
- name: Verify the pin matches the last-published image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
|
||||
# only builds on pushes touching these paths — so the published image is named by the last
|
||||
# commit to touch them.
|
||||
#
|
||||
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
||||
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
|
||||
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
|
||||
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
|
||||
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
|
||||
echo "Image sources last changed in: ${expected}"
|
||||
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
|
||||
if [ "${#pins[@]}" -ne 1 ]; then
|
||||
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
|
||||
exit 1
|
||||
fi
|
||||
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
|
||||
if [ -z "$pin_full" ]; then
|
||||
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$pin_full" != "$expected" ]; then
|
||||
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
|
||||
exit 1
|
||||
fi
|
||||
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
|
||||
|
||||
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
|
||||
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
|
||||
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
|
||||
# cache-save issues seen on the relocated runner (server-management#570).
|
||||
docs-reminder:
|
||||
name: Docs update reminder
|
||||
runs-on: small # seconds-long git diff; keep it off the build runners
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Warn when a screen/route change skips the parity doc
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
|
||||
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
||||
screen_or_route=no
|
||||
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
||||
screen_or_route=yes
|
||||
fi
|
||||
parity=no
|
||||
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
|
||||
parity=yes
|
||||
fi
|
||||
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
|
||||
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
|
||||
else
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
||||
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
||||
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
|
||||
# vanishing from the active set without an archive copy) and that the generated active catalog
|
||||
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
|
||||
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
|
||||
decisions-guard:
|
||||
name: decisions lifecycle
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Validate decision lifecycle
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
||||
- name: Active catalog in sync
|
||||
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
||||
- name: Kickoff guard
|
||||
run: bash scripts/check-kickoff-guard.sh
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
@@ -43,7 +43,8 @@ public record CreateChannelFromLineupAdvancedOptions(
|
||||
ChannelIdleBehavior? IdleBehavior = null,
|
||||
bool? ShuffleScheduleItems = null,
|
||||
bool? RandomStartPoint = null,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
|
||||
IReadOnlyList<CreateChannelFromLineupClearField> Clear = null);
|
||||
|
||||
public record CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType MediaType,
|
||||
|
||||
@@ -190,11 +190,21 @@ public class CreateChannelFromLineupHandler(
|
||||
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
|
||||
}
|
||||
|
||||
// "clear to none" (#135): a field named in advanced.Clear is forced to none even when the
|
||||
// template sets one; both setting and clearing the same field is contradictory.
|
||||
Either<BaseError, Unit> clearValidation = ValidateClear(advanced);
|
||||
foreach (BaseError error in clearValidation.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
ResolvedClearableOptions resolved = ResolveClearable(advanced, template);
|
||||
|
||||
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
|
||||
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
|
||||
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
|
||||
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
|
||||
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
|
||||
int? fallbackFillerId = resolved.FallbackFillerId;
|
||||
int? preRollFillerId = resolved.PreRollFillerId;
|
||||
int? midRollFillerId = resolved.MidRollFillerId;
|
||||
int? postRollFillerId = resolved.PostRollFillerId;
|
||||
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
|
||||
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
|
||||
|
||||
@@ -207,8 +217,8 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
|
||||
dbContext,
|
||||
advanced,
|
||||
template,
|
||||
ffmpegProfileId,
|
||||
resolved,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in referenceValidation.LeftToSeq())
|
||||
{
|
||||
@@ -272,6 +282,7 @@ public class CreateChannelFromLineupHandler(
|
||||
request,
|
||||
template,
|
||||
advanced,
|
||||
resolved,
|
||||
name,
|
||||
number,
|
||||
group,
|
||||
@@ -291,6 +302,7 @@ public class CreateChannelFromLineupHandler(
|
||||
playbackOrder,
|
||||
advanced,
|
||||
template,
|
||||
resolved,
|
||||
fallbackFillerId,
|
||||
preRollFillerId,
|
||||
midRollFillerId,
|
||||
@@ -383,6 +395,7 @@ public class CreateChannelFromLineupHandler(
|
||||
CreateChannelFromLineup request,
|
||||
ChannelTemplate template,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ResolvedClearableOptions resolved,
|
||||
string name,
|
||||
string number,
|
||||
string group,
|
||||
@@ -421,16 +434,14 @@ public class CreateChannelFromLineupHandler(
|
||||
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
|
||||
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
|
||||
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
|
||||
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
|
||||
WatermarkId = resolved.WatermarkId,
|
||||
FallbackFillerId = fallbackFillerId,
|
||||
Artwork = artwork,
|
||||
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
|
||||
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
|
||||
PreferredAudioLanguageCode =
|
||||
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
|
||||
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
|
||||
PreferredSubtitleLanguageCode =
|
||||
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
|
||||
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle = resolved.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
|
||||
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate =
|
||||
@@ -462,6 +473,7 @@ public class CreateChannelFromLineupHandler(
|
||||
PlaybackOrder playbackOrder,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template,
|
||||
ResolvedClearableOptions resolved,
|
||||
int? fallbackFillerId,
|
||||
int? preRollFillerId,
|
||||
int? midRollFillerId,
|
||||
@@ -478,11 +490,9 @@ public class CreateChannelFromLineupHandler(
|
||||
MidRollFillerId = midRollFillerId,
|
||||
PostRollFillerId = postRollFillerId,
|
||||
FallbackFillerId = fallbackFillerId,
|
||||
PreferredAudioLanguageCode =
|
||||
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
|
||||
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
|
||||
PreferredSubtitleLanguageCode =
|
||||
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
|
||||
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle = resolved.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
|
||||
};
|
||||
|
||||
@@ -526,20 +536,21 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
private static async Task<Either<BaseError, Unit>> ValidateReferences(
|
||||
TvContext dbContext,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template,
|
||||
int ffmpegProfileId,
|
||||
ResolvedClearableOptions resolved,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
|
||||
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
|
||||
{
|
||||
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
|
||||
}
|
||||
|
||||
// Validate the post-clear effective ids: a cleared reference resolves to null and skips the
|
||||
// existence check (there is nothing to point at).
|
||||
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
|
||||
dbContext,
|
||||
advanced.WatermarkId ?? template.WatermarkId,
|
||||
advanced.FallbackFillerId ?? template.FallbackFillerId,
|
||||
resolved.WatermarkId,
|
||||
resolved.FallbackFillerId,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in channelReferences.LeftToSeq())
|
||||
{
|
||||
@@ -548,9 +559,9 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
|
||||
dbContext,
|
||||
advanced.PreRollFillerId ?? template.PreRollFillerId,
|
||||
advanced.MidRollFillerId ?? template.MidRollFillerId,
|
||||
advanced.PostRollFillerId ?? template.PostRollFillerId,
|
||||
resolved.PreRollFillerId,
|
||||
resolved.MidRollFillerId,
|
||||
resolved.PostRollFillerId,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in itemFillers.LeftToSeq())
|
||||
{
|
||||
@@ -560,6 +571,80 @@ public class CreateChannelFromLineupHandler(
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// A field named in advanced.Clear must not also carry a set value: that request is contradictory.
|
||||
// A null/empty set value alongside a clear is fine (redundant, not conflicting). (#135)
|
||||
private static Either<BaseError, Unit> ValidateClear(CreateChannelFromLineupAdvancedOptions advanced)
|
||||
{
|
||||
if (advanced.Clear is null || advanced.Clear.Count == 0)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
var cleared = advanced.Clear.ToHashSet();
|
||||
|
||||
(CreateChannelFromLineupClearField Field, bool HasSetValue)[] checks =
|
||||
[
|
||||
(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId.HasValue),
|
||||
(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId.HasValue),
|
||||
(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId.HasValue),
|
||||
(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId.HasValue),
|
||||
(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId.HasValue),
|
||||
(CreateChannelFromLineupClearField.PreferredAudioLanguage,
|
||||
!string.IsNullOrEmpty(advanced.PreferredAudioLanguageCode)),
|
||||
(CreateChannelFromLineupClearField.PreferredAudioTitle,
|
||||
!string.IsNullOrEmpty(advanced.PreferredAudioTitle)),
|
||||
(CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
|
||||
!string.IsNullOrEmpty(advanced.PreferredSubtitleLanguageCode))
|
||||
];
|
||||
|
||||
foreach ((CreateChannelFromLineupClearField field, bool hasSetValue) in checks)
|
||||
{
|
||||
if (cleared.Contains(field) && hasSetValue)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Advanced option '{field}' cannot be both set and cleared in the same request");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// Compute the effective value of every clearable field once: cleared -> none, else the advanced
|
||||
// override coalesced with the template value (the historical omitted=inherit contract). (#135)
|
||||
private static ResolvedClearableOptions ResolveClearable(
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template)
|
||||
{
|
||||
System.Collections.Generic.HashSet<CreateChannelFromLineupClearField> cleared = advanced.Clear is null
|
||||
? []
|
||||
: advanced.Clear.ToHashSet();
|
||||
|
||||
int? Id(CreateChannelFromLineupClearField field, int? adv, int? tmpl) =>
|
||||
cleared.Contains(field) ? null : adv ?? tmpl;
|
||||
|
||||
string Str(CreateChannelFromLineupClearField field, string adv, string tmpl) =>
|
||||
cleared.Contains(field) ? string.Empty : adv ?? tmpl ?? string.Empty;
|
||||
|
||||
return new ResolvedClearableOptions(
|
||||
Id(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId, template.WatermarkId),
|
||||
Id(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId, template.FallbackFillerId),
|
||||
Id(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId, template.PreRollFillerId),
|
||||
Id(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId, template.MidRollFillerId),
|
||||
Id(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId, template.PostRollFillerId),
|
||||
Str(
|
||||
CreateChannelFromLineupClearField.PreferredAudioLanguage,
|
||||
advanced.PreferredAudioLanguageCode,
|
||||
template.PreferredAudioLanguageCode),
|
||||
Str(
|
||||
CreateChannelFromLineupClearField.PreferredAudioTitle,
|
||||
advanced.PreferredAudioTitle,
|
||||
template.PreferredAudioTitle),
|
||||
Str(
|
||||
CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
|
||||
advanced.PreferredSubtitleLanguageCode,
|
||||
template.PreferredSubtitleLanguageCode));
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
|
||||
TvContext dbContext,
|
||||
int? watermarkId,
|
||||
@@ -803,4 +888,16 @@ public class CreateChannelFromLineupHandler(
|
||||
Playlist Playlist,
|
||||
ProgramSchedule ProgramSchedule,
|
||||
Playout Playout);
|
||||
|
||||
// Effective values for the clearable advanced fields after applying advanced.Clear + template
|
||||
// coalescing (#135). Strings coalesce to string.Empty (never null); ids stay nullable.
|
||||
private sealed record ResolvedClearableOptions(
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
int? PreRollFillerId,
|
||||
int? MidRollFillerId,
|
||||
int? PostRollFillerId,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
string PreferredSubtitleLanguageCode);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class TimeShiftOnDemandPlayoutHandler(IPlayoutTimeShifter playoutTimeShifter)
|
||||
public class TimeShiftOnDemandPlayoutHandler(
|
||||
IPlayoutTimeShifter playoutTimeShifter,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<TimeShiftOnDemandPlayout, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await playoutTimeShifter.TimeShift(request.PlayoutId, request.Now, request.Force, cancellationToken);
|
||||
List<string> staleGuideChannels = await playoutTimeShifter.TimeShift(
|
||||
request.PlayoutId,
|
||||
request.Now,
|
||||
request.Force,
|
||||
cancellationToken);
|
||||
|
||||
// the time shift rewrote stored PlayoutItem timestamps but not the cached XMLTV
|
||||
// fragment; rebuild the guide for the shifted channel (and any mirrors of it) so a
|
||||
// client tuning in doesn't see a stale timeline. this is a post-commit side effect
|
||||
// (TimeShift already saved) so it runs on CancellationToken.None — a session token that
|
||||
// cancels between the DB commit and this enqueue must not leave the guide stale
|
||||
// (decisions.md api.postcommit-cancellation-none)
|
||||
foreach (string channelNumber in staleGuideChannels)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -26,7 +26,9 @@ namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
private static int _workAheadCount;
|
||||
// process-wide, shared by every session — the work-ahead limit is a global resource budget
|
||||
private static readonly WorkAheadSlots _workAheadSlots = new();
|
||||
|
||||
private readonly OutputFormatKind _outputFormatKind;
|
||||
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
|
||||
private readonly Dictionary<long, int> _discontinuityMap = [];
|
||||
@@ -243,10 +245,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
|
||||
// claim the slot here rather than checking here and claiming inside Transcode: the check
|
||||
// and the claim have to be one atomic step or every simultaneous tune-in wins (#536)
|
||||
bool initialWorkAhead = _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
|
||||
_state = initialWorkAhead ? HlsSessionState.SeekAndWorkAhead : HlsSessionState.SeekAndRealtime;
|
||||
|
||||
if (!await Transcode(!initialWorkAhead, cancellationToken))
|
||||
if (!await Transcode(initialWorkAhead, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -269,8 +273,8 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
// only use realtime encoding when we're at least 30 seconds ahead
|
||||
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
|
||||
bool subsequentWorkAhead =
|
||||
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
|
||||
if (!await Transcode(!subsequentWorkAhead, cancellationToken))
|
||||
!realtime && _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
|
||||
if (!await Transcode(subsequentWorkAhead, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -456,15 +460,23 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> Transcode(bool realtime, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Runs one transcode. The caller is the one that races for a work-ahead slot, so ownership is
|
||||
/// passed IN: <paramref name="ownsWorkAheadSlot" /> means the caller already claimed a slot from
|
||||
/// <see cref="_workAheadSlots" />, and this method releases it in its <c>finally</c> — acquire
|
||||
/// and release stay one-for-one (#536).
|
||||
/// </summary>
|
||||
private async Task<bool> Transcode(bool ownsWorkAheadSlot, CancellationToken cancellationToken)
|
||||
{
|
||||
// a session works ahead exactly when it holds a slot; everything else runs realtime (throttled)
|
||||
bool realtime = !ownsWorkAheadSlot;
|
||||
|
||||
try
|
||||
{
|
||||
bool wasSeekAndWorkAhead = _state is HlsSessionState.SeekAndWorkAhead;
|
||||
|
||||
if (!realtime)
|
||||
{
|
||||
Interlocked.Increment(ref _workAheadCount);
|
||||
_logger.LogDebug("HLS segmenter will work ahead for channel {Channel}", _channelNumber);
|
||||
|
||||
HlsSessionState nextState = _state switch
|
||||
@@ -747,9 +759,15 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
// do nothing
|
||||
}
|
||||
|
||||
if (!realtime)
|
||||
if (ownsWorkAheadSlot && !_workAheadSlots.Release())
|
||||
{
|
||||
Interlocked.Decrement(ref _workAheadCount);
|
||||
// Release() reports false only when the pool was already empty, i.e. this slot was
|
||||
// released more than once. Nothing reaches here in a correct program, but if a
|
||||
// future second release site breaks the ownership contract this is the one in-band
|
||||
// signal that the unthrottled-transcode budget is inflated (ersatztv#536/#539 §3).
|
||||
_logger.LogWarning(
|
||||
"Released a work-ahead slot that was not held for channel {Channel} - the unthrottled-transcode budget may be inflated",
|
||||
_channelNumber);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// The process-wide pool of work-ahead slots shared by every HLS session (ersatztv#536).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>workAheadSegmenterLimit</c> is a resource guarantee, not a tuning knob: it bounds how many
|
||||
/// transcodes may run unthrottled (no <c>-readrate</c>) at once, and the QSV hardware-frame pool
|
||||
/// sizing from ersatztv#529 assumes that bound holds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Acquisition must therefore be atomic. The previous shape — <c>Volatile.Read(count) < limit</c>
|
||||
/// in the caller, <c>Interlocked.Increment</c> later inside the transcode — is a check-then-act
|
||||
/// TOCTOU separated by at least one <c>await</c> (the limit is a DB-backed config read), so N
|
||||
/// simultaneous tune-ins all observed <c>0 < limit</c> and all ran unthrottled. Same class as
|
||||
/// ersatztv#231 / #250.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class WorkAheadSlots
|
||||
{
|
||||
private int _count;
|
||||
private int _unbalancedReleases;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of slots currently held. For diagnostics and tests only — never branch on
|
||||
/// this to decide whether to work ahead; that is exactly the race <see cref="TryAcquire" /> exists to close.
|
||||
/// </summary>
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims one slot if fewer than <paramref name="limit" /> are held.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when a slot was claimed; the caller then owns it and MUST
|
||||
/// <see cref="Release" /> it exactly once.</returns>
|
||||
public bool TryAcquire(int limit)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int current = Volatile.Read(ref _count);
|
||||
if (current >= limit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// only the thread whose compare-exchange observes the value it read wins the slot, so
|
||||
// the count can never transiently exceed the limit and two racers can never both claim
|
||||
if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of releases that were not matched by a successful acquire. Non-zero always
|
||||
/// means the ownership contract was broken somewhere (no false positives), so the value is a
|
||||
/// reliable "something is wrong" signal — but it can UNDER-count and zero does not prove
|
||||
/// correctness. It only increments when a release finds the pool already empty; an over-release
|
||||
/// that happens while the count is positive — e.g. one cancelling out a coexisting leak —
|
||||
/// decrements a real-looking slot and is never recorded, so the two bugs hide each other. This
|
||||
/// is inherent to a single counter; exact accounting would need per-owner tokens (ersatztv#539 §2).
|
||||
/// </summary>
|
||||
public int UnbalancedReleases => Volatile.Read(ref _unbalancedReleases);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a slot claimed by <see cref="TryAcquire" />. Only ever called by the owner of that slot.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> when a held slot was returned; <c>false</c> when the pool was already empty, i.e.
|
||||
/// the release was unbalanced (also counted in <see cref="UnbalancedReleases" />). Callers should
|
||||
/// log the <c>false</c> case: it is the only in-band signal that the budget contract was broken.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Ownership is a discipline, not a token — the same call-once contract as `EntityLocker` (#231).
|
||||
/// The one failure this defends against is an unbalanced release inflating the budget: this pool
|
||||
/// is process-wide and lives for the life of the app, so a leaked slot would silently and
|
||||
/// permanently admit one extra unthrottled transcode, re-opening the #529 QSV pool exhaustion.
|
||||
/// It clamps at zero rather than throwing — the single caller releases from a `finally`, where a
|
||||
/// throw would swallow the real exception. Unlike a decrement-first-then-clamp shape, this never
|
||||
/// publishes a negative count even transiently, so a concurrent <see cref="TryAcquire" /> can
|
||||
/// never read the pool as having phantom room and over-admit (ersatztv#539 §1); and it records
|
||||
/// the unbalanced release synchronously here, rather than blaming a later, innocent release.
|
||||
/// </remarks>
|
||||
public bool Release()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int current = Volatile.Read(ref _count);
|
||||
if (current <= 0)
|
||||
{
|
||||
Interlocked.Increment(ref _unbalancedReleases);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _count, current - 1, current) == current)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using ErsatzTV.Application.Streaming;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Streaming;
|
||||
|
||||
// Verifies the atomic-claim contract of the work-ahead slot pool (ersatztv#536): at most
|
||||
// `limit` sessions may hold a slot at once, no matter how many race for one simultaneously.
|
||||
//
|
||||
// NEGATIVE CONTROL (per the ersatztv#231/#250 lesson — a one-shot Barrier + Task.WhenAll does NOT
|
||||
// catch this class on this hardware): to prove these tests are non-vacuous, temporarily replace the
|
||||
// compare-exchange in WorkAheadSlots.TryAcquire with the check-then-act shape this issue fixed —
|
||||
//
|
||||
// int current = Volatile.Read(ref _count);
|
||||
// if (current >= limit) return false;
|
||||
// Interlocked.Increment(ref _count);
|
||||
// return true;
|
||||
//
|
||||
// — and TryAcquire_ParallelCallers_NeverExceedsLimit must FAIL (badRounds > 0). Do NOT "break" it by
|
||||
// stubbing `if (true)`: that leaves values assigned-but-never-read, and CS0219 under
|
||||
// warnings-as-errors fails the build silently, so `dotnet test --no-build` then runs the STALE
|
||||
// (fixed) dll and the control falsely passes. Always grep the build output for `error CS` first.
|
||||
[TestFixture]
|
||||
public class WorkAheadSlotsTests
|
||||
{
|
||||
[Test]
|
||||
public void TryAcquire_BelowLimit_Succeeds()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.TryAcquire(2).ShouldBeTrue();
|
||||
slots.TryAcquire(2).ShouldBeTrue();
|
||||
slots.TryAcquire(2).ShouldBeFalse();
|
||||
slots.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryAcquire_DoesNotConsumeASlotWhenItFails()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.TryAcquire(1).ShouldBeTrue();
|
||||
slots.TryAcquire(1).ShouldBeFalse();
|
||||
slots.Count.ShouldBe(1);
|
||||
|
||||
// the failed attempt must not have leaked a slot: releasing the one real holder frees the pool
|
||||
slots.Release();
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.TryAcquire(1).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryAcquire_WithNonPositiveLimit_NeverSucceeds()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.TryAcquire(0).ShouldBeFalse();
|
||||
slots.TryAcquire(-1).ShouldBeFalse();
|
||||
slots.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// The pool is process-wide and never recreated, so a negative count would not self-heal: it would
|
||||
// permanently admit more than `limit` unthrottled transcodes, with nothing in the logs to find it by.
|
||||
[Test]
|
||||
public void Release_WithoutAcquire_ClampsAtZeroAndIsRecorded()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.Release();
|
||||
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.UnbalancedReleases.ShouldBe(1);
|
||||
|
||||
// the budget is intact: a limit of 1 still admits exactly one holder, not two
|
||||
slots.TryAcquire(1).ShouldBeTrue();
|
||||
slots.TryAcquire(1).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnbalancedReleases_IsZeroUnderCorrectUse()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.TryAcquire(1).ShouldBeTrue();
|
||||
slots.Release();
|
||||
|
||||
slots.UnbalancedReleases.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Hammers the acquire race over many rounds rather than a single simultaneous burst: the
|
||||
// read->increment window is far too narrow to collide reliably when threads release once.
|
||||
[Test]
|
||||
[TestCase(1)]
|
||||
[TestCase(2)]
|
||||
[TestCase(3)]
|
||||
public void TryAcquire_ParallelCallers_NeverExceedsLimit(int limit)
|
||||
{
|
||||
const int threads = 8;
|
||||
const int rounds = 20_000;
|
||||
|
||||
var slots = new WorkAheadSlots();
|
||||
var winners = 0;
|
||||
var badRounds = 0;
|
||||
|
||||
using var startRound = new Barrier(threads);
|
||||
using var endRound = new Barrier(
|
||||
threads,
|
||||
_ =>
|
||||
{
|
||||
int held = Volatile.Read(ref winners);
|
||||
if (held > limit || held != slots.Count)
|
||||
{
|
||||
Interlocked.Increment(ref badRounds);
|
||||
}
|
||||
|
||||
// reset for the next round: release every slot claimed this round
|
||||
for (var i = 0; i < held; i++)
|
||||
{
|
||||
slots.Release();
|
||||
}
|
||||
|
||||
Volatile.Write(ref winners, 0);
|
||||
});
|
||||
|
||||
var workers = new Thread[threads];
|
||||
for (var t = 0; t < threads; t++)
|
||||
{
|
||||
workers[t] = new Thread(() =>
|
||||
{
|
||||
for (var r = 0; r < rounds; r++)
|
||||
{
|
||||
startRound.SignalAndWait();
|
||||
if (slots.TryAcquire(limit))
|
||||
{
|
||||
Interlocked.Increment(ref winners);
|
||||
}
|
||||
|
||||
endRound.SignalAndWait();
|
||||
}
|
||||
});
|
||||
workers[t].Start();
|
||||
}
|
||||
|
||||
foreach (Thread worker in workers)
|
||||
{
|
||||
worker.Join();
|
||||
}
|
||||
|
||||
badRounds.ShouldBe(0);
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.UnbalancedReleases.ShouldBe(0);
|
||||
}
|
||||
|
||||
// §1 (ersatztv#539): an unbalanced Release() must never publish a NEGATIVE count, even
|
||||
// transiently. The pre-#539 shape decremented FIRST (0 -> -1) and clamped afterwards, so a
|
||||
// concurrent TryAcquire(limit) could read the -1, see "-1 < limit" as phantom room, and admit a
|
||||
// holder the budget doesn't have (the second acquirer then reads 0 and admits another). Because
|
||||
// TryAcquire is the sole, CAS-guarded increment path, a count that is provably never negative is
|
||||
// exactly what forecloses that over-admit. Here one thread hammers unbalanced releases on an
|
||||
// empty pool while readers sample the count; none may ever observe a value below zero.
|
||||
//
|
||||
// NEGATIVE CONTROL (per the class-level ersatztv#231/#250 lesson) — revert Release() to its
|
||||
// pre-#539 decrement-first body to prove this test is non-vacuous:
|
||||
//
|
||||
// if (Interlocked.Decrement(ref _count) >= 0) return true;
|
||||
// Interlocked.Increment(ref _unbalancedReleases);
|
||||
// while (true) { int c = Volatile.Read(ref _count);
|
||||
// if (c >= 0 || Interlocked.CompareExchange(ref _count, 0, c) == c) return false; }
|
||||
//
|
||||
// — and this test must FAIL (sawNegative > 0): the readers catch the transient -1. Do NOT stub
|
||||
// `if (true)`: that leaves values assigned-but-never-read, and CS0219 under warnings-as-errors
|
||||
// fails the build silently so `dotnet test --no-build` runs the STALE dll and the control falsely
|
||||
// passes. Always grep the build output for `error CS` first.
|
||||
[Test]
|
||||
public void Release_Unbalanced_NeverPublishesNegativeCount()
|
||||
{
|
||||
const int releases = 2_000_000;
|
||||
const int readers = 4;
|
||||
|
||||
var slots = new WorkAheadSlots();
|
||||
var sawNegative = 0;
|
||||
var done = false;
|
||||
|
||||
var readerThreads = new Thread[readers];
|
||||
for (var i = 0; i < readers; i++)
|
||||
{
|
||||
readerThreads[i] = new Thread(() =>
|
||||
{
|
||||
while (!Volatile.Read(ref done))
|
||||
{
|
||||
if (slots.Count < 0)
|
||||
{
|
||||
Interlocked.Increment(ref sawNegative);
|
||||
}
|
||||
}
|
||||
});
|
||||
readerThreads[i].Start();
|
||||
}
|
||||
|
||||
// every release finds the pool empty, so every one is unbalanced
|
||||
for (var r = 0; r < releases; r++)
|
||||
{
|
||||
slots.Release();
|
||||
}
|
||||
|
||||
Volatile.Write(ref done, true);
|
||||
foreach (Thread reader in readerThreads)
|
||||
{
|
||||
reader.Join();
|
||||
}
|
||||
|
||||
sawNegative.ShouldBe(0);
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.UnbalancedReleases.ShouldBe(releases);
|
||||
}
|
||||
|
||||
// The release path is the fiddly half: a slot freed by its owner must become available again,
|
||||
// and a burst of acquire/release cycles must not drift the count in either direction.
|
||||
[Test]
|
||||
public void AcquireAndRelease_UnderContention_LeavesNoLeakedOrDoubleFreedSlots()
|
||||
{
|
||||
const int threads = 8;
|
||||
const int rounds = 20_000;
|
||||
const int limit = 3;
|
||||
|
||||
var slots = new WorkAheadSlots();
|
||||
var overLimit = 0;
|
||||
var live = 0;
|
||||
|
||||
var workers = new Thread[threads];
|
||||
for (var t = 0; t < threads; t++)
|
||||
{
|
||||
workers[t] = new Thread(() =>
|
||||
{
|
||||
for (var r = 0; r < rounds; r++)
|
||||
{
|
||||
if (!slots.TryAcquire(limit))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Interlocked.Increment(ref live) > limit)
|
||||
{
|
||||
Interlocked.Increment(ref overLimit);
|
||||
}
|
||||
|
||||
Interlocked.Decrement(ref live);
|
||||
slots.Release();
|
||||
}
|
||||
});
|
||||
workers[t].Start();
|
||||
}
|
||||
|
||||
foreach (Thread worker in workers)
|
||||
{
|
||||
worker.Join();
|
||||
}
|
||||
|
||||
overLimit.ShouldBe(0);
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.UnbalancedReleases.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
// The "clear to none" signal for POST /api/v1/channels/from-lineup (#135). For these
|
||||
// template-inheritable advanced fields a null/omitted override means INHERIT the template value;
|
||||
// naming the field here forces it to NONE on the new channel even when the template sets one.
|
||||
// Omitting the field entirely keeps the historical omitted=inherit behavior stable for existing
|
||||
// clients. Sending both a set value and a clear for the same field is a validation error (see
|
||||
// CreateChannelFromLineupHandler). Lives in Core so the OpenAPI string-enum scan (Startup
|
||||
// UseStringEnumSchemas) renders it as a string enum, matching every sibling advanced-options enum.
|
||||
public enum CreateChannelFromLineupClearField
|
||||
{
|
||||
Watermark,
|
||||
FallbackFiller,
|
||||
PreRollFiller,
|
||||
MidRollFiller,
|
||||
PostRollFiller,
|
||||
PreferredAudioLanguage,
|
||||
PreferredAudioTitle,
|
||||
PreferredSubtitleLanguage
|
||||
}
|
||||
@@ -2,5 +2,14 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
public interface IPlayoutTimeShifter
|
||||
{
|
||||
Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Slides an on-demand playout's materialized timeline forward so the item the viewer had
|
||||
/// reached is active again at <paramref name="now" />.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The channel numbers whose cached XMLTV guide is now stale and should be rebuilt — the shifted
|
||||
/// channel plus any channels that mirror it — when a non-zero shift was persisted; otherwise an
|
||||
/// empty list.
|
||||
/// </returns>
|
||||
Task<List<string>> TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ public class PlayoutTimeShifter(
|
||||
ILogger<PlayoutTimeShifter> logger)
|
||||
: IPlayoutTimeShifter
|
||||
{
|
||||
public async Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken)
|
||||
public async Task<List<string>> TimeShift(
|
||||
int playoutId,
|
||||
DateTimeOffset now,
|
||||
bool force,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
@@ -39,7 +43,7 @@ public class PlayoutTimeShifter(
|
||||
{
|
||||
if (playout.Channel.PlayoutMode is not ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!force && segmenterService.IsActive(playout.Channel.Number))
|
||||
@@ -49,7 +53,7 @@ public class PlayoutTimeShifter(
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name);
|
||||
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (playout.Items.Count == 0)
|
||||
@@ -59,7 +63,7 @@ public class PlayoutTimeShifter(
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name);
|
||||
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (playout.OnDemandCheckpoint is null)
|
||||
@@ -122,7 +126,26 @@ public class PlayoutTimeShifter(
|
||||
playout.OnDemandCheckpoint = now;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// a non-zero shift moved every PlayoutItem (and its guide window), so the cached
|
||||
// XMLTV fragment for this channel is now stale; report the channel number so the
|
||||
// caller can rebuild the guide and keep it in sync with playback
|
||||
if (toOffset != TimeSpan.Zero)
|
||||
{
|
||||
// mirror channels relay this channel's timeline (shifted by their PlayoutOffset),
|
||||
// so their cached guides are stale too — refresh them alongside, mirroring the
|
||||
// fan-out BuildPlayoutHandler already does after it time-shifts
|
||||
List<string> mirrorNumbers = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Filter(c => c.MirrorSourceChannelId == channel.Id)
|
||||
.Map(c => c.Number)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return [playout.Channel.Number, .. mirrorNumbers];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,110 @@ public class CreateChannelFromLineupHandlerTests
|
||||
item.MidRollFillerId.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clear_Should_Force_Template_Inherited_Values_To_None()
|
||||
{
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedMovie(42);
|
||||
|
||||
// Give the template a watermark + preferred audio language so "clear" has something to drop
|
||||
// (the base template has fillers 2-5 but no watermark / audio language).
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 21, Name = "wm", Image = "wm.png" });
|
||||
ChannelTemplate template = await context.ChannelTemplates.SingleAsync();
|
||||
template.WatermarkId = 21;
|
||||
template.PreferredAudioLanguageCode = "eng";
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var advanced = new CreateChannelFromLineupAdvancedOptions(
|
||||
PlaybackOrder: PlaybackOrder.Shuffle,
|
||||
Clear:
|
||||
[
|
||||
CreateChannelFromLineupClearField.Watermark,
|
||||
CreateChannelFromLineupClearField.FallbackFiller,
|
||||
CreateChannelFromLineupClearField.PreRollFiller,
|
||||
CreateChannelFromLineupClearField.MidRollFiller,
|
||||
CreateChannelFromLineupClearField.PostRollFiller,
|
||||
CreateChannelFromLineupClearField.PreferredAudioLanguage
|
||||
]);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result =
|
||||
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
|
||||
RightOf(result);
|
||||
|
||||
await using TvContext assert = _db.CreateContext();
|
||||
DomainChannel channel = await assert.Channels.SingleAsync();
|
||||
|
||||
// Cleared -> none, even though the template supplies a value.
|
||||
channel.WatermarkId.ShouldBeNull();
|
||||
channel.FallbackFillerId.ShouldBeNull();
|
||||
channel.PreferredAudioLanguageCode.ShouldBe(string.Empty);
|
||||
|
||||
ProgramScheduleItem item = await assert.ProgramScheduleItems.SingleAsync();
|
||||
item.PreRollFillerId.ShouldBeNull();
|
||||
item.MidRollFillerId.ShouldBeNull();
|
||||
item.PostRollFillerId.ShouldBeNull();
|
||||
item.FallbackFillerId.ShouldBeNull();
|
||||
item.PreferredAudioLanguageCode.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clear_And_Set_Same_Field_Should_Be_Validation_Error()
|
||||
{
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedMovie(42);
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.FillerPresets.Add(MakeFiller(6, FillerKind.Fallback));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var advanced = new CreateChannelFromLineupAdvancedOptions(
|
||||
PlaybackOrder: PlaybackOrder.Shuffle,
|
||||
FallbackFillerId: 6,
|
||||
Clear: [CreateChannelFromLineupClearField.FallbackFiller]);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result =
|
||||
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("cannot be both set and cleared");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clear_And_Set_String_Field_Conflicts_But_Empty_String_Is_Redundant()
|
||||
{
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedMovie(42);
|
||||
|
||||
// A real value + clear on the same string field is contradictory.
|
||||
var conflicting = new CreateChannelFromLineupAdvancedOptions(
|
||||
PlaybackOrder: PlaybackOrder.Shuffle,
|
||||
PreferredAudioLanguageCode: "eng",
|
||||
Clear: [CreateChannelFromLineupClearField.PreferredAudioLanguage]);
|
||||
|
||||
BaseError error = LeftOf(
|
||||
await MakeHandler().Handle(MakeRequest(advanced: conflicting), CancellationToken.None));
|
||||
error.Value.ShouldContain("cannot be both set and cleared");
|
||||
|
||||
// An empty string + clear is redundant, not conflicting -> the create still succeeds. (This
|
||||
// runs second: the conflicting request above returned Left without persisting, so number "12"
|
||||
// is still free.)
|
||||
var redundant = new CreateChannelFromLineupAdvancedOptions(
|
||||
PlaybackOrder: PlaybackOrder.Shuffle,
|
||||
PreferredAudioLanguageCode: "",
|
||||
Clear: [CreateChannelFromLineupClearField.PreferredAudioLanguage]);
|
||||
|
||||
RightOf(await MakeHandler().Handle(MakeRequest(advanced: redundant), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_Validation_Error_When_Not_Exactly_One_Id_Provided()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Channel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Playouts;
|
||||
|
||||
[TestFixture]
|
||||
public class TimeShiftOnDemandPlayoutHandlerTests
|
||||
{
|
||||
private IPlayoutTimeShifter _timeShifter = null!;
|
||||
private Channel<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_timeShifter = Substitute.For<IPlayoutTimeShifter>();
|
||||
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
}
|
||||
|
||||
private TimeShiftOnDemandPlayoutHandler CreateHandler() => new(_timeShifter, _worker.Writer);
|
||||
|
||||
private List<string> DrainWorker()
|
||||
{
|
||||
var messages = new List<string>();
|
||||
while (_worker.Reader.TryRead(out IBackgroundServiceRequest message))
|
||||
{
|
||||
message.ShouldBeOfType<RefreshChannelData>();
|
||||
messages.Add(((RefreshChannelData)message).ChannelNumber);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Rebuild_Guide_When_Playout_Was_Shifted()
|
||||
{
|
||||
// a non-zero shift reports the channel number whose cached XMLTV is now stale
|
||||
_timeShifter
|
||||
.TimeShift(Arg.Any<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(["42"]);
|
||||
|
||||
Option<ErsatzTV.Core.BaseError> result = await CreateHandler().Handle(
|
||||
new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
|
||||
// the guide for that channel must be rebuilt so playback and EPG stay in sync
|
||||
DrainWorker().ShouldBe(["42"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Rebuild_Guides_For_Shifted_Channel_And_Its_Mirrors()
|
||||
{
|
||||
// a mirror channel relays the shifted timeline, so its cached guide is stale too
|
||||
_timeShifter
|
||||
.TimeShift(Arg.Any<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(["42", "43", "44"]);
|
||||
|
||||
Option<ErsatzTV.Core.BaseError> result = await CreateHandler().Handle(
|
||||
new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
DrainWorker().ShouldBe(["42", "43", "44"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Rebuild_Guide_When_No_Shift_Occurred()
|
||||
{
|
||||
// no shift (wrong mode / active session / no elapsed time) => nothing to rebuild
|
||||
_timeShifter
|
||||
.TimeShift(Arg.Any<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
Option<ErsatzTV.Core.BaseError> result = await CreateHandler().Handle(
|
||||
new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
DrainWorker().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Scheduling;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
public class PlayoutTimeShifterTests
|
||||
{
|
||||
// fixed timestamps so the test is deterministic (Date.Now is never read here)
|
||||
private static readonly DateTimeOffset Checkpoint = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset Now = Checkpoint.AddDays(3);
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IFFmpegSegmenterService _segmenter = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_segmenter = Substitute.For<IFFmpegSegmenterService>();
|
||||
_segmenter.IsActive(Arg.Any<string>()).Returns(false);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private PlayoutTimeShifter CreateSubject() =>
|
||||
new(_db.Factory, _segmenter, NullLogger<PlayoutTimeShifter>.Instance);
|
||||
|
||||
private static Channel MakeChannel(int id, string number, ChannelPlayoutMode mode) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Id = id,
|
||||
Number = number,
|
||||
Name = $"Channel {number}",
|
||||
Group = "",
|
||||
Categories = "",
|
||||
StreamSelector = "",
|
||||
PreferredAudioLanguageCode = "",
|
||||
PreferredAudioTitle = "",
|
||||
PreferredSubtitleLanguageCode = "",
|
||||
MusicVideoCreditsTemplate = "",
|
||||
PlayoutMode = mode
|
||||
};
|
||||
|
||||
private async Task<int> SeedPlayout(
|
||||
ChannelPlayoutMode mode,
|
||||
DateTimeOffset? checkpoint,
|
||||
bool withMirror = false)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
Channel channel = MakeChannel(1, "1", mode);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Id = 1,
|
||||
ChannelId = channel.Id,
|
||||
Channel = channel,
|
||||
OnDemandCheckpoint = checkpoint,
|
||||
Items =
|
||||
[
|
||||
new PlayoutItem
|
||||
{
|
||||
Id = 1,
|
||||
Start = Checkpoint.UtcDateTime,
|
||||
Finish = Checkpoint.UtcDateTime.AddHours(1),
|
||||
GuideStart = Checkpoint.UtcDateTime,
|
||||
GuideFinish = Checkpoint.UtcDateTime.AddHours(1)
|
||||
}
|
||||
],
|
||||
PlayoutHistory = [],
|
||||
ProgramScheduleAnchors = []
|
||||
};
|
||||
|
||||
await context.Channels.AddAsync(channel);
|
||||
await context.Playouts.AddAsync(playout);
|
||||
|
||||
if (withMirror)
|
||||
{
|
||||
Channel mirror = MakeChannel(2, "2", ChannelPlayoutMode.Continuous);
|
||||
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
||||
mirror.MirrorSourceChannelId = channel.Id;
|
||||
await context.Channels.AddAsync(mirror);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Shift_Items_And_Report_Channel_Number_For_OnDemand_Playout()
|
||||
{
|
||||
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint);
|
||||
|
||||
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
|
||||
|
||||
// reports the channel number so the caller can rebuild the (now-stale) cached guide
|
||||
shifted.ShouldBe(["1"]);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Playout playout = await context.Playouts.Include(p => p.Items).SingleAsync(p => p.Id == playoutId);
|
||||
PlayoutItem item = playout.Items.Single();
|
||||
|
||||
TimeSpan expectedOffset = Now - Checkpoint; // 3 days
|
||||
// both playback (Start/Finish) and the guide window (GuideStart/GuideFinish) advance together
|
||||
item.Start.ShouldBe(Checkpoint.UtcDateTime + expectedOffset);
|
||||
item.Finish.ShouldBe(Checkpoint.UtcDateTime.AddHours(1) + expectedOffset);
|
||||
item.GuideStart!.Value.ShouldBe(Checkpoint.UtcDateTime + expectedOffset);
|
||||
item.GuideFinish!.Value.ShouldBe(Checkpoint.UtcDateTime.AddHours(1) + expectedOffset);
|
||||
playout.OnDemandCheckpoint.ShouldBe(Now);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Also_Report_Mirror_Channels_So_Their_Guides_Rebuild()
|
||||
{
|
||||
// a channel mirroring the shifted on-demand channel relays its timeline, so its cached
|
||||
// guide is stale after the thaw and must be rebuilt too
|
||||
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint, withMirror: true);
|
||||
|
||||
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
|
||||
|
||||
shifted.ShouldBe(["1", "2"]); // shifted channel first, then its mirror
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Shift_And_Seed_Checkpoint_For_Never_Watched_Playout()
|
||||
{
|
||||
// null checkpoint => it is seeded to the earliest item start, then the whole timeline slides
|
||||
// forward to `now` (the subtlest branch: an unwatched channel still resumes correctly)
|
||||
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, checkpoint: null);
|
||||
|
||||
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
|
||||
|
||||
shifted.ShouldBe(["1"]);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Playout playout = await context.Playouts.Include(p => p.Items).SingleAsync(p => p.Id == playoutId);
|
||||
playout.OnDemandCheckpoint.ShouldBe(Now);
|
||||
// item started at Checkpoint; with the seed = earliest start, the offset is Now - Checkpoint
|
||||
playout.Items.Single().Start.ShouldBe(Now.UtcDateTime);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Shift_Or_Report_For_Continuous_Playout()
|
||||
{
|
||||
// negative control: a Continuous channel is never time-shifted
|
||||
int playoutId = await SeedPlayout(ChannelPlayoutMode.Continuous, Checkpoint);
|
||||
|
||||
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
|
||||
|
||||
shifted.ShouldBeEmpty();
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
PlayoutItem item = await context.PlayoutItems.SingleAsync();
|
||||
item.Start.ShouldBe(Checkpoint.UtcDateTime); // untouched
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Shift_When_Active_And_Not_Forced()
|
||||
{
|
||||
// an active session must not have its timeline yanked out from under it unless forced
|
||||
_segmenter.IsActive("1").Returns(true);
|
||||
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint);
|
||||
|
||||
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: false, CancellationToken.None);
|
||||
|
||||
shifted.ShouldBeEmpty();
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
PlayoutItem item = await context.PlayoutItems.SingleAsync();
|
||||
item.Start.ShouldBe(Checkpoint.UtcDateTime); // untouched
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Report_When_Shift_Is_Zero()
|
||||
{
|
||||
// non-vacuous gate: an OnDemand playout already at `now` produces a zero offset,
|
||||
// so there is no stale guide to rebuild
|
||||
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Now);
|
||||
|
||||
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
|
||||
|
||||
shifted.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -64,7 +65,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest(
|
||||
ChannelIdleBehavior? IdleBehavior = null,
|
||||
bool? ShuffleScheduleItems = null,
|
||||
bool? RandomStartPoint = null,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior = null)
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
|
||||
List<CreateChannelFromLineupClearField>? Clear = null)
|
||||
{
|
||||
public CreateChannelFromLineupAdvancedOptions ToCommand() =>
|
||||
new(
|
||||
@@ -91,7 +93,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest(
|
||||
IdleBehavior,
|
||||
ShuffleScheduleItems,
|
||||
RandomStartPoint,
|
||||
FixedStartTimeBehavior);
|
||||
FixedStartTimeBehavior,
|
||||
Clear);
|
||||
}
|
||||
|
||||
public record CreateChannelFromLineupItemRequest(
|
||||
|
||||
@@ -24544,9 +24544,31 @@
|
||||
"$ref": "#/components/schemas/FixedStartTimeBehavior"
|
||||
}
|
||||
]
|
||||
},
|
||||
"clear": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CreateChannelFromLineupClearField"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateChannelFromLineupClearField": {
|
||||
"enum": [
|
||||
"Watermark",
|
||||
"FallbackFiller",
|
||||
"PreRollFiller",
|
||||
"MidRollFiller",
|
||||
"PostRollFiller",
|
||||
"PreferredAudioLanguage",
|
||||
"PreferredAudioTitle",
|
||||
"PreferredSubtitleLanguage"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CreateChannelFromLineupItemRequest": {
|
||||
"required": [
|
||||
"mediaType",
|
||||
|
||||
@@ -92,4 +92,7 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
|
||||
that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237
|
||||
is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in
|
||||
`docs/decisions.md`) — not a live pointer.
|
||||
- **`docs/tracker-retrofit-triage-237.md`** — audit trail for the #524 triage of ersatztv#237's 111
|
||||
comments (method, per-comment classification, totals). Evidence for the
|
||||
`docs.tracker-comment-retrofit` decision; read it only when triaging another over-cap tracker.
|
||||
- **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2).
|
||||
|
||||
@@ -102,6 +102,15 @@ Exemplars:
|
||||
(`GetAllHealthCheckResultsForApi(bool Refresh = false)`) which forces a fresh run past the service's TTL
|
||||
result cache — the cached poll path is the default, the flag is the explicit opt-out (see `decisions.md`
|
||||
2026-07-19, #431).
|
||||
- **"Clear to none" vs "inherit" on a coalescing DTO: a typed `clear` enum list, not null.** When a
|
||||
create/patch DTO resolves a field as `request.X ?? inherited.X` (e.g. `advanced.X ?? template.X`),
|
||||
`null` already means *inherit*, so it cannot also mean *set to none*. Add an optional `clear` field
|
||||
typed as a **list of a string enum** naming the fields to force to none — additive, so omitted =
|
||||
inherit stays byte-stable for existing clients. Validate that a field is not both set and cleared
|
||||
(reject as 422). **Define the enum in `ErsatzTV.Core`** (not the Application command) so
|
||||
`Startup.UseStringEnumSchemas` renders it as a string enum in the spec — an Application-layer enum
|
||||
shows as a bare `integer`. Exemplar: `CreateChannelFromLineupClearField` on
|
||||
`POST /api/v1/channels/from-lineup` (`decisions.md` 2026-07-21, `api.from-lineup-clear-to-none`, #135).
|
||||
- **Evolving a frozen DTO: deprecate-in-place, add the richer field, never remove.** `/api/v1` is
|
||||
frozen-additive (#286), so when a response field's shape needs to grow, keep the old member
|
||||
populated (mark it deprecated in an XML/`//` comment) and add the replacement alongside. Exemplar:
|
||||
|
||||
+24
-1
@@ -7,7 +7,7 @@ Defined in `ErsatzTV.Core/Domain/Channel.cs`. Key fields:
|
||||
- **Identity**: `Number` (e.g., "1", "2.1"), `Name`, `UniqueId` (GUID for M3U/XMLTV)
|
||||
- **Encoding**: `FFmpegProfileId` — video/audio codec, bitrate, resolution, hardware acceleration
|
||||
- **Streaming**: `StreamingMode` (TransportStream, HLS Direct, HLS Segmenter, TS Hybrid)
|
||||
- **Behavior**: `PlayoutMode` (Continuous vs OnDemand), `IdleBehavior` (StopOnDisconnect vs KeepRunning)
|
||||
- **Behavior**: `PlayoutMode` (Continuous vs OnDemand — OnDemand *is* the "resume where I left off" mode, see [On-demand resume](#on-demand-resume-freeze-the-clock-when-unwatched)), `IdleBehavior` (StopOnDisconnect vs KeepRunning)
|
||||
- **Visual**: `WatermarkId`, `FallbackFillerId`, artwork (logos)
|
||||
- **Mirroring**: `PlayoutSource` (Generated vs Mirror) — a mirror channel copies another with optional time offset
|
||||
- **Display**: `Group`, `Categories`, `ShowInEpg`, `IsEnabled`
|
||||
@@ -137,6 +137,29 @@ Channel
|
||||
|
||||
The scheduling engine (`ErsatzTV.Core/Scheduling/`) resolves schedule items into concrete `PlayoutItem` entries with precise start/finish times. Each `PlayoutItem` references a specific `MediaItem` and includes trim points (`InPoint`/`OutPoint`), filler classification, and per-item audio/subtitle overrides.
|
||||
|
||||
## On-demand resume (freeze the clock when unwatched)
|
||||
|
||||
A channel with `PlayoutMode = OnDemand` is the built-in "resume / bookmark" behavior (issue #68): its
|
||||
playout clock advances only while someone is watching and freezes when nobody is, so on the next
|
||||
tune-in it resumes where the last viewer stopped rather than jumping to a live wall-clock point.
|
||||
|
||||
- **Resume position**: `Playout.OnDemandCheckpoint` (a `DateTimeOffset?`) persists the viewer's spot.
|
||||
`UpdateOnDemandCheckpointHandler` advances it — monotonically, minus one segmenter-timeout of
|
||||
rewind-for-context — on each transcode iteration while watching.
|
||||
- **Freeze/thaw**: on tune-in, `HlsSessionWorker.Run` sends `TimeShiftOnDemandPlayout`, and
|
||||
`PlayoutTimeShifter.TimeShift` slides the **whole** materialized timeline (`PlayoutItem.Start/Finish`
|
||||
**and** `GuideStart/GuideFinish`, plus history/anchors) forward by `now − checkpoint`, so the item the
|
||||
viewer had reached is active at `now` again.
|
||||
- **Guide stays in sync — the make-or-break requirement.** Guide and playback both read the same stored
|
||||
`PlayoutItem.Start/Finish`, so freezing them together avoids the classic desync (guide ticking on
|
||||
wall-clock while playback resumes from a saved spot). Because the XMLTV guide is served from a
|
||||
**cached** fragment, the shift also enqueues `RefreshChannelData` for the channel — and for any
|
||||
channels that mirror it — so every affected cache is rebuilt from the just-shifted rows; see
|
||||
`decisions.md` → `scheduling.ondemand-guide-refresh-on-thaw` (#68).
|
||||
- **Scope**: resume is **per-channel** (a single checkpoint on the playout), not per-viewer. Pair it with
|
||||
a sequential playout (Sequential/Chronological order) for a "continue watching" channel of ordered
|
||||
content.
|
||||
|
||||
## Watermarks
|
||||
|
||||
`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom
|
||||
|
||||
+42
-3
@@ -143,6 +143,10 @@ stalling every PR run. `--cpu-shares` below the default 1024 is what makes a run
|
||||
prod media host acceptable: under contention CI loses to the transcoders (ersatztv 1536 /
|
||||
jellyfin), which are the reason those hosts exist.
|
||||
|
||||
That same "dispatched even when `if` skips it" behavior is why the git-only PR gates live in
|
||||
their own **[PR gates workflow](#pr-gates-workflow)** (`pr-checks.yml`, `on: pull_request`)
|
||||
rather than in `docker-build.yml` — see that section (ersatztv#535).
|
||||
|
||||
**`small` is git-only, and that is load-bearing (server-management#639).** Everything in
|
||||
the lane is a checkout plus a `git diff`: `decisions-guard`, `ci-image-pin`,
|
||||
`docs-reminder`. Nothing there runs a compiler or a `docker build`, which is why the lane
|
||||
@@ -535,7 +539,7 @@ CI-validated, so the tree-match check correctly declines. So the skip is a genui
|
||||
win (clean, up-to-date, un-rebased merges in quiet periods) — correct-but-conservative by
|
||||
construction, not a general dedup. It never fires unsafely; when in doubt it runs the full matrix.
|
||||
|
||||
### `docs-reminder` job (non-blocking, PR-only)
|
||||
### `docs-reminder` job (non-blocking, PR-only — in `pr-checks.yml`)
|
||||
|
||||
A lightweight nudge that enforces the CLAUDE.md "docs-update is part of done" rule for the
|
||||
one case that's easy to forget and easy to detect: a PR that touches a SPA screen
|
||||
@@ -548,7 +552,7 @@ thus no `actions/cache`), so it can't hit the cache-save hangs seen on the VM-12
|
||||
(domain-model, spa-conventions) — those stay on the author. (The API contract is mechanized by the
|
||||
blocking `api-docs` job, and `docs/decisions.md` by the blocking `decisions-guard` job below.)
|
||||
|
||||
### `decisions-guard` job (`decisions lifecycle`, blocking, PR-only)
|
||||
### `decisions-guard` job (`decisions lifecycle`, blocking, PR-only — in `pr-checks.yml`)
|
||||
|
||||
Enforces decision-record lifecycle invariants (ersatztv#521, supersedes the ersatztv#303 H9
|
||||
append-only mechanic): well-formed 5-field metadata, exactly one `active` record per `key`,
|
||||
@@ -565,6 +569,40 @@ compiler/docker build), so it doesn't violate the "small is git-only" lane rule.
|
||||
`docs-reminder`, otherwise a seconds-long `git diff` + parse with no dotnet/node setup
|
||||
(`runs-on: small`).
|
||||
|
||||
## PR gates workflow
|
||||
|
||||
**File:** `.gitea/workflows/pr-checks.yml` — `on: pull_request` only.
|
||||
|
||||
The three git-only PR gates — `ci-image-pin`, `docs-reminder`, `decisions-guard` (described above)
|
||||
— live here, **not** in `docker-build.yml`, and that separation is the fix for **ersatztv#535**.
|
||||
|
||||
**Why they are split out.** All three are pure `checkout + git diff` gates on the `small` lane
|
||||
(no `container:`) and are PR-only (`if: github.event_name == 'pull_request'`). While they lived in
|
||||
`docker-build.yml` — which also triggers on push to `main` and on `v*` tags — Gitea still
|
||||
**dispatched them as runner tasks on every such push to evaluate the skip** (the `small`-lane
|
||||
behavior documented above: *a job is dispatched even when its `if` skips it*). On the **v26.12.0**
|
||||
release tag those dispatched skip-tasks wedged in act's setup phase and were killed by a runner
|
||||
restart mid-setup, so they reported `failure` (no logs) and reddened the tag's overall commit
|
||||
status even though the release built, scanned, and deployed fine. The two PR-only jobs on
|
||||
`ubuntu-latest` (`api-docs`, `format`) carry the **identical** `if:` and skipped cleanly on the
|
||||
same tag — the job logic was never the problem; the kill lands in the dispatch window, before any
|
||||
step or `if:`-skip runs, so tweaking the `if:`/step logic could not fix it.
|
||||
|
||||
**Why a separate workflow fixes it.** Gitea evaluates a workflow's **trigger** before creating any
|
||||
job, so a `pull_request`-only workflow produces **zero** jobs on a tag/main push: no dispatch, no
|
||||
kill, no spurious red — for the whole class, permanently. The per-job `if:` guards are kept as
|
||||
belt-and-suspenders (they also encode "these steps need a PR `base_ref`").
|
||||
|
||||
**What stays put and why.** These three carry **no** CI toolchain image pin, so `ci-image-pin`'s
|
||||
grep of `docker-build.yml` still validates the five pin-bearing jobs
|
||||
(`test`/`migrations`/`functional-e2e`/`api-docs`/`format`) that remain there. `api-docs` and
|
||||
`format` stay in `docker-build.yml` because they carry the shared-image `container:` + pin and run
|
||||
on the healthy `ubuntu-latest` lane (where they skipped correctly). None of the three moved jobs is
|
||||
a **required** check — branch protection requires only `Build & test (.NET)` and `EF migration
|
||||
integrity` — so relocating them (their status-context prefix changes from `Build ErsatzTV Image /
|
||||
…` to `PR Gates / …`) does not affect merges. The file declares `defaults: run: shell: bash`
|
||||
because `ci-image-pin` uses `mapfile`/`set -o pipefail`.
|
||||
|
||||
## CI toolchain image (`docker/ci/Dockerfile`, `.gitea/workflows/ci-image.yml`)
|
||||
|
||||
The jobs that need a toolchain — `test`, `migrations`, `functional-e2e`, `api-docs`, `format` —
|
||||
@@ -617,7 +655,8 @@ registry. Renovate tracks the Dockerfile's image pins (`dockerfile` manager, see
|
||||
(`entrypoint=["/bin/sleep" "10800"]`), so this is belt-and-braces for anyone running the image by
|
||||
hand — unlike the two above, which are real.
|
||||
|
||||
**Bumping the pin is enforced, not remembered.** The `ci-image-pin` job (blocking, PR-only) fails if
|
||||
**Bumping the pin is enforced, not remembered.** The `ci-image-pin` job (blocking, PR-only; defined
|
||||
in `pr-checks.yml`, but it greps `docker-build.yml` where the pins live) fails if
|
||||
`docker-build.yml`'s pin isn't the short sha of the last commit to touch `docker/ci/**` or
|
||||
`ci-image.yml`, or if the five jobs ever pin different tags. This exists because **Renovate manages
|
||||
`docker/ci/Dockerfile`'s base pins but cannot bump an opaque `:<sha>`** in `container.image` — so a
|
||||
|
||||
+163
-4
@@ -16,7 +16,10 @@ A migrated record is an H2 whose first non-blank content line is a metadata bloc
|
||||
|
||||
(Blockquoted here so the illustrative `key`/heading don't parse as a real record; the `## 2026-07-17
|
||||
— No persistent compiler servers …` entry below is a live example.) Five metadata fields: `key`
|
||||
(dotted, e.g. `ci.runner-placement`), `status`, `since`, `supersedes`, `superseded-by`. `status` is
|
||||
(dotted, e.g. `ci.runner-placement`), `status`, `since`, `supersedes`, `superseded-by`. The
|
||||
**`Signals:`** line is also required (validator-enforced, ersatztv#545): it is the keywords/paths
|
||||
MemPalace's recall matches on, so a record without it ingests with weak recall metadata and
|
||||
under-surfaces — pack it with synonyms, symbol names, `paths:`, and issue refs. `status` is
|
||||
one of:
|
||||
- **`active`** — the current, authoritative record for its `key`. Exactly one active record per key.
|
||||
- **`superseded`** — reversed by a newer record; relocated to `docs/decisions/archive/` with
|
||||
@@ -35,9 +38,10 @@ in on both records. Never silently rewrite a record's rationale prose in place.
|
||||
(this file + the topic files, excluding the archive) via `scripts/build_decisions_catalog.py` — run
|
||||
it after any status change; CI's `decisions lifecycle` job fails on drift (`--check`).
|
||||
|
||||
**Enforcement**: `scripts/decisions_validate.py` checks metadata well-formedness, one-active-record-
|
||||
per-key, reciprocal links, that no record vanishes from the active set without an archive copy, that
|
||||
the active catalog is in sync, and an aggregate active-corpus line budget (replaces the old 1800-line
|
||||
**Enforcement**: `scripts/decisions_validate.py` checks metadata well-formedness (including a required
|
||||
`Signals:` line), one-active-record-per-key, reciprocal links, that no record vanishes from the active
|
||||
set without an archive copy, that the active catalog is in sync, and an aggregate active-corpus line
|
||||
budget (replaces the old 1800-line
|
||||
floor on this single file). The Husky `pre-commit` hook runs the structural checks over the working
|
||||
tree; the CI `decisions lifecycle` job additionally runs the body-diff check with `--base`/`--head`.
|
||||
|
||||
@@ -62,6 +66,7 @@ in-file entries.
|
||||
- [`decisions/api-auth-security.md`](decisions/api-auth-security.md) — API/SPA auth & security posture (#197 bundles + #279 headers, #206, #283, #292, #295, #301, #319, #330).
|
||||
- [`decisions/release-ci-governance.md`](decisions/release-ci-governance.md) — release / CI / merge governance (#303 H3/H4/H5/H6/H9/H10, #311, #314, #315, #335).
|
||||
- [`decisions/spa-modularization.md`](decisions/spa-modularization.md) — App.tsx screen/shell extraction epic #243 (#244, #245, #247).
|
||||
- [`decisions/workflow-process.md`](decisions/workflow-process.md) — session workflow, CI-run triage, review routing, worktree/parallel-session hygiene and tooling gotchas (#542). Extracted from the kickoff handoff doc, which was their only copy.
|
||||
|
||||
**In this file:**
|
||||
|
||||
@@ -131,6 +136,9 @@ in-file entries.
|
||||
- [2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)](#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521)
|
||||
- [2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520)](#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520)
|
||||
- [2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)](#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525)
|
||||
- [2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)](#2026-07-21--check-the-worked-issue-before-the-decision-corpus-a-closed-trackers-comments-need-no-retrofit-524)
|
||||
- [2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)](#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536)
|
||||
- [2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)](#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541)
|
||||
|
||||
---
|
||||
|
||||
@@ -2854,6 +2862,14 @@ again — the duplication is what let the defect exist in triplicate. Covered by
|
||||
> made the failure near-deterministic. See `ffmpeg.qsv-extra-hw-frames-floor`; the decision recorded
|
||||
> here still stands, with that floor in place.
|
||||
|
||||
> **Correction (2026-07-21, #536):** the bullet below stating that "every concurrent tune-in falls
|
||||
> back to the throttled path" described the *intent*, not the behaviour. The slot check and its
|
||||
> increment straddled an `await`, so N simultaneous tune-ins all read `0 < limit` and all started
|
||||
> unthrottled; the "one winner, two throttled" observation held only because those tunes were
|
||||
> effectively staggered. The measurement and the conclusions drawn from it stand — slot availability
|
||||
> *was* the variable behind the bimodality — but the guarantee itself was not enforced until
|
||||
> `ffmpeg.work-ahead-slot-atomic`.
|
||||
|
||||
- **`-readrate` throttles from the first read, so it sets a floor on time-to-first-segment.** The
|
||||
realtime playback path pins input reading to 1.05× wall clock so a channel behaves like live TV.
|
||||
Since `OutputFormatHls.SegmentSeconds` is 4 and the segmenter serves the playlist only once the
|
||||
@@ -3282,3 +3298,146 @@ stored value moves too (harmless — only the QSV path ever reads it — but it
|
||||
on a field the user didn't touch); and a machine client that `PUT`s `0` gets a `200` and then reads
|
||||
back `64`, which is a silent transform of a submitted value that the OpenAPI description does not
|
||||
advertise.
|
||||
|
||||
## 2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)
|
||||
`key: docs.tracker-comment-retrofit` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent.
|
||||
**Signals:** tracker retrofit, over-cap issue exclusion, worked-issue-first, tracker-is-not-a-knowledge-store, #237 comment history · paths: `docs/tracker-retrofit-triage-237.md` · issues: #524, #237, #520, #521, server-management#642
|
||||
**Mechanics:** `docs/tracker-retrofit-triage-237.md` — method, per-comment classification table, totals, and the one candidate that was raised and disproved
|
||||
|
||||
**Why the worked-issue-first ordering is the load-bearing part.** The session protocol that produced
|
||||
#237's log required each session to post its full closing record on the issue it actually worked, and
|
||||
*then* summarize across issues on the tracker. The tracker entry is therefore the lossy copy. A
|
||||
comment that looks like a unique source is nearly always a précis of a primary the exporter already
|
||||
indexes — so the instinct "the tracker is excluded, therefore its knowledge is orphaned" inverts the
|
||||
real dependency. Checking the corpus first and the worked issue second wastes the effort; the reverse
|
||||
order settles most items in one lookup.
|
||||
|
||||
**Worked example.** Comment [106/111] recorded that #497's music-video metadata reconcile deliberately
|
||||
excludes Guids (not eager-loaded by `GetOrAdd`, so reconciling would duplicate-insert every scan) and
|
||||
Directors (not add-persisted for `MusicVideoMetadata`). Genuinely decision-shaped, and genuinely
|
||||
absent from the decision corpus — it survives a corpus-only check and looks like a retrofit target.
|
||||
Issue #497's own closing comment states both exclusions with fuller reasoning, and the exporter
|
||||
ingests it as `497.md`. One worked-issue lookup disposes of it.
|
||||
|
||||
**Scope of the claim — deliberately narrow.** The coverage test was applied to **decision-shaped
|
||||
items only**. Items classified as cross-cutting lore were classified but *not* coverage-checked, and
|
||||
that bucket is **not** empty: two facts from comment [109/111] were found to have no home anywhere —
|
||||
`scripts/e2e-local.sh`'s readiness probe hanging on a reused config dir, and the troubleshooting
|
||||
playback API's inability to exercise channel branding. Both were swept into the handoff lore by this
|
||||
PR. So the correct statement is "no *decision-shaped* orphans," not "no orphans"; a tracker triage
|
||||
that skips the lore bucket will leave real knowledge on the floor.
|
||||
|
||||
**A zero result is a legitimate outcome**, not evidence the triage was done wrong. What would change
|
||||
the answer is a tracker whose sessions did *not* also write per-issue closing records — one where the
|
||||
tracker genuinely was the primary rather than the narration layer.
|
||||
|
||||
## 2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)
|
||||
|
||||
`key: ffmpeg.work-ahead-slot-atomic` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another.
|
||||
**Signals:** work-ahead slot, workAheadSegmenterLimit, check-then-act across an await, unthrottled tune-in, TOCTOU · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Run`/`Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #536, #350, #529, #231, #250
|
||||
**Mechanics:** `WorkAheadSlots.TryAcquire(limit)` CAS loop; `Transcode(bool ownsWorkAheadSlot, …)`
|
||||
|
||||
**The defect was structural, not a missing `Interlocked`.** The write side already used
|
||||
`Interlocked.Increment`, which is why the code read as thread-safe. The read side was a separate,
|
||||
earlier `Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(...)` in `Run`, and the
|
||||
increment happened later inside `Transcode` — with at least one `await` (a DB-backed config read) in
|
||||
between. `Interlocked` on one half of a check-then-act buys nothing. Three simultaneous tune-ins on
|
||||
prod with `workAheadSegmenterLimit: 1` all observed `0 < 1` and all ran with no `-readrate`.
|
||||
|
||||
**Why the caller claims and the callee releases.** Moving the whole acquire/release pair inside
|
||||
`Transcode` would be more symmetric, but `Run` needs the outcome *before* the call: it sets
|
||||
`_state = SeekAndWorkAhead | SeekAndRealtime`, and `Transcode` reads that state on entry
|
||||
(`wasSeekAndWorkAhead`) to decide whether the item starts at `DateTimeOffset.Now` or at
|
||||
`_transcodedUntil`. Folding acquisition inward would have silently changed that branch. Instead the
|
||||
parameter was inverted — `Transcode(bool ownsWorkAheadSlot, …)` with `realtime = !ownsWorkAheadSlot`
|
||||
derived on the first line — so the contract is stated in the signature rather than implied by a
|
||||
double negative, and there is exactly one release site guarded by the same flag.
|
||||
|
||||
**Compare-exchange rather than increment-then-back-off.** The issue proposed
|
||||
`Interlocked.Increment` followed by a decrement when the post-increment value exceeds the limit.
|
||||
That is correct on holder count, but the counter transiently overshoots, so a concurrent reader of
|
||||
`Count` can observe a value above the limit. The CAS loop never publishes a state that violates the
|
||||
invariant, which matters because the QSV hardware-frame pool sizing (`ffmpeg.qsv-extra-hw-frames-floor`)
|
||||
is derived from that bound.
|
||||
|
||||
**Testing shape is inherited from #231/#250.** A single `Barrier(N)` + `Task.WhenAll` round does not
|
||||
reliably collide on this hardware; the tests hammer 8 threads × 20 000 rounds with a per-round
|
||||
barrier that validates the winner count and resets the pool. The negative control is documented in
|
||||
the test file: reinstate the check-then-act body (**not** `if (true)`, which trips CS0219 under
|
||||
warnings-as-errors and leaves `--no-build` running a stale, still-fixed dll). Verified: with the
|
||||
pre-fix shape, 15 912 of 20 000 rounds over-claimed.
|
||||
|
||||
## 2026-07-21 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68)
|
||||
|
||||
`key: scheduling.ondemand-guide-refresh-on-thaw` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline.
|
||||
**Signals:** on-demand resume, bookmark playout, freeze the clock when unwatched, guide/EPG desync, "guide showed S2E3 while tune-in played S1E1", XMLTV cache staleness, mirror channel guide · paths: `ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs`, `ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs`, `ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs` · issues: #68
|
||||
**Mechanics:** `IPlayoutTimeShifter.TimeShift` returns `List<string>` (shifted channel + its mirrors on a non-zero shift, empty otherwise); handler `foreach`-enqueues `RefreshChannelData` on `CancellationToken.None`
|
||||
|
||||
**This is what #68 ("resume/bookmark for sequential channels") actually needed.** The freeze-when-unwatched, resume-where-you-left-off behavior already existed as `ChannelPlayoutMode.OnDemand`: `Playout.OnDemandCheckpoint` persists the viewer's position, `UpdateOnDemandCheckpoint` advances it (monotonically, minus one segmenter-timeout of rewind-for-context) while watching, and `PlayoutTimeShifter` slides the whole schedule forward by `now − checkpoint` on the next tune-in so the item the viewer had reached is active again. Because the shift rewrites `GuideStart/GuideFinish` alongside `Start/Finish`, ErsatzTV **freezes the guide and playback together** — structurally avoiding the free-running-wall-clock desync the issue was filed about (a guide that keeps ticking while playback resumes from a saved spot).
|
||||
|
||||
**The one gap was cache freshness, not the timing model.** `RefreshChannelData` was enqueued by playout-build and channel/config edits, but **not** by the on-tune-in time shift, and `GetChannelGuideHandler` serves a cached `.xml` fragment (the live-recomputed SPA JSON guide already self-healed). So after a thaw an external EPG client (Jellyfin) could poll a guide reflecting the pre-shift timeline until the next incidental rebuild. `BuildPlayoutHandler` already refreshes the guide after it time-shifts — including a fan-out to every channel that mirrors the built channel, because `RefreshChannelDataHandler` refreshes only the one channel it is handed and does not cascade downstream. The tune-in path (`TimeShiftOnDemandPlayoutHandler`) was unpatched, so this decision replicates both halves: the source channel's guide **and** its mirrors' guides are rebuilt on thaw. A channel mirroring an on-demand source is an uncommon combination, but omitting it would leave the same desync one hop out.
|
||||
|
||||
**Why the return-value plumbing rather than enqueuing inside the shifter.** `PlayoutTimeShifter` lives in `ErsatzTV.Infrastructure`, which cannot reference the `RefreshChannelData` request type (an `ErsatzTV.Application` type), so the enqueue must happen in the Application-layer handler. `TimeShift` therefore returns `Option<string>` — `Some(channelNumber)` only when a non-zero offset was actually persisted, `None` on every early-out (wrong mode, active-and-unforced, empty playout) and on a zero-offset re-tune — so a guide rebuild fires exactly once per real thaw, never on a no-op. The zero-offset `None` gate is covered by a dedicated non-vacuous test.
|
||||
|
||||
**Per-viewer resume was deliberately not built.** `OnDemandCheckpoint` is a single value on the playout, so resume is per-channel, not per-viewer. #68 states per-channel suffices for a single household; multi-viewer identity would diverge from this model and is out of scope.
|
||||
|
||||
## 2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)
|
||||
|
||||
`key: session.shared-checkout-refresh` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`.
|
||||
**Signals:** shared checkout, stale kickoff paste, `/Users/timothy/ersatztv`, session-end protocol, H13 · paths: `scripts/refresh-shared-checkout.sh`, `docs/handoffs/chicorytv-issue-queue.md` · issues: #541, #520, #311, #312
|
||||
**Mechanics:** `docs/handoffs/chicorytv-issue-queue.md` → session-end step 6 + the shared-tree lore bullet
|
||||
|
||||
**The existing rule had a hole, and it is a hole no rule can close.** The standing guidance — never
|
||||
commit in the shared tree, never read its `HEAD`/`git log`/`git status` as truth about `main` — is
|
||||
written entirely around a session **reading git state**. On 2026-07-21 the trap arrived as a **file**:
|
||||
the kickoff prompt was pasted out of that tree while it was 81 commits behind, and the handoff doc it
|
||||
carried still described the queue protocol #520 had retired the previous day (read tracker #237, which
|
||||
`main` now says must *not* be read for queue state). No `git` command touched that tree all session, so
|
||||
no discipline check could have fired. Selection happened to go through `scripts/select-queue.sh`, which
|
||||
is why nothing broke — routing luck, not a control.
|
||||
|
||||
**So the fix removes the stale condition rather than adding a check**, which is what the shared-tree
|
||||
lore bullet already prescribed for its first two failure modes: *"a check does not stay true."*
|
||||
|
||||
**The script is deliberately timid, because the tree is shared.** It refuses — loudly, exit 0,
|
||||
changing nothing — when the tree is not on `main`, is dirty, has local commits, or is mid-rebase or
|
||||
mid-merge. It never switches branches, never stashes, never discards. A refusal is a normal outcome,
|
||||
not a failure, because the common reason for one is that another session is legitimately mid-flight.
|
||||
|
||||
**Two details that testing forced.** The first version used `npm install`, which **rewrote
|
||||
`package-lock.json`** and left the shared tree dirty — the exact state the next run refuses on, so the
|
||||
tool would have disabled itself after one use. It uses `npm ci`, which installs strictly from the
|
||||
lockfile and never writes it. And it asserts the tree is clean at exit, reporting loudly if not:
|
||||
leaving the shared tree dirty is the one outcome that would make this script a net negative.
|
||||
## 2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539)
|
||||
|
||||
`key: ffmpeg.work-ahead-slot-release-never-negative` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return.
|
||||
**Signals:** work-ahead slot release, unbalanced release, negative slot count, over-admit at limit 1, phantom work-ahead room, UnbalancedReleases counter · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #539, #536, #529, #231, #250
|
||||
**Mechanics:** `bool Release()` CAS loop guarded by `current <= 0`; call site `if (ownsWorkAheadSlot && !_workAheadSlots.Release()) _logger.LogWarning(...)`
|
||||
|
||||
**Why never-negative, given the release is already unreachable-when-unbalanced.** Today `HlsSessionWorker` has exactly one release site, guarded by `ownsWorkAheadSlot`, so an unbalanced release cannot happen — these were three **Low** findings from the #536 re-review, filed against the day someone adds a second release site. The pre-#539 shape decremented first (`0 → −1`) and clamped afterward, leaving a reachable interleaving where a concurrent `TryAcquire(limit)` reads the `−1`, sees phantom room, and admits a holder the budget doesn't have (a second acquirer then reads `0` and admits another) — two unthrottled transcodes at limit 1, re-opening the #529 QSV pool exhaustion. Because `TryAcquire` is the sole, CAS-guarded increment path, a count proven never-negative is exactly what forecloses that over-admit. Clamping *before* the decrement also records the breakage **synchronously on the offending thread**, instead of the decrement-first shape where the blame lands on a later, innocent release.
|
||||
|
||||
**`Release()` returns `bool` so the breach is visible in the logs, not only to tests.** #536's motivation was "a silently inflated budget with nothing in the logs to find it by," yet `UnbalancedReleases` was reachable only from a debugger or a test. `WorkAheadSlots` has no logger by design (it is a leaf primitive), so rather than plumb one in, `Release()` returns `false` on an unbalanced release and the caller — which already holds an `ILogger` — logs the warning. This is the one in-band signal that the ownership contract broke.
|
||||
|
||||
**`UnbalancedReleases` can under-count, and that is documented rather than fixed.** It only increments when a release finds the pool already empty. An over-release while the count is positive — e.g. one cancelling out a coexisting leak — decrements a real-looking slot and is never recorded, so the two bugs hide each other. There are no false positives (non-zero still means the contract broke), but zero does not prove correctness. Exact accounting would need per-owner tokens, which the #536 "ownership is a discipline, not a token" decision deliberately avoids; the docstring now states the limitation instead.
|
||||
|
||||
**Negative control (inherited from #231/#250).** A dedicated test hammers unbalanced releases on an empty pool while reader threads sample the count; none may ever observe a value below zero. Reinstating the pre-#539 decrement-first body makes it fail (`sawNegative > 0` — the readers catch the transient `−1`); verified. As with the #536 tests, break the primitive by reverting the real body, **not** `if (true)` (CS0219 under warnings-as-errors leaves `--no-build` running a stale, still-fixed dll).
|
||||
## 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135)
|
||||
|
||||
`key: api.from-lineup-clear-to-none` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error.
|
||||
**Signals:** clear to none, inherit vs none, advanced override, watermark/filler clear, template-minus-one-setting · paths: `ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs`, `ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs`, `ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs`, `web/src/builder/advancedOptions.tsx` · issues: #135, #89, #385, #386
|
||||
**Mechanics:** `CreateChannelFromLineupHandler.ResolveClearable`/`ValidateClear`; SPA `applyOverridesToRequest`/`collectClears`/`CLEAR` sentinel; api-conventions.md §2, spa-conventions.md
|
||||
|
||||
**Why a `clear` list, not a sentinel or per-field flags.** The gap (found in #89 review) was that the handler resolved every advanced override with `advanced.X ?? template.X`, so a client sending `null` always *inherited*. That is correct for the common path but leaves "this channel should have NO watermark / pre-roll filler even though the template has one" inexpressible. The fix had to keep `omitted = inherit` byte-stable for existing clients (`/api/v1` is frozen-additive, #286), so it is a new optional field, not a reshaping of the existing ones. A `{set, value}` wrapper per field would have rewritten every field's wire type; a reserved `0` sentinel is magic and asymmetric between int ids and strings; parallel `clearX` bools add one field per clearable. A single **typed enum list** is additive, self-documenting, type-checked (an invalid value is a 400 at model binding), covers ids and strings with one mechanism, and extends by adding an enum member. The clearable set is the eight template-inheritable fields where "none" is meaningful: watermark, the four fillers, and the preferred audio/subtitle language + audio title.
|
||||
|
||||
**Set + clear of the same field is rejected, not silently resolved.** The SPA never produces that state (a select is inherit, a value, or None), so the check exists to keep hand-crafted / machine-client requests unambiguous rather than picking a winner. A null/empty set value alongside a clear is fine (redundant, not conflicting).
|
||||
|
||||
**The enum lives in `ErsatzTV.Core`, not the Application command, on purpose.** The OpenAPI string-enum pass (`Startup.UseStringEnumSchemas`) scans the Core assembly wholesale; an enum defined in `ErsatzTV.Application` renders as a bare `integer` in the spec while every sibling advanced-options enum (`PlaybackOrder`, `ChannelSubtitleMode`, …) is a string enum. Placing `CreateChannelFromLineupClearField` in `ErsatzTV.Core/Api/Channels/` makes the wire contract a string enum by construction, matching its siblings.
|
||||
|
||||
**SPA is id-fields-first; the API is complete ahead of the UI.** The Channel Builder + Auto-Tune DetailPanel re-add a real "None" option to the five id selects (watermark + fillers) — the pickers #89 had degraded to "Inherit"-only — routed through a `CLEAR` overrides sentinel folded into `advanced.clear` at request-build time (`applyOverridesToRequest`, so the sentinel never leaks as a field value). The three string clear-fields are covered by the backend enum for machine clients (MCP) but the SPA text inputs keep "empty = inherit"; adding a tri-state to those inputs is deferred, not blocked. This is the deliberate "REST API is a real audience" posture (`rest-api-purpose-mcp-and-new-ui`).
|
||||
|
||||
@@ -12,6 +12,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `api.async-op-contract` | Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. | 2026-07-11 | [link](../decisions.md#2026-07-11--async-op-api-contract-normalization--playout-build-observability--f9-scan-endpoints-235) |
|
||||
| `api.channel-health-signal` | Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands). | 2026-07-17 | [link](../decisions.md#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72) |
|
||||
| `api.decode-by-id` | Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | [link](../decisions.md#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side) |
|
||||
| `api.from-lineup-clear-to-none` | `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error. | 2026-07-21 | [link](../decisions.md#2026-07-21--from-lineup-advanced-overrides-express-clear-to-none-via-a-typed-clear-enum-list-135) |
|
||||
| `api.healthcheck-remediation-dto` | Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself. | 2026-07-17 | [link](../decisions.md#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164) |
|
||||
| `api.healthcheck-ttl-cache` | Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh. | 2026-07-19 | [link](../decisions.md#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431) |
|
||||
| `api.logs-sort-params` | `GET /api/logs` takes allow-listed `sortField` (`timestamp`\|`level`) and `sortDirection` (`asc`\|`desc`) query params, normalized (not rejected) on an unrecognized value. | 2026-07-11 | [link](../decisions.md#2026-07-11--logs-column-sorting-allow-listed-sortfieldsortdirection-on-get-apilogs) |
|
||||
@@ -29,15 +30,25 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `api.versioning-v1` | The entire `/api` surface is versioned to `/api/v1` uniformly (no unversioned corner); legacy unversioned callers are rewritten in-pipeline (not redirected) with Deprecation/Link/Sunset headers, and post-freeze `/api/v1` is additive-only — a breaking change requires `/api/v2`. | 2026-07-13 | [link](../decisions.md#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286) |
|
||||
| `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](../decisions.md#2026-07-11--pre-removal-blazor-rollback-tag-blazor-final-205) |
|
||||
| `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](../decisions.md#2026-07-11--blazor-server-ui-removed-91-phase-b) |
|
||||
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--batch-your-pushes-there-is-no-agent-side-cancel-route-on-gitea-1254-542) |
|
||||
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](../decisions.md#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip) |
|
||||
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](workflow-process.md#2026-07-21--cancelled-is-not-failure-a-cancelled-run-is-no-verdict-542) |
|
||||
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-lone-decisions-lifecycle-red-is-a-known-infra-flake-do-nothing-542) |
|
||||
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-detect-must-be-shallow-checkout-safe-fetch_head--two-dot-not-originmain--three-dot-416-follow-up) |
|
||||
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-ci-skip-gates-steps-in-always-running-required-jobs-never-if-skips-them-416) |
|
||||
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](../decisions.md#2026-07-19--the-format-gate-runs-dotnet-format-whitespace----folder-not-the-full-solution-format-469) |
|
||||
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs curl-only, deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. | 2026-07-16 | [link](../decisions.md#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) |
|
||||
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](workflow-process.md#2026-07-21--giteas-milestones-issue-filter-silently-no-ops-on-names-containing--or--542) |
|
||||
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](workflow-process.md#2026-07-21--an-infra-shaped-red-under-host-load-is-not-a-code-failure-542) |
|
||||
| `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-killed-ci-job-reports-conclusion-failure-read-the-log-tail-before-diagnosing-the-diff-542) |
|
||||
| `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](workflow-process.md#2026-07-21--arm-the-ci-monitor-at-pr-open-via-the-commit-status-endpoint-542) |
|
||||
| `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](workflow-process.md#2026-07-21--do-not-gate-or-throttle-pushes-on-host-health-542) |
|
||||
| `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](../decisions.md#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) |
|
||||
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--root-screenshot-guard-pre-commit-refuses-root-level-png-303-h3) |
|
||||
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](../decisions.md#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) |
|
||||
| `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](../decisions.md#2026-07-20--runs-on-small-means-git-only-the-two-docker-build-jobs-move-to-ubuntu-latest-server-management639) |
|
||||
| `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](workflow-process.md#2026-07-21--build-and-verify-locally-then-trust-it-ci-confirms-542) |
|
||||
| `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](workflow-process.md#2026-07-21--heavy-render-web-tests-need-explicit-per-test-vitest-timeouts-on-the-ci-vm-542) |
|
||||
| `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection) |
|
||||
| `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](optimistic-concurrency.md#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269) |
|
||||
| `concurrency.force-write-non-ifmatch` | Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500). | 2026-07-12 | [link](optimistic-concurrency.md#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump) |
|
||||
@@ -47,11 +58,14 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `concurrency.schedule-item-child-identity` | `PUT /api/schedules/{id}/items` reconciles by an optional round-tripped child `Id` (null/absent/0 ⇒ new item), never by array position, so fill-group/shuffle state follows the logical item across reorders; an unknown or duplicate id is rejected 422 (checked after the §7a `CheckVersion`, so 412 precedes 422). | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--stable-child-identity-for-schedule-item-replace-259-split-from-252253) |
|
||||
| `docs.convention-docs-session-start` | Docs-first, not source-first: conventions (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) are read from docs, not reverse-engineered from code, via `docs/README.md`'s task-signal map — only the sections it points to for the task at hand, not the whole set. Each doc is updated in the same PR that changes what it documents, replacing deferred/follow-up doc updates. | 2026-07-07 | [link](../decisions.md#2026-07-07--convention-docs-read-at-session-start-updated-in-pr) |
|
||||
| `docs.decision-lifecycle` | every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. | 2026-07-21 | [link](../decisions.md#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521) |
|
||||
| `docs.tracker-comment-retrofit` | When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent. | 2026-07-21 | [link](../decisions.md#2026-07-21--check-the-worked-issue-before-the-decision-corpus-a-closed-trackers-comments-need-no-retrofit-524) |
|
||||
| `ffmpeg.external-logo-graphics-engine` | External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. | 2026-07-20 | [link](../decisions.md#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502) |
|
||||
| `ffmpeg.hls-cold-start-burst` | HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for. | 2026-07-20 | [link](../decisions.md#2026-07-20--hls-cold-start-is-fixed-with--readrate_initial_burst-not-by-raising-the-work-ahead-limit-350) |
|
||||
| `ffmpeg.qsv-decode-encode-split` | QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. | 2026-07-20 | [link](../decisions.md#2026-07-20-498--qsv-decode-is-split-from-qsv-encode-via-a-single-qsvprefernativedecoder-bool) |
|
||||
| `ffmpeg.qsv-extra-hw-frames-floor` | a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all. | 2026-07-21 | [link](../decisions.md#2026-07-21--qsv-hardware-frame-headroom-is-a-floor-not-an-operator-preference-529) |
|
||||
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](../decisions.md#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) |
|
||||
| `ffmpeg.work-ahead-slot-atomic` | `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another. | 2026-07-21 | [link](../decisions.md#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536) |
|
||||
| `ffmpeg.work-ahead-slot-release-never-negative` | `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return. | 2026-07-21 | [link](../decisions.md#2026-07-21--workaheadslotsrelease-clamps-before-decrementing-and-reports-unbalance-in-band-539) |
|
||||
| `graphics.channel-logo-caching` | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | 2026-07-21 | [link](../decisions.md#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525) |
|
||||
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](../decisions.md#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) |
|
||||
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](../decisions.md#2026-07-20--one-logo-drives-the-bug-via-a-shared-channellogo-preset-not-new-schema-67) |
|
||||
@@ -61,6 +75,24 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `media.remote-stream-probe` | `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle. | 2026-07-19 | [link](../decisions.md#2026-07-19--media-server-remote-stream-urls-are-probed-before-use-a-redirected-404-fails-closed-everything-else-fails-open-no-toggle-473) |
|
||||
| `media.remote-stream-probe-externaljson` | External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows. | 2026-07-20 | [link](../decisions.md#2026-07-20--external-json-playout-channels-now-probe-the-remote-stream-url-too-closing-the-473-scope-gap-480) |
|
||||
| `media.source-mgmt-write-api` | Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under `/app/libraries/*`, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored `apiKey`, and each PUT-replace family's identity contract is documented per-family (not assumed uniform). | 2026-07-11 | [link](../decisions.md#2026-07-11--media-source-management-rest-write-api--spa-202) |
|
||||
| `process.bom-format-detection-recipe` | Before any push touching `.cs`, detect BOMs with the `xxd` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh. | 2026-07-21 | [link](workflow-process.md#2026-07-21--bomformat-pre-push-detection-use-the-xxd-loop-and-run-dotnet-format---include-under-bash--c-542) |
|
||||
| `process.branch-off-feature-branch` | To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building. | 2026-07-21 | [link](workflow-process.md#2026-07-21--fix-onto-an-unmerged-feature-branch-by-branching-off-it-and-ff-pushing-542) |
|
||||
| `process.build-concurrency-limits` | Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. | 2026-07-21 | [link](workflow-process.md#2026-07-21--bound-parallel-builds-by-free-ram-never-regenerate-goldens-542) |
|
||||
| `process.codex-cheap-worker-launch` | For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings. | 2026-07-21 | [link](workflow-process.md#2026-07-21--launch-codex-cheap-workers-via-codex-exec-not-spawn_agent-542) |
|
||||
| `process.consistency-fix-new-code-scrutiny` | Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount. | 2026-07-21 | [link](workflow-process.md#2026-07-21--make-x-consistent-with-y-review-findings-are-new-code-and-get-new-code-scrutiny-542) |
|
||||
| `process.enumerate-workaround-behaviors-before-deleting` | When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first. | 2026-07-21 | [link](workflow-process.md#2026-07-21--before-deleting-a-workaround-enumerate-every-behavior-it-provided-542) |
|
||||
| `process.foreign-worktree-plumbing-merge` | Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead. | 2026-07-21 | [link](workflow-process.md#2026-07-21--never-commit-or-merge-inside-a-worktree-you-did-not-create-542) |
|
||||
| `process.harden-with-runtime-posture-not-clamp` | When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first. | 2026-07-21 | [link](workflow-process.md#2026-07-21--harden-a-soon-to-grow-feature-with-a-runtime-posture-not-a-hardcoded-clamp-542) |
|
||||
| `process.independent-review-rubric` | Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment. | 2026-07-21 | [link](workflow-process.md#2026-07-21--independent-cross-model-review-is-mandatory-on-risky-diffs-a-skip-is-a-stated-auditable-exemption-542) |
|
||||
| `process.issue-qualification-audit` | Run `scripts/issue-qualification-audit.sh` at session end and label everything it flags, including issues you filed that session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--every-open-issue-carries-a-priority-label--run-the-h12-audit-at-session-end-542) |
|
||||
| `process.local-gate-before-push` | Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR. | 2026-07-21 | [link](workflow-process.md#2026-07-21--local-buildtest-gate--cold-context-review-run-before-the-push-not-after-542) |
|
||||
| `process.lock-ownership-enumerate-producers` | Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-lockchannel-no-cross-release-verdict-must-enumerate-every-producer-via-grep-542) |
|
||||
| `process.one-worktree-one-committing-agent` | Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back. | 2026-07-21 | [link](workflow-process.md#2026-07-21--one-worktree-one-committing-agent-542) |
|
||||
| `process.parallel-session-claim` | Apply the `in-progress` label before starting an issue, and still read its dependency notes before touching shared surfaces — a claim prevents duplicate pickup, not overlapping code changes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--claim-with-in-progress-before-working-claiming-is-not-collision-safety-542) |
|
||||
| `process.pr-routine-sequence` | Worktree off origin/main → implement → regenerate API artifacts → full local tests + cold review + live-E2E ALL before the push → push, open PR, arm the CI monitor at open → fixes after the push are follow-up commits, never amend/force-push. | 2026-07-21 | [link](workflow-process.md#2026-07-21--the-pr-routine-is-a-fixed-sequence-validate-locally-then-push-then-only-follow-up-commits-542) |
|
||||
| `process.review-disagreement-frontier-judge` | When independent reviews disagree on a gate PR, escalate to the frontier judge, and put the proposed fix approach in front of it — not just the disputed finding. | 2026-07-21 | [link](workflow-process.md#2026-07-21--review-disagreement-on-a-gate-pr-escalates-to-the-frontier-judge--and-the-proposed-fix-escalates-with-it-542) |
|
||||
| `process.shared-tree-readonly` | Never commit in `/Users/timothy/ersatztv` and never read its `git log`/`git status`/HEAD to infer anything about `main` — work in a worktree off `origin/main`, which is the only source of truth. | 2026-07-21 | [link](workflow-process.md#2026-07-21--the-shared-tree-at-userstimothyersatztv-is-read-only-and-tells-you-nothing-about-main-542) |
|
||||
| `process.subagent-drop-resume` | Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-dropped-subagent-connection-is-transient-resume-dont-restart-542) |
|
||||
| `release.api-contract-ci-gate` | A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship regenerated OpenAPI artifacts (`v1.json`, `v1.d.ts`, `endpoint-index.md`) in the same diff, enforced by a blocking `api-docs` CI job that regenerates-and-diffs against a fresh build. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h4h5) |
|
||||
| `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6) |
|
||||
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--formatting-as-you-touch-enforced-rebase-not-merge-for-pr-branches-311-h11--format-ci) |
|
||||
@@ -86,6 +118,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `sched.shuffle-source-builder` | Shuffle-source construction moves to a static, DI-free `ShuffleSourceBuilder` (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into `PlayoutBuilder` statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381. | 2026-07-17 | [link](../decisions.md#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380) |
|
||||
| `sched.weighted-shuffle` | Fair-share/weighted airtime distribution ships as one new `PlaybackOrder.WeightedShuffle = 9` order (equal weights = fair-share), not a retrofit of `ShuffleInOrder` (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on `MultiCollectionItem`/`MultiCollectionSmartItem` (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects `WeightedShuffle` at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random. | 2026-07-17 | [link](../decisions.md#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70) |
|
||||
| `sched.weightedshuffle-editor` | WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode. | 2026-07-19 | [link](../decisions.md#2026-07-19--weightedshuffle-spa-weights-edited-on-the-multi-collection-order-offered-only-on-classic-multicollection-schedule-items-fair-share-is-a-reset-not-a-mode-404) |
|
||||
| `scheduling.ondemand-guide-refresh-on-thaw` | When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline. | 2026-07-21 | [link](../decisions.md#2026-07-21--an-on-demand-time-shift-rebuilds-the-channels-cached-xmltv-so-the-guide-cant-lag-playback-68) |
|
||||
| `security.artwork-content-type-sniff` | Artwork content type is always derived from the stored bytes (never the client-declared value or a `?contentType=` query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel `MaxRequestBodySize` bounds upload DoS. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss) |
|
||||
| `security.baseline-response-headers` | `SecurityHeadersMiddleware`, registered first in the pipeline, sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin` on every response (CSP/HSTS deliberately deferred); API-key comparison is constant-time and playout pagination is clamped. | 2026-07-11 | [link](api-auth-security.md#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279) |
|
||||
| `security.blazor-removal-auth-posture` | Removing the Blazor UI's OIDC-challenged surface exposes nothing a user couldn't already reach via the already-open `/app` SPA (open since phase (a)); real SPA/API authentication is deliberately deferred to #197, and the removal PR must preserve `ConditionalIptvAuthorizeFilter`, `ApiKeyAuthorizationFilter`, and `JwtHelper` access_token support. | 2026-07-11 | [link](api-auth-security.md#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206) |
|
||||
@@ -95,6 +128,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `security.fail-closed-api-auth` | Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292) |
|
||||
| `security.session-auth-dual-credential` | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only) |
|
||||
| `security.session-cutover-postify` | The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`). | 2026-07-12 | [link](api-auth-security.md#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification) |
|
||||
| `session.shared-checkout-refresh` | Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`. | 2026-07-21 | [link](../decisions.md#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541) |
|
||||
| `spa.add-to-layer` | All add-to-collection/playlist/schedule affordances share one component layer at `web/src/media/addTo/`; multi-select is an explicit screen-level toggle, and the per-card menu offers schedule only for the server-validated kinds. | 2026-07-10 | [link](../decisions.md#2026-07-10--shared-add-to-layer-lives-in-websrcmediaaddto-select-mode-is-an-explicit-toggle) |
|
||||
| `spa.app-shell-extraction` | `App.tsx` is only the composition root over `web/src/app/routes.tsx` (stable route-object identity), `app/AppShell.tsx` (shell chrome), and `app/ScreenContent.tsx` (exhaustive screen dispatch); primary actions are one explicit `PrimaryActionProvider` registration per screen, replacing the old global `ctv:primary-action` window event. | 2026-07-15 | [link](spa-modularization.md#2026-07-15--app-shellrouting-extraction--explicit-primary-action-ownership-247) |
|
||||
| `spa.autotune-detailpanel-slideover` | The Auto-Tune DetailPanel SPA is a reusable `SlideOver` primitive sharing `useOverlayBehavior` with `Dialog`, plus a shared advanced-options model extracted from ChannelBuilder; decorative panes without backend support are dropped. | 2026-07-18 | [link](../decisions.md#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386) |
|
||||
@@ -118,3 +152,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `spa.topbar-primary-action` | The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button. | 2026-07-12 | [link](../decisions.md#2026-07-12--topbar-primary-action-button-wire-creates-drop-the-rest-238) |
|
||||
| `spa.yaml-validator-textarea` | The YAML playout validator takes pasted YAML via a `<textarea>`, not a server-side file path, since the SPA has no filesystem access. | 2026-07-09 | [link](../decisions.md#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path) |
|
||||
| `startup.parallel-orientation` | A fresh session runs two concurrent tracks at startup — Orientation (`AGENTS.md`/`CLAUDE.md` → `docs/README.md` task-signal map → the active decisions catalog `docs/decisions/README.md`) and, only when no issue is named, Selection (`scripts/select-queue.sh N`, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state. | 2026-07-21 | [link](../decisions.md#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520) |
|
||||
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — a reused one never emits the probe's ready line and the script kills a healthy server. | 2026-07-21 | [link](workflow-process.md#2026-07-21--run-scriptse2e-localsh-against-a-fresh-config-dir-a-reused-one-hangs-the-readiness-probe-542) |
|
||||
| `testing.live-e2e-prepush-timing` | Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. | 2026-07-21 | [link](workflow-process.md#2026-07-21--live-e2e-runs-before-the-push-and-downloads-are-curled-not-browsed-542) |
|
||||
| `testing.playwright-mcp-download-and-recovery` | In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--playwright-mcp-curl-download-endpoints-never-open-a-tab-or-windowopen-542) |
|
||||
| `testing.troubleshoot-path-cannot-test-branding` | Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding. | 2026-07-21 | [link](workflow-process.md#2026-07-21--channel-branding-is-not-testable-through-the-troubleshooting-playback-api-542) |
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
# Session-workflow, CI-triage & review-process decisions (#542)
|
||||
|
||||
How we *work* — orchestration, CI-run triage, review routing, worktree and parallel-session hygiene,
|
||||
and the tooling gotchas that have cost real sessions. Distinct from the engineering "why the code
|
||||
does X" records: nothing here decides product behaviour.
|
||||
|
||||
These records were extracted from `docs/handoffs/chicorytv-issue-queue.md` in #542. That doc had
|
||||
become the **only** copy of most of this knowledge — an inventory found ~30 of ~38 lore bullets had
|
||||
no decision record at all — so it was neither retrievable via the catalog nor safe from a careless
|
||||
prune. The rules are now records; the handoff doc keeps the instruction and points here for the why.
|
||||
|
||||
Related: `release-ci-governance.md` (merge/release gates and hooks), `startup.parallel-orientation`
|
||||
(session-start tracks), `session.shared-checkout-refresh` (session-end shared-tree refresh).
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-21 — A killed CI job reports `conclusion: failure`; read the log tail before diagnosing the diff (#542)
|
||||
|
||||
`key: ci.killed-job-triage` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure.
|
||||
**Signals:** killed job · runner restart · conclusion failure · log truncated mid-step · `❌ Failure - Main` marker · `Unable to pull refs/heads/v4` · act cache noise · UTC vs UTC+2 · semantically-null diff · paths: n/a · issues: #542
|
||||
**Mechanics:** Gitea Actions job logs + runner container uptime on the runner host.
|
||||
|
||||
When the operator restarts the runners (a retune, a reboot), in-flight jobs die and Gitea marks them
|
||||
**`failure`**, not `cancelled`. The tell is that the log **stops mid-step with no error and no
|
||||
`❌ Failure - Main …` marker** — a real failure always leaves that marker. On run 1006, `EF migration
|
||||
integrity` and `Functional E2E` both "failed" on a **BOM-removal-only** diff (6 files, one line each,
|
||||
zero content change) that could not possibly break them; the logs simply truncated mid-`dotnet build`
|
||||
at 12:08 UTC and both runners showed `Up About an hour` — the retune had killed them.
|
||||
|
||||
**Log timestamps are UTC; the host is UTC+2.** Convert before correlating, or the restart looks two
|
||||
hours off and a correct theory gets wrongly discarded.
|
||||
|
||||
Corollary: a diff that *cannot* cause a failure is evidence the failure isn't yours — when a job that
|
||||
passed on the previous head fails on a semantically-null delta, suspect the environment and go read
|
||||
the log rather than re-litigating the diff. Beware warnings that look fatal: `Unable to pull
|
||||
refs/heads/v4: …` is act refreshing its `/root/.cache/act` action cache and is followed by `Cloned …`
|
||||
— it is noise, not a cause. Grep for the failure marker, not for the word "error".
|
||||
|
||||
## 2026-07-21 — `cancelled` is not `failure`; a cancelled run is no verdict (#542)
|
||||
|
||||
`key: ci.cancelled-is-not-a-verdict` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor.
|
||||
**Signals:** conclusion cancelled · run-level vs job-level conclusion · pre-cancel genuine failure · CI monitor state != pending · phantom failure · paths: n/a · issues: #542
|
||||
**Mechanics:** Gitea Actions run/job API; monitor logic, e.g. `fail=[j for j in jobs if j['conclusion']=='failure']; canc=[j for j in jobs if j['conclusion']=='cancelled']`.
|
||||
|
||||
The operator cancels runs by hand — they are the only party who can (see
|
||||
`ci.batch-pushes-no-cancel-route`) — and a run-level `conclusion: cancelled` means the run produced
|
||||
**no verdict** about your diff. Two traps follow. First, a run whose *overall* state is `failure` may
|
||||
hold a **genuine job failure that happened before the cancel**: check job-level `conclusion` plus
|
||||
timestamps rather than attributing the red to the cancel. Second, a cancelled run tells you nothing,
|
||||
so never claim green on one.
|
||||
|
||||
A monitor that only asks "is state != pending" will report a cancelled run as a failure and send the
|
||||
next session debugging a phantom. Split the two counts explicitly.
|
||||
|
||||
## 2026-07-21 — An infra-shaped red under host load is not a code failure (#542)
|
||||
|
||||
`key: ci.infra-shaped-red-under-load` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure.
|
||||
**Signals:** load average 243 · `Setup .NET` 11ms failure · `remove /root/.cache/act/…/.gitignore: no such file or directory` · buildx `DeadlineExceeded: failed to compute cache key` · `Build & push image` · `EF migration integrity` · `FactoryServer-L` game server · phantom CI bug · paths: n/a · issues: #542
|
||||
**Mechanics:** `uptime` on the runner host (bumblebee); Gitea Actions job step logs.
|
||||
|
||||
On 2026-07-17, with bumblebee at load average **243**, two unrelated jobs died in ways that look like
|
||||
code bugs but weren't: `EF migration integrity` failed in ~11ms inside **`Setup .NET`** with
|
||||
`remove /root/.cache/act/…/.gitignore: no such file or directory` (act's shared cache), and main's
|
||||
`Build & push image` died on buildx `DeadlineExceeded: failed to compute cache key` after ~210s of
|
||||
retries. Both re-ran green at normal load, and the migration job had no model change to test in the
|
||||
first place.
|
||||
|
||||
The diagnostic tell is the **location** of the failure: a setup or cache step, before your code
|
||||
compiles. One sample under load is not evidence of a systemic problem — this nearly got filed as #390
|
||||
lane-rebalance fallout, which the evidence did not support, and would have sent the next session
|
||||
chasing a phantom. Note also that a game server (`FactoryServer-L`) shares that host with the
|
||||
runners, so high load is not always CI's own doing.
|
||||
|
||||
## 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 (#542)
|
||||
|
||||
`key: ci.batch-pushes-no-cancel-route` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes.
|
||||
**Signals:** cancel run 404 · Gitea 1.25.4 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542
|
||||
**Mechanics:** Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI.
|
||||
|
||||
Cancellation is impossible from the agent side on this Gitea (**1.25.4**):
|
||||
`POST /api/v1/.../actions/runs/{id}/cancel` returns **404**, MCP `actions_run_write`'s `cancel_run`
|
||||
returns **404**, and the web-UI route needs a session + CSRF that does not script (login 303s with no
|
||||
session cookie). **Only the operator can cancel, in the browser** — so if you must supersede a live
|
||||
run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and
|
||||
still works for re-triggering a **main** run.)
|
||||
|
||||
This corrects the older "superseded runs drain on their own" framing: they do finish, but they hold
|
||||
one of the 4 runner slots while doing it, which is a real cost with several parallel sessions.
|
||||
|
||||
## 2026-07-21 — Do not gate or throttle pushes on host health (#542)
|
||||
|
||||
`key: ci.no-host-health-gating` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs.
|
||||
**Signals:** trust the build queue · runner retune · don't sample load before pushing · queueing is the queue's job · paths: n/a · issues: #542
|
||||
**Mechanics:** Gitea build queue (two runners, 4 slots).
|
||||
|
||||
The runners were retuned for stability (operator, 2026-07-17); queueing is the queue's job, not the
|
||||
agent's. Sampling host load before a push is both unreliable and unnecessary, and hand-scheduling
|
||||
around other sessions produces coordination that no one can verify.
|
||||
|
||||
Batch your pushes (see `ci.batch-pushes-no-cancel-route`) because orphaned runs cannot be cancelled —
|
||||
**not** because the host needs protecting. The two rules have the same action and different reasons;
|
||||
conflating them leads to load-watching behaviour that this record forbids.
|
||||
|
||||
## 2026-07-21 — A lone `decisions lifecycle` red is a known infra flake: do nothing (#542)
|
||||
|
||||
`key: ci.decisions-lifecycle-flake` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI.
|
||||
**Signals:** decisions lifecycle red · append-only gate · known flake · operator selective rerun · no-op push · convincing local explanation · paths: `docs/decisions.md` · issues: #542, #473, PR #479
|
||||
**Mechanics:** Gitea Actions `decisions lifecycle` job; operator-driven single-job rerun in the web UI.
|
||||
|
||||
Operator-stated, 2026-07-19. Report it as a known flake and carry on; only if *other* jobs are red too
|
||||
does the run deserve diagnosis. Same family as the killed-job rule — a spurious single-job red is
|
||||
cleared by the operator's selective rerun, never by pushing, and pushes cannot be cancelled anyway.
|
||||
|
||||
**The trap is that a convincing local explanation is always available.** On #473/PR #479 the job went
|
||||
red just after `main` landed its own `decisions.md` entry, so "mine is no longer at EOF, I must
|
||||
rebase" looked airtight. The rebase happened — and the job went red **again** on a head whose diff was
|
||||
a verified pure EOF append with zero deleted lines. A rebase that provably satisfied the gate's stated
|
||||
rule did not turn it green, which is the proof that the red was never about the diff. Check this rule
|
||||
*before* theorising about a cause.
|
||||
|
||||
## 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542)
|
||||
|
||||
`key: ci.monitor-armed-at-pr-open` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work.
|
||||
**Signals:** arm monitor at PR open · commit status endpoint · head sha · red run sat unnoticed · MySQL-apply flake · paths: n/a · issues: #542, #216
|
||||
**Mechanics:** `GET /api/v1/repos/timothy/ersatztv/commits/{sha}/status`.
|
||||
|
||||
CI runs concurrently with the review and E2E work that follows a push, so the cost of a late-armed
|
||||
monitor is entirely wasted wall-clock. In the #216 session three PR runs sat red for roughly an hour
|
||||
on a CI-only flake while review and E2E ran to completion — the reds were only discovered afterwards.
|
||||
|
||||
Related context for interpreting an early red: the old MySQL host-port 3306 collision is fixed on main
|
||||
(`ef8915f1`), so a lone MySQL-apply red now indicates the known infra flake and warrants a rerun
|
||||
rather than diagnosis.
|
||||
|
||||
## 2026-07-21 — Heavy-render web tests need explicit per-test vitest timeouts on the CI VM (#542)
|
||||
|
||||
`key: ci.web-test-per-test-timeouts` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test.
|
||||
**Signals:** vitest timeout · 5s default · 100+ item grid · CI VM slower than local · run 686 · paths: `web/` tests · issues: #542
|
||||
**Mechanics:** per-test timeout argument in the vitest test declaration.
|
||||
|
||||
The CI VM is materially slower than a dev Mac for render-heavy work. A web test rendering a 100+ item
|
||||
grid runs in about 1s locally but hit the **5s** vitest default on the CI VM (run 686) and went red
|
||||
for no code reason.
|
||||
|
||||
Bump the timeout on the specific test. Raising the global default hides genuine hangs across the whole
|
||||
suite in exchange for fixing one known-slow case.
|
||||
|
||||
## 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542)
|
||||
|
||||
`key: ci.verify-locally-ci-confirms` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt.
|
||||
**Signals:** ~9 min warm CI · docs-only PR seconds · CI VM 127 · commit status watch · local gate first · paths: n/a · issues: #542, #416
|
||||
**Mechanics:** `GET /api/v1/repos/timothy/ersatztv/commits/{sha}/status`; CI on VM 127.
|
||||
|
||||
A warm full CI run takes roughly **9 minutes**; a docs-only PR completes in seconds since ersatztv#416's
|
||||
docs-only skip. Neither duration justifies blocking on the run when the same gates already passed
|
||||
locally.
|
||||
|
||||
Watch the run by commit status rather than by polling the UI, and continue working. CI *confirms* the
|
||||
local verdict; it is not the first line of defence.
|
||||
|
||||
## 2026-07-21 — Independent cross-model review is mandatory on risky diffs; a skip is a stated, auditable exemption (#542)
|
||||
|
||||
`key: process.independent-review-rubric` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment.
|
||||
**Signals:** cross-model review rotation · correlated blindspots · skip rubric · self-exemption · review-only agent · quota exhaustion · reviewer does not implement · paths: n/a · issues: #542, #235, #250
|
||||
**Mechanics:** rotation — Codex reviews Claude-authored changes, Claude reviews Codex-authored changes; use whatever native integration is installed (client helpers like `codex:rescue` are optional accelerators, not protocol dependencies). On quota/tool exhaustion, fall back to a cold-context review-only agent in the current client and retry cross-model next window. Independent reviewers do review and stuck-diagnosis work only — never implementation on the same change they review.
|
||||
|
||||
The justification is correlated blindspots: independent model families miss *different* classes of
|
||||
defect, so a single reviewer on a risky diff is the gap, not a saving. #235 (a reviewer clearing a lock
|
||||
as sound because it never grepped for every producer) and #250 (a vacuous negative control that
|
||||
silently ran a stale dll) are both cases the second, differently-blind pass caught.
|
||||
|
||||
The skip clause is deliberately narrow and deliberately loud. A self-exemption is an auditable claim,
|
||||
not a vibe — silence is indistinguishable from forgetting, so the exemption must be written down where
|
||||
the merge record can be re-read later.
|
||||
|
||||
## 2026-07-21 — Local build/test gate + cold-context review run BEFORE the push, not after (#542)
|
||||
|
||||
`key: process.local-gate-before-push` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR.
|
||||
**Signals:** local gate before push · cold review before CI · CI is not the first line of defence · uncancellable run · queue pressure · paths: n/a · issues: #542
|
||||
**Mechanics:** local build/test gate + a scoped review-only agent over `origin/main...HEAD`; CI (armed on the PR head sha at PR-open) *confirms* the result.
|
||||
|
||||
Gitea runners should only ever see already-validated work. CI here is a scarce shared slow resource and
|
||||
a run cannot be cancelled on this Gitea version, so spending a run to surface a lint error or a
|
||||
review-catchable bug is pure waste plus queue pressure on every other in-flight session.
|
||||
|
||||
CI confirms; it is not the first line of defence. Treating it as the first line inverts the cost: the
|
||||
cheap fast private check is skipped in favour of the expensive slow shared one.
|
||||
|
||||
## 2026-07-21 — Review disagreement on a gate PR escalates to the frontier judge — and the proposed FIX escalates with it (#542)
|
||||
|
||||
`key: process.review-disagreement-frontier-judge` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** When independent reviews disagree on a gate PR, escalate to the frontier judge, and put the proposed fix approach in front of it — not just the disputed finding.
|
||||
**Signals:** review disagreement · frontier judge · escalate the fix not just the verdict · gate PR reconciliation · paths: n/a · issues: #542, #235
|
||||
**Mechanics:** n/a — escalation is a routing decision by the orchestrator.
|
||||
|
||||
The reconciliation is not only "who is right about the finding". On #235 the frontier model ratified
|
||||
the disputed High *and* caught that the orchestrator's own proposed fix (a per-source lock-or-skip over
|
||||
a fixed loop order) would deterministically starve the 2nd and later media source — prescribing instead
|
||||
the pattern that shipped.
|
||||
|
||||
That is the whole argument: a judge given only the verdict question would have returned "the High is
|
||||
real" and let a starving fix ship. Send the fix approach up with the finding.
|
||||
|
||||
## 2026-07-21 — "Make X consistent with Y" review findings are new code and get new-code scrutiny (#542)
|
||||
|
||||
`key: process.consistency-fix-new-code-scrutiny` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount.
|
||||
**Signals:** consistency fix · propagating the pattern propagates the bug · mount-debounce · timer/effect fire timing · paths: n/a · issues: #542, #216
|
||||
**Mechanics:** n/a — a review-attention rule.
|
||||
|
||||
Propagating a pattern propagates its bugs. A #216 review fix copied MediaBrowseScreen's mount-debounce
|
||||
selection-wipe race into SearchScreen instead of catching it — the finding said "be consistent", and
|
||||
consistency was achieved, including with the defect.
|
||||
|
||||
The mount question is the cheap general form of the check: most of this class hides in an effect or
|
||||
timer that fires once more than the author pictured.
|
||||
|
||||
## 2026-07-21 — A lock/channel "no cross-release" verdict must enumerate every producer via grep (#542)
|
||||
|
||||
`key: process.lock-ownership-enumerate-producers` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer.
|
||||
**Signals:** cross-release · lock ownership · channel message producers · scheduler enqueues too · grep the host project · paths: n/a · issues: #542, #235, #267, #250
|
||||
**Mechanics:** grep for every sender of the message type across the host project (e.g. all `Synchronize{X}Collections(` call sites), not just the controllers present in the PR diff. General form of "re-derive from a grep rather than enumerating from a list": see `sched.weighted-shuffle`.
|
||||
|
||||
On #235/#267 a cold fork cleared a new `scan-collections` per-provider lock as sound — "only the
|
||||
acquirer enqueues, so only its message triggers the scanner unlock" — having checked the three new
|
||||
controllers in the diff and stopped there. Codex grepped: `SchedulerService` also enqueues those
|
||||
messages periodically and unlocked, and `ScannerService`'s `finally` released the lock whenever held,
|
||||
so a scheduled scan cross-releases an API scan's lock (the #250 class).
|
||||
|
||||
Corollary: the independent pass earns its keep even when the first reviewer returns MERGEABLE. The
|
||||
first verdict was not careless — it was complete over the evidence it looked at, which is exactly the
|
||||
failure mode a second, differently-scoped reviewer catches.
|
||||
|
||||
## 2026-07-21 — Before deleting a "workaround", enumerate every behavior it provided (#542)
|
||||
|
||||
`key: process.enumerate-workaround-behaviors-before-deleting` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first.
|
||||
**Signals:** remove the workaround · double duty · second purpose · bounded version re-scoped · PENDING_GRACE_TICKS · paths: n/a · issues: #542, #232
|
||||
**Mechanics:** n/a — a scoping rule applied when reading a removal issue.
|
||||
|
||||
#232 said "remove the SPA `PENDING_GRACE_TICKS` grace-tick workaround now the API is honest". The grace
|
||||
ticks did double duty: compensating the lying-200 (genuinely obsolete) *and* absorbing the inherent
|
||||
queue→observed-active lag and fast-completion race (still needed). Removing them wholesale wedged the
|
||||
scan button disabled-until-reload for short scans; both the fork and Codex caught it.
|
||||
|
||||
The landing move is usually not "delete" or "keep" but a bounded version re-scoped to the purpose that
|
||||
still holds.
|
||||
|
||||
## 2026-07-21 — The shared tree at /Users/timothy/ersatztv is read-only and tells you nothing about `main` (#542)
|
||||
|
||||
`key: process.shared-tree-readonly` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Never commit in `/Users/timothy/ersatztv` and never read its `git log`/`git status`/HEAD to infer anything about `main` — work in a worktree off `origin/main`, which is the only source of truth.
|
||||
**Signals:** shared checkout · "main checkout" · worktree discipline · stale HEAD · origin/main is truth · node_modules copy · paths: `/Users/timothy/ersatztv`, `.claude/worktrees/`, `.worktrees/` · issues: #542
|
||||
**Mechanics:** `git worktree add -b <branch> <path> origin/main`; `git fetch origin main` and reference `origin/main` explicitly — the local `main` ref lags and nobody is obliged to update it. The one sanctioned use of the shared tree is read-only: copying `web/node_modules` out of it (keep that fresh — a stale copy broke typecheck in a #198 worktree).
|
||||
|
||||
There is no "main checkout". `/Users/timothy/ersatztv` is a shared mutable working tree whose HEAD is
|
||||
whatever the last session left there; its name lies. The rule holds even for a one-line docs fix — make
|
||||
a worktree.
|
||||
|
||||
This is a design flaw, not a discipline failure: do not "fix" it by telling sessions to check harder,
|
||||
because a check does not stay true. It broke for two different sessions on the same day, 2026-07-17,
|
||||
both doing the obvious thing. Session A assumed it was on `main` — correct when it pulled at 12:46,
|
||||
stale by 14:17 — and committed; the commit landed on the CI session's `ci/604-lane-rebalance`, 24
|
||||
seconds after that session's own commit at 14:17:28. Two writers, one tree; nothing tangled, by luck.
|
||||
Session B ran `git log` there, read that same branch's HEAD, and concluded `main` was "4 behind origin"
|
||||
— a phantom. A session-start `git status` *appears to confirm* the false assumption, which makes it
|
||||
worse than a plainly wrong claim.
|
||||
|
||||
Cross-reference `session.shared-checkout-refresh`, which covers the complementary failure mode: any
|
||||
FILE (including a pasted kickoff prompt) read out of that tree may be stale, and the session-end
|
||||
refresh script that mitigates it. This record covers the git-state failure mode only.
|
||||
|
||||
## 2026-07-21 — One worktree, one committing agent (#542)
|
||||
|
||||
`key: process.one-worktree-one-committing-agent` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back.
|
||||
**Signals:** parallel agents · concurrent commits · worktree per slice · read-only review agent · paths: n/a · issues: #542
|
||||
**Mechanics:** `git worktree add` a fresh tree per slice off the feature branch; merge the slices back into the feature branch when they land.
|
||||
|
||||
Read-only review agents sharing a tree are fine — the constraint is on *writers*. Sequence slices only
|
||||
where one genuinely depends on another's output; "backend-first narrow, SPA-wide after" worked well for
|
||||
#216.
|
||||
|
||||
## 2026-07-21 — Never commit or merge inside a worktree you did not create (#542)
|
||||
|
||||
`key: process.foreign-worktree-plumbing-merge` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead.
|
||||
**Signals:** sibling session worktree · branch already checked out · plumbing merge · read-tree · commit-tree · GIT_INDEX_FILE · paths: n/a · issues: #542
|
||||
**Mechanics:** `git read-tree -m <base> <ours> <theirs>` into a temporary `GIT_INDEX_FILE` → `git write-tree` → `git commit-tree -p <ours> -p <theirs>` → push the resulting commit to the branch ref. The owning session then `git pull`s it.
|
||||
|
||||
With several sessions on one machine, any given branch may already be checked out in a sibling
|
||||
session's worktree. Touching that checkout writes into another agent's working state; the plumbing
|
||||
route mutates only the ref, so the merge lands without disturbing the owner's tree.
|
||||
|
||||
## 2026-07-21 — Fix onto an unmerged feature branch by branching off it and FF-pushing (#542)
|
||||
|
||||
`key: process.branch-off-feature-branch` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building.
|
||||
**Signals:** unmerged feature branch · FF-push · path leak · sibling worktree recon · wrong tree edits · "0 new tests" · paths: n/a · issues: #542
|
||||
**Mechanics:** `git worktree add -b <fix-branch> <path> <feature-branch>`; land with a fast-forward push to the feature branch. Never touch a sibling worktree that has the branch checked out (see `process.foreign-worktree-plumbing-merge`).
|
||||
|
||||
The path-leak trap, hit on 2026-07-12 (#289 → PR#76): reconnaissance by `Read`ing a *sibling* worktree
|
||||
leaked that tree's absolute paths into subsequent `Edit`s, so the edits landed there rather than in the
|
||||
fresh worktree. `dotnet` then built the unchanged tree, and the tell was a build reporting "0 new
|
||||
tests". A `git status` in the new worktree before the first build catches this in one command.
|
||||
|
||||
## 2026-07-21 — Bound parallel builds by FREE RAM, never regenerate goldens (#542)
|
||||
|
||||
`key: process.build-concurrency-limits` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`.
|
||||
**Signals:** build concurrency · memory_pressure · RAM starvation · fan-out cap · golden regeneration · paths: n/a · issues: #542
|
||||
**Mechanics:** `memory_pressure -Q` — under 20% free, do not launch more build agents; under 10%, pause or stagger. Host is an M4, 10 cores, 16 GB.
|
||||
|
||||
Parallelize by default when slices are genuinely disjoint: use up to the client's concurrency limit,
|
||||
normally 2–3 delegated agents plus the orchestrator. Fan out bounded recon early, but do not invent
|
||||
artificial slices or exceed a runtime-imposed agent cap. CPU load spikes during builds are benign and
|
||||
are not a stop signal; the resource that actually runs out is memory — the historic crash was RAM
|
||||
starvation from an 8–9-way fan-out, which is why 5+ concurrent builds is never acceptable.
|
||||
|
||||
Separately: never set the golden-regeneration env vars. Regenerating goldens to make a diff disappear
|
||||
hides exactly the change the golden net exists to surface.
|
||||
|
||||
## 2026-07-21 — A dropped subagent connection is transient; resume, don't restart (#542)
|
||||
|
||||
`key: process.subagent-drop-resume` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives.
|
||||
**Signals:** subagent disconnect · connection drop · laptop sleep · SendMessage resume · paths: n/a · issues: #542
|
||||
**Mechanics:** n/a — client-side resume.
|
||||
|
||||
Restarting the agent from scratch discards context the agent still holds; the drop is at the transport,
|
||||
not the agent.
|
||||
|
||||
## 2026-07-21 — Every open issue carries a `priority:` label — run the H12 audit at session end (#542)
|
||||
|
||||
`key: process.issue-qualification-audit` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Run `scripts/issue-qualification-audit.sh` at session end and label everything it flags, including issues you filed that session.
|
||||
**Signals:** issue qualification · priority label · queue tiering · unqualified issues · H12 · session-end check · paths: `scripts/issue-qualification-audit.sh` · issues: #542, #312
|
||||
**Mechanics:** `scripts/issue-qualification-audit.sh` (H12, ersatztv#312) lists OPEN issues missing a `priority:` label. It is fail-open without Gitea creds and advisory — exit 1 when any issue is unqualified. Sibling to H11 (both #311/#312).
|
||||
|
||||
Queue tiering keys off `priority:` and gate labels, so an issue without one is simply invisible to the
|
||||
selector — it is filed but will never be picked. "Fully qualified" means the issue has a
|
||||
`priority: {high,medium,low}` label, which signals triage ran; gate-vs-backlog is then derivable from
|
||||
the `review` label and milestone. A milestone is **not** required — backlog is unmilestoned.
|
||||
|
||||
## 2026-07-21 — Claim with `in-progress` before working; claiming is not collision safety (#542)
|
||||
|
||||
`key: process.parallel-session-claim` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Apply the `in-progress` label before starting an issue, and still read its dependency notes before touching shared surfaces — a claim prevents duplicate pickup, not overlapping code changes.
|
||||
**Signals:** parallel sessions · in-progress label · claim race · dependency notes · shared surfaces · lore pruning · paths: `docs/handoffs/chicorytv-issue-queue.md` · issues: #542
|
||||
**Mechanics:** `in-progress` label on the Gitea issue. The tiny read→claim race window is accepted; the later claimant backs off. Runner topology: two runners (ci-runner VM 127 + bumblebee-runner), 4 slots total.
|
||||
|
||||
The critical nuance is what a claim does *not* buy. Two sessions on non-overlapping issues can still
|
||||
collide in the same files. Before touching a shared surface, read the issue's dependency notes ("#234
|
||||
after #231", "coordinate with #215").
|
||||
|
||||
When editing the standing lore/handoff doc, prune covered and stale bullets rather than appending — it
|
||||
is not append-only, and git keeps the history. `git pull --rebase` before committing it, since it is
|
||||
the single most contended file across parallel sessions.
|
||||
|
||||
## 2026-07-21 — BOM/format pre-push detection: use the `xxd` loop, and run `dotnet format --include` under `bash -c` (#542)
|
||||
|
||||
`key: process.bom-format-detection-recipe` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Before any push touching `.cs`, detect BOMs with the `xxd` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh.
|
||||
**Signals:** UTF-8 BOM · `efbbbf` · `dotnet format --verify-no-changes` · `--include` · `mapfile` · zsh vs bash · #311 format gate · detector verification · paths: `ErsatzTV.sln`, `.editorconfig` · issues: #542, #311, #70, PR #402, PR #405
|
||||
**Mechanics:** Local pre-push shell; CI's format job recipe (`ci.format-gate-folder-mode`). The BOM *policy* is `release.format-as-you-touch-rebase`; this record is the mechanics only.
|
||||
|
||||
Detection loop, verbatim:
|
||||
|
||||
```bash
|
||||
for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done
|
||||
```
|
||||
|
||||
**Verify your detector.** An `od -An -c | grep '357 273 277'` check reported all-clean while 19 files
|
||||
were actually dirty. A detector that can only say "ok" is worse than none — use the `xxd` form or read
|
||||
the bytes directly, and distrust a clean result you didn't prove can go dirty.
|
||||
|
||||
**`dotnet format --include` DOES work here** — an earlier note claiming it silently no-ops was WRONG.
|
||||
The apparent no-op was **the shell**: CI's recipe uses `mapfile`, which is bash-only, and the default
|
||||
shell here is zsh → empty array → zero files → exit 0. Working form:
|
||||
|
||||
```bash
|
||||
bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs")
|
||||
dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"' # exit 0 = gate passes
|
||||
```
|
||||
|
||||
## 2026-07-21 — Run `scripts/e2e-local.sh` against a FRESH config dir; a reused one hangs the readiness probe (#542)
|
||||
|
||||
`key: testing.e2e-local-fresh-config-dir` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Always point `scripts/e2e-local.sh` at a fresh config dir — a reused one never emits the probe's ready line and the script kills a healthy server.
|
||||
**Signals:** `READY_LINE` · `Done migrating search index` · `Search index is already version 50` · readiness probe timeout · e2e-local hang · paths: `scripts/e2e-local.sh`, `docs/e2e-local.md` · issues: #542, #533, #502, #524
|
||||
**Mechanics:** The script greps one fixed `READY_LINE` — there is no "wait for either line" option today.
|
||||
|
||||
The probe waits for `Done migrating search index`, which only a **fresh** config emits. A reused config
|
||||
logs `Search index is already version 50` instead, so the line never arrives, the probe times out, and
|
||||
the script kills a perfectly healthy server — a failure that reads as a broken build.
|
||||
|
||||
Surfaced in the #502 session (two failed launches before the cause was obvious) and recovered from the
|
||||
old tracker's comment log by the #524 triage. `docs/e2e-local.md` documents the ready line and
|
||||
recommends a fresh dir, but not this failure mode. Widening the probe to
|
||||
`Done migrating search index|Search index is already version` is tracked as **#533**.
|
||||
|
||||
## 2026-07-21 — Channel branding is NOT testable through the troubleshooting-playback API (#542)
|
||||
|
||||
`key: testing.troubleshoot-path-cannot-test-branding` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding.
|
||||
**Signals:** `PrepareTroubleshootingPlaybackHandler` · synthetic channel · `Artwork = []` · `ChannelId > 0` branch · watermark · channel logo · bug overlay · paths: `ErsatzTV.Application` troubleshooting handlers · issues: #542
|
||||
**Mechanics:** n/a — a property of the handler, not a toggle.
|
||||
|
||||
`PrepareTroubleshootingPlaybackHandler` builds a **synthetic** channel with `Artwork = []`, and its
|
||||
`ChannelId > 0` branch ignores the media item. Both together mean no branding state ever reaches the
|
||||
pipeline through that path.
|
||||
|
||||
So the troubleshoot endpoint is a false witness for this class: it exercises the transcode pipeline but
|
||||
not the overlay inputs. Route branding verification through a real channel playout instead.
|
||||
|
||||
## 2026-07-21 — Playwright-MCP: curl download endpoints, never open a tab or `window.open` (#542)
|
||||
|
||||
`key: testing.playwright-mcp-download-and-recovery` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session.
|
||||
**Signals:** Playwright MCP · file download endpoint · window.open · browser tab stall · `ms-playwright-mcp` · paths: n/a · issues: #542
|
||||
**Mechanics:** `pkill -f ms-playwright-mcp` then re-drive from a fresh MCP session.
|
||||
|
||||
Download endpoints hand the browser a file rather than a page; the tab has nothing to snapshot and the
|
||||
tool waits on navigation that never completes. curl gives you the bytes and the status code directly.
|
||||
|
||||
Repeated stalls are a wedged MCP browser process, not a slow app — kill it and restart rather than
|
||||
retrying the same call.
|
||||
|
||||
## 2026-07-21 — Launch Codex cheap workers via `codex exec`, not `spawn_agent` (#542)
|
||||
|
||||
`key: process.codex-cheap-worker-launch` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings.
|
||||
**Signals:** codex exec · `--ephemeral` · `spawn_agent` · `gpt-5.4-mini` · `gpt-5.1-codex-mini` HTTP 400 · `model_reasoning_effort` · `~/.codex/models_cache.json` · paths: `~/.codex/models_cache.json` · issues: #542
|
||||
**Mechanics:** Local `codex` CLI on this ChatGPT-authenticated host. Tested 2026-07-14.
|
||||
|
||||
The native `spawn_agent` interface currently exposes no model or effort selector, so it parallelises at
|
||||
full cost. Launch a separate worker instead:
|
||||
|
||||
```
|
||||
codex exec --ephemeral --color never -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only -C <repo> "<hard-capped scope contract>"
|
||||
```
|
||||
|
||||
GPT-5.4-Mini is the current supported small/cost-efficient profile on this host; the older
|
||||
`gpt-5.1-codex-mini` guess fails with **HTTP 400**. Keep `low` whenever any shell/MCP/web tool is
|
||||
exposed; reserve `minimal` for explicitly tool-free synthesis over already-supplied bounded evidence.
|
||||
On model rollover, inspect the visible entries in `~/.codex/models_cache.json` rather than guessing
|
||||
names, then update this record once verified.
|
||||
|
||||
## 2026-07-21 — Live-E2E runs BEFORE the push, and downloads are curled, not browsed (#542)
|
||||
|
||||
`key: testing.live-e2e-prepush-timing` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab.
|
||||
**Signals:** live E2E timing · pre-push validation · `scripts/e2e-local.sh` · download endpoint · browser tab · paths: `scripts/e2e-local.sh`, `docs/e2e-local.md` · issues: #542
|
||||
**Mechanics:** Seeding recipe: `docs/e2e-local.md` → "Seeding a local TV library for E2E" (tiny ffmpeg testsrc MKVs + `LibraryPath` SQL rows + scan). The *requirement* itself is `release.live-e2e-required`; this record adds only the timing and the download rule.
|
||||
|
||||
Live-E2E after the push wastes a CI run to learn something the local harness would have said first —
|
||||
and on this Gitea a run cannot be cancelled. Fold E2E findings into the branch while nothing is pushed.
|
||||
|
||||
Download endpoints must be curled for the same reason as the Playwright rule: a tab has no page to
|
||||
settle on and the browser tool hangs.
|
||||
|
||||
## 2026-07-21 — The PR routine is a fixed sequence: validate locally, then push, then only follow-up commits (#542)
|
||||
|
||||
`key: process.pr-routine-sequence` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Worktree off origin/main → implement → regenerate API artifacts → full local tests + cold review + live-E2E ALL before the push → push, open PR, arm the CI monitor at open → fixes after the push are follow-up commits, never amend/force-push.
|
||||
**Signals:** PR routine · worktree off origin/main · `./scripts/update-openapi.sh` · `npm run generate:api` · cold-context review · CI monitor at PR open · no force-push on a pushed branch · derived-consent merge gate · paths: `scripts/update-openapi.sh`, `scripts/e2e-local.sh` · issues: #542
|
||||
**Mechanics:** CI watch by commit status: `/api/v1/repos/timothy/ersatztv/commits/{sha}/status`. Merge via the derived-consent gate (`pretooluse-merge-consent.sh`).
|
||||
|
||||
The sequence: create a worktree off `origin/main`; implement; for **API changes build the app project
|
||||
FIRST**, then `./scripts/update-openapi.sh`, then `npm run generate:api` (order matters — the generator
|
||||
reads the built app's spec). Then run the full local test pass, a cold-context adversarial review
|
||||
scoped to "review only", and live-E2E for UI/write-path changes — **all before the push**. Fold
|
||||
pre-push fixes into the branch; amend/reword is fine while nothing is pushed.
|
||||
|
||||
Then push, open the PR, and arm the CI monitor **at open**, not at the end. Once the branch is pushed,
|
||||
every further fix — a post-push cross-model pass, or a CI-surfaced issue — lands as a **follow-up
|
||||
commit**; never amend or force-push a pushed branch. On green, merge through the derived-consent gate
|
||||
and write the structured close comments the applicable `AGENTS.md` / `CLAUDE.md` protocol requires.
|
||||
|
||||
## 2026-07-21 — Harden a soon-to-grow feature with a runtime posture, not a hardcoded clamp (#542)
|
||||
|
||||
`key: process.harden-with-runtime-posture-not-clamp` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first.
|
||||
**Signals:** runtime posture · feature toggle · `ERSATZTV_ALLOW_WRITES` · read-only default · hardcoded clamp · roadmap end-state · paths: n/a · issues: #542, #289, #58
|
||||
**Mechanics:** `ERSATZTV_ALLOW_WRITES` env var, default `false`. See `mcp.server-foundation`.
|
||||
|
||||
The MCP hardening in #289 could have shipped a GET-only clamp. But the MCP's final design (#58) is read
|
||||
**and** write, so that clamp would have been ripped out within a release — churn plus a window where
|
||||
the removal, not the original fix, is the risky change.
|
||||
|
||||
A runtime read-only posture (`ERSATZTV_ALLOW_WRITES`, default false) gives the same safety today and
|
||||
grows into the intended design without a revert. The generalisable step is the cheap one: check the
|
||||
feature's issue for its end-state *before* choosing between a switch and a wall.
|
||||
|
||||
## 2026-07-21 — Gitea's `?milestones=` issue filter silently no-ops on names containing `:` or `+` (#542)
|
||||
|
||||
`key: ci.gitea-milestone-filter-noop` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`.
|
||||
**Signals:** `GET /issues?milestones=` · milestone name filter · returns the whole open-issue list · `:` or `+` in a milestone title · mis-tiered issues · count looks like "all open issues" · paths: `scripts/select-queue.sh` · issues: #542, #77, #72
|
||||
**Mechanics:** `?state=open&type=issues&limit=50`, then filter in the client, e.g. `[i for i in issues if (i['milestone'] or {}).get('title') == NAME]`. `scripts/select-queue.sh` already does this; the trap is for anyone writing a *new* query.
|
||||
|
||||
The filter does not error on a name it cannot round-trip — it returns the **entire open-issue list** as
|
||||
though every issue matched. Milestone titles here routinely contain the offending characters (e.g.
|
||||
`Scheduling: refactor + distribution`), so the failure is the normal case, not an edge case.
|
||||
|
||||
Anything trusting that response mis-tiers issues: during #77 selection it made unmilestoned #72 look
|
||||
like a member of the milestone and hid #77's true sibling set. **The tell is a result count suspiciously
|
||||
equal to the total number of open issues** — if a filtered query returns everything, the filter silently
|
||||
failed; re-derive membership locally rather than reasoning about the result.
|
||||
|
||||
The dependencies API is unaffected: `POST /issues/{n}/dependencies` with `{"owner","repo","index"}` sets
|
||||
blocked-by correctly, though the bare `{index}` form returns 201 without reliably attaching — verify
|
||||
with the GET. (That last sentence is carried forward from an earlier revision of the handoff doc,
|
||||
commit `f93458c7`, where it was dropped by a later prune rather than disproved.)
|
||||
@@ -79,7 +79,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` |
|
||||
| **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) |
|
||||
| **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` |
|
||||
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) |
|
||||
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand — OnDemand is the per-channel "resume where I left off" freeze/thaw mode, #68; see `channels.md` → On-demand resume), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) |
|
||||
| **Channel health / `PlayoutCount`** (#72) | Whether a channel can play at all. `PlayoutCount` (channel's own playouts, **plus the mirror source's** when `PlayoutSource is Mirror` — computed by `Mapper.GetPlayoutsCount`) rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`; `0` ⇒ the channel can never play, rendered as a "No playout" badge + a matching **No playout** filter on the channels list (both name only the one fault the API can prove — a broader "Problems" label would read as a false all-clear to a user whose *other* fault classes below are uncomputed). It is a **raw fact, not a status enum** — see `decisions.md` 2026-07-17. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". Empty-schedule, broken-source and auto-tuned-vs-user origin are deliberately **not** computed (see that decision entry for why each is unsafe today). | `Channel.Playouts` | `/app/channels` (read-only signal) |
|
||||
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. The SPA's JSON guide grid is built by the sibling `GetChannelGuideDataHandler`. Both the JSON guide (`ChannelGuideChannelResponseModel`) and the channels-list DTO (`ChannelResponseModel`) expose a rooted, directly-usable `Logo` URL (#464) via `Mapper.GetLogoUrl` — `/iptv/logos/{file}` for an uploaded logo, the absolute URL passed through for an external one, `null` when unset (SPA then draws the generated initials "bug"). | `GetChannelGuideHandler`, `GetChannelGuideDataHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
|
||||
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
|
||||
|
||||
@@ -15,7 +15,7 @@ history in `docs/decisions/archive/startup.md`). Do not read it for current queu
|
||||
The ChicoryTV rewrite + go-live arc is **COMPLETE** and closed out. The project is now in
|
||||
**maintenance / backlog mode**. This is a *terminal* fact (the arc will not re-open), so it belongs
|
||||
in this standing file — unlike any "current frontier issue", which must never be written here (read
|
||||
it live; see the queue-drift lesson in the archived section below).
|
||||
it live — status embedded in prose always drifts).
|
||||
|
||||
So a fresh session stops re-discovering this every time:
|
||||
- The **arc / gate / open-`review` tiers are normally EMPTY now.** An empty arc frontier is the
|
||||
@@ -115,8 +115,8 @@ never orchestrator-tier work:
|
||||
`GET /issues/{n}/dependencies` on every candidate (dropping any with an OPEN blocker), tiers by
|
||||
LOCAL `.milestone.state`/`review`/`priority:` filters (never the server `?milestones=` name filter,
|
||||
which no-ops on `:`/`+`), and orders by (tier, priority, issue#). **Its DEPS/tiering/ordering are
|
||||
deterministic — trust them, do not re-derive them by hand** (see the archived section below for why
|
||||
that used to be necessary and isn't anymore). It also raises two JUDGMENT flags it deliberately does
|
||||
deterministic — trust them, do not re-derive them by hand** (the hand-derivation failure modes the
|
||||
script encodes are recorded at `ci.gitea-milestone-filter-noop`). It also raises two JUDGMENT flags it deliberately does
|
||||
not decide — `CLAIM?` (a claim can precede the label) and `UMBRELLA?` (an epic whose children are the
|
||||
real pickups) — resolve ONLY those by reading the flagged issue's comments/body. The script does not
|
||||
rank an arc tier (there is no active arc right now — maintenance/backlog mode, see above); if a new
|
||||
@@ -233,6 +233,10 @@ Then work the queue:
|
||||
record` (template below) on each issue you closed or substantially progressed; remove your
|
||||
`in-progress` labels; and complete the per-issue Task Completion Protocol from the applicable
|
||||
`AGENTS.md` / `CLAUDE.md` instructions (including the `done` workflow when required).
|
||||
6. **Leave the shared checkout fresh** (H13): run `scripts/refresh-shared-checkout.sh`. It
|
||||
fast-forwards `/Users/timothy/ersatztv` to `origin/main` and refreshes `web/node_modules` if the
|
||||
lockfile moved; it refuses and changes nothing unless that tree is on a clean, non-ahead `main`.
|
||||
A NO-OP is a normal result — report it and carry on.
|
||||
|
||||
## Closing record (session-end / issue-close artifact)
|
||||
|
||||
@@ -251,363 +255,100 @@ is both the human-readable summary and the per-issue unit MemPalace mines for re
|
||||
```
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- Work in worktrees off `origin/main` — ALWAYS, including for a one-line docs commit. `/Users/timothy/ersatztv`
|
||||
is a **shared mutable tree, not a `main` mirror**: never commit in it, and never read its HEAD/`git log`
|
||||
to learn anything about `main` (you'll get another session's branch). Its only sanctioned use is
|
||||
**read-only**: copying `web/node_modules` out of it. `origin/main` is the source of truth — `git fetch
|
||||
origin main` and reference it explicitly. See the lore bullet for the two sessions this bit on 2026-07-17.
|
||||
- PARALLELIZE BY DEFAULT when there are genuinely disjoint slices: use up to the client's concurrency
|
||||
limit, normally 2–3 delegated agents plus the orchestrator. Fan out bounded recon early, but do not
|
||||
invent artificial slices or exceed a runtime-imposed agent cap. At most 3–4 concurrent
|
||||
dotnet/npm builds are fine on this Mac (M4, 10 cores, 16 GB); the go/no-go signal is FREE
|
||||
RAM, not CPU load (`memory_pressure -Q`: <20% free → don't launch more build agents; <10% →
|
||||
pause/stagger). CPU load spikes during builds are benign. Never 5+ builds (the historic
|
||||
crash was RAM starvation from an 8–9-way fan-out). NEVER set ETV_UPDATE_GOLDENS.
|
||||
- Never two committing agents on ONE worktree — give each parallel slice its own worktree
|
||||
branched off the feature branch and merge back. Sequence only where a slice genuinely depends
|
||||
on another's output (backend-first narrow, SPA-wide after worked well for #216).
|
||||
- **BATCH YOUR PUSHES — you cannot cancel a CI run** (operator-requested, 2026-07-17). Hold review
|
||||
fixes, doc corrections and format fixes locally, verify, and push **once**; each push spawns a run
|
||||
that orphans the previous one on a 4-slot runner. Cancellation is impossible from the agent side on
|
||||
this Gitea (**1.25.4**): `POST /api/v1/.../actions/runs/{id}/cancel` → **404**, MCP
|
||||
`actions_run_write` `cancel_run` → **404**, and the web-UI route needs a session+CSRF that does not
|
||||
script (login 303s, no session cookie). **Only the operator can cancel, in the browser** — so if you
|
||||
must supersede a live run, SAY SO explicitly instead of leaving it burning. (`dispatch_workflow` is
|
||||
a different route and still works for re-triggering a **main** run.) Corrects the older "superseded
|
||||
runs drain on their own" note below: they do finish, but they hold a slot while doing it.
|
||||
- **A lone `decisions lifecycle` red is a KNOWN INFRA FLAKE — do NOTHING** (operator-stated,
|
||||
2026-07-19). When it is the **only** red: do not investigate it, and do **not** create a new run or
|
||||
commit to clear it — no rebase, no `--amend`, no no-op push. **The operator reruns that single job
|
||||
from the Gitea UI.** Report it as a known flake and carry on; only if *other* jobs are red too does
|
||||
the run deserve diagnosis. Same family as the killed-job rule below (a spurious single-job red is
|
||||
cleared by the operator's selective rerun, never by pushing — and pushes can't be cancelled, see the
|
||||
batching constraint above). **The trap is that a convincing local explanation is always available.**
|
||||
On #473/PR #479 the job went red just after `main` landed its own `decisions.md` entry, so "mine is
|
||||
no longer at EOF, I must rebase" looked airtight — I rebased, and it went red **again** on a head
|
||||
whose diff was a verified pure EOF append with zero deleted lines. A rebase that provably satisfied
|
||||
the gate's stated rule did not turn it green, which is the proof the red was never about the diff.
|
||||
**Check this rule before theorising about the cause.**
|
||||
- **Do NOT gate or throttle your push on host health — trust the Gitea build queue.** The runners were
|
||||
retuned for stability (operator, 2026-07-17); queueing is the queue's job, not yours. Don't SSH to
|
||||
bumblebee to sample load/RAM before pushing, and don't hand-schedule around other sessions' runs. Batch
|
||||
your pushes (above) because orphaned runs can't be cancelled — not because the host needs protecting.
|
||||
- **An infra-shaped red under host pressure is not a code failure — check the host before diagnosing the
|
||||
diff** (2026-07-17). While bumblebee was saturated, two unrelated jobs died in ways that look like code
|
||||
bugs: `EF migration integrity` failed in ~11ms inside **`Setup .NET`** with
|
||||
`remove /root/.cache/act/…/.gitignore: no such file or directory` (act's shared cache), and main's
|
||||
`Build & push image` died on buildx `DeadlineExceeded: failed to compute cache key` after ~210s of
|
||||
retries. Both re-ran green once the host recovered; the migration job had no model change to test in the
|
||||
first place. Tell: the failure is in a *setup/cache* step, before your code compiles. Don't file a CI bug
|
||||
off one sample under pressure — you'll send the next session chasing a phantom (this nearly got filed as
|
||||
#390 lane-rebalance fallout, which the evidence did not support).
|
||||
- **Before any push touching `.cs`, check the touched set for a UTF-8 BOM.** The #311 gate is
|
||||
fix-as-you-touch: a legacy file's inherited BOM becomes *your* problem the moment you touch it, and
|
||||
`charset=utf-8` means no BOM.
|
||||
`for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done`
|
||||
Strip any hit (1-line diff, zero content change). Bit **two sessions on 2026-07-17** — PR #405 (6
|
||||
files) and #70/PR #402 (19 files, where Python `io.open(..., encoding='utf-8-sig')` wrote BOMs back
|
||||
while "preserving existing style"). **Verify your detector**: an `od -An -c | grep '357 273 277'`
|
||||
check reported all-clean while 19 files were dirty — a detector that can only say "ok" is worse than
|
||||
none. Use the `xxd` form or read bytes directly.
|
||||
**`dotnet format --include` DOES work here** (corrected 2026-07-17, #70/PR #402 — an earlier note
|
||||
claiming it silently no-ops was WRONG). The apparent no-op was **the shell**: CI's recipe uses
|
||||
`mapfile`, which is **bash-only**, and the default shell here is **zsh** → empty array → zero files
|
||||
→ exit 0. Run it under `bash -c`:
|
||||
```bash
|
||||
bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs")
|
||||
dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"' # exit 0 = gate passes
|
||||
```
|
||||
- Merge consent is DERIVED, not asked: the H6/H10 gate (`pretooluse-merge-consent.sh`) auto-grants a
|
||||
merge when the linked issue's `## Done-when` boxes are all ticked, a fresh positive `Review-verdict`
|
||||
references the current head, and CI is green — no conversational confirmation on that satisfied path
|
||||
(ersatztv#314). Confirm in-conversation only when the gate ASKS (state not derivable: no creds, no
|
||||
linked issue, no Done-when/verdict, Gitea down). Prior pre-approvals still do NOT carry over.
|
||||
- **Local gate + cold review run BEFORE the push to Gitea CI, not after** (Timothy, 2026-07-17):
|
||||
run the local build/test gate AND a cold-context adversarial review agent (SCOPED "review only")
|
||||
over the diff, fold any fixes, and only THEN push / open the PR. Gitea runners should only ever
|
||||
see already-validated work — CI is a scarce shared slow resource and a run cannot be cancelled on
|
||||
this Gitea, so spending a run to surface a lint error or a review-caught bug is pure waste + queue
|
||||
pressure. CI (armed at PR-open, below) *confirms*; it is not the first line of defence.
|
||||
- Arm a CI monitor on the PR head sha AT PR-OPEN (commit-status endpoint), not at the end.
|
||||
- Live-E2E via scripts/e2e-local.sh (also before the push for write-path / UI changes); NEVER
|
||||
exercise download endpoints via browser tabs (curl them).
|
||||
- **Work in a worktree off `origin/main` — always**, including a one-line docs commit. Never commit in
|
||||
`/Users/timothy/ersatztv` and never read its git state to learn about `main`. → `process.shared-tree-readonly`
|
||||
- **One committing agent per worktree.** Give each parallel slice its own; read-only review agents may
|
||||
share. → `process.one-worktree-one-committing-agent`
|
||||
- **Never commit or merge inside a worktree you did not create** — land on a sibling-owned branch with a
|
||||
plumbing merge. → `process.foreign-worktree-plumbing-merge`
|
||||
- **Parallelize by default** on genuinely disjoint slices (normally 2–3 delegated agents). Cap builds at
|
||||
3–4 and gate on FREE RAM, not CPU. **Never set `ETV_UPDATE_GOLDENS`.** → `process.build-concurrency-limits`
|
||||
- **Local gate + cold-context review BEFORE the push**, never after. → `process.local-gate-before-push`
|
||||
- **Independent review is mandatory** for locks/concurrency, auth/security, API write-path handlers, DB
|
||||
migrations, or >~150 changed C# lines; a skip must be stated with its reason. → `process.independent-review-rubric`
|
||||
- **Batch your pushes — you cannot cancel a CI run.** Only the operator can cancel, in the browser.
|
||||
→ `ci.batch-pushes-no-cancel-route`
|
||||
- **Do not gate your push on host health** — trust the build queue. → `ci.no-host-health-gating`
|
||||
- **Arm the CI monitor at PR-open**, on the head sha. → `ci.monitor-armed-at-pr-open`
|
||||
- **Before any push touching `.cs`, BOM-check the touched set** and run the format gate under `bash -c`.
|
||||
→ `process.bom-format-detection-recipe`
|
||||
- **Keep a PR branch current by REBASING on `origin/main`** — never merge main in; regenerate (never
|
||||
hand-resolve) generated artifacts after a conflicted rebase. → `release.format-as-you-touch-rebase`
|
||||
- **Live-E2E via `scripts/e2e-local.sh` before the push** for write-path/UI changes; curl download
|
||||
endpoints, never browser tabs; use a fresh config dir.
|
||||
→ `testing.live-e2e-prepush-timing`, `testing.e2e-local-fresh-config-dir`
|
||||
- **Merge consent is DERIVED, not asked.** The H6/H10 gate auto-grants when the `## Done-when` boxes are
|
||||
ticked, a fresh `Review-verdict` references the current head, and CI is green; confirm in conversation
|
||||
only when the gate ASKS. Prior pre-approvals never carry over. → `release.merge-consent-autogrant`
|
||||
- **After the push, fixes are follow-up commits** — never amend or force-push a pushed branch.
|
||||
→ `process.pr-routine-sequence`
|
||||
|
||||
---
|
||||
|
||||
# Lessons / workflow lore (STANDING workflow/orchestration rules only)
|
||||
|
||||
> **Scope**: this section is *how we work* (orchestration, CI, review, cross-session hygiene) —
|
||||
> not engineering decisions. **Every "why the code does X" decision lives in `docs/decisions.md`**
|
||||
> (+ `api-conventions.md` §7a/b/c for the concurrency/side-effect mechanics, `spa-conventions.md`,
|
||||
> `e2e-local.md`). Do NOT re-record a code/API/SPA decision here — it will duplicate and drift.
|
||||
> Pruned 2026-07-12: issue-specific engineering bullets now covered in those docs, and dead
|
||||
> Blazor-parity process notes, were removed (git history has them). Pruned 2026-07-21: pre-script
|
||||
> selector lore and the #237-specific queue-drift narrative moved to the archived section below —
|
||||
> the mechanism they describe no longer exists.
|
||||
> **Scope**: *how we work* — orchestration, CI triage, review routing, cross-session hygiene. Each rule
|
||||
> below is one or two lines plus its decision `key:`; the evidence, the incident that produced it and the
|
||||
> full rationale live in that record (`docs/decisions/workflow-process.md` for most of them). **Keep it
|
||||
> that way** — this file is pasted into every session's kickoff, so a paragraph of narrative here is a tax
|
||||
> paid by every future session, while a record is retrieved only when it is needed (#542).
|
||||
>
|
||||
> Editing: prune covered and stale bullets rather than appending — this is not append-only and git keeps
|
||||
> the history. If you add a rule, write the record first and cite it here.
|
||||
|
||||
- READ docs/README.md → the convention docs FIRST; point recon/implementer agents at specific
|
||||
doc sections. Only recon the task-specific delta.
|
||||
- **The mechanical selection checks live in `scripts/select-queue.sh` — run it instead of
|
||||
re-deriving them by hand.** It does the deterministic work in code (dependency exclusion via
|
||||
`GET /dependencies`, LOCAL `.milestone.state`/`review`/`priority:` tiering, (tier, priority, issue#)
|
||||
ordering, `in-progress`/`parked`/PR exclusion) and only *flags* `CLAIM?`/`UMBRELLA?` for judgment.
|
||||
**Trust its DEPS/tiering/ordering; do the focused recheck only on the flagged rows.** If you extend
|
||||
the tiers/filters, update the script first, then these bullets. (See the archived section below for
|
||||
the pre-script failure modes that motivated writing it, if you need the history.)
|
||||
- **Codex cheap-worker launch (tested 2026-07-14)**: the native `spawn_agent` interface currently
|
||||
has no model/effort selector, so it provides parallelism but not cost savings. For bounded,
|
||||
tool-bearing selector/recon work, launch a separate worker with
|
||||
`codex exec --ephemeral --color never -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only
|
||||
-C <repo> "<hard-capped scope contract>"`. GPT-5.4-Mini is the current supported small/cost-efficient
|
||||
profile on this ChatGPT-authenticated host; the older `gpt-5.1-codex-mini` guess fails with HTTP 400.
|
||||
Keep `low` whenever any shell/MCP/web tool is exposed; reserve `minimal` for explicitly tool-free
|
||||
synthesis over already-supplied bounded evidence. If the model rolls over, inspect the visible entries
|
||||
in `~/.codex/models_cache.json` instead of guessing names, then update this dated note once verified.
|
||||
- Keep the MAIN checkout's `web/node_modules` fresh (`npm install` after pulling a PR that
|
||||
adds a dep) — worktrees copy it, and a stale copy broke typecheck in a #198 worktree.
|
||||
- **Playwright-MCP E2E: never open tabs/window.open for file-download endpoints** — curl them.
|
||||
If browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session.
|
||||
- **THERE IS NO "main checkout". `/Users/timothy/ersatztv` is a shared mutable working tree whose HEAD
|
||||
is whatever the last session left there — it is NOT a `main` mirror, and its name lies.** Treat it as
|
||||
**read-only, and never as a source of truth**:
|
||||
- **Never commit in it** — not even a one-line docs fix. Need a commit on `main`? Make a worktree off
|
||||
`origin/main` (`git worktree add -b <branch> <path> origin/main`) and commit there.
|
||||
- **Never read `git log` / `git status` / `HEAD` there and infer anything about `main`** — you will be
|
||||
reading some other session's branch.
|
||||
- **`origin/main` is the only source of truth.** `git fetch origin main` and reference `origin/main`
|
||||
explicitly. Do not trust the local `main` ref either (it lags; nobody is obliged to update it).
|
||||
- Its **one sanctioned use is read-only**: copying `web/node_modules` into your worktree (keep it
|
||||
fresh — a stale copy broke typecheck in a #198 worktree).
|
||||
**Orientation**
|
||||
- Read `docs/README.md` → the convention docs first; point agents at named sections, recon only the
|
||||
task-specific delta. → `docs.convention-docs-session-start`
|
||||
- Run `scripts/select-queue.sh` for queue selection; trust its deps/tiering/ordering and resolve only its
|
||||
`CLAIM?`/`UMBRELLA?` flags. → `startup.parallel-orientation`
|
||||
- Claim with `in-progress` before working — but a claim prevents duplicate *pickup*, not overlapping code
|
||||
changes. → `process.parallel-session-claim`
|
||||
|
||||
**This is a design flaw, not a discipline failure — do not "fix" it by telling sessions to check
|
||||
harder.** A check does not stay true. Worktree discipline is healthy (10 feature worktrees on
|
||||
2026-07-17: Claude under `.claude/worktrees/`, Codex under `.worktrees/`, some in `/private/tmp/`);
|
||||
the shared tree is the single place the model breaks, and it broke for **two different sessions on the
|
||||
same day, both doing the obvious thing**:
|
||||
- Session A assumed it was on `main` (true at 12:46 when it pulled — **correct when made**, stale by
|
||||
14:17) and committed → the commit landed on the #604/CI session's `ci/604-lane-rebalance`, **24
|
||||
seconds after that session's own commit at 14:17:28**. Two writers, one tree. Nothing tangled, by luck.
|
||||
- Session B ran `git log` there, read `ci/604-lane-rebalance`'s HEAD, and concluded `main` was "4
|
||||
behind origin" — a phantom.
|
||||
**Building and reviewing**
|
||||
- The PR routine is a fixed sequence; for API changes build the app project FIRST, then
|
||||
`./scripts/update-openapi.sh`, then `npm run generate:api`. → `process.pr-routine-sequence`
|
||||
- Re-review the FIX COMMIT, not just the initial diff; loop until a clean verdict or an explicit
|
||||
acceptable-defer, and file a tracked issue for anything deferred. → `release.review-verdict-gate`
|
||||
- When independent reviews disagree, escalate to the frontier judge — and escalate the proposed FIX with
|
||||
the finding. → `process.review-disagreement-frontier-judge`
|
||||
- "Make X consistent with Y" findings are new code; for any timer/effect ask when it fires, including on
|
||||
mount. → `process.consistency-fix-new-code-scrutiny`
|
||||
- Before trusting a "single owner / no cross-release" claim, grep the whole host project for every
|
||||
producer — the background scheduler is the usual miss. → `process.lock-ownership-enumerate-producers`
|
||||
- Before deleting a "workaround", enumerate every behavior it provided.
|
||||
→ `process.enumerate-workaround-behaviors-before-deleting`
|
||||
- Concurrency tests need a stress loop and a negative control that inverts the real condition — never
|
||||
`if (true)`. → `ffmpeg.work-ahead-slot-atomic`
|
||||
- Enumerate lazy LanguageExt returns in tests; live-E2E is the only net for that class.
|
||||
→ `release.live-e2e-required`
|
||||
- Harden a soon-to-grow feature with a runtime posture, not a hardcoded clamp.
|
||||
→ `process.harden-with-runtime-posture-not-clamp`
|
||||
|
||||
A session-start `git status` *appears to confirm* the false assumption, which makes it worse than a
|
||||
plainly wrong claim. (Supersedes the old "the user's main checkout stays ON main and pulled each
|
||||
session" note.) Same family as the sibling bullet below: never commit inside a checkout or worktree
|
||||
you didn't create.
|
||||
- Subagent connection drops = laptop sleep/transient; re-resume via SendMessage (work survives).
|
||||
- Build/verify (and review) locally, then trust it; CI (VM 127) confirms (~9 min warm; seconds for
|
||||
a docs-only PR since ersatztv#416). Watch by commit
|
||||
status: `/api/v1/repos/timothy/ersatztv/commits/{sha}/status`.
|
||||
- PR routine (works): worktree off origin/main → implement (strongest tier for judgment-heavy /
|
||||
balanced tier for mechanical work when model routing is available; doc pointers + exact facts;
|
||||
use the available C# language tooling) → API changes: build app project
|
||||
FIRST then `./scripts/update-openapi.sh` then `npm run generate:api` → **full local test pass +
|
||||
cold-context adversarial review (SCOPED "review only") + live-E2E for UI/write-path changes
|
||||
(`scripts/e2e-local.sh`), ALL BEFORE the push** — fold pre-push review fixes into the branch
|
||||
(amend/reword is fine while nothing is pushed) → push, PR (arm the CI monitor at open) → CI
|
||||
*confirms* → any further review fixes (a post-push cross-model pass, or CI-surfaced issues) as
|
||||
follow-up COMMITS (never amend/force-push a pushed branch) → CI green → merge (derived consent
|
||||
gate) → structured close comments per the applicable `AGENTS.md` / `CLAUDE.md` protocol.
|
||||
- Never run TWO committing agents on one worktree concurrently. Read-only review agents are fine.
|
||||
- **A KILLED job reports `conclusion: failure` — the conclusion field is not enough. Read the log tail.**
|
||||
(2026-07-17.) When the operator restarts the runners (a retune, a reboot), in-flight jobs die and Gitea
|
||||
marks them **`failure`**, not `cancelled`. Tell: the log **stops mid-step with no error and no
|
||||
`❌ Failure - Main …` marker** — a real failure always leaves a marker. Run 1006's `EF migration
|
||||
integrity` + `Functional E2E` "failed" on a **BOM-removal-only** diff (6 files, 1 line each, zero
|
||||
content change) that could not possibly break them; the logs simply truncated mid-`dotnet build` at
|
||||
12:08 **UTC**, and both runners showed `Up About an hour` — the retune had killed them. **Log
|
||||
timestamps are UTC; the host is UTC+2** — convert before correlating, or the restart looks 2h off and
|
||||
the theory gets wrongly discarded.
|
||||
Corollary: **a diff that cannot cause a failure is evidence the failure isn't yours** — when a job that
|
||||
passed on the previous head fails on a semantically-null delta, suspect the environment and go read the
|
||||
log, don't re-litigate the diff. Also beware log **warnings that look fatal**: `Unable to pull
|
||||
refs/heads/v4: …` is act refreshing its `/root/.cache/act` action cache and is followed by `Cloned …`
|
||||
— it is NOISE, not a cause. Grep for the failure marker, not for the word "error".
|
||||
- **`cancelled` is NOT `failure` — never read a cancel as a CI verdict** (2026-07-17, operator-surfaced).
|
||||
The operator cancels runs by hand (they're the only one who can — see the batching constraint above), and a
|
||||
run-level `conclusion: cancelled` means **no verdict**, not a pass and not a fail. Two traps: (1) a
|
||||
run whose overall state is `failure` may hold a *genuine* job failure that happened **before** the
|
||||
cancel — check job-level `conclusion` + timestamps, don't attribute it to the cancel; (2) a
|
||||
`cancelled` run tells you nothing about your diff, so don't claim green on it. Report FAILED and
|
||||
CANCELLED counts **separately** in any CI monitor:
|
||||
`fail=[j for j in jobs if j['conclusion']=='failure']; canc=[j for j in jobs if j['conclusion']=='cancelled']`
|
||||
— a monitor that only asks "is state != pending" will call a cancelled run a failure and send you
|
||||
debugging a phantom.
|
||||
- **An infra-shaped red under host load is not a code failure — check `uptime` on bumblebee BEFORE
|
||||
diagnosing the diff** (2026-07-17). At load average **243**, two unrelated jobs died in ways that
|
||||
look like code bugs but aren't: `EF migration integrity` failed in ~11ms inside **`Setup .NET`**
|
||||
with `remove /root/.cache/act/…/.gitignore: no such file or directory` (act's shared cache), and
|
||||
main's `Build & push image` died on buildx `DeadlineExceeded: failed to compute cache key` after
|
||||
~210s of retries. Both re-ran green at normal load; the migration job had no model change to test in
|
||||
the first place. Tell: the failure is in a *setup/cache* step, before your code compiles. Don't file
|
||||
a CI bug off one sample under load — you'll send the next session chasing a phantom (this nearly got
|
||||
filed as #390 lane-rebalance fallout, which the evidence did not support). A game server
|
||||
(`FactoryServer-L`) shares that host with the runners, so load isn't always CI's doing.
|
||||
- **Arm a CI monitor the moment the PR opens** (commit-status endpoint), not at the end — in the
|
||||
#216 session three PR runs sat red ~an hour on a CI-only flake while review/E2E ran. (The old
|
||||
MySQL host-port 3306 collision is fixed on main, `ef8915f1`; a lone MySQL-apply red now = the
|
||||
known infra flake, rerun — see MEMORY / `ci-migration-job-mysql-flake`.)
|
||||
- **"Make X consistent with Y" review findings deserve new-code scrutiny** — a #216 review fix
|
||||
propagated MediaBrowseScreen's mount-debounce selection-wipe race to SearchScreen instead of
|
||||
catching it. For any timer/effect, ask "when does this fire?" (incl. mount).
|
||||
- Live-E2E seeding recipe now lives in `docs/e2e-local.md` → "Seeding a local TV library for E2E"
|
||||
(tiny ffmpeg testsrc MKVs + `LibraryPath` SQL rows + scan). Live-E2E is a **stated requirement**
|
||||
for write-path handler changes — see that doc's "When live-E2E is required" + the decisions.md
|
||||
entry; it's the only net for the lazy-enumeration / reload-through-read-path 500 class (#229).
|
||||
- **Parallel sessions**: claim before working (`in-progress` label — the tiny read→claim race window
|
||||
is accepted; later claimant backs off). Claiming prevents duplicate pickup, NOT overlapping code
|
||||
changes — check the issue's dependency notes ("#234 after #231", "coordinate with #215") before
|
||||
touching shared surfaces. Editing THIS lore: prune covered/stale bullets too (not append-only — git
|
||||
keeps history), `git pull --rebase` before commit. Two runners (ci-runner VM 127 + bumblebee-runner,
|
||||
4 slots); **no cancel route exists on this Gitea version — verified 1.25.4: REST and MCP
|
||||
`cancel_run` both 404** (see the batching constraint above). Superseded runs do finish on their own,
|
||||
but they hold a runner slot while doing it, so with 4 parallel sessions they are a real cost — batch
|
||||
pushes rather than relying on them to drain.
|
||||
- **Two sessions touching one machine**: a branch may be checked out in ANOTHER session's
|
||||
worktree — never commit/merge inside a worktree you didn't create. To land a merge on such a
|
||||
branch without touching their checkout: plumbing merge (`git read-tree -m base ours theirs`
|
||||
into a temp GIT_INDEX_FILE → `write-tree` → `commit-tree -p ours -p theirs` → push the
|
||||
commit to the branch ref); the owning session then `git pull`s.
|
||||
- **Cross-model independent review rotation** (2026-07-11): use the other client/model family when
|
||||
it is available — Codex reviews Claude-authored changes; Claude reviews Codex-authored changes.
|
||||
Use whatever native integration is actually installed; client-specific helpers such as
|
||||
`codex:rescue` are optional accelerators, not protocol dependencies. On quota/tool exhaustion,
|
||||
use a cold-context review-only agent in the current client and retry cross-model review next window.
|
||||
Independent reviewers do review and stuck-diagnosis work, not implementation on the same change.
|
||||
**Skip rubric (self-exemption is an auditable claim, not a vibe)**: an *independent* review pass
|
||||
(preferably cross-model; otherwise a cold agent) is **mandatory** for any diff touching
|
||||
locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than
|
||||
~150 changed C# lines. It is **skippable** only for a pure-SPA/docs leaf with no server-state
|
||||
effect — and when you skip it, **say so and why** in the PR or close comment (a stated exemption,
|
||||
not silence). This is the correlated-blindspot net: independent model families miss *different* classes
|
||||
(see the #235 "enumerate every producer" and #250 lessons), so a single reviewer on a risky diff
|
||||
is the gap.
|
||||
- **Force enumeration of lazy LanguageExt returns in tests** (2026-07-11, PR #229): a write-path 500
|
||||
survived a green fixed-point test because the handler returned a lazy `Map` the test never enumerated.
|
||||
Enumerate handler return values in tests; live E2E remains the only net for this class. (Write-path
|
||||
reload-through-read-path mechanics: api-conventions §7.)
|
||||
- **Keep a PR branch current by REBASING on origin/main — do NOT merge main in** (ersatztv#311,
|
||||
enforced by the H11 pre-push hook: a push from a branch behind origin/main is blocked → `git fetch
|
||||
origin main && git rebase origin/main`). A *merge* commit pulls in every file main changed, incl.
|
||||
files you never touched (e.g. the ~2500 legacy-BOM `.cs`), which then trip the format hook/CI on code
|
||||
that isn't yours (the #309 session). Rebasing keeps your diff to exactly what you changed. After a
|
||||
rebase that hits conflicts in **generated artifacts** (v1.json, v1.d.ts, endpoint-index), REGENERATE
|
||||
them (`./scripts/update-openapi.sh` + `npm run generate:api`) — never hand-resolve; git text-merges
|
||||
them plausibly-but-wrong and `npm run check:api` is the guard. Escape hatch for a deliberate
|
||||
non-rebased push: `ETV_SKIP_REBASE_CHECK=1 git push`.
|
||||
- **H12 issue-qualification audit** (ersatztv#312, `scripts/issue-qualification-audit.sh`): a
|
||||
session-end check that lists OPEN issues missing a `priority:` label — the queue tiering keys off
|
||||
`priority:`/gate labels, so an unlabeled issue is invisible to it. "Fully qualified" = has a
|
||||
`priority: {high,medium,low}` label (that signals triage ran; gate-vs-backlog is then derivable
|
||||
from the `review` label / milestone, and a milestone is NOT required — backlog is unmilestoned).
|
||||
Run it at session end and label anything it flags (esp. issues you filed this session). Fail-open
|
||||
without creds; advisory (exit 1 when any are unqualified). Sibling to H11 (both #311/#312).
|
||||
- **CI VM test timeouts**: heavy-render web tests (100+ item grids) need explicit vitest
|
||||
timeouts (e.g. 15s) — the CI VM hit the 5s default on a test that runs in ~1s locally
|
||||
(run 686). Bump per-test, don't raise the global default.
|
||||
- **Concurrency tests need a negative control + a stress loop, not a single round** (2026-07-11,
|
||||
#231): a one-shot `Barrier(N)` + `Task.WhenAll` "exactly one winner" test did NOT catch a
|
||||
deliberately non-atomic check-then-set flag on this Mac — the load→store window is too narrow to
|
||||
collide when threads release once. It green-lit broken code. Fix: hammer the race over many rounds
|
||||
(N worker threads × ~20k rounds, two Barriers per round: acquire → validate one-winner → reset),
|
||||
and ALWAYS prove non-vacuous by temporarily breaking the primitive and watching the test fail.
|
||||
Cheap (sub-second) and deterministic-enough. Same discipline as #12's negative control.
|
||||
- **Before deleting a "workaround", check it isn't serving a SECOND purpose** (2026-07-11, #232):
|
||||
an issue said "remove the SPA `PENDING_GRACE_TICKS` grace-tick workaround now the API is honest".
|
||||
The grace ticks did double duty — compensating the lying-200 (now genuinely obsolete) AND
|
||||
absorbing the inherent queue→observed-active lag / fast-completion race (still needed). Removing
|
||||
wholesale wedged the scan button disabled-until-reload for short scans; both fork + Codex caught
|
||||
it. Keep a bounded version, re-scoped honestly. When an issue says "delete X", enumerate every
|
||||
behavior X provided before deleting.
|
||||
- **Re-review the FIX COMMIT, not just the initial PR diff, before closing** (2026-07-11, #242,
|
||||
user-surfaced): the pre-fix review is only half the loop — the fix commit is new code no
|
||||
independent reviewer has seen. Here the first fix (gate Edit on `saving`) only PARTIALLY closed
|
||||
the in-flight-save clobber class; a Codex re-review of the fix commit caught that **Delete** was
|
||||
the one schedule-switch path (`applySwitch`) still missing the guard. Fold a scoped re-review
|
||||
(cross-model if quota, else a cold review agent) over the fix commit into the close protocol; loop until a clean
|
||||
MERGEABLE verdict or an explicit acceptable-defer (file a tracked issue for the deferral, as #248
|
||||
here). Cheap relative to shipping a half-fix that reopens the issue.
|
||||
- **Negative-controlling a lock/flag fix: invert the condition, don't stub `if (true)`** (2026-07-11,
|
||||
#250): to prove a lock-release test is non-vacuous I temporarily broke the fix. First attempt made
|
||||
the guard `if (true)` — but that left the ownership flag "assigned but never read" → **CS0219 under
|
||||
warnings-as-errors → the Application build silently FAILED → `dotnet test --no-build` ran the STALE
|
||||
(fixed) dll → false PASS** that looked like the control had failed to catch the bug. The tell: build
|
||||
output must show `Build succeeded` / `0 Error(s)`, not just a truncated tail. Break the primitive by
|
||||
**inverting** the real condition (`if (!lockAcquired)`) so the flag stays read and the code compiles;
|
||||
always grep the build for `error CS` before trusting a `--no-build` test result. (Codex-found High
|
||||
this session: an outer `catch` that unlocked unconditionally cross-released another session's lock on
|
||||
a pre-acquisition exception — the fix was a Handle-scoped ownership flag gating the catch.)
|
||||
- **A lock/channel-ownership "no cross-release" verdict must enumerate EVERY producer of that message
|
||||
type, not just the enqueuers visible in the PR diff** (2026-07-11, #235/#267). The cold fork cleared the
|
||||
new `scan-collections` per-provider lock as sound — "only the acquirer enqueues, so only its message
|
||||
triggers the scanner unlock" — because it checked the three new controllers but never grepped for ALL
|
||||
`Synchronize{X}Collections(` senders. Codex did: `SchedulerService` also enqueues them *periodically and
|
||||
unlocked*, and `ScannerService`'s `finally` released the lock whenever held → a scheduled scan
|
||||
cross-releases an API scan's lock (#250 class). Rule: before trusting any "single owner / no double
|
||||
release / no cross-release" claim, `grep` the whole host project for every writer of that channel message
|
||||
(or acquirer of that lock) — the background scheduler/worker is the usual missing producer. This is why the
|
||||
independent Codex pass earns its keep alongside the fork even when the fork returns MERGEABLE.
|
||||
- **Independent-review disagreement on a gate PR → frontier judge; judge the fix, not just the verdict**
|
||||
(2026-07-11, #235): the reconciliation isn't only "who's right" — Fable historically ratified Codex's High AND caught
|
||||
that the orchestrator's proposed *fix* (per-source lock-or-skip) would deterministically starve the 2nd+
|
||||
media source (fixed loop order), prescribing the shipped library-scan §C7 pattern instead (lock once per
|
||||
provider, batch-enqueue, release on the `Unlock: isLast` message). Escalate the FIX approach too, not just
|
||||
the finding.
|
||||
- **Harden a soon-to-grow feature with a runtime posture/toggle, not a hardcoded clamp** (2026-07-12, #289):
|
||||
the MCP's final design (#58) is read+write, so a GET-clamp would be ripped out — shipped a runtime read-only
|
||||
posture (`ERSATZTV_ALLOW_WRITES`, default false) instead. When a security fix constrains a capability the
|
||||
roadmap will later want, make the safe state the *default of a switch*, not a wall; check the feature's issue
|
||||
for its end-state first.
|
||||
- **Fixing onto an unmerged feature branch: branch off it, land by FF-push, never touch a sibling worktree that
|
||||
has it checked out** (2026-07-12, #289 → PR#76). Gotcha: recon by `Read`ing a *sibling* worktree leaked its
|
||||
paths into my later `Edit`s → they landed there, not the fresh worktree (`dotnet` built the unchanged tree →
|
||||
"0 new tests" tipped it off). After creating a worktree, do the first Edit/Read against ITS absolute paths and
|
||||
`git status` it before building.
|
||||
- **`HttpCompletionOption.ResponseHeadersRead` moves the body read OUTSIDE `HttpClient.Timeout`** (2026-07-12,
|
||||
#289): with a `None` read token a slow-drip upstream hangs forever. Fix = per-request linked
|
||||
`CancellationTokenSource.CancelAfter(timeout)` into BOTH `SendAsync` and the stream reads +
|
||||
`HttpClient.Timeout = InfiniteTimeSpan`; catch transport/timeout exceptions and turn them into a response.
|
||||
(Not yet in decisions.md — #289 landed on PR#76, not main.)
|
||||
**Reading a red CI run** (check these before diagnosing your diff)
|
||||
- A **killed** job reports `conclusion: failure` — read the log tail for the `❌ Failure - Main` marker;
|
||||
log timestamps are UTC, the host is UTC+2. → `ci.killed-job-triage`
|
||||
- **`cancelled` is not a verdict** — report FAILED and CANCELLED separately. → `ci.cancelled-is-not-a-verdict`
|
||||
- A failure inside a **setup/cache step**, before your code compiles, is environmental.
|
||||
→ `ci.infra-shaped-red-under-load`
|
||||
- A lone **`decisions lifecycle`** red is a known flake: do **nothing**, the operator reruns it. A
|
||||
convincing local explanation is always available — check this rule before theorising.
|
||||
→ `ci.decisions-lifecycle-flake`
|
||||
- Heavy-render web tests need explicit per-test vitest timeouts. → `ci.web-test-per-test-timeouts`
|
||||
- Verify locally and trust it; CI confirms (~9 min warm, seconds for docs-only).
|
||||
→ `ci.verify-locally-ci-confirms`
|
||||
|
||||
---
|
||||
**Tooling gotchas**
|
||||
- Playwright-MCP: curl download endpoints, never open a tab; `pkill -f ms-playwright-mcp` on repeated
|
||||
stalls. → `testing.playwright-mcp-download-and-recovery`
|
||||
- Channel branding is NOT testable through the troubleshooting-playback API.
|
||||
→ `testing.troubleshoot-path-cannot-test-branding`
|
||||
- Codex cheap workers launch via `codex exec`, not `spawn_agent`. → `process.codex-cheap-worker-launch`
|
||||
- A dropped subagent connection is transient — resume via SendMessage, don't restart.
|
||||
→ `process.subagent-drop-resume`
|
||||
- Fixing onto an unmerged feature branch: branch off it, FF-push, and beware the sibling-worktree path
|
||||
leak. → `process.branch-off-feature-branch`
|
||||
|
||||
## Archived — pre-script selector history (do not follow)
|
||||
|
||||
> This section is history, not instruction. It explains why `scripts/select-queue.sh` exists and
|
||||
> why the closed #237 tracker used to be the queue-state store; none of it is a live protocol. The
|
||||
> current protocol is "Two concurrent tracks at session start" above.
|
||||
|
||||
- **(Pre-2026-07-19) Before the selector script existed**, a cheap model was dispatched each session
|
||||
to rank the backlog by reading tracker prose directly, and the orchestrator was told to RE-DERIVE
|
||||
its mechanical claims because the model kept getting them wrong: it reported blocked issues as
|
||||
`deps:clear` (never called `/dependencies`), mis-tiered issues when the server-side `?milestones=`
|
||||
filter silently no-ops on `:`/`+` names, ranked by issue number and ignored priority-within-tier,
|
||||
and missed live `in-progress` claims. `scripts/select-queue.sh` was written to do the mechanical
|
||||
parts deterministically in code instead — the evidence bullets below are kept for context, not
|
||||
because you need to re-derive anything by hand anymore.
|
||||
- **Gitea milestone-name issue filter silently NO-OPS on names with special chars (2026-07-17,
|
||||
#77 selection)**: `GET /issues?milestones=<name>` returns the WHOLE open-issue list — not a
|
||||
filtered set — when the milestone title contains `:` or `+` (e.g. `Scheduling: refactor +
|
||||
distribution`), because the raw name doesn't round-trip through Gitea's filter. A selector that
|
||||
trusts that response will mis-tier issues (it made #72 look like a milestone-10 member when it's
|
||||
unmilestoned, and hid #77's true sibling set). Robust recipe (now encoded in the script): fetch
|
||||
all open issues once and filter LOCALLY on each issue's `.milestone.title`.
|
||||
- **Confirmed again + widened (2026-07-17, #72/#164 selection): the packet does not reliably see
|
||||
`blocked-by`, priority-within-tier, `in-progress` claim state, or umbrella-vs-child** — that run's
|
||||
dependency data was correct, yet it still ranked #383 first while missing that #384 was
|
||||
`in-progress` (claimed 40 minutes earlier) and that #383 is that bundle's umbrella. The script's
|
||||
`CLAIM?`/`UMBRELLA?` flags now surface exactly this instead of relying on a full manual re-check.
|
||||
- **The cheap selector's dependency + priority data was NOT trustworthy — the fix was to make the
|
||||
script compute them, not to re-derive the whole contested tier by hand every session** (2026-07-17,
|
||||
#73 selection): the old packet reported #385/#386 as `deps:clear` when both were blocked, and
|
||||
missed #383 entirely (the only `priority: medium` in tier 1). The script now runs
|
||||
`GET /issues/{n}/dependencies` on every surviving candidate and tiers by priority-within-tier
|
||||
deterministically, closing this class.
|
||||
- **Queue-drift root cause (2026-07-12, user-surfaced, historical)**: pickups repeatedly re-picked
|
||||
already-done work because DONE/OPEN status was read from **prose** (the old tracker's arc body
|
||||
inline markers + session comments' "Recommended next"), which lags real issue state — worst under
|
||||
parallel sessions narrating each other's merges. STANDING FIX (still the rule, just no longer tied
|
||||
to any specific tracker): **live Gitea state is the ONLY source of truth for status**; a comment's
|
||||
"Recommended next" is a candidate that MUST be re-verified OPEN before pickup, never propagated
|
||||
when stale. Do not name today's frontier issue in this standing file — that's how the drift
|
||||
started the first time.
|
||||
**Session end**
|
||||
- Run the H12 audit and label everything it flags. → `process.issue-qualification-audit`
|
||||
- Run `scripts/refresh-shared-checkout.sh` so the next session's checkout is current.
|
||||
→ `session.shared-checkout-refresh`
|
||||
|
||||
+17
-8
@@ -431,14 +431,23 @@ string-keyed dispatcher, or generic screen-action framework participates in norm
|
||||
`window.confirm` guard belongs on *screen navigation / unload* while any uncommitted edit exists,
|
||||
not on panel close. Show an "Edited" badge on customised rows so the pending edits are visible.
|
||||
- **Advanced channel-options overrides are shared, not duplicated** (`web/src/builder/advancedOptions.tsx`).
|
||||
The 24-field `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
|
||||
`ADVANCED_KEYS`, `effectiveValue`, and the **INHERIT/omit** field adapters (`useAdvancedOverrides`) —
|
||||
is one module consumed by both the Channel Builder and the Auto-Tune DetailPanel. The contract
|
||||
(a select on `INHERIT` or a cleared text input **omits** the field so the create handler coalesces
|
||||
it with the template value; there is no "None" — #135) must not be re-implemented per screen. Each
|
||||
screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode` are surfaced as
|
||||
dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and merged into
|
||||
`advanced` at create, not carried in the override map.
|
||||
The `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
|
||||
`ADVANCED_KEYS`, `effectiveValue`, and the field adapters (`useAdvancedOverrides`) — is one module
|
||||
consumed by both the Channel Builder and the Auto-Tune DetailPanel, and must not be re-implemented
|
||||
per screen. Each screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode`
|
||||
are surfaced as dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and
|
||||
merged into `advanced` at create, not carried in the override map.
|
||||
- **Three per-field states: INHERIT / set / CLEAR** (#135, `api.from-lineup-clear-to-none`). A select on
|
||||
`INHERIT` (or a cleared text input) **omits** the field so the create handler coalesces it with the
|
||||
template value. A concrete value overrides the template. The **`CLEAR` sentinel** ("None") is stored in
|
||||
the overrides map like any override but is folded into the request's `advanced.clear` list at build
|
||||
time by `applyOverridesToRequest`/`collectClears` — so the sentinel never reaches the wire as a field
|
||||
value, and `effectiveValue` reads a `CLEAR` override as none. **Build the request only through
|
||||
`applyOverridesToRequest`**, never a raw spread of the overrides map (a spread leaks the `CLEAR`
|
||||
sentinel). Today only the five id selects (watermark + fillers) expose a "None" option; the backend
|
||||
`clear` enum also covers the preferred audio/subtitle language strings for machine clients, but the SPA
|
||||
text inputs keep "empty = inherit" (a tri-state text control is deferred) — API-ahead-of-UI, matching
|
||||
the "REST API is a real audience" posture.
|
||||
|
||||
## 12. Reusable rule builder (`web/src/builder/rules/`)
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Tracker-retrofit triage — ersatztv#237 (#524)
|
||||
|
||||
Evidence for the decision record `docs.tracker-comment-retrofit` (`docs/decisions.md`). That record
|
||||
states the rule; this file is the audit trail — what was examined, by what method, and how every one
|
||||
of the 111 comments was classified, so the result can be spot-checked or re-derived rather than taken
|
||||
on trust.
|
||||
|
||||
Deliberately **not** under `docs/decisions/`: files there are parsed as decision records by
|
||||
`scripts/decisions_lib.py`, and this is evidence, not a record. It is also not in
|
||||
`docs/decisions/migration-map.md`, whose scope is the #521 legacy-heading→key migration.
|
||||
|
||||
## Why the triage happened
|
||||
|
||||
server-management#642 exports Gitea issue history into MemPalace but **excludes #237** — at 111
|
||||
comments it exceeds the miner's per-file cap. #520 separately removed #237 from session startup. The
|
||||
worry behind #524: a durable fact living *only* in a #237 comment would become unretrievable, and
|
||||
should be curated into lifecycle records.
|
||||
|
||||
## Method
|
||||
|
||||
1. **Corpus.** All 111 comments pulled verbatim from the Gitea API with per-comment provenance (id,
|
||||
author, timestamp, canonical URL). Nothing sampled — every comment read. The corpus itself is not
|
||||
committed (that would be the 1:1 dump #524 rejects); to re-derive or spot-check a row below,
|
||||
re-pull from the permanent primary source:
|
||||
`GET /api/v1/repos/timothy/ersatztv/issues/237/comments` (returns all 111 in one call).
|
||||
2. **Three-way split** (from #524), applied to each *content item*, since one session comment
|
||||
routinely mixes all three:
|
||||
- **decision** — a durable "we do X rather than Y because Z" that governs future behavior;
|
||||
- **issue-work** — per-session/per-issue narration (closures, PR/CI numbers, review loops, claims),
|
||||
which already lives on the individual Gitea issue and is ingested from there;
|
||||
- **lore** — a cross-cutting workflow/orchestration lesson, belonging in the handoff lore or MEMORY.
|
||||
3. **Coverage test, applied to decision-shaped items only.** Semantic candidate discovery against the
|
||||
`ErsatzTV-Decisions` / `ErsatzTV-Decisions-Archive` MemPalace wings, then verification against
|
||||
canonical Markdown by exact search (`docs/decisions.md`, `docs/decisions/*.md`,
|
||||
`docs/decisions/archive/*.md`, `docs/handoffs/chicorytv-issue-queue.md`, convention docs). No item
|
||||
was declared covered on a semantic hit alone — MemPalace was discovery, never authority.
|
||||
4. **Worked-issue check.** For any item surviving step 3, the issue the comment narrates was read
|
||||
live. This is the step that disposes of most apparent orphans.
|
||||
|
||||
## Result
|
||||
|
||||
**Decision-shaped orphans: zero.** No durable decision-shaped fact exists only in #237.
|
||||
|
||||
Item-level counts from the coverage pass (units are *items*, so totals exceed 111 — one comment
|
||||
commonly carries a decision restatement, its issue-work narration, and a lore aside):
|
||||
|
||||
| Slice | Only-in-#237 | Already covered | Issue-work | Lore |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| comments 1–28 | 0 | 14 | 12 | 2 |
|
||||
| comments 29–56 | 0 | 18 | 22 | 14 |
|
||||
| comments 57–84 | 0 | 12 | 14 | 8 |
|
||||
| comments 85–111 | 0 (1 raised, disproved) | 16 | 21 | 9 |
|
||||
| **total** | **0** | **60** | **69** | **33** |
|
||||
|
||||
Comment-level primary class (units are *comments*; sums to exactly 111): decision **14**,
|
||||
issue-work **88**, lore **9**.
|
||||
|
||||
### The one candidate raised, and its disproof
|
||||
|
||||
Comment [106/111] (`issues/237#issuecomment-14077`) recorded #497's deliberate scope exclusions —
|
||||
Guids (not eager-loaded by `GetOrAdd`, so reconciling would duplicate-insert every scan) and
|
||||
Directors (not add-persisted for `MusicVideoMetadata`). Absent from the decision corpus, so it
|
||||
survived the corpus check. It fails the worked-issue check: issue #497's comments **14013** and
|
||||
**14072** state both exclusions with fuller reasoning, and the exporter ingests that issue as
|
||||
`497.md`. Category 2, not a retrofit target. (`scan.musicvideo-reconciliation` covers #494's
|
||||
neighbouring reconcile decision but not this field-scope question — distinct records.)
|
||||
|
||||
### Spot-checks verified independently
|
||||
|
||||
- **#357, parked player-owned-playback architecture options** — preserved on issue #357 itself (8
|
||||
comments carrying the three paths and the no-go). Note the docs copy is *not* on `main`:
|
||||
`docs/player-owned-playback-poc.md` and the `docs/decisions.md` verdict entry exist only on the
|
||||
unmerged `feat/357-player-owned-playback` branch, so the issue is currently the sole reachable
|
||||
home. Category 2 holds, but by a narrower margin than the other items.
|
||||
- **#235 / PR #267, the `Unlock: isLast` batch fix** — this *was* a fresh design decision (Fable
|
||||
prescribed lock-once-per-provider batching because the naive per-source fix would starve the 2nd+
|
||||
media source; now live at `SchedulerService.cs`), not merely an application of
|
||||
`locking.entitylocker-atomic-flags`. It is still category 2: issue #235 comment **8880** and PR
|
||||
#267 comment **8794** both carry the rationale.
|
||||
|
||||
### Known gap in this triage
|
||||
|
||||
The 33 lore-class items were classified but **not** coverage-checked. That bucket is not empty: two
|
||||
facts from comment [109/111] have no home anywhere and were swept into
|
||||
`docs/handoffs/chicorytv-issue-queue.md` by this PR — the `e2e-local.sh` readiness-probe hang on a
|
||||
reused config dir, and the troubleshooting playback API's inability to exercise channel branding. A
|
||||
future tracker triage should coverage-check the lore bucket too, or state that it didn't.
|
||||
|
||||
## Per-comment classification
|
||||
|
||||
`primary-class` is the single dominant class of each comment; `durable home` names where that
|
||||
content lives (the worked issue, a doc, or `ephemeral` for queue bookkeeping with no durable home).
|
||||
|
||||
| # | primary-class | durable home |
|
||||
| --- | --- | --- |
|
||||
| 1 | issue-work | #231–#235 filed (triage session); ephemeral |
|
||||
| 2 | decision | triage-verdict rule (Blocker/High ⇒ priority pickup); decisions.md 2026-07-11 |
|
||||
| 3 | issue-work | #213, #215, #217–#221, #236 |
|
||||
| 4 | decision | tracker "Pending adversarial reviews" section; ephemeral (protocol) |
|
||||
| 5 | issue-work | #126, #207, #212 closed; #230, #238 filed |
|
||||
| 6 | issue-work | #231, #232 via PR #241 |
|
||||
| 7 | lore | re-review the fix commit, not just the initial PR diff |
|
||||
| 8 | issue-work | #244 via PR #249 (ChannelsScreen extraction) |
|
||||
| 9 | issue-work | #233, #234 via PR #250; #235 remainder |
|
||||
| 10 | issue-work | audit sweep filing #251–#254 |
|
||||
| 11 | issue-work | arc/backlog triage for #251–#254; ephemeral |
|
||||
| 12 | issue-work | #251, #252 via PR #258 |
|
||||
| 13 | issue-work | adversarial-reviewer#24; #255–#257 triage |
|
||||
| 14 | issue-work | #238, #243, #246 body edits; ephemeral |
|
||||
| 15 | decision | #253 concurrency contract (Version, ETag, 412); optimistic-concurrency.md |
|
||||
| 16 | decision | #204 redirect matcher; decisions.md `spa.legacy-redirect-matcher` |
|
||||
| 17 | decision | #205, #206 auth posture + rollback tag; blazor-route-parity.md §5 |
|
||||
| 18 | issue-work | #202, #255–#257 via PR #262; #264 filed |
|
||||
| 19 | issue-work | #253 PR1 landed; #265 filed |
|
||||
| 20 | lore | api-conventions.md §7a/§7b section-number collision on parallel merge |
|
||||
| 21 | issue-work | #235 via PR #267 (F9 gate closed) |
|
||||
| 22 | issue-work | #91b SPA pre-work PR #272; #271 filed |
|
||||
| 23 | lore | Codex "interrupted" status unreliable; trust the rollout transcript |
|
||||
| 24 | issue-work | #253 PR3 landed; #269 filed |
|
||||
| 25 | decision | #91b Blazor removal; rollback tag `blazor-final` = bb310f6b |
|
||||
| 26 | decision | #253 PR4 phasing — collapse Phase-1/428 flip, no separate soak release |
|
||||
| 27 | issue-work | #197 decomposed into #280–#289; PR #279 Phase-0 |
|
||||
| 28 | decision | #197 Bundle A — single API key, `RequireKeyForReads` default true |
|
||||
| 29 | issue-work | #283, #294 |
|
||||
| 30 | issue-work | #289 (PR #76) — `ERSATZTV_ALLOW_WRITES` posture |
|
||||
| 31 | issue-work | #295 design ratified (OIDC + local login) |
|
||||
| 32 | issue-work | #245–#247 (App.tsx modularization plan review) |
|
||||
| 33 | issue-work | #287, #288 (Bundle C) |
|
||||
| 34 | issue-work | #271 |
|
||||
| 35 | issue-work | ephemeral (arc/tracker bookkeeping correction) |
|
||||
| 36 | lore | queue-status-in-prose drift; docs/handoffs/chicorytv-issue-queue.md |
|
||||
| 37 | issue-work | #295 PR1, #299, #301 |
|
||||
| 38 | issue-work | #269 (500-exposure slice); #303 H6/H10 |
|
||||
| 39 | decision | #303 H9 append-only + H3 root-png guard; release-ci-governance.md |
|
||||
| 40 | decision | #303 H10 review-verdict merge gate; release-ci-governance.md |
|
||||
| 41 | issue-work | #269 closed; #308 filed |
|
||||
| 42 | decision | #311 H11 rebase-not-merge + scoped format gate; contributing.md |
|
||||
| 43 | issue-work | #303 close-out; #314, #315 filed |
|
||||
| 44 | issue-work | #317 merge-gate double-prompt fix; #314, #319 |
|
||||
| 45 | issue-work | #295 PR2, #301; #320 filed |
|
||||
| 46 | lore | merge-gate needs `ETV_GITEA_BASICAUTH` in env; #315 |
|
||||
| 47 | issue-work | #319 (CSP / Permissions-Policy) |
|
||||
| 48 | issue-work | #253, #265 closed |
|
||||
| 49 | issue-work | #238 (TopBar primary action) |
|
||||
| 50 | issue-work | #286 (`/api/v1` versioning); #289 superseded |
|
||||
| 51 | issue-work | #172 (scheduling API hardening); #327 filed |
|
||||
| 52 | issue-work | #314 (authenticated security scan); #330 filed |
|
||||
| 53 | issue-work | #197 go-live gate closed; #334 filed |
|
||||
| 54 | issue-work | #335, #336 (v26.8.0 go-live) |
|
||||
| 55 | issue-work | #237 tracker closure summary; ephemeral |
|
||||
| 56 | issue-work | #330 (CORP header); #340 filed |
|
||||
| 57 | issue-work | #333, #344–#346 |
|
||||
| 58 | issue-work | #344, #347 |
|
||||
| 59 | issue-work | #245, #350, #352 |
|
||||
| 60 | issue-work | #354–#357 |
|
||||
| 61 | issue-work | #357, #358 (player-owned playback PoC) |
|
||||
| 62 | issue-work | #246, #359, #360 |
|
||||
| 63 | issue-work | #243, #247 (modularization epic) |
|
||||
| 64 | issue-work | #299, #363 |
|
||||
| 65 | issue-work | #99, #333, #334 (queue-selection correction) |
|
||||
| 66 | issue-work | #345, #357, #367 |
|
||||
| 67 | issue-work | #99, #368 |
|
||||
| 68 | decision | selector tier order; docs/handoffs/chicorytv-issue-queue.md |
|
||||
| 69 | issue-work | #354, server-management#599 |
|
||||
| 70 | decision | selector eligibility — exclude parked / closed milestones; #334, #371, #372 |
|
||||
| 71 | lore | coverlet.runsettings Migrations exclusion (exit-137 OOM); #15, #373 |
|
||||
| 72 | issue-work | #333, #344, #345 |
|
||||
| 73 | lore | Gitea `?milestones=` filter no-ops on `:`/`+` names |
|
||||
| 74 | issue-work | #70, #73, #77, #383–#386 |
|
||||
| 75 | issue-work | #383–#386, #397 |
|
||||
| 76 | issue-work | #72, #164, #401, #405, #414, #415, #417 |
|
||||
| 77 | issue-work | #70, #376, #383, #385, #386, #419, #421 |
|
||||
| 78 | issue-work | #176, #320, #385, #386, #423 |
|
||||
| 79 | issue-work | #385, #386, #425, #428 |
|
||||
| 80 | issue-work | #164, #385, #425, #430, #431 |
|
||||
| 81 | issue-work | #386, #425, #432 |
|
||||
| 82 | issue-work | #386, #425, #439 |
|
||||
| 83 | issue-work | #308, #441; optimistic-concurrency.md |
|
||||
| 84 | issue-work | #176, #293, #395, #425, #442 |
|
||||
| 85 | issue-work | #363, #444, #445, #447 |
|
||||
| 86 | issue-work | #350, #446 |
|
||||
| 87 | issue-work | #381, #383, #395, #396, #448 |
|
||||
| 88 | issue-work | #310, #327, #338, #367, #401, #409, #410, #447, #458 |
|
||||
| 89 | issue-work | #350, #403, #404, #412, #431 |
|
||||
| 90 | issue-work | #398, #420 (upload-artifact@v3 pin) |
|
||||
| 91 | issue-work | #409, #458, #460 |
|
||||
| 92 | issue-work | v26.10.0 release; #350, #461 (selector script) |
|
||||
| 93 | issue-work | #404, #463, #464 |
|
||||
| 94 | issue-work | #431, #469 |
|
||||
| 95 | issue-work | #363, #444 |
|
||||
| 96 | issue-work | #350, #472–#474, server-management#630 |
|
||||
| 97 | lore | verify the emitter is deployed before trusting an empty log |
|
||||
| 98 | issue-work | #350, #412, server-management#604 |
|
||||
| 99 | issue-work | #476, #478 |
|
||||
| 100 | issue-work | #478, server-management#631 |
|
||||
| 101 | issue-work | #474, #477, #480, #484, #485 |
|
||||
| 102 | issue-work | #350, #472 |
|
||||
| 103 | issue-work | #381, #395, #480 |
|
||||
| 104 | issue-work | #474, #487, #488, #491, #494 |
|
||||
| 105 | issue-work | #494, #496 |
|
||||
| 106 | issue-work | #497, #498, #500 (the raised-and-disproved candidate) |
|
||||
| 107 | issue-work | #25, #67, #68, #502, #503 |
|
||||
| 108 | issue-work | #350, #474, #487, #510, #511 |
|
||||
| 109 | issue-work | #502, #510, #511 (+ the two lore facts swept to the handoff) |
|
||||
| 110 | decision | retire #237 from startup; #520, #521, server-management#642 |
|
||||
| 111 | lore | independent caps don't compose into a budget; #511 |
|
||||
@@ -18,7 +18,11 @@ from pathlib import Path
|
||||
import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as module)
|
||||
|
||||
SKIP_HEADINGS = {"Index", "Active catalog", "Contents"}
|
||||
REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by")
|
||||
# `signals` is required alongside the lifecycle fields: the `**Signals:**` line (plus `key:`) is what
|
||||
# MemPalace's keyword recall matches on when surfacing a decision, so a record without it ingests with
|
||||
# weak recall metadata and produces confident false-negatives for the "MemPalace to find, file to
|
||||
# confirm" workflow (ersatztv#545).
|
||||
REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by", "signals")
|
||||
EDIT_TOKEN = "[decisions-edit]" # noqa: S105 (a commit-message marker, not a credential)
|
||||
|
||||
|
||||
@@ -291,7 +295,12 @@ def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base")
|
||||
ap.add_argument("--head")
|
||||
ap.add_argument("--budget", type=int, default=4800) # aggregate; re-baselined post-#521 migration
|
||||
# Aggregate active-corpus budget. Re-baselined 2026-07-21 (#542): 4800 -> 5600. The corpus grew
|
||||
# by ~530 lines because 31 workflow/CI/review rules were MOVED into it from
|
||||
# docs/handoffs/chicorytv-issue-queue.md, which was their only copy. That is the corpus doing its
|
||||
# job, not drift — the knowledge existed already, it just wasn't retrievable. Do not raise this
|
||||
# again to accommodate genuinely new records; that is what the warning is for.
|
||||
ap.add_argument("--budget", type=int, default=5600)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
records = dl.all_active_records()
|
||||
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# H13 (ersatztv#541) — session-end: leave the SHARED checkout at /Users/timothy/ersatztv fresh, so
|
||||
# the next session's kickoff paste is current.
|
||||
#
|
||||
# Why this is a script and not a rule. The standing lore already says the shared tree is not a `main`
|
||||
# mirror and its HEAD must never be read as truth — but on 2026-07-21 the trap arrived through a
|
||||
# route that rule doesn't cover: a session pasted `docs/handoffs/chicorytv-issue-queue.md` FROM that
|
||||
# tree, which was 81 commits behind, and the file still described a queue protocol retired the day
|
||||
# before (read tracker #237 — since closed, and now explicitly "do not read for queue state"). No
|
||||
# `git log` was consulted, so no discipline check would have fired. A stale checkout silently
|
||||
# serves stale FILES, and the only durable fix is to stop it being stale.
|
||||
#
|
||||
# What it does: fast-forwards the shared tree to origin/main, and reinstalls web/node_modules when
|
||||
# the lockfile moved (worktrees copy that directory; a stale copy has broken typecheck before).
|
||||
#
|
||||
# What it will NOT do — it is deliberately timid, because the tree is shared and another session may
|
||||
# be mid-flight in it. It refuses (loudly, exit 0) and leaves the WORKING TREE untouched when it is
|
||||
# not on `main`, is dirty, has local commits, or is mid-rebase/merge. It never switches branches,
|
||||
# never stashes, never discards. (A refusal reached after the fetch will have advanced
|
||||
# refs/remotes/origin/main — remote-tracking refs only, never your files.) A refusal is a normal
|
||||
# outcome, not a failure.
|
||||
#
|
||||
# Run at session end (kickoff session-end protocol). Safe to run at session start too — it is
|
||||
# idempotent. Fail-OPEN: a missing/unreachable tree prints a notice and exits 0.
|
||||
#
|
||||
# ETV_SHARED_CHECKOUT overrides the target directory.
|
||||
set -uo pipefail
|
||||
|
||||
shared="${ETV_SHARED_CHECKOUT:-/Users/timothy/ersatztv}"
|
||||
|
||||
skip() {
|
||||
echo "refresh-shared-checkout: NO-OP — $1"
|
||||
echo " (nothing was changed)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
[ -d "$shared/.git" ] || skip "no git checkout at $shared"
|
||||
|
||||
cd "$shared" || skip "cannot cd to $shared"
|
||||
|
||||
# $shared/.git existing is NOT proof $shared is the repo root: if it is an invalid/emptied .git dir
|
||||
# and some ancestor IS a repo, git walks UP and every command below would silently operate on that
|
||||
# ancestor instead — the worst possible outcome for a script that fast-forwards and reinstalls.
|
||||
toplevel="$(git rev-parse --show-toplevel 2>/dev/null)" || skip "not a git repo: $shared"
|
||||
[ "$toplevel" = "$(pwd -P)" ] || skip "$shared is not a repo root (git resolves it to $toplevel)"
|
||||
|
||||
# mid-operation? never interfere
|
||||
git_dir="$(git rev-parse --git-dir 2>/dev/null)" || skip "not a git repo: $shared"
|
||||
# NOTE: keep the `|| true` — without it this loop's `&&` tail would abort the script if anyone
|
||||
# ever adds `set -e` (a non-match returns 1 as the body's last status).
|
||||
for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD BISECT_LOG REVERT_HEAD; do
|
||||
{ [ -e "$git_dir/$state" ] && skip "a $state is in progress"; } || true
|
||||
done
|
||||
|
||||
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
||||
[ "$branch" = "main" ] || skip "on branch '$branch', not main — that is another session's work"
|
||||
|
||||
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||
skip "working tree is dirty (uncommitted changes)"
|
||||
fi
|
||||
|
||||
git fetch origin main --quiet || skip "could not fetch origin/main"
|
||||
|
||||
ahead="$(git rev-list --count origin/main..HEAD 2>/dev/null)" || skip "cannot compare against origin/main"
|
||||
behind="$(git rev-list --count HEAD..origin/main 2>/dev/null)" || skip "cannot compare against origin/main"
|
||||
|
||||
if [ "$ahead" != "0" ]; then
|
||||
skip "$ahead local commit(s) not on origin/main — someone committed here; resolve by hand"
|
||||
fi
|
||||
|
||||
if [ "$behind" = "0" ]; then
|
||||
echo "refresh-shared-checkout: already current with origin/main ($(git rev-parse --short HEAD))."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
lock_before=""
|
||||
[ -f web/package-lock.json ] && lock_before="$(git hash-object web/package-lock.json 2>/dev/null)"
|
||||
|
||||
if ! git merge --ff-only origin/main --quiet; then
|
||||
skip "fast-forward to origin/main failed"
|
||||
fi
|
||||
|
||||
echo "refresh-shared-checkout: fast-forwarded $behind commit(s) to $(git rev-parse --short HEAD)."
|
||||
|
||||
lock_after=""
|
||||
[ -f web/package-lock.json ] && lock_after="$(git hash-object web/package-lock.json 2>/dev/null)"
|
||||
|
||||
if [ -n "$lock_after" ] && [ "$lock_before" != "$lock_after" ]; then
|
||||
echo "refresh-shared-checkout: web/package-lock.json changed — refreshing node_modules..."
|
||||
# `npm ci`, not `npm install`: install can REWRITE the lockfile, which would leave this shared
|
||||
# tree dirty — and a dirty tree is exactly what the next run (and every worktree-hygiene check)
|
||||
# refuses on. ci installs strictly from the lockfile and never writes it.
|
||||
# Accepted trade-off: `ci` DELETES node_modules first, so a session copying it at that instant gets
|
||||
# a partial copy. Bounded and rare — everything removed is regenerable from the lockfile, no human
|
||||
# work can be lost, and this only runs on a merge that actually moved the lockfile.
|
||||
if (cd web && npm ci --no-audit --no-fund >/dev/null 2>&1); then
|
||||
echo "refresh-shared-checkout: node_modules refreshed."
|
||||
else
|
||||
echo "refresh-shared-checkout: WARNING — npm ci failed; worktrees copying web/node_modules"
|
||||
echo " from here may fail typecheck. Run 'npm ci' in $shared/web by hand."
|
||||
fi
|
||||
fi
|
||||
|
||||
# belt and braces: leaving the shared tree dirty is the one outcome that would make this script a
|
||||
# net negative, so assert it and say so loudly rather than exiting quietly on a bad state
|
||||
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||
echo "refresh-shared-checkout: WARNING — the shared tree is DIRTY after refresh:"
|
||||
git status --short | sed 's/^/ /'
|
||||
echo " Nothing was discarded. Clean it by hand before the next session pastes files from here."
|
||||
fi
|
||||
@@ -15,6 +15,7 @@ def _rec(**kw: Any) -> dl.Record:
|
||||
since="2026-01-01",
|
||||
supersedes="none",
|
||||
superseded_by="none",
|
||||
signals="concept · paths: a/b.py · issues: #1",
|
||||
)
|
||||
base.update(kw)
|
||||
return dl.Record(**base)
|
||||
@@ -131,6 +132,20 @@ def test_missing_since_fails():
|
||||
assert any("missing required metadata since" in e for e in _v([rec]))
|
||||
|
||||
|
||||
def test_missing_signals_fails():
|
||||
# `**Signals:**` is required (ersatztv#545): it is what MemPalace's keyword recall matches on, so
|
||||
# a record without it ingests with weak metadata and under-surfaces ("no convention exists").
|
||||
assert any("missing required metadata signals" in e for e in _v([_rec(key="a.b", signals=None)]))
|
||||
|
||||
|
||||
def test_empty_signals_fails():
|
||||
assert any("missing required metadata signals" in e for e in _v([_rec(key="a.b", signals="")]))
|
||||
|
||||
|
||||
def test_signals_present_passes():
|
||||
assert not any("signals" in e for e in _v([_rec(key="a.b", signals="concept · paths: x.py · issues: #1")]))
|
||||
|
||||
|
||||
def test_demoted_heading_fails():
|
||||
assert any(
|
||||
"demoted to legacy-unmigrated" in e for e in _v([_rec(key="a.b")], demoted=["2026-01-01 — Some decision (#1)"])
|
||||
|
||||
@@ -11,6 +11,7 @@ export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChan
|
||||
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
|
||||
export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest'];
|
||||
export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel'];
|
||||
export type CreateChannelFromLineupClearField = components['schemas']['CreateChannelFromLineupClearField'];
|
||||
export type CreateChannelRequest = components['schemas']['CreateChannelRequest'];
|
||||
|
||||
export interface ChannelsScreenData {
|
||||
|
||||
Vendored
+2
@@ -347,7 +347,9 @@ export interface components {
|
||||
"shuffleScheduleItems"?: null | boolean;
|
||||
"randomStartPoint"?: null | boolean;
|
||||
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
|
||||
"clear"?: null | Array<components["schemas"]["CreateChannelFromLineupClearField"]>;
|
||||
};
|
||||
"CreateChannelFromLineupClearField": "Watermark" | "FallbackFiller" | "PreRollFiller" | "MidRollFiller" | "PostRollFiller" | "PreferredAudioLanguage" | "PreferredAudioTitle" | "PreferredSubtitleLanguage";
|
||||
"CreateChannelFromLineupItemRequest": {
|
||||
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
} from '../components';
|
||||
import {
|
||||
ADVANCED_KEYS,
|
||||
applyOverridesToRequest,
|
||||
FIXED_START_TIME_BEHAVIORS,
|
||||
IDLE_BEHAVIORS,
|
||||
MUSIC_VIDEO_CREDITS_MODES,
|
||||
@@ -940,15 +941,13 @@ function ChannelBuilder({
|
||||
}
|
||||
}
|
||||
|
||||
// advanced null/omitted = inherit template value (handler coalesces with ??);
|
||||
// clear-to-none is not expressible — see #135. Overrides only ever holds
|
||||
// real values (inherited fields are absent), so we copy present keys as-is.
|
||||
const advanced: CreateChannelFromLineupRequest['advanced'] = { playbackOrder, playoutMode };
|
||||
for (const key of ADVANCED_KEYS) {
|
||||
if (key in overrides) {
|
||||
(advanced as Record<string, unknown>)[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
// advanced null/omitted = inherit template value (handler coalesces with ??); a field set to
|
||||
// the CLEAR sentinel is translated into advanced.clear (force to none) — see #135.
|
||||
// applyOverridesToRequest copies present set-values and attaches `clear`.
|
||||
const advanced: CreateChannelFromLineupRequest['advanced'] = applyOverridesToRequest(
|
||||
{ playbackOrder, playoutMode },
|
||||
overrides
|
||||
);
|
||||
|
||||
const body: CreateChannelFromLineupRequest = {
|
||||
name: trimmedName,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyOverridesToRequest,
|
||||
CLEAR,
|
||||
collectClears,
|
||||
effectiveValue,
|
||||
type Overrides
|
||||
} from './advancedOptions';
|
||||
import type { ChannelTemplate } from '../api';
|
||||
|
||||
// Minimal template stub: only the fields the assertions read.
|
||||
const template = {
|
||||
watermarkId: 21,
|
||||
preRollFillerId: 2,
|
||||
preferredAudioLanguageCode: 'eng'
|
||||
} as unknown as ChannelTemplate;
|
||||
|
||||
describe('advancedOptions clear-to-none (#135)', () => {
|
||||
it('effectiveValue reads a CLEAR override as none, an absent key as the template value', () => {
|
||||
const overrides: Overrides = { watermarkId: CLEAR, preRollFillerId: 7 };
|
||||
// Cleared -> null (renders "None").
|
||||
expect(effectiveValue('watermarkId', template, overrides)).toBeNull();
|
||||
// Set -> the override value.
|
||||
expect(effectiveValue('preRollFillerId', template, overrides)).toBe(7);
|
||||
// Absent -> inherits the template value.
|
||||
expect(effectiveValue('preferredAudioLanguageCode', template, overrides)).toBe('eng');
|
||||
});
|
||||
|
||||
it('collectClears maps CLEAR-valued clearable keys to their backend enum values', () => {
|
||||
const overrides: Overrides = {
|
||||
watermarkId: CLEAR,
|
||||
fallbackFillerId: CLEAR,
|
||||
preferredAudioLanguageCode: CLEAR,
|
||||
preRollFillerId: 7
|
||||
};
|
||||
expect(collectClears(overrides).sort()).toEqual(
|
||||
['FallbackFiller', 'PreferredAudioLanguage', 'Watermark'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('applyOverridesToRequest copies set values and folds CLEAR into advanced.clear (never as a value)', () => {
|
||||
const overrides: Overrides = { watermarkId: CLEAR, preRollFillerId: 7, midRollFillerId: 3 };
|
||||
const advanced = applyOverridesToRequest({ playbackOrder: 'Shuffle' as const }, overrides);
|
||||
|
||||
// Set values copied through.
|
||||
expect(advanced).toMatchObject({ playbackOrder: 'Shuffle', preRollFillerId: 7, midRollFillerId: 3 });
|
||||
// The CLEAR sentinel never leaks onto the wire as a field value.
|
||||
expect('watermarkId' in advanced).toBe(false);
|
||||
// It becomes a clear-list entry instead.
|
||||
expect((advanced as { clear?: string[] }).clear).toEqual(['Watermark']);
|
||||
});
|
||||
|
||||
it('applyOverridesToRequest omits the clear key entirely when nothing is cleared', () => {
|
||||
const advanced = applyOverridesToRequest({ playbackOrder: 'Chronological' as const }, { preRollFillerId: 7 });
|
||||
expect('clear' in advanced).toBe(false);
|
||||
});
|
||||
|
||||
it('a non-clearable key holding the CLEAR sentinel is copied through, never silently dropped', () => {
|
||||
// Only reachable if a user literally typed the sentinel into a non-clearable text input.
|
||||
const advanced = applyOverridesToRequest({}, { streamSelector: CLEAR });
|
||||
expect((advanced as { streamSelector?: unknown }).streamSelector).toBe(CLEAR);
|
||||
expect('clear' in advanced).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,19 @@
|
||||
// a channel template's baseline, used by BOTH the manual Channel Builder
|
||||
// (ChannelBuilder.tsx) and the Auto-Tune per-channel DetailPanel (AutoTuneScreen.tsx).
|
||||
//
|
||||
// The tricky, bug-prone part is the INHERIT/omit contract (see #135): a field left
|
||||
// on "Inherit from template" is OMITTED from the request entirely (not sent as null),
|
||||
// because the create handler coalesces a missing value with the template's value and
|
||||
// the API cannot express "clear to none". Both consumers MUST honour that, so it lives
|
||||
// here once. Consumers render their own field JSX; only the model + adapters are shared.
|
||||
// The tricky, bug-prone part is the INHERIT/omit vs CLEAR contract (see #135). Three states
|
||||
// per advanced field:
|
||||
// - INHERIT: the field is OMITTED from the request; the create handler coalesces the missing
|
||||
// value with the template's value (the historical default for every existing client).
|
||||
// - a set value: sent as-is, overriding the template.
|
||||
// - CLEAR ("None"): the field is added to `advanced.clear`, forcing it to none on the new
|
||||
// channel even when the template sets one. Only the id fields (watermark + the four fillers)
|
||||
// expose a "None" option in the SPA today; the backend `clear` enum also covers the preferred
|
||||
// audio/subtitle language strings for machine clients (API-ahead-of-UI — see spa-conventions).
|
||||
// Both consumers MUST honour that, so it lives here once. Consumers render their own field JSX;
|
||||
// only the model + adapters are shared.
|
||||
|
||||
import type { ChannelTemplate } from '../api';
|
||||
import type { ChannelTemplate, CreateChannelFromLineupClearField } from '../api';
|
||||
|
||||
// ---- Enum unions (hand-listed from generated v1.d.ts; keep in sync) --------
|
||||
export const PLAYBACK_ORDERS = [
|
||||
@@ -93,19 +99,74 @@ export type AdvancedKey = (typeof ADVANCED_KEYS)[number];
|
||||
|
||||
export type Overrides = Partial<Record<AdvancedKey, unknown>>;
|
||||
|
||||
// The current effective value for an advanced field = user override (if any)
|
||||
// else the selected template's value.
|
||||
// Sentinel select value meaning "inherit from the template" — the field is
|
||||
// omitted from `advanced` entirely (not sent as null). See #135.
|
||||
export const INHERIT = '__inherit__';
|
||||
|
||||
// Sentinel override value meaning "clear to none" — the field is added to the request's
|
||||
// `advanced.clear` list, forcing it to none even when the template sets one (#135). Stored in the
|
||||
// overrides map (a clear IS a divergence from the template) and translated at request-build time.
|
||||
export const CLEAR = '__clear__';
|
||||
|
||||
// AdvancedKeys whose "None" maps to a backend clear-field. Only these can be cleared; every other
|
||||
// key set to CLEAR is ignored by collectClears. The id keys drive the SPA "None" option today; the
|
||||
// string keys are covered so a machine client (MCP) can clear them via the same enum.
|
||||
export const CLEARABLE_FIELDS: Partial<Record<AdvancedKey, CreateChannelFromLineupClearField>> = {
|
||||
watermarkId: 'Watermark',
|
||||
fallbackFillerId: 'FallbackFiller',
|
||||
preRollFillerId: 'PreRollFiller',
|
||||
midRollFillerId: 'MidRollFiller',
|
||||
postRollFillerId: 'PostRollFiller',
|
||||
preferredAudioLanguageCode: 'PreferredAudioLanguage',
|
||||
preferredAudioTitle: 'PreferredAudioTitle',
|
||||
preferredSubtitleLanguageCode: 'PreferredSubtitleLanguage'
|
||||
};
|
||||
|
||||
// The current effective value for an advanced field = user override (if any) else the selected
|
||||
// template's value. A CLEAR override reads as none (null), so pickers/previews render "None".
|
||||
export function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides: Overrides): unknown {
|
||||
if (key in overrides) {
|
||||
return overrides[key];
|
||||
return overrides[key] === CLEAR ? null : overrides[key];
|
||||
}
|
||||
return (template as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
// Sentinel select value meaning "inherit from the template" — the field is
|
||||
// omitted from `advanced` entirely (not sent as null). The API cannot express
|
||||
// clear-to-none, so there is no "None" choice; see #135.
|
||||
export const INHERIT = '__inherit__';
|
||||
// The backend clear-field enum values for every key set to CLEAR in the overrides map.
|
||||
export function collectClears(overrides: Overrides): CreateChannelFromLineupClearField[] {
|
||||
const clears: CreateChannelFromLineupClearField[] = [];
|
||||
for (const key of ADVANCED_KEYS) {
|
||||
if (overrides[key] === CLEAR) {
|
||||
const field = CLEARABLE_FIELDS[key];
|
||||
if (field) {
|
||||
clears.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
return clears;
|
||||
}
|
||||
|
||||
// Fold an overrides map into an `advanced` request object: copy every set (non-CLEAR) override, then
|
||||
// attach `clear` for any CLEAR-valued key. Shared by both consumers so the CLEAR sentinel never leaks
|
||||
// onto the wire as a field value. `base` carries consumer-specific extras (playbackOrder/playoutMode).
|
||||
export function applyOverridesToRequest<T extends Record<string, unknown>>(base: T, overrides: Overrides): T {
|
||||
for (const key of ADVANCED_KEYS) {
|
||||
if (!(key in overrides)) {
|
||||
continue;
|
||||
}
|
||||
// A CLEAR sentinel is folded into `clear` below, but only for clearable fields. Any other key
|
||||
// holding the sentinel is only reachable if a user literally typed it into a text input — copy
|
||||
// it through as its value rather than silently dropping the field from the request.
|
||||
if (overrides[key] === CLEAR && key in CLEARABLE_FIELDS) {
|
||||
continue;
|
||||
}
|
||||
(base as Record<string, unknown>)[key] = overrides[key];
|
||||
}
|
||||
const clear = collectClears(overrides);
|
||||
if (clear.length > 0) {
|
||||
(base as Record<string, unknown>).clear = clear;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Templates carry no playbackOrder; the Shuffle toggle *is* the template's
|
||||
// declared shuffle intent (template.shuffleScheduleItems -> Shuffle/Chronological).
|
||||
@@ -146,25 +207,30 @@ export function useAdvancedOverrides(
|
||||
|
||||
const templateValueOf = (key: AdvancedKey): unknown => (template as unknown as Record<string, unknown>)[key];
|
||||
|
||||
// Override selects: the first option is always "Inherit from template" (the
|
||||
// INHERIT sentinel -> field omitted from the request). There is deliberately
|
||||
// NO "None" option for filler/watermark ids: the create handler coalesces
|
||||
// null with the template value, so clear-to-none is not expressible (#135).
|
||||
// Override selects: the first option is always "Inherit from template" (the INHERIT sentinel ->
|
||||
// field omitted from the request), followed by an explicit "None" (the CLEAR sentinel -> field
|
||||
// added to advanced.clear, forcing it to none even when the template sets one) for clearable
|
||||
// fields, then the concrete rows. "None" is distinct from "Inherit": inherit keeps the template
|
||||
// value, None drops it (#135).
|
||||
const idSelectOptions = (key: AdvancedKey, rows: Array<{ id: number; name: string | null }>) => {
|
||||
const inherited = templateValueOf(key) as number | null;
|
||||
return [
|
||||
inheritOption(inherited == null ? null : fillerName(rows, inherited)),
|
||||
...(key in CLEARABLE_FIELDS ? [{ value: CLEAR, label: 'None' }] : []),
|
||||
...rows.map((row) => ({ value: String(row.id), label: row.name ?? `#${row.id}` }))
|
||||
];
|
||||
};
|
||||
|
||||
// Selected value for an override select: the override when present, else INHERIT.
|
||||
// Selected value for an override select: the override when present (CLEAR renders as the "None"
|
||||
// option since String(CLEAR) === CLEAR), else INHERIT.
|
||||
const selValue = (key: AdvancedKey): string => (key in overrides ? String(overrides[key]) : INHERIT);
|
||||
const onSelect =
|
||||
(key: AdvancedKey, map: (raw: string) => unknown) => (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === INHERIT) {
|
||||
removeOverride(key);
|
||||
} else if (raw === CLEAR) {
|
||||
setOverride(key, CLEAR);
|
||||
} else {
|
||||
setOverride(key, map(raw));
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '../api';
|
||||
import { registerNavigationGuard } from '../navigationGuard';
|
||||
import {
|
||||
applyOverridesToRequest,
|
||||
FIXED_START_TIME_BEHAVIORS,
|
||||
IDLE_BEHAVIORS,
|
||||
MUSIC_VIDEO_CREDITS_MODES,
|
||||
@@ -325,9 +326,11 @@ export function AutoTuneScreen() {
|
||||
}
|
||||
channel.logo = logo;
|
||||
}
|
||||
const advanced: Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode } = {
|
||||
...(override?.advanced ?? {})
|
||||
};
|
||||
// applyOverridesToRequest copies set overrides and folds any CLEAR sentinels into
|
||||
// advanced.clear, so the sentinel never leaks onto the wire as a field value (#135).
|
||||
const advanced = applyOverridesToRequest<
|
||||
Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode }
|
||||
>({}, override?.advanced ?? {});
|
||||
if (override?.playbackOrder != null) {
|
||||
advanced.playbackOrder = override.playbackOrder;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user