8dcd4f3602652c8353a7c331ce9e7e3073faa784
66
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
63fa81fbb5 |
docs(648,649): the jq contract + the shared PR-file enumeration record
Adds docs/ci-cd.md "The jq contract" (1.6 floor, the three divergent constructs, and the deliberate pin-vs-floor asymmetry with its merge-deadlock reason), plus two decision records: ci.jq-version-contract and ci.shared-pr-file-enumeration. ci.script-tests-job stops restating the three jq rules and points at the new record. Also corrects the script-tests preflight description: it is now two steps (git presence, then jq VERSION via scripts/jq-preflight.sh --expect 1.6), not one. A literal NUL byte had crept into the ci-cd.md paragraph describing jq 1.6's NUL truncation — which git treats as a binary file. Replaced with the literal text. Decisions-Edit: yes |
||
|
|
e4c0db7702 |
Merge pull request 'fix(510): one watermark resolver for all four attachment points' (#655) from fix/510-watermark-resolution-policy into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m28s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Failing after 16m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
|
||
|
|
c0376dcbce |
Merge pull request 'fix(644): stop seven SPA list loads truncating silently — one shared pager, bounded media pickers' (#656) from fix/644-spa-paging into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
|
||
|
|
1a7f15fb27 |
fix(510): address independent review — Resource null guard, honest routing claim
Two independent reviews (cross-family Codex + cold Opus) both returned BLOCKED. Findings, all verified against source before acting: 1. Resource arm could throw ArgumentNullException (Codex, Medium). Making the channel/global Resource arm reachable exposed that CreateWatermarkHandler and UpdateWatermarkHandler write `Image = null` for EVERY non-Custom watermark, so an API-created Resource watermark reached Path.Combine(folder, null). Added the blank/null guard the arm never had. This was live at the playout-item level too, not just newly-reachable code. 2. "Routing is unaffected" was false (Codex, Low but sharp). The predicate is unchanged, but CanUseFFmpegNativeWatermark also tests Count == 1, and dropping an unresolvable watermark shortens the list. A deco with one valid and one missing permanent watermark now routes ffmpeg-native where it previously routed to the graphics engine. Intended, but observable -- so it is documented and pinned by a test rather than claimed away. 3. "Exactly one resolver" over-claimed (Opus, High). True of the selector, not the application: the song-progress overlay is built as a WatermarkOptions directly by the streaming and troubleshooting handlers, unchecked, and can still hand ffmpeg a nonexistent -i. Pre-existing; scoped the claim in the record and channels.md and filed #653. 4. Undeclared crash->degrade change (Opus, Medium). Channel/global Custom had no blank-image guard, so a cleared image hit ImageCache's fileName[..2] and threw out of stream startup. Now declared in the record and tested. 5. Contradictory rule text (Opus, Medium) -- the catalog one-liner said "always no bug" while the body documents the playout-item fall-through exception. Qualified; catalog regenerated. 6. History was wrong in both the record and the XML docs: the three precedence levels did NOT all check every source -- channel/global had no Resource arm and threw. Corrected. Tests: 30 in the fixture now (was 18). New coverage for the preserved blank-Custom fall-through (to channel AND to global), the complement case (missing-but-named must NOT fall through), null/blank Resource, and the valid+missing routing case. 17 of 24 failed against the pre-fix resolver before this round; the fixture stays mutation-sensitive. Also: hoisted the mock-filesystem Initialize() out of its loop so a multi-file case cannot silently seed only the last file, and marked the generated-URL test honestly as redundant-by-construction rather than claiming independent coverage. The decision record is now 81 prose lines, over the 60-line ceiling. Declared as a legitimate decline per docs.corpus-size-signal: the length is the review findings above, each a distinct fact, not redundancy. refs #510 #652 #653 |
||
|
|
94182cdd53 |
fix(644): split loadAllPages by list class; bound media-library pickers to one page
Cold adversarial review of
|
||
|
|
41e2870113 |
fix(505): tonemap QSV HDR through OpenCL; vpp_qsv=tonemap is a silent no-op
#505 asked to route the #498 native-decode path through TonemapQsvFilter to move HDR tonemapping off the CPU. Measured on the Intel host (jazz: FFmpeg 8.1.2 / iHD 25.1.4 / UHD 630) against real HDR HEVC Main10, that filter is a SILENT no-op: a graph ending in vpp_qsv=tonemap=1 returns a frame byte-identical (same md5) to the same graph with no tonemap at all, with no warning and no error. QSV VPP tonemapping needs Gen11+; pre-Gen11 iHD ignores it. So the issue's premise was inverted, and the branch it wanted to extend was already broken: the existing DecoderHardwareAccelerationMode == Qsv path shipped untonemapped HDR whenever QsvPreferNativeDecoder was off -- which is exactly the escape hatch #498/#523 recommend. Prod was unaffected (native-decode is the default and took the working software branch). Tonemap on the GPU via OpenCL instead, the route VaapiPipelineBuilder already uses and the one Jellyfin uses. The scale has to run first, in scale_vaapi: tonemapping full-size is slower than the software path it replaces (15.5s wall for 12.5s of content, below realtime), while scale-first cuts total CPU ~60% (35.6s -> 14.1s) and lands at the no-tonemap wall-clock floor. A QSV surface maps to neither OpenCL nor VA-API, so the gate requires software frames: the QSV decoder and deinterlace_qsv both fall back to the software tonemap, slower but correct. TonemapQsvFilter is deleted -- a filter that silently does nothing is worse than no filter. Also fixes output tagging: the first end-to-end run was correctly tonemapped yet still announced bt2020 primaries, because SetPixelFormat's usesVppQsv predicate ("did a hardware filter strip color info") listed only the QSV filters. Both new filters are now in it. Validated end to end on jazz with the exact generated command: exit 0, YAVG 26.39 (software reference 26.6, untonemapped 44.3), and ffprobe reports bt709 space/transfer/primaries. fixes #505 |
||
|
|
9cbe70e486 |
fix(510): one watermark resolver for all four attachment points
WatermarkSelector resolved watermarks in two places with two policies. The three precedence levels (playout item, channel, global) existence-checked every image source and degraded to None; the deco path had its own copy of the same switch that returned whatever path it computed, unchecked. So one channel could disagree with itself about whether an on-screen bug rendered, based only on how the watermark was attached. #502 deferred this here but scoped it to ChannelLogo. It was never ChannelLogo-only: the deco path skipped the existence check for Custom and Resource too. Extract one ResolveWatermark used by all four sites. Severity is not cosmetic. A dead LOCAL path is not harmlessly skipped -- CanUseFFmpegNativeWatermark hands a single permanent watermark to ffmpeg as a bare -i argument and excludes only URLs, so the deco path could hand ffmpeg a nonexistent input file. The generated-initials nameplate was real: a live-E2E on a real transcoded frame confirmed it composited via the deco path (/iptv/logos/gen is on ArtworkController, which has no auth filter, so the container-internal self-fetch succeeded). The #502-era comment claiming "it has never rendered here" was wrong, and the new record says so. It is still removed: serving it means an HTTP fetch inside stream startup, which graphics.channel-logo-caching (#525) eliminated for logos, and it depends on #1's hardcoded localhost. Reviving it by caching the image instead is #652. Measured blast radius on prod: 0 Deco rows, 0 DecoWatermark rows, all 43 channels have logo artwork -- no rendered output changes. Preserved deliberately: a playout-item Custom watermark with a blank image still falls THROUGH to the channel/global watermark; unifying resolution must not change which watermark wins. Routing is untouched. Strict improvement: the channel and global arms previously threw NotSupportedException on a Resource watermark; they now resolve it. The default arm still throws so a new image source fails loudly. Tests: 18 new cases including a positive control and 8 deco-vs-channel parity cases. 12 of the 18 fail against the pre-fix resolver, which is what proves they are load-bearing rather than vacuous. fixes #510 |
||
|
|
fefd11dffe |
fix(620): signal corpus size per RECORD; the aggregate becomes an unthresholded trend
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 28s
PR Gates / decisions lifecycle (pull_request) Successful in 30s
Review verdict / Set review-verdict status (pull_request) Successful in 12s
PR Gates / Script tests (pytest) (pull_request) Successful in 42s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ fefd11d
Squashed from 7 commits (4 review rounds) to keep the rebase onto #621 tractable; the full round-by-round history is on PR #642. corpus was 5658/5600 — over budget and warning again — 3h35m after #619 put it at 5228, with nobody consolidating anything. So this does NOT re-baseline. An aggregate over a monotonically growing corpus can only ratchet; that is the "permanently red, therefore no signal" state #542 re-baselined away from, and growth is not even a smooth rate to plan against (the corpus FELL from 5089 to 5042 across four days, then gained 427 in one evening as two large records landed). Replaces it with a per-record prose ceiling (default 60), non-blocking, naming each record over it — not monotonic, so it can go red AND green, and it points at a file. The aggregate is still printed as an unthresholded trend notice, with record prose and non-record scaffolding reported separately because they are not the same unit. The GENERATED catalog is no longer counted at all: it gains one row per record and no consolidation can shrink it, which made the metric partly a record COUNT in a line-count costume. The calibration test took FOUR versions, and the failures are the durable lesson: v1 true by construction (`max(under) <= 60 < min(over)` over lists built by that test) v2 a gap WIDTH — a ceiling of 200 also sits in a wide gap, so it passed v3 fraction band + "clear air" vs the nearest record above — hostage to an unrelated record: one ordinary 62-line addition reddened it with the ceiling correctly placed, and the only remedy was to RAISE the ceiling. That is this very treadmill, as a hard failure in what #631 makes a blocking job. v4 `p90 <= ceiling <= p95` — the property stated directly and scale-free. Two rules recorded: a guard test must depend only on the thing it guards, and a threshold over a growing population must be expressed in that population's own terms. Candidates: all over-ceiling records assessed, each actioned or declined with a reason. The largest (scan.libraryfolder-unique-identity, 230 lines) is a legitimate DECLINE — a dozen-odd distinct traps whose only copy that is. Nothing pruned, so no archive or supersession was required. An automated redundancy metric is explicitly rejected. Also: `--budget` is accepted but announces its retirement rather than no-opping silently; the dead `budget_ok` parameter is gone; and five "untresholded" typos are fixed, one of which was propagating into the generated catalog row and MemPalace's per-key drawer. Refs #620 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
69d8d3ccfe |
ci(631): run scripts/tests in CI as its own job, not inside the flake-covered decisions-guard
Nothing executed scripts/tests/. No workflow and no Husky hook invoked pytest: decisions-guard runs decisions_validate.py and build_decisions_catalog.py directly (the code, never its tests) and the `test` job is dotnet test only. The 111 tests guarding the decision corpus, the #610 migration-equivalence harness, the merge-consent exemption logic and the #622 review-verdict poster were enforced only by whoever remembered to run pytest locally. Adds a `script-tests` job to pr-checks.yml rather than the step inside decisions-guard the issue proposed. decisions-guard is covered by ci.decisions-lifecycle-flake, a standing instruction that a lone `decisions lifecycle` red is a known flake and must not be investigated — so a genuine pytest regression there would surface as exactly the red every session is told to wave through. A gate inherits the credibility of the job it lives in. Runs unconditionally: the suite executes the real post-review-verdict.sh and pretooluse-merge-consent.sh, so a `scripts/**` path filter would miss a .claude/hooks/** edit. ~10s, pure stdlib + pytest. Negative control verified locally (broken test -> exit 1); CI-red measurement follows on a scratch PR per the Done-when. Refs #631 |
||
|
|
3d720a6bc1 |
fix(621): exempt by exact path, not basename — my "no basename exemption" claim was false
review-verdict/h10 Awaiting review verdict for 3d720a6
Review verdict / Set review-verdict status (pull_request) Successful in 2s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m48s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review found the basename exemption was not actually removed: the code still dropped
any wing-root README.md, and the docstring defended that with "no such file exists
today; the catalog README lives in TOPIC_DIR". That is false — docs/decisions/archive/
README.md exists and parses to []. So the rule text in the record, the catalog row and
ci-cd.md all asserted something the shipped code contradicted.
It was also a live hole, not just wrong prose: dl.active_files() globs RECORDS_DIR.rglob
with NO filter, so a future docs/decisions/records/README.md would be a corpus source
while exempt from the guard — verbatim the hole this PR cites as the reason to stop
using _NON_DECISION_FILES, reintroduced one directory over.
Now exempt by exact RELATIVE PATH ({archive/README.md}), with a test pinning that the
same basename in the ACTIVE wing is still faulted. Mutation-verified.
DISAGREEING with one review finding, deliberately. It asked for the empty-wing
anti-vacuity check to be symmetric across both wings. The semantics differ: zero ACTIVE
records means the scan measured nothing and any clean result is vacuous, while zero
ARCHIVED records is a normal state — nothing superseded yet, true of any young repo and
every fresh clone before the first supersession. Making it symmetric fails a correct
corpus (it reddened 7 tests). The asymmetry stays, with the reason in the code and a
test pinning it so nobody "fixes" it back.
127 passed.
Refs #621
|
||
|
|
98b3e8715b |
fix(621): close three exemption holes and pin the guard's own wiring, per cold review
An independent cold-context review found the guard could be disabled or bypassed in four ways, one of them silently. HIGH — the guard's only wiring was untested. Deleting `wing_faults=record_wing_faults()` from main() left the ENTIRE suite green (120 passed) while a real block-scalar record vanished again with `decisions-validate: OK`. Every existing test either called the collector directly or handed validate() a hand-built list, so nothing pinned that main() ever invokes it — the #609 "prints OK while doing nothing" defect, one level up, inside the fix for that same defect. Independently reproduced before fixing. Now pinned by a monkeypatched sentinel test. MEDIUM — a record could escape by BASENAME. `_NON_DECISION_FILES` is a set of TOPIC-dir names ({README, migration-map, retrieval-eval}) and was applied to the wings too, so a genuine record at records/docs/retrieval-eval.md was skipped. Not hypothetical: the path<->key rule forces key `docs.retrieval-eval` to that exact filename, and dl.active_files() applies that filter only to the TOPIC_DIR glob — so the file would be a corpus source while exempt from the guard. Now nothing is exempt by basename. MEDIUM — the archive exemption was by LOCATION but justified by IDENTITY. Anything directly in archive/ was skipped, so a new unparseable archive/foo.md would vanish in the one directory left unwatched. Now exempt only if it really is a #610 stripped index: one keyless record with a known generated heading. LOW — parse-to-WRONG. `rule: >-` with an UNINDENTED continuation containing a colon parses to one VALID keyed record whose rule is literally ">-", plus a junk key, silently truncating the real value. PyYAML rejects that input, so the hand reader is more permissive than the writer. Now faulted via an unknown-frontmatter-key check. LOW — an absent or empty wing reported clean. Now loud: refusing to certify a corpus from a scan of nothing. Doc corrections, all measured rather than asserted: "all 167 records use single quoted lines" was false (117 of 168 rule: values are unquoted plain scalars — the invariant is single-LINE); the archive shape in the record, ci-cd.md and the rule: field said `archive/<area>/` where the code deliberately uses archive/** ; the mutation count is 10, not five; and the "converts ANY reader limitation" claim is softened to the class it actually covers. 125 passed. Refs #621 |
||
|
|
0f565b1f7e |
fix(621): make an unparseable decision record loud instead of silently invisible
A file under the record wings that the dependency-free frontmatter reader cannot parse returned [] and simply vanished from the corpus: decisions_validate.py printed OK, build_decisions_catalog.py --check said "up to date", the record was absent, and nothing anywhere errored. Reproduced end-to-end with a YAML block scalar (`rule: >-`), which is the natural thing to reach for on this corpus's very long rule: values and which parsed fine under PyYAML before #610's dependency-free reader. An EXISTING record disappearing was already loud (the no-vanish diff check). The hole was a NEWLY ADDED record, which that check structurally cannot see — no base state to diff against — so the author's own PR looks clean. Hence a per-PATH check, not a per-construct or diff-driven one: asserting "this path yields exactly one keyed record" turns any present or future reader limitation from silent to loud in one move. Adds record_wing_files/record_wing_faults to decisions_validate.py, surfaced through validate() as ERRORS (a file in the record wings that is not a record is a mistake by definition) and reported first, since a file that failed to parse leaves every downstream check silently evaluating an incomplete corpus. The five top-level stripped legacy archive files are exempt — they are generated "Records formerly in this file" indexes, keyless by construction, and are what keeps older date-based pointers resolvable. _read_frontmatter is deliberately NOT extended to accept block scalars; rationale in the new docs.record-wing-parse-guard record. 8 tests, mutation-verified: with the check neutered 5 go red, restored all 119 pass. A live-corpus positive control asserts the wings are non-empty so a clean result can never be vacuous. Refs #621 |
||
|
|
28a9d0dbfd |
fix(629): round-4 — require the marker at COLUMN 0, narrowing the grammar instead of patching again
review-verdict/h10 Awaiting review verdict for 28a9d0d
Review verdict / Set review-verdict status (pull_request) Successful in 3s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 23m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round 4 found the last false-open: markdown has a SECOND code-block form the fence stripper does
not cover — indented blocks (4 spaces or a tab). A pasted indented example still self-approved:
Example:
Review-verdict: MERGEABLE @ <head> -> positive
Adding an indented-code stripper would be the same move that produced rounds 2, 3 and 4: fixing the
instance, not the class. So the grammar is narrowed instead — the marker must sit at COLUMN 0. That
kills every indentation-based ambiguity at once (4-space, tab, list-nested, arbitrary indent).
Cost, accepted deliberately: a verdict indented under a list item is now ignored and classifies
`absent`, which asks a human. For a gate, erring toward ignoring is the safe direction. Fence
detection KEEPS its leading-whitespace tolerance, because stripping more is always safe.
`test_leading_indent_is_tolerated` asserted the old behaviour and is replaced by
`test_falseopen_an_indented_verdict_is_not_a_verdict`, parameterised over four indent shapes and
mutation-verified: restoring `^[[:space:]]*` fails all four, control green. 172 tests.
Round 4 verified clean by execution: the rc plumbing fails closed for a forced failure in the inner
jq, awk, grep AND the pipeline producer (rc 3/4/5/93 -> exit 2); fence-length semantics, mismatched
markers, CRLF fences, 10-marker fences, blockquote fences; emoji, CRLF, a 120k line, 200 comments;
NUL rejection with no JSON-encoding bypass.
refs #629
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decisions-Edit: yes
|
||
|
|
0f83b54334 |
fix(629): close three false-opens in the H10 verdict grammar, and give it tests
The H10 classification lived inline in `pretooluse-merge-consent.sh` with no tests. Three
protections the `release.review-verdict-gate` record described were never actually
implemented, and each graded an unreviewed head as approved. All three reproduced first:
1 MERGEABLE-LATER -> positive the token was prefix-matched, so any word STARTING
with mergeable/approved/lgtm passed
2 fenced code block -> positive the line-start anchor is satisfied inside ```, so
documentation showing the convention was a verdict
3 URL-borne sha -> positive the sha came from the first `@<hex>` ANYWHERE on the
line, so a markdown link could supply it
Fixes: whole-word token matching, with a token in neither vocabulary classified `unknown`
(never positive, and not guessed into a block either — it goes to a human); fenced blocks
stripped with fence state reset per comment body; the sha read from the verdict's OWN
`@ <sha>` field, which also makes multi-`@` lines unambiguous.
The grammar moves to `scripts/check-review-verdict.sh` so it can be tested at all — 38 tests,
and each fix mutation-verified: restoring the old regex/extraction makes exactly the
corresponding test fail, control green.
#629's fourth reported item is NOT a defect and is not claimed as a fix. A later `@ <head>`
on a BLOCKED line was reported as "masking a negative"; under the documented grammar that
line is a verdict for the sha in its own field, so `stale` is correct — and was correct
before this change too. Kept as a characterization test.
`test_post_review_verdict.py`'s cross-check re-implemented the hook's regexes in Python and
asserted the shell still contained them. That mirror is removed: it is the same duplication
that let these three survive, and a Python copy would keep passing while the shell drifted.
It now runs the real classifier.
The decision record is corrected — it asserted the URL protection this commit actually adds.
Note: the active corpus is 5637 lines against a 5600 budget, so the validator emits its
consolidation warning (non-blocking). That is #620's subject, not regressed here.
fixes #629
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decisions-Edit: yes
|
||
|
|
f394d6ce83 |
Merge pull request 'fix(616): paging is 0-based — correct the MCP contract, fix a live 1-based SPA caller, un-cap two MCP tools' (#635) from fix/616-paging-traps into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m37s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 15m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 17m49s
|
||
|
|
98f9e6ec34 |
Merge pull request 'fix(622): bind H10 merge consent to the reviewed sha via a required commit status' (#630) from feat/622-verdict-status-check into main
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 16m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 16m40s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 22m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
|
||
|
|
5648f8e92e |
docs(616): scope the pageSize cap per endpoint — it is not one number
Self-review of the previous commit caught an overclaim I introduced. I wrote the paging docs as if the pageSize cap were uniformly 100. It is not: most reads (collections, library browse, logs, playouts) Math.Clamp(.., 1, 100) GetAutoTuneChannelMembers pageSize <= 0 ? 100 : Min(.., 200) GET /api/v1/search/all-items default 500, cap 1000 That made a concrete example in docs/mcp.md simply false. It claimed `pageSize=500&pageNum=1` returns items 101-200 "not 501-1000" — but ersatztv_search_all_items also takes Page(), and 500 is UNDER its cap, so there page 1 really is items 501-1000. A caller following that example on the one tool most likely to be paged hard would have mis-derived its offsets, which is the same class of silent-short-set error this issue is about. The invariant that actually holds everywhere is the derivation, not any single cap: the offset comes from the EFFECTIVE (bounded) page size, never the requested one. Reworded to say that, in docs/mcp.md, the api.paging-zero-based record (rule + mechanics + body), and the ToolCatalog Page() comment. Catalog regenerated. The record's mechanics line no longer claims every controller uses Math.Clamp — ChannelController does not. No behaviour change; MCP suite still 59/59. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8d35a2792f |
fix(616): document paging as 0-based, expose channelId on playout detail
#616 filed three MCP/API paging traps. Two were real; one was not, and one was already half-fixed on main. Verified each against the code before changing it. REAL — pageNum documented as 1-based. `ToolCatalog.Page()` described pageNum as "1-based page number" while every paged controller defaults it to 0, floors it with `Math.Max(0, pageNum)`, and skips `PageNum * PageSize`. A caller that trusted the description started at page 1 and silently lost the first page: no error, just a short set that reads as data loss rather than an off-by-one (it cost #487 a verification pass). Fixed in the description rather than by making the MCP layer 1-based: /api/v1 is additive-only post-freeze, 0-based is load-bearing in a dozen controllers and the SPA, and a 1-based wrapper over a 0-based API would make the same parameter name mean two different things on two surfaces a reader reads together. NOT REAL — "pageSize caps the page but the offset honors the requested value". Not reproducible on any endpoint. Every controller clamps before passing, every handler skips by the clamped size, and GetCollectionItemsHandler re-clamps defensively. The reported observation (pageSize=500&pageNum=2 on a 204-item collection returning 4 items) is exactly correct 0-based behaviour at the clamped width of 100 — page 2 is items 201-204. The issue's own trap-1 table states this. Pinned by test rather than "fixed". ALREADY FIXED — playout LIST rows gained channelId in #297 (2026-07-22), three days before #616 was filed; the report was measured against prod, which runs an older :prod image. The DETAIL response (PlayoutResponseModel) genuinely still lacked it, so channelId is added there (additive) and the reset_channel_playout argument now names the trap: the id spaces overlap numerically, so passing a playout id silently resets a different channel and returns a plausible 202. Tests, both mutation-verified (each fails when its fix is reverted): - ToolCatalogTests pins "0-based" on EVERY paged tool's pageNum description, with a non-empty guard so it can't pass vacuously over an empty tool set. - GetCollectionItemsHandlerTests pins 0-based page boundaries and proves the offset derives from the clamped pageSize (page 1 at pageSize=500 returns items 101-150; the mutation that honors 500 returns an empty page). Docs: new decision record api.paging-zero-based (catalog regenerated), the api-conventions paging bullet, and a Paging section in docs/mcp.md. OpenAPI v1.json + web/src/api/generated/v1.d.ts regenerated for the added field. fixes #616 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
628ae46f7f |
fix(622): bind H10 merge consent to the reviewed sha via a required commit status
review-verdict/h10 Awaiting review verdict for 628ae46
Review verdict / Set review-verdict status (pull_request) Successful in 9s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 1m28s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m59s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 19m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
`pretooluse-merge-consent.sh` proves all three consent conditions at the moment the merge tool is called. With `merge_when_checks_succeed=true`, condition (a) is delegated to Gitea, which then merges whatever head is green at ITS merge time — while (b) Done-when and (c) the head-referencing verdict were proven against the head at SCHEDULING time. Every commit pushed in between merges unreviewed. The gate is not bypassed; it is satisfied against a snapshot that stops being true. Demonstrated as a controlled A/B rather than inferred, with a CI check left pending so Gitea waits as it really does: without a required verdict context: unreviewed commit B MERGED with it: same sequence REFUSED after reviewing B and posting it: merges — blocked, not deadlocked NOTE the anecdote in #622 is wrong and is corrected in the docs rather than repeated: PR #619 does carry `Review-verdict: MERGEABLE @ 02c82b35`, posted six seconds before the merge, explicitly re-reviewing the follow-up commits. #622 was filed off a lagging API read. The hole is real regardless, and structural — nothing FORCED that re-review inside the window Gitea would have merged in. This turns a property that held by diligence into one that holds by construction. The fix is the sha, not a smarter check. A Gitea commit status belongs to exactly one commit, so a status written for a parent cannot be inherited by a child that did not exist yet. `review-verdict/h10` becomes a REQUIRED status check on main: push a new head and the context is simply absent, which Gitea reads as not-passing (verified against 1.25.4: missing AND pending both block, and auto-merge re-checks the current head). It also covers merge paths the hook never sees — Gitea UI, raw API, another agent's session. - scripts/post-review-verdict.sh writes the comment and the status together so they cannot drift, and re-reads head after commenting: if a commit landed mid-flight it writes NO status and exits non-zero rather than retargeting the verdict at a commit nobody read. - .gitea/workflows/review-verdict.yml auto-passes the two exempt classes that would otherwise deadlock — Renovate-authored (platformAutomerge) and docs-only — and marks everything else pending. Exemptions are void when the PR touches .claude/, .gitea/, .husky/, scripts/ or docker/ci/. - The hook refuses to SCHEDULE an auto-merge unless that status is green on head, and no longer claims "CI green" on the mwcs path, where it never read CI. Two silent false negatives in the exemption path, both found by verifying rather than reasoning, both fixed at BOTH call sites (workflow and the hook's pre-existing docs-only carve-out): - The files endpoint caps at 50 rows and IGNORES a larger `limit` — PR #619 has 194 changed files and `?limit=100` returns 50. A single page saw ZERO protected paths there where the full enumeration finds ten. Both now page to exhaustion and withhold the exemption if they cannot complete. - A rename is ONE row whose `filename` is the destination, the source only in `previous_filename`. Verified live: `.gitea/workflows/renovate.yml` -> `docs/innocuous-note.md` presented as docs-only with no protected path visible. Both now read BOTH sides. Limits are documented, not papered over: base changes leave the head sha (and so the verdict) unchanged, and a PR editing the workflow is judged by its own edited copy — so PROTECTED is a guardrail against accident, not a tamper-proof control. fixes #622 |
||
|
|
8e83183b8b |
docs(491): migrate the decision record to the per-file corpus format
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 22m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
#610 split docs/decisions.md into one YAML-frontmatter file per record while this branch was open, so the inline record could not be merged -- it had to be converted. Same content and metadata, now at docs/decisions/records/scan/libraryfolder-unique-identity.md with an index entry and a regenerated catalog. |
||
|
|
fba5233caf |
feat(610): split the decision corpus into one YAML-frontmatter file per record
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a `git mv`. WHY: the monolith was a concurrency problem before an aesthetic one. A 3,900-line append target made parallel sessions collide -- PR #605 and PR #614 both hit append-vs-append conflicts during routine rebases, and hand-resolving those inside the corpus is exactly the operation the rationale-rewrite guard exists to police. HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness does not rest on reading it. The parser was taught BOTH formats first, so the body-diff guard parses the old form at the merge-base and the new form at head -- the migration validates itself, no bypass. The proof is a field-level equivalence harness: 168 records before and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing. Reviewers should scrutinise the harness; it is the actual evidence. What measuring caught that reading would not have: - ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each topic file's preamble, mostly the only copy. Source files are kept and stripped, never deleted. They also cannot be filed per-area: topic files hold several areas and 4 of 23 areas span several files. - Archive discovery was a non-recursive glob; after the split it found ZERO archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure. - ~32 live docs point into the corpus BY DATE, which the split dangles. Each stripped file now ends with a generated "Records formerly in this file" index, which also rescues the identical breadcrumbs in old issue comments. - decisions.md's "In this file:" list was 97 same-file anchor bullets that the split makes WRONG, not merely stale. Dropped; the generated index replaces them with links that resolve. The equivalence harness now runs against a checked-in FIXTURE, not the live corpus. The earlier version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the SCRIPT rather than the repo's current state. Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain two directories for one concept. Renaming a key is not a move -- it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work. refs #610 |
||
|
|
f00dfde0c5 |
docs(609): correct the --no-merges rationale — merges are discouraged, not blocked
Review Low x2, both correct and both the stale-comment class: The claim that prepush-rebase-check.sh forbids merging main into a PR branch is false. That hook refuses a branch that is BEHIND origin/main; a merge makes origin/main an ancestor, so the push is allowed. Merging main in is discouraged by convention only. So --no-merges does cost a real false negative: an author who marks ONLY a conflict-resolving merge commit gets a legitimate rewrite rejected. Keeping --no-merges and stating the trade explicitly -- that failure is loud and costs one extra commit, whereas honoring forge-composed merge bodies disables the guard silently, which is #609 itself. The module comment also still claimed a quoted example cannot arm the guard, which contradicts the residual the decision record now states accurately. Aligned both, and narrowed the record's Rule line from "some commit" to "some NON-MERGE commit" so the stated contract matches the implementation. No logic change -- comments, docstring, record prose and regenerated catalog only. fixes #609 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c597c49f02 |
fix(609): arm the decisions body-diff with a git trailer, not a bare substring
The rationale-edit exemption was a substring test over the whole commit range, so any message containing the literal marker armed it -- including prose explaining why no marker was needed, which is how it fired live in PR #605: a green --base/--head run that was vacuous on the body-diff dimension, in the one PR that hand-resolved a merge conflict inside the corpus the guard exists to police. Now read as an affirmative `Decisions-Edit:` git trailer. Git parses trailers only in the final paragraph, so a quoted example message cannot arm it -- which matters because this commit and its decision record both quote one. A non-affirmative value (`no`) does not arm it either; the retired substring arms nothing and gets a ::warning:: nudge. Tests: negative controls (prose mention, quoted mid-body trailer, `no` value, retired substring) plus positive controls (trailer, uppercase, alongside Co-Authored-By). All four negative controls verified red against the old matcher. fixes #609 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4bdad4bf52 |
fix(496,484): thread #484's projection-failure guard through the new music-video scanner [decisions-edit]
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 24s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Rebasing onto #612 exposed a silent gap rather than a conflict. #612 added a `projectionFailureCount` parameter to MediaServerReconciliationGuard.ShouldFlagMissing that refuses the sweep when the enumeration reported swallowed projection exceptions — but the parameter is OPTIONAL with a default of 0, so this scanner compiled unchanged while opting out of the protection entirely. ProjectToMusicVideo has exactly the swallowing catch #484 exists to defend against, so the music-video sweep would have been the only one unguarded. - MediaServerMusicVideoLibraryScanner creates one MediaServerProjectionFailureCounter per enumeration (never a field on the singleton api client, so concurrent scans of different libraries can't leak failures into each other's sweep decision), passes it to GetMusicVideoLibraryItems, and feeds its Count to ShouldFlagMissing. - The single guard call also gates the #496 legacy path diff, which is more exposed: a legacy row has no etag to fall back on. - ProjectToMusicVideo now returns MediaServerProjectionResult<JellyfinMusicVideo>, combining #612's Skipped/Failed distinction with #496's identity type. - main's own #484 music-video test targeted the pre-#496 hard-delete scanner (FindMusicVideoPaths /DeleteByPath) and no longer applies; it is replaced by two tests in the new architecture covering the identity sweep and the legacy sweep. Both proven non-vacuous — removing projectionFailures.Count from the guard call fails exactly those two. 4,277 tests green; format/BOM clean; decisions validator OK. |
||
|
|
64decd492e |
fix(496): give music videos a per-library server identity; itemId diff + soft trash
Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served by two libraries with overlapping local paths is a single row owned by whichever library scanned it first, so that owner's sweep destroyed a row another library still served — taking collection membership and playout references with it, irreversibly. This is #494's deferred "option 2": - New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table, varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo. - New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per library via LibraryPath.LibraryId. - New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim. - The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep. - Pre-identity rows are ADOPTED in place: the identity row is inserted against the same MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership survives and a local/second-library row is never hijacked. - AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection fills them from the server-reported path, which would break every later PathHash lookup. Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as superseded; new active record scan.musicvideo-server-identity. fixes #496 |
||
|
|
6f4497e1ce |
fix(484): guard the nested TV season and episode sweeps against projection failures
Review finding 1 (blocking). ScanSeasons' FlagFileNotFoundSeasons and ScanEpisodes' FlagFileNotFoundEpisodes had no guard at all — neither #477's nor #484's — so ProjectToSeason / ProjectToEpisode returning Failed() was computed and discarded. #477 scoped those out because "the blast radius is one show's seasons / one season's episodes", which holds for a per-parent EMPTY fetch but not for a projection failure: that is systematic by construction. One bad code path fires on every parent, so every season enumerates zero episodes, existing.Except([]) is the whole episode library, and EmptyTrashHandler deletes it permanently. Threads the counter into GetSeasonLibraryItems / GetEpisodeLibraryItems(WithoutPeople) for Jellyfin and Emby using the same optional-trailing-param shape, and guards both sweeps with MediaServerReconciliationGuard.ShouldFlagMissingDescendants — the same class and the same private failure predicate as ShouldFlagMissing, deliberately WITHOUT #477's empty-fetch branch so per-parent empty behaviour (and #476's cascade, which depends on it) is unchanged. Also from the review: - finding 3: tests now pin the same-instance JOIN at every level (movie, show, season, episode, music video) by driving the real ScanLibrary entry point and recording the failure from inside the enumeration, so a refactor handing the api client a fresh counter goes red. - finding 4: the missing-library Failed() branch is documented as defensive and unreachable. - finding 2: the mass-Skip residual (Emby's response-shape-dependent MediaSources guard, Plex's pre-projection filter) is stated as a known limitation in the decision record. - finding 5: the log-contract change (only the #484 message when both refusals apply) is noted. fixes #484 |
||
|
|
e3645a2840 |
feat(484): suppress the media-server sweep on projection failures; reject the ratio threshold
Extends MediaServerReconciliationGuard (#477) with a second deterministic refusal: when the enumeration that produced the incoming set silently dropped items whose projection THREW, the file-not-found sweep is refused. A dropped item the server did return is indistinguishable from a deletion at the reconcile step, so a projection regression could otherwise mass-flag a healthy library FileNotFound (which EmptyTrash then deletes permanently). Deliberate guard-clause skips (STRM files, virtual items, unsupported types) are explicitly NOT failures and never suppress a sweep — counting them would permanently disable reconciliation for any library holding a single STRM file. The ratio / missing-fraction threshold is REJECTED, not deferred: it is a two-sided heuristic with no tunable default and no telemetry, and the failure it approximates is exactly observable via the projection-failure count (a genuine bulk deletion produces zero failures). Seam is deliberately narrow — the private ProjectTo* contract inside each api client changed from Option<T> to MediaServerProjectionResult<T> (projected/skipped/failed), the paged helper counts IsFailure in one place, and the scanner reads it through an optional trailing MediaServerProjectionFailureCounter on only the five library-level methods that feed a sweep. The counter is per-enumeration state created by the scanner, never a field on an api client. fixes #484 |
||
|
|
8e1e15e4d7 |
docs(603): backfill stale-after + Sources onto the three ci.* outside-world records
The mechanism shipped unused. First adopters are the records that encode measured host behavior, which is what drifts silently: - ci.runner-placement 2027-01-15 — bumblebee sizing (25 GiB/12 cores) + container caps. Premise has already partly moved: the media transcoders left for jazz on 2026-07-20 and the runners were retuned since. - ci.infra-shaped-red-under-load 2027-02-15 — a triage heuristic calibrated against load 243 on a host that has since been retuned. - ci.peak-anon-measurement 2027-03-15 — weakest of the three (mostly our own script) but it does assert that memory.peak is cache-inflated, a cgroup fact. Dates staggered so they don't all come due in the same week. Each Sources line cites the incident evidence already named in the record's own prose. |
||
|
|
0ad02db651 |
feat(603): adopt OKF's optional stale-after and Sources decision-record metadata
Evaluated the Open Knowledge Format (GoogleCloudPlatform/knowledge-catalog okf v0.2, scaccogatto/okf-skills) as a replacement for our decision-record system and rejected it: its conformance rules are deliberately permissive exactly where ours are strict (broken links, unknown types and missing fields must all be tolerated; `deprecated` points at no successor), and its stable identity is the file path, which the breadcrumb rule tells agents not to trust. Adopted two of its optional families instead, additively: - `stale-after: YYYY-MM-DD` on the metadata line — marks a record asserting an outside-world fact as due for re-confirmation. Absolute date, no TTL. - `**Sources:**` in the metadata block — the evidence a record rests on, as distinct from `Signals:` (recall keywords). Neither is required; absence is never an error. A malformed `stale-after` is blocking (it would silently never fire), but a past-due record is only a non-blocking `::notice::` — going stale is the passage of time, not a defect in whatever commit is under test. The catalog's new "Review due" section renders the date only and never a clock-derived verdict, so it cannot drift `--check` red on a calendar boundary with no commit touching the corpus. No backfill: no existing record adopts either field here. fixes #603 |
||
|
|
0f1951340e |
fix(445,533): harden e2e-ui lifecycle + retire stale #533 decision record [decisions-edit]
Addresses the adversarial review round. Codex returned BLOCKED on the harness lifecycle contract; the second (cold) reviewer independently flagged the same trap-ordering defect, which is what made it credible. **Trap installed AFTER boot -> signal mid-boot orphans the server.** The window between `e2e-local.sh` returning and `trap ... EXIT INT TERM` had no handler, so a Ctrl-C/TERM there (or the PID-parse bail-out) left dotnet holding the port — violating the script's own "always kills the server" contract. The trap is now installed BEFORE boot. When the PID is not yet known, cleanup falls back to reaping whatever LISTENS on our port; attribution is sound because the pre-flight proved that port free moments earlier. **Signals were not re-raised.** A TERM landing beside a passing Playwright run could exit 0, reporting success for a cancelled run. INT/TERM now clean up and re-raise, so the wrapper dies BY the signal. Verified, not assumed: SIGTERM mid-run -> wrapper exits 143 (128+15) and leaves zero listeners and zero stray ErsatzTV processes. `e2e-local.sh` backgrounded a MULTI-command subshell, so `$!` is the subshell — not dotnet — wherever bash does not collapse it. Measured both ways: bash 3.2.57 (stock macOS /bin/bash), 2-command subshell : $! = SUBSHELL bash 5.3.15 (homebrew) : $! = leaf with `exec` (both versions) : $! = leaf Consequence on stock-macOS bash: every PID-based kill/liveness check targeted the wrong process, the escalation silently no-opped, and the server leaked. Fixed at the source with `exec`, which benefits all consumers (the CI step's trap and the e2e-functional.sh pairing), not just e2e-ui.sh. My first attempt to test this was WRONG and would have cleared the finding: a single-command subshell is collapsed on both versions. Only the 2-command form reproduces it. `testing.e2e-local-fresh-config-dir` was still `status: active` asserting the bug 'wait for either line' option today", "widening the probe ... is tracked as #533". `READY_LINE` existed ONLY in that record; nowhere in code. Amended rather than superseded: the operative rule (use a fresh config dir) is unchanged and still correct — only its RATIONALE moved from "the probe hangs" to "state bleed". Heading deliberately left alone: the validator matches records by `## HEADING`, so renaming fails CI as an "unlogged removal"; an explicit note now tells the reader the heading is historical. - Boot-failure diagnostics were lost: `OUT="$(...)"` aborts under `set -e` before the print. Now `if ! OUT=$(...)` so the output is shown. - Playwright failures surfaced no server-side evidence (nothing uploads the traces in web/e2e/.output). The server log tail is now printed on failure. - `lsof -ti :PORT` also matched outbound/TIME_WAIT sockets -> false positives. Now `-sTCP:LISTEN`. Documented honestly that the CI image ships no lsof, so the pre-flight is a local-developer guard only. - Predictable `/tmp/etv-pw-probe.$$` -> `mktemp` (symlink-redirect on a shared host). - Suggested escape-hatch port was 8411, which is scripts/security-scan.sh's default; 8409/8410 are prod/ersatztv-test on jazz. Now suggests 8419 and names the conflicts. - boot-gate.spec.ts comment overclaimed: after logout the browser holds NO cookie, so that assertion cannot prove stamp rotation (the curl harness does, by replaying the same cookie). Comment corrected to what it actually proves. Deliberately NOT changed: `ci-image-pin`'s regex accepts any hex length rather than the exact 7 chars ci-image.yml publishes (pre-existing guard weakness, not introduced here — filed as a follow-up rather than widened in this PR). Also trimmed the two records I had bloated: the active decisions corpus went over its 5600-line budget as a result of this PR (baseline on main was under), so the overflow was mine to pay down, not to pass on. - SIGTERM mid-run: exit 143, no orphan listener/process - curl harness unaffected by the `exec` change: 45/45 PASS - UI-E2E 2x clean; typecheck + lint clean; decisions validator OK, no size warning Refs #445 #533 |
||
|
|
d8c0b3e752 |
feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe [decisions-edit]
Adds the last deferred #299/#363 follow-up: the flows that CANNOT be expressed
as curl calls. Scope rule (the durable part) — assert only what the curl
harness structurally cannot reach:
1. client-side form validation (the Setup confirm-password gate is pure React
state and makes no request, so there is no HTTP contract to assert)
2. AuthGate's RENDERED states (Setup vs Login vs app)
3. the session cookie authenticating the SPA's OWN /api XHRs — curl proves the
cookie works for curl, not that the app sends it
4. sign-out through the UserMenu back to the login gate
New: web/e2e/boot-gate.spec.ts, web/playwright.config.ts, scripts/e2e-ui.sh
(owns the whole lifecycle: fresh config dir -> boot -> specs -> always kill).
Runs as a second step of the EXISTING advisory `functional-e2e` job rather than
a new job: the dominant cost there is `npm ci` + the Release build, both already
done, so this adds ~5s instead of duplicating a heavy job. It boots its own
fresh instance on port 8410 because the first spec asserts the one-shot Setup
gate that the curl step has already claimed on its config dir.
Determinism (the issue asked for it explicitly): `serial`, `workers: 1`,
`retries: 0` even in CI — a retry would let a flaky flow merge looking green.
Measured 5 consecutive clean runs, ~2s each.
Pins all five `container:` jobs to the toolchain image built by the preceding
commit, which bakes `chromium-headless-shell`.
Non-obvious coupling fixed: vitest's default include glob would have collected
web/e2e/*.spec.ts and run it under jsdom. Excluded `e2e/**` by spreading
`configDefaults.exclude` rather than narrowing `include` to `src/**`, because
web/scripts/ holds a real vitest test an src-only include would silently stop
running.
`RebuildSearchIndexHandler` logs one of two mutually-exclusive lines just before
`SystemStartup.SearchIndexIsReady()`:
fresh config -> "Done migrating search index in {Duration}"
reused config -> "Search index is already version {Version}"
The probe watched only the first, so a reused dir waited out the full 120s
timeout and then killed a perfectly healthy server. Widened to a `grep -Eq`
alternation; the handler's if/else is exhaustive, so the pair covers every path
to readiness.
Verified with a negative control: on a reused dir the server is ready in 2s via
the "already version" line, and the OLD probe string is genuinely ABSENT from
that run's log — so the old code would have hung, i.e. the fix is load-bearing
rather than incidentally passing.
The "prefer a fresh config dir" guidance stays: that guards state bleed, which
is a separate concern from the probe hanging.
- `wait "$PID"` in the cleanup trap was a NO-OP: the server is a grandchild
(launched in e2e-local.sh's subshell, which then exits), so `wait` fails
instantly and was swallowed by `|| true` — cleanup did not actually ensure the
port was released, exactly what its comment claimed. Replaced with a bounded
`kill -0` poll, then SIGKILL.
- Added a port pre-flight check: previously an occupied port surfaced as a 120s
readiness timeout that reads like a broken build. Now fails in 0s naming the
PIDs, and warns against blanket-killing `dotnet ErsatzTV.dll` (that reaps
other sessions' servers).
- UI-E2E: 5x clean (3 specs, ~2s); back-to-back runs pass with no manual cleanup
- curl harness unaffected by the boot-script change: 45/45 PASS
- web: 983 tests / 105 files green; typecheck + lint clean
- vitest collection verified: excludes web/e2e, still collects web/scripts
- Dockerfile sequence + browser launch validated verbatim in a container on the
real amd64 base before committing; chromium launches as root with NO sandbox
opt-out needed
- decisions validator green; catalog regenerated
- docs/decisions.md TOC repaired: it had drifted to 69 of 97 records and held a
dangling anchor to the #72 record that #415 superseded into archive/.
Regenerated with a generator validated against the 68 existing anchors (0
mismatches) -> 97/97, no dangling, no duplicates.
Docs: docs/e2e-local.md (new "UI-E2E harness" section), docs/ci-cd.md (toolchain
image + UI-E2E step), docs/testing.md, docs/README.md, docs/decisions.md
(new `ci.ui-e2e-harness` record; `ci.functional-e2e-harness` amended — its Rule
said "curl-only", now accurate).
Refs #445 #533
|
||
|
|
c8e79f49f4 |
chore(586,594,485): PID-scoped E2E cleanup, ci-image-pin length guard, .gitignore core fix
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m55s
Three independent CI/repo-hygiene fixes swept together; disjoint file sets. fixes #586 — E2E cleanup is scoped by PID, never a pattern-wide pkill - New decision record `testing.e2e-cleanup-scope-by-pid`. - docs/e2e-local.md states the constraint where a BRIEF-WRITER sees it (the #586 root cause was a delegation gap, not agent error). - scripts/e2e-local.sh: reviewed against e2e-ui.sh's trap lifecycle and deliberately does NOT adopt it — its contract is to hand a running instance back to its caller, so an EXIT trap would kill the server the instant the launcher returned (both callers use `OUT="$(e2e-local.sh ...)"`). Recorded. - Instead it gains what actually prevents the incident: an lsof pre-flight that NAMES a foreign listener's PID rather than letting Kestrel fail its bind and surface as "process N exited before becoming ready". - Pre-flight probes BOTH bound ports, and ETV_STREAMING_PORT now defaults to ETV_UI_PORT. Program.cs binds a second listener whose port defaults to 8409 independently of ETV_UI_PORT, so `ETV_UI_PORT=8420` alone still bound 8409 and died against a foreign holder — i.e. the documented escape hatch was a dead end that led straight back to the confusion behind the pattern kill. fixes #594 — ci-image-pin accepts any hex length - Length is a separate invariant from correctness: the resolve/staleness checks compare resolved shas, so an 8-char pin of the right commit passes green while matching NO registry tag, and all five container: jobs then die at image-pull with `manifest unknown` (reads like a registry outage). - Guard fails at the gate and prints the exact tag to use. Verified against doctored pins: 7 green; 6/8/10 red. - Uses a literal 7 rather than a derived `--short=7`: in a full clone git may widen an ambiguous abbreviation, demanding a pin ci-image.yml can never publish. Escape hatch documented inline. - Also fixes a pre-existing misdiagnosis: zero pins reported "MORE THAN ONE". - docs/ci-cd.md documents the 7-char rule and `git rev-parse --short=7 HEAD`. fixes #485 — .gitignore `core` silently ignored `*/Core/` files - A bare `core` matched any path component named `core`; case-insensitively on macOS that swallowed every `*/Core/` SOURCE dir, so new untracked files were dropped by `git add -A` while tracked ones stayed fine — a clean local build and a CI checkout that fails to compile. - Now `/core` + `/core.[0-9]*`, both anchored (an unanchored `core.[0-9]*` would re-introduce the same silent-exclusion class this fixes). - Verified by diffing the full ignored-file set before/after: identical, and the three real Core/ dirs are trackable without -f. Docs updated in-PR: docs/e2e-local.md, docs/ci-cd.md, docs/decisions/ workflow-process.md (+ regenerated catalog), docs/handoffs/chicorytv-issue-queue.md. Follow-ups filed: #596 (the same shared-host reap in the Playwright-MCP recovery record) and the ci-image.yml `--short=7` publisher-side fix, which cannot ride this PR — editing ci-image.yml re-points ci-image-pin's `expected` at this commit and reds the gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8664220aa5 |
Merge pull request 'chore(583): make per-agent model routing a hard constraint + PreToolUse gate' (#585) from chore/agent-model-routing into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m2s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
|
||
|
|
d221a06522 |
chore(583): make per-agent model routing a hard constraint + PreToolUse gate
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m42s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The kickoff tells the orchestrator to route agents by capability, but that rule lived only in a prose paragraph. On 2026-07-25 a session dispatched two implementers (#436, #440) with `model` omitted, silently inheriting the orchestrator tier — while following every bullet in HARD CONSTRAINTS in the same session. The bulleted list is what functions as the checklist; prose above it reads as background. - HARD CONSTRAINTS: keyed routing bullet requiring the tier to be stated in the dispatch itself, so an invisible omission becomes visible output. Prose paragraph tightened to point at the key rather than restate the table (the kickoff is pasted into every session — duplication is a per-session tax, #542). - .claude/hooks/pretooluse-agent-model.sh: PreToolUse on Agent, `ask` when a committing agent is dispatched with no explicit `model`. Narrow by design — passes through read-only/recon types, `fork` (model override ignored by the tool), and any dispatch already naming a tier, because a gate that fires on every fan-out trains one-shot dismissal. `ask` not `deny`: routing is a judgment call with no derivable right answer, unlike the H6/H10 merge gate. - New decision record `process.per-agent-model-routing`; catalog regenerated. Decision matrix verified against 9 payloads incl. a replay of the dispatch that missed. decisions-validate: OK. fixes #583 |
||
|
|
6cf99718ee |
feat(436): arbitrary-depth rule-builder group nesting
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 34s
PR Gates / Docs update reminder (pull_request) Successful in 38s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 42s
PR Gates / decisions lifecycle (pull_request) Successful in 1m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m3s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The rule builder's Group nesting was capped at one level (#176's Kodi model). Generalize it to recursive nesting bounded by a single shared constant, MAX_GROUP_DEPTH (types.ts, = 5, root group is depth 0): - parse.ts: replace the allowNested boolean with a depth counter that recurses to the cap; deeper input stays out of subset (null -> raw-text fallback), so parse remains the exact inverse of compile. Sub-group detection now requires the leading '(' to be the one closed by the trailing ')' (quote/escape aware), so '(a)x(b)' can't be mistaken for one wrapped group. - RuleBuilder.tsx: 'Add group' is offered while depth < MAX_GROUP_DEPTH instead of only at the root; nested group boxes get box-sizing: border-box so per-level padding can't overflow (no global reset). - roundtrip.test.ts: the 500-tree generator nests to the cap and asserts the corpus actually reached it; explicit depth-3 cases added to compile/parse/validation tests and a depth-gate test to RuleBuilder. compile.ts and validation.ts already recursed correctly and are unchanged. No backend/OpenAPI change. Docs: spa-conventions.md §12; decisions lifecycle — new active record spa.rulebuilder-nesting, predecessor spa.smartcollection-rule-builder relocated to docs/decisions/archive/spa.md as superseded. fixes #436 |
||
|
|
65c0e09179 |
feat(415): per-channel fault detection — server-derived health object + Problems filter (#581)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 15m48s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Closes #415. Server-derived health object on the channel list + detail DTOs (built-timeline detection, kind-agnostic across all 5 PlayoutScheduleKind; assessable gate keyed to the owning channel's mode), single "Problems" SPA filter with per-fault badges. Supersedes #72's api.channel-health-signal decision. Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com> |
||
|
|
9cb51f6a73 |
docs(434): [decisions-edit] update field-values decision + api-conventions for DB sourcing
Edits the api.search-field-values decision record's rationale prose in place (same key, same date, not a reversal) to describe the DB-sourced per-field distinct-values design and the narrowed allow-list, replacing the superseded Lucene-term-dictionary description. Updates the endpoint's api-conventions.md entry the same way. Regenerated docs/decisions/README.md via build_decisions_catalog.py; decisions_validate.py passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c2fb62dc88 |
docs(434,435,438): decisions records, spa-conventions §12, api-conventions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8b9a7ed541 |
feat(414): stamp immutable Channel.Origin (auto-tuned vs user-created) and surface it (#575)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m1s
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com> |
||
|
|
0c063c23fb |
harden(421,559): percent-encode access_token in IPTV URLs, redact from logs, no-store on tokened manifests (#574)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m45s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com> |
||
|
|
e99e72ee71 | docs(392): record per-schedule clock-padding decision + domain-model field | ||
|
|
c80839b338 | docs(74): channel-level graphics attachment + On Now/Next overlay | ||
|
|
7b77a37f0f |
docs(395): keep the decision record's original heading (fix decisions-lifecycle)
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 21s
PR Gates / Docs update reminder (pull_request) Successful in 24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The lifecycle validator matches records by their `##` heading for removal
detection (gone = base_headings - head_headings), ungated by [decisions-edit].
Renaming the heading read as an unlogged record removal. Restore the exact
original heading -- which stays literally true ("excluded from the golden net":
#395 adds a unit test, not a golden) -- and keep the rationale correction in the
Rule/Signals/body, which is what [decisions-edit] authorizes.
[decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
f54c9ae195 |
refactor(395): dedup Scripted≡YAML enumerator construction into ContentEnumeratorBuilder
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m25s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m36s
PR Gates / decisions lifecycle (pull_request) Failing after 14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
Extract the byte-identical PlaybackOrder -> IMediaCollectionEnumerator switch shared by SchedulingEngine.EnumeratorForContent (Scripted) and EnumeratorCache.GetEnumeratorForContent (Sequential/YAML) into one static per-family seam, mirroring #380's ShuffleSourceBuilder. Each engine keeps its own "not supported" warning on the None branch, so the per-engine message is unchanged. Adds ContentEnumeratorBuilderTests pinning the block-shuffle-not-classic trap and the unsupported-order -> None (#70) contract across all 8 unsupported orders. Corrects the testing.scripted-playout-golden-deferred decision record: Scripted's external-process + HTTP pipeline is integration-only (deferred to #563), but the in-process SchedulingEngine it drives IS unit-testable (ScriptedScheduleController is a 1:1 pass-through) -- the earlier "un-golden-able by construction" framing conflated transport with engine. docs/testing.md reframed to match. [decisions-edit] fixes #395 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
80a9824a4e |
test(381): golden coverage for Sequential (YAML) playout builder; document Scripted deferral
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 28s
PR Gates / Docs update reminder (pull_request) Successful in 46s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m53s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m29s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Extends the #163 PlayoutBuildGoldenTests in-memory net to the Sequential (YAML) builder: a committed fixture (Goldens/Fixtures/sequential-schedule.yml) with two `count: 2` instructions over one chronological collection, built via SequentialPlayoutBuilder over the pinned window. The count/all/duration handlers do UTC-only arithmetic off the caller-supplied start, so the case is TZ-independent (passes, not skips, under a non-UTC TZ) and needs no Assume guard. Non-vacuity: a fixture count tweak flips the golden + the contiguity assertion. Scripted is deliberately excluded from the golden net — ScriptedPlayoutBuilder shells out via Cli.Wrap to an external process that drives SchedulingEngine over HTTP, which no in-memory golden can characterize. Recorded as the Done-when "documented decision" arm in docs/decisions.md (testing.scripted-playout-golden-deferred) + docs/testing.md; the scripted integration harness is tracked as follow-up #563. fixes #381 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f8ae4d62ab |
fix(552): mint a short-lived JWT so the SPA reaches /iptv/* under JWT auth
Under a JWT-enabled deployment (JWT:IssuerSigningKey set), /iptv/* is gated by ConditionalIptvAuthorizeFilter and the "jwt" scheme does not accept the SPA's ctv-session cookie, and nothing minted a JWT for the browser. So the #60 channel preview was declared Unavailable and could not run at all. Add GET /api/v1/auth/iptv-token (session-gated, on the [IgnoreApi] AuthController): mints a short-lived global token via JwtHelper.GenerateBrowserToken (60 min default, JWT:BrowserTokenLifetimeMinutes override), 204 when JWT is disabled. The SPA's new withIptvToken(url) helper appends it as ?access_token= to the manifest URL (a no-op when JWT is off), used by the channel-preview panel and the troubleshooting screen. Mapper.GetPreview drops its iptvJwtEnabled -> Unavailable guard; preview is now JWT-agnostic. Live-E2E under JWT: /iptv manifest 401s without a token and passes with a valid one (garbage token -> 401); token endpoint 401s anonymous, mints with a session. Honest finding: the issue's point 2 (troubleshooting screen broken under JWT) does not reproduce -- its live.m3u8 is static-served (UseStaticFiles at /iptv/session), outside the JWT filter, so it was never gated. The withIptvToken call there is a harmless defensive no-op. Docs: security.iptv-browser-token (api-auth-security.md), amended api.channel-preview-capability, spa-conventions §5b. No OpenAPI change (IgnoreApi + unchanged Preview schema). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
22d4023263 | docs(60): record the channel-preview capability decision | ||
|
|
928784ba48 |
fix(135): from-lineup advanced overrides can express "clear to none"
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m44s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
CreateChannelFromLineupHandler resolved every advanced override with advanced.X ?? template.X, so null always meant INHERIT and a channel could not drop a template-set watermark / filler / preferred language. Add an optional typed `clear` enum list to CreateChannelFromLineupAdvancedOptions: omitted/null still inherits (byte-stable for existing clients), a field named in `clear` is forced to none. Set+clear of the same field is a 422. The enum (CreateChannelFromLineupClearField) lives in ErsatzTV.Core so the OpenAPI string-enum scan renders it as a string enum, matching every sibling advanced-options enum. Handler resolves clearable fields once via ResolveClearable and validates set/clear conflicts via ValidateClear; reference validation skips existence checks for cleared (null) refs. SPA: the shared advancedOptions model re-adds a real "None" option to the five id selects (watermark + fillers) in both the Channel Builder and the Auto-Tune DetailPanel, routed through a CLEAR overrides sentinel that applyOverridesToRequest folds into advanced.clear (never leaking onto the wire as a field value). The backend enum also covers the preferred audio/subtitle language strings for machine clients; the SPA text inputs keep "empty = inherit" (tri-state deferred). Docs: api-conventions.md §2, spa-conventions.md §11, decisions.md record api.from-lineup-clear-to-none; v1.json + generated TS regenerated. fixes #135 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1ce5743bc1 |
fix(539): WorkAheadSlots.Release clamps before decrementing, reports unbalance in-band
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m31s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three Low findings from the #536 clamp re-review, unreachable today (one guarded release site) but filed against the day a second release site is added. - §1: Release() now reads the count and CAS-decrements only when current > 0, so it never publishes a negative count even transiently. The prior decrement-first-then-clamp shape dipped to -1, which a concurrent TryAcquire could read as phantom room and over-admit at the limit (re-opening the #529 QSV pool exhaustion). It records the unbalanced release synchronously on the offending thread rather than blaming a later innocent release. - §3: Release() returns bool; HlsSessionWorker logs a warning on the false (unbalanced) return — the one in-band signal a future second release site would need. WorkAheadSlots stays logger-free by design. - §2: UnbalancedReleases doc-comment corrected — it can under-count (an over-release while count > 0 cancels a coexisting leak and goes unrecorded); no false positives, but zero does not prove correctness. Test: Release_Unbalanced_NeverPublishesNegativeCount (2M unbalanced releases vs 4 count-samplers) with a documented, verified negative control (reverting to the decrement-first body makes readers observe the transient -1). Adds a decisions.md entry (ffmpeg.work-ahead-slot-release-never-negative). fixes #539 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
12e5c3d26f |
docs(542): record the workflow lore, then prune the kickoff doc to instructions
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Timothy asked why the kickoff handoff doc stores historical narrative when it should be instructions. It shouldn't — its own lore section is chartered as "STANDING workflow/orchestration rules only" with the why belonging in docs/decisions.md. But an inventory of every bullet against the decision corpus inverted the premise: only ~8 of ~38 were actually covered. 19 had no record anywhere and 11 more were half-covered, so that single file was the ONLY copy of the mandatory review rubric, the whole CI-triage vocabulary, the build-concurrency policy, the H12 session-end audit, and the plumbing-merge recipe. Pruning first would have destroyed them. So the records come first. New topic file docs/decisions/workflow-process.md carries 32 records (ci.*, process.*, testing.*) covering every NONE and PARTIAL the inventory found, including the Gitea `?milestones=` no-op bug whose only copy was the archived selector section this prune deletes. Only then the prune: HARD CONSTRAINTS and the lore section become one- or two-line rules, each citing the decision key that holds its evidence, and the 40-line "Archived — do not follow" section is gone. 636 -> 353 lines, with every cited key verified to resolve against the corpus. The aggregate corpus budget is re-baselined 4800 -> 5600 with the reason in the code: the corpus grew because knowledge MOVED into it, which is the system working, not drift. refs #542 |