From b072b0b034b5ee90bf450737278b29f0f518be69 Mon Sep 17 00:00:00 2001 From: Brian Lehnen Date: Wed, 6 May 2026 11:04:07 -0500 Subject: [PATCH 01/16] shipyard: scope Phase 14 (v1.2 inference engines + parallel validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROJECT.md gains a "Post-v1.1 Scope — v1.2" section capturing the brainstormed v1.2 decisions: three sequential PRs covering #13 (Roboflow Inference, self- hosted only), #14 (DOODS2 with operator-selectable HTTP/gRPC transports), and #23 (per-action `ParallelValidators: true` with strict-AND aggregation, default-false for backward compat). ROADMAP.md gains a Phase 14 entry mirroring Phase 13's structure (Goal / Dependencies / Risk per PR / Deliverables / Verification / Success criteria), plus four open questions surfaced for the planner: DOODS2 .proto sourcing, Roboflow Testcontainers feasibility, ParallelValidators flag-location precedence, and aggregate-counter logging in parallel mode. Phase Count footer rolled 13 → 14. STATE.json + HISTORY.md updated. Next: /shipyard:plan 14. Co-Authored-By: Claude Opus 4.7 (1M context) --- .shipyard/HISTORY.md | 1 + .shipyard/PROJECT.md | 63 ++++++++++++++++++++++++++++++++++++++++++++ .shipyard/ROADMAP.md | 56 ++++++++++++++++++++++++++++++++++++++- .shipyard/STATE.json | 8 +++--- 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/.shipyard/HISTORY.md b/.shipyard/HISTORY.md index b2a4201..18d62b0 100644 --- a/.shipyard/HISTORY.md +++ b/.shipyard/HISTORY.md @@ -848,3 +848,4 @@ - [2026-04-28T20:44:58Z] Session ended during build (may need /shipyard:resume) - [2026-04-28T20:49:22Z] Session ended during build (may need /shipyard:resume) - [2026-04-28T20:49:51Z] Session ended during build (may need /shipyard:resume) +- [2026-05-06T00:00:00Z] Phase 14 scoped (v1.2 — issues #13/#14/#23). PROJECT.md and ROADMAP.md updated; ready for /shipyard:plan 14. diff --git a/.shipyard/PROJECT.md b/.shipyard/PROJECT.md index d7effe9..bdef378 100644 --- a/.shipyard/PROJECT.md +++ b/.shipyard/PROJECT.md @@ -196,3 +196,66 @@ CHANGELOG classifies #35 as additive (semver minor): metric names persist, only - A new operator can go from zero to a working Grafana dashboard in under fifteen minutes following `docs/observability.md` + the `docker/observability/` stack. - Adding a future token (e.g. `{score}`) requires editing `EventTokenTemplate.AllowedTokens` only — the BlueIris-side allowlist no longer exists. - A future contributor adding a new counter to `DispatcherDiagnostics` cannot ship without choosing a tag set, because the per-counter doc-comment template makes it the obvious next field to fill in. + +## Post-v1.1 Scope — v1.2 (more inference engines + parallel-AND validation) + +v1.1.0 shipped 2026-05-04 with full observability tagging and the `BlueIrisUrlTemplate` consolidation; the post-release ID-29 hotfix (eviction-callback log staleness) is queued under `[Unreleased]` for the next tag. v1.2 is the deferred-from-v1.1 scope from #13/#14/#23: two additional self-hosted inference engines as `IValidationPlugin` implementations, plus a parallel-AND mode that makes multi-engine validation a first-class option. + +### Goals (v1.2) + +1. **Two new self-hosted validators.** Roboflow Inference (RF-DETR headline) and DOODS2 (TFLite / TF / YOLOv5 detector hub) ship as separate plugin projects following the existing `IValidationPlugin` + `IPluginRegistrar` pattern established by `FrigateRelay.Plugins.CodeProjectAi`. +2. **Parallel-AND validator execution as a per-action opt-in.** Today validators run sequentially per-action (decision V3). v1.2 adds a per-action `ParallelValidators: true` flag — when set, all validators in `ActionEntry.Validators` run concurrently and the aggregate decision is strict AND (every validator must `Verdict.Allow` for the action to fire). Default remains sequential for backward compatibility. +3. **Demonstrate the multi-engine story end-to-end.** Integration tests cover at least one action protected by ≥ 2 validators in parallel, proving the new mode operationally. + +### In scope (v1.2) + +- **#13 — Roboflow Inference validator** (`FrigateRelay.Plugins.Roboflow`). + - Self-hosted Roboflow Inference only (e.g. `http://roboflow:9001`). No support for the Roboflow Hosted Cloud API in v1.2 — matches the project's infra-friendly stance. + - Per-instance `ModelId` config (e.g. `rfdetr-base`); operators declare multiple validator instances (e.g. `roboflow_persons`, `roboflow_vehicles`) if they need different models per camera. + - Same config shape as CPAI: `Validators::Type: "Roboflow"`, plus `BaseUrl`, `ModelId`, `MinConfidence`, `AllowedLabels`, `OnError`, `Timeout`. + - Transport: HTTP via `HttpClient` (typed client per `IPluginRegistrar`); WireMock-driven unit tests; Testcontainers integration test if `roboflow/inference` image is available, otherwise WireMock-only with a documented manual-smoke recipe. + +- **#14 — DOODS2 validator** (`FrigateRelay.Plugins.Doods2`). + - Operator-selectable transport: `Transport: "Http" | "Grpc"`. Both must be implemented and tested in v1.2 — gRPC is the perf-first path for high-throughput hosts (sub-millisecond serialisation, persistent HTTP/2 streams); HTTP matches the rest of the plugin family for simplicity. Operators choose per validator instance. + - HTTP path: `POST /detect` with base64-encoded image + JSON detections back. WireMock-driven unit tests. + - gRPC path: vendored DOODS2 `.proto` file compiled in-project via `Grpc.Tools`; `Grpc.Net.Client` + `Google.Protobuf` added as deps to **this plugin only** — not to `FrigateRelay.Abstractions`, not to `FrigateRelay.Host`. In-process gRPC test server for unit tests. + - Same `MinConfidence` / `AllowedLabels` / `OnError` / `Timeout` knobs as CPAI/Roboflow. + +- **#23 — Per-action `ParallelValidators: true`** in `ActionEntry`. + - Default `false`; sequential behavior unchanged for existing config. + - When `true`: validators run concurrently via `Task.WhenAll`; each validator's own `Timeout` applies; aggregate fails closed if any validator times out (matching the existing per-validator `OnError: FailClosed` semantics — "parallel" changes scheduling, not failure semantics). + - Aggregation: strict AND. Each rejecting validator still emits its own `validators.rejected` counter for per-validator dashboard visibility (no behavioral change to the counter tag matrix from v1.1). + - First reject does **not** short-circuit other in-flight validators — operators get full per-validator visibility on every dispatch. Documented as a deliberate cost-of-information tradeoff (and intentionally simpler than the cancellation-token plumbing the alternative would require). + +### Out of scope (deferred to v1.3+) + +- **Vote-based aggregation.** v1.2's parallel mode is strict AND only. Future work could add `RequireVotes: N-of-M` for soft consensus. +- **Roboflow Hosted Cloud API.** Adds an auth surface and quota error handling v1.2 does not need. +- **Per-action validator config override.** All validators referenced by an `ActionEntry` use the validator instance's own config; no per-action `ModelId` override knob. +- **First-result-wins cancellation.** Considered and deliberately rejected — the cancellation plumbing is a non-trivial surface and the per-validator visibility benefit is real. + +### v1.2 PR sequencing + +Three sequential PRs against `main`: + +1. **#13 first** — Roboflow Inference plugin. Smaller surface than #14 (HTTP-only); establishes the second-validator pattern that #14 follows. +2. **#14 second** — DOODS2 plugin (HTTP + gRPC). Builds on #13's pattern; adds the gRPC dep contained to this plugin only. +3. **#23 last** — parallel-AND opt-in. Lands after both #13 and #14 are in so its integration tests can exercise three validator types (CPAI + Roboflow + DOODS2) in a single AND chain — proves the design holds beyond the toy CPAI-only case. + +CHANGELOG classifies all three as additive (semver minor). #23's `ParallelValidators` defaults to `false`, so existing `ActionEntry` configs are unaffected. + +### v1.2 verification gates + +- `dotnet build FrigateRelay.sln -c Release` zero warnings (warnings-as-errors, both Linux and Windows). New plugin projects compile clean. +- gRPC dep containment: `dotnet list .csproj package --include-transitive` shows no `Grpc.*` transitive on either project. The dep lives in `FrigateRelay.Plugins.Doods2` only. +- All existing tests pass. +- New unit tests per validator: at minimum allow / reject / timeout / OnError-FailClosed / OnError-FailOpen / cancellation, driven by WireMock for HTTP and an in-process gRPC server for DOODS2's gRPC path. +- New integration test demonstrating ≥ 2 validators running in parallel under a single `ActionEntry` with `ParallelValidators: true`. WireMock or Testcontainers as available; the CPAI + Roboflow combination is the smallest meaningful coverage. +- `git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/` still empty (architectural invariant unchanged). +- `git grep -nE 'Grpc\.' src/FrigateRelay.Host src/FrigateRelay.Abstractions` returns empty (host + abstractions stay gRPC-free; gRPC is plugin-local). + +### v1.2 success criteria + +- An operator can declare `Validators: ["cpai", "roboflow", "doods2"]` with `ParallelValidators: true` on a single action and see all three validators contribute decisions in production logs and counters. +- Adding a hypothetical fourth validator follows the #13/#14 pattern: a new plugin project + `IPluginRegistrar` registration, no host changes required. +- Existing v1.0/v1.1 deployments upgrade to v1.2 with no config changes — sequential validation remains the default. Operators opting into parallel mode flip exactly one boolean per action. diff --git a/.shipyard/ROADMAP.md b/.shipyard/ROADMAP.md index f804c5a..cde8aba 100644 --- a/.shipyard/ROADMAP.md +++ b/.shipyard/ROADMAP.md @@ -412,9 +412,63 @@ Phases 1 and 2 can execute in parallel once the `.sln` exists (Phase 2 needs a s --- +## Phase 14 — v1.2 Inference Engines + Parallel Validation *[NOT STARTED]* + +**Status.** Not started. Phase 14 is the implementation phase for the v1.2 scope captured in `PROJECT.md` "Post-v1.1 Scope — v1.2 (more inference engines + parallel-AND validation)" (PROJECT.md:200–261). Three sequential PRs cover issues #13 (Roboflow Inference validator), #14 (DOODS2 validator with HTTP + gRPC transports), and #23 (per-action `ParallelValidators: true` opt-in). Decision rationale and the in-/out-of-scope boundary live in PROJECT.md; this section captures only the verifiable execution shape. + +**Goal.** Ship v1.2.0 with (a) two new self-hosted `IValidationPlugin` implementations — Roboflow Inference (`FrigateRelay.Plugins.Roboflow`) and DOODS2 (`FrigateRelay.Plugins.Doods2`, HTTP + gRPC transports operator-selectable per validator instance); (b) a per-`ActionEntry` `ParallelValidators: true` opt-in that runs the action's validators concurrently under a strict-AND aggregation, with default-false preserving today's sequential behavior; (c) at least one integration test that exercises ≥ 2 validators in parallel under a single `ActionEntry` so the multi-engine story is operationally proven, not hypothetical. + +**Dependencies.** Phase 13 (v1.1.0 GA on `main`; tagged counters and observability stack in place so any new counters introduced by #23 inherit the v1.1 tag matrix). The `[Unreleased]` ID-29 hotfix (eviction-callback log staleness) will likely roll out as v1.1.1 before or alongside Phase 14 — Phase 14 is **not** gated on it; the hotfix and v1.2 scope are independent. + +**Risk.** **Low–Medium**. Composite breakdown: +- **#13 — Low.** HTTP-only validator that mirrors the established CPAI pattern (typed `HttpClient` per `IPluginRegistrar`, WireMock unit tests). No new dep families. Smaller surface than #14. +- **#14 — Medium.** Highest risk in this phase. Vendored DOODS2 `.proto` + `Grpc.Tools` codegen + `Grpc.Net.Client` + `Google.Protobuf` are a new dep family for this codebase. The architectural invariant is that gRPC stays plugin-local — never reaches abstractions or host. In-process gRPC test server is straightforward but new ground for FrigateRelay's test infrastructure. +- **#23 — Low–Medium.** Touches the validator execution loop in the host (the per-action chain that today runs sequentially). Feature-flagged with `ParallelValidators: false` as the default, so existing configs are unaffected by a regression here. Failure surface is bounded to actions that opt in. + +**Estimate.** 6–9 hours of active work across the three PRs. #14 dominates (proto vendoring, gRPC client wiring, transport-selection plumbing); #13 and #23 are smaller. + +**PR sequencing (decided).** Three sequential PRs against `main`, in the order #13 → #14 → #23 (PROJECT.md:237–245): +1. **#13 first.** Smaller surface (HTTP-only); establishes the second-validator pattern that #14 reuses for its HTTP transport. +2. **#14 second.** Builds on #13's pattern; adds the gRPC dep contained to this plugin only. +3. **#23 last.** Lands after both #13 and #14 are merged so its integration test can exercise three validator types (CPAI + Roboflow + DOODS2) in a single AND chain — proves the parallel design holds beyond a CPAI-only toy case. + +CHANGELOG classifies all three as additive (semver minor). #23's `ParallelValidators` defaults to `false`, so existing `ActionEntry` configs are unaffected on upgrade. + +**Deliverables.** + +- **#13 — Roboflow Inference validator.** New `src/FrigateRelay.Plugins.Roboflow/` project: `RoboflowValidator : IValidationPlugin`, `RoboflowOptions` (`BaseUrl`, `ModelId`, `MinConfidence`, `AllowedLabels`, `OnError`, `Timeout`), `Roboflow.PluginRegistrar` registering a typed `HttpClient`. Self-hosted Roboflow Inference only — no Roboflow Hosted Cloud API in v1.2 (PROJECT.md:213–214). Per-instance `ModelId` so operators declare multiple validator instances if they need different models per camera. New `tests/FrigateRelay.Plugins.Roboflow.Tests/` with WireMock-driven coverage of allow/reject/timeout/OnError-FailClosed/OnError-FailOpen/cancellation. Optional Testcontainers integration test if `roboflow/inference` exists on a public registry with acceptable boot time; otherwise WireMock-only with a documented manual-smoke recipe. +- **#14 — DOODS2 validator (HTTP + gRPC).** New `src/FrigateRelay.Plugins.Doods2/` project: `Doods2Validator : IValidationPlugin`, `Doods2Options` (`Transport: "Http" | "Grpc"`, `BaseUrl`, `MinConfidence`, `AllowedLabels`, `OnError`, `Timeout`), `Doods2.PluginRegistrar`. HTTP path: `POST /detect` with base64-encoded image + JSON detections back, WireMock-driven unit tests. gRPC path: vendored DOODS2 `.proto` compiled in-project via `Grpc.Tools`; `Grpc.Net.Client` + `Google.Protobuf` added as deps to **this plugin only** — not abstractions, not host. In-process gRPC test server for unit tests. Operator chooses transport per validator instance — both paths must ship and be tested in v1.2. +- **#23 — Per-action `ParallelValidators: true`.** New boolean field on `ActionEntry` (default `false`). When `true`, the host's per-action validator chain runs concurrently via `Task.WhenAll`; each validator's own `Timeout` applies; aggregate fails closed if any validator times out (matches existing per-validator `OnError: FailClosed` semantics — "parallel" changes scheduling, not failure semantics). Aggregation: strict AND. First-validator-rejects does **not** short-circuit other in-flight validators — operators get full per-validator visibility on every dispatch (PROJECT.md:228). Each rejecting validator still emits its own `validators.rejected` counter (no behavioral change to the v1.1 counter tag matrix). Affected files: `ActionEntry` in `FrigateRelay.Abstractions`, the validator-execution path in `FrigateRelay.Host` (PR-time grep will confirm exact location), plus a new integration test exercising ≥ 2 validators in parallel under a single `ActionEntry`. + +**Verification (gates reproduced from PROJECT.md:248–256).** +- `dotnet build FrigateRelay.sln -c Release` zero warnings on both Linux and Windows (warnings-as-errors invariant unchanged). New plugin projects compile clean. +- **gRPC dep containment.** `dotnet list src/FrigateRelay.Abstractions/FrigateRelay.Abstractions.csproj package --include-transitive` and `dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive` both show **no** `Grpc.*` transitive entries. The dep lives in `FrigateRelay.Plugins.Doods2` only. +- `git grep -nE 'Grpc\.' src/FrigateRelay.Host src/FrigateRelay.Abstractions` returns empty (host + abstractions stay gRPC-free; gRPC is plugin-local). +- All existing tests pass. **Test-count gate:** 242 baseline (post-Phase 13) → 242+N expected; architect to determine N during Phase 14 PLAN dispatch (per-PR new-test minimums will be set at PR-1 / PR-2 / PR-3 planning briefs). +- New unit tests per validator, at minimum: allow / reject / timeout / OnError-FailClosed / OnError-FailOpen / cancellation, driven by WireMock for HTTP transports and an in-process gRPC server for DOODS2's gRPC path. +- New integration test demonstrating ≥ 2 validators running in parallel under a single `ActionEntry` with `ParallelValidators: true`. WireMock or Testcontainers as available; the CPAI + Roboflow combination is the smallest meaningful coverage, the CPAI + Roboflow + DOODS2 trio is the target if PR-3's test infrastructure allows it. +- `git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/` still empty (architectural invariant unchanged). +- Three merged PRs on `main` (one per issue), then `v1.2.0` tag — operator-cut per the CONTEXT-12 D7 manual tag-cut policy; release.yml auto-builds + pushes multi-arch GHCR images on the tag push. + +**Success criteria (reproduced from PROJECT.md:258–261).** +- An operator can declare `Validators: ["cpai", "roboflow", "doods2"]` with `ParallelValidators: true` on a single action and see all three validators contribute decisions in production logs and counters. +- Adding a hypothetical fourth validator follows the #13/#14 pattern: a new plugin project + `IPluginRegistrar` registration, no host changes required. +- Existing v1.0/v1.1 deployments upgrade to v1.2 with no config changes — sequential validation remains the default. Operators opting into parallel mode flip exactly one boolean per action. + +### Phase 14 open questions (surface, do not decide silently) + +The v1.2 scope in `PROJECT.md` is closed; these are clarifications a planner will need at the start of Phase 14 PR dispatch but that the roadmap does not pre-decide: + +- **OQ-1 — DOODS2 `.proto` sourcing.** Vendor a copy of the upstream DOODS2 `.proto` at a pinned commit, or pull it via `git submodule`? Implications: vendoring is simpler for license attribution (single LICENSE-attribution line in the plugin's project notes) and keeps Dependabot's reach scoped to NuGet only; submodule requires a `.gitmodules` entry, recursive clone for contributors, and adds a second update path. Lean: vendor at a pinned commit with the upstream commit hash + license noted at the top of the `.proto`. Confirm at PR-2 planning. +- **OQ-2 — Roboflow Inference Testcontainers image.** Does `roboflow/inference` exist on a public registry, and is its boot time acceptable for CI (target: under the existing 30s integration-test SLO from Phase 4)? If yes, ship a Testcontainers-driven integration test alongside #13's WireMock unit suite; if no, fall back to WireMock-only with a documented manual-smoke recipe in the PR description. Decide at PR-1 planning, after a five-minute `docker pull` + boot check. +- **OQ-3 — `ParallelValidators` flag location precedence.** PROJECT.md:224 places the flag on `ActionEntry` only — no per-subscription default that the action could override. Surface here in case PR-3 planning reconsiders adding a per-subscription default for ergonomic reasons (operator with 9 actions all wanting parallel mode would otherwise repeat the boolean 9 times). Default position: `ActionEntry`-only as PROJECT.md states; only revisit if PR-3 planning surfaces concrete config-bloat evidence from the example fixtures. +- **OQ-4 — First-validator-rejects logging behavior in parallel mode.** PROJECT.md:227–228 is explicit: each rejecting validator emits its own `validators.rejected` counter for per-validator dashboard visibility, and there is no aggregate "action_rejected_by_parallel_validators" counter. Surface in case PR-3 planning wants to add an aggregate counter for dashboard ergonomics. Default position: per-validator emission only as PROJECT.md states; if an aggregate counter is added later, it goes through the v1.1 tag-set discipline (no `event_id`; tags include `action`, `subscription`, `camera`, `label`). + +--- + ## Phase Count -**13 phases.** Phases 1–12 delivered v1.0; Phase 13 is the v1.1 Observability + Cleanup work. Phases 1–2 parallelizable after the `.sln` exists; Phases 3–13 sequential. +**14 phases.** Phases 1–12 delivered v1.0; Phase 13 delivered v1.1; Phase 14 delivers v1.2 (more inference engines + parallel-AND validation). Phases 1–2 parallelizable after the `.sln` exists; Phases 3–14 sequential. ## Questions Appendix diff --git a/.shipyard/STATE.json b/.shipyard/STATE.json index 1666129..60370f7 100644 --- a/.shipyard/STATE.json +++ b/.shipyard/STATE.json @@ -1,8 +1,8 @@ { "schema": 3, - "phase": 13, - "position": "Phase 13 COMPLETE on main at 06b4ba4. PR #38 (issue #35), PR #39 (issue #36), PR #40 (issue #34) all merged. 242/242 tests, 0 warnings. CHANGELOG [Unreleased] aggregates all three PRs' bullets — ready for promotion to [1.1.0] and `git tag v1.1.0` per RELEASING.md.", - "status": "phase_complete", - "updated_at": "2026-05-04T00:00:00Z", + "phase": 14, + "position": "Phase 14 scoped in PROJECT.md and ROADMAP.md (commit pending). Three sequential PRs cover #13 (Roboflow Inference), #14 (DOODS2 HTTP+gRPC), #23 (per-action ParallelValidators). Ready for /shipyard:plan 14.", + "status": "ready", + "updated_at": "2026-05-06T00:00:00Z", "blocker": null } From 143a907aee5623b93025ff254205ceb70099a0da Mon Sep 17 00:00:00 2001 From: Brian Lehnen Date: Wed, 6 May 2026 11:40:58 -0500 Subject: [PATCH 02/16] shipyard: plan phase 14 (v1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 plans across 3 waves covering issues #13/#14/#23: - Wave 1 (PR #13 Roboflow): PLAN-1.1 scaffold + DI; PLAN-1.2 8 WireMock tests + CHANGELOG - Wave 2 (PR #14 DOODS2): PLAN-2.1 dual-transport scaffold + vendored .proto + DI; PLAN-2.2 7 HTTP tests; PLAN-2.3 5 gRPC tests + dep-containment verification (gRPC stays plugin-local) - Wave 3 (PR #23 ParallelValidators): PLAN-3.1 ActionEntry/DispatchItem field + converters; PLAN-3.2 dispatcher branch + 6 unit tests; PLAN-3.3 end-to-end integration test + CHANGELOG 21 tasks total, 1570 plan-lines. Test target: 242 baseline → 269 final. VERIFICATION = PASS (all deliverables covered, no plan exceeds 3 tasks, no intra-wave file conflicts). CRITIQUE = READY (all referenced files/line ranges exist; verify commands syntactically valid; gRPC test harness flagged with documented Kestrel fallback for builder). CONTEXT-14 captures user decisions D1-D6 (PR sequencing, Roboflow self-hosted-only, DOODS2 dual-transport, per-action ParallelValidators, strict-AND no-short-circuit) and OQ-1/3/4 resolutions. OQ-2 (Roboflow Testcontainers) resolved by researcher: NOT VIABLE (16.7GB image) → WireMock-only with manual-smoke recipe in PR-1 CHANGELOG. Co-Authored-By: Claude Opus 4.7 (1M context) --- .shipyard/HISTORY.md | 1 + .shipyard/STATE.json | 4 +- .shipyard/phases/14/CONTEXT-14.md | 62 ++++ .shipyard/phases/14/CRITIQUE.md | 295 ++++++++++++++++ .shipyard/phases/14/RESEARCH.md | 482 ++++++++++++++++++++++++++ .shipyard/phases/14/VERIFICATION.md | 330 ++++++++++++++++++ .shipyard/phases/14/plans/PLAN-1.1.md | 191 ++++++++++ .shipyard/phases/14/plans/PLAN-1.2.md | 178 ++++++++++ .shipyard/phases/14/plans/PLAN-2.1.md | 335 ++++++++++++++++++ .shipyard/phases/14/plans/PLAN-2.2.md | 129 +++++++ .shipyard/phases/14/plans/PLAN-2.3.md | 221 ++++++++++++ .shipyard/phases/14/plans/PLAN-3.1.md | 165 +++++++++ .shipyard/phases/14/plans/PLAN-3.2.md | 182 ++++++++++ .shipyard/phases/14/plans/PLAN-3.3.md | 169 +++++++++ 14 files changed, 2742 insertions(+), 2 deletions(-) create mode 100644 .shipyard/phases/14/CONTEXT-14.md create mode 100644 .shipyard/phases/14/CRITIQUE.md create mode 100644 .shipyard/phases/14/RESEARCH.md create mode 100644 .shipyard/phases/14/VERIFICATION.md create mode 100644 .shipyard/phases/14/plans/PLAN-1.1.md create mode 100644 .shipyard/phases/14/plans/PLAN-1.2.md create mode 100644 .shipyard/phases/14/plans/PLAN-2.1.md create mode 100644 .shipyard/phases/14/plans/PLAN-2.2.md create mode 100644 .shipyard/phases/14/plans/PLAN-2.3.md create mode 100644 .shipyard/phases/14/plans/PLAN-3.1.md create mode 100644 .shipyard/phases/14/plans/PLAN-3.2.md create mode 100644 .shipyard/phases/14/plans/PLAN-3.3.md diff --git a/.shipyard/HISTORY.md b/.shipyard/HISTORY.md index 18d62b0..df596fd 100644 --- a/.shipyard/HISTORY.md +++ b/.shipyard/HISTORY.md @@ -849,3 +849,4 @@ - [2026-04-28T20:49:22Z] Session ended during build (may need /shipyard:resume) - [2026-04-28T20:49:51Z] Session ended during build (may need /shipyard:resume) - [2026-05-06T00:00:00Z] Phase 14 scoped (v1.2 — issues #13/#14/#23). PROJECT.md and ROADMAP.md updated; ready for /shipyard:plan 14. +- [2026-05-06T00:00:00Z] Phase 14 planned (v1.2). 8 plans, 21 tasks across 3 waves. VERIFICATION=PASS, CRITIQUE=READY. diff --git a/.shipyard/STATE.json b/.shipyard/STATE.json index 60370f7..bb099b6 100644 --- a/.shipyard/STATE.json +++ b/.shipyard/STATE.json @@ -1,8 +1,8 @@ { "schema": 3, "phase": 14, - "position": "Phase 14 scoped in PROJECT.md and ROADMAP.md (commit pending). Three sequential PRs cover #13 (Roboflow Inference), #14 (DOODS2 HTTP+gRPC), #23 (per-action ParallelValidators). Ready for /shipyard:plan 14.", - "status": "ready", + "position": "Phase 14 planned. 8 plans across 3 waves (Wave 1 = PR #13 Roboflow; Wave 2 = PR #14 DOODS2 HTTP+gRPC; Wave 3 = PR #23 ParallelValidators). 21 tasks, 1570 plan-lines. VERIFICATION = PASS, CRITIQUE = READY (0 blocking issues). Test target: 242 baseline → 269 final.", + "status": "planned", "updated_at": "2026-05-06T00:00:00Z", "blocker": null } diff --git a/.shipyard/phases/14/CONTEXT-14.md b/.shipyard/phases/14/CONTEXT-14.md new file mode 100644 index 0000000..913dc60 --- /dev/null +++ b/.shipyard/phases/14/CONTEXT-14.md @@ -0,0 +1,62 @@ +# CONTEXT-14 — User Decisions for Phase 14 Planning + +These decisions were captured in the `/shipyard:brainstorm` session that produced the v1.2 scope in `PROJECT.md` (commit `b072b0b`) plus the three OQ resolutions captured at the start of `/shipyard:plan 14`. They are authoritative for the architect — do not silently revisit. + +## Scope decisions (from brainstorm) + +### D1 — Release shape: 3 sequential PRs, semver minor (v1.2.0) +PR order is **#13 → #14 → #23** against `main`. Each PR fully reviewed and merged before the next opens. Rationale: same rhythm as v1.1; cleaner blame, easier rollback, and #23's integration test gets richer per-PR (CPAI alone → CPAI+Roboflow → CPAI+Roboflow+DOODS2). All three classified as additive. + +### D2 — Roboflow scope: self-hosted Inference only +No Roboflow Hosted Cloud API in v1.2. Auth surface and quota error handling deferred. Endpoint shape: `http://roboflow:9001`-style. + +### D3 — Roboflow model identification: per-instance `ModelId` +`Validators::Type: "Roboflow"` with `BaseUrl`, `ModelId` (e.g. `rfdetr-base`), `MinConfidence`, `AllowedLabels`, `OnError`, `Timeout`. Operators declare multiple validator instances if they need different models per camera (e.g. `roboflow_persons`, `roboflow_vehicles`). No per-action model override; matches CPAI's per-instance config pattern. + +### D4 — DOODS2 transports: HTTP **and** gRPC, operator-selectable +`Doods2Options.Transport: "Http" | "Grpc"`. Both paths must ship and be tested in v1.2. +- **HTTP path:** `POST /detect` with base64-encoded image + JSON detections back. WireMock unit tests. +- **gRPC path:** vendored `.proto` compiled in-project via `Grpc.Tools`; `Grpc.Net.Client` + `Google.Protobuf` deps live in **this plugin only** — never abstractions, never host. In-process gRPC test server for unit tests. + +User explicitly chose both transports despite the extra surface. Rationale: gRPC is "quite quicker than http" on the hot path. + +### D5 — Parallel mode opt-in: `ActionEntry.ParallelValidators: bool` (default `false`) +- When `false` (default): existing sequential validator chain unchanged. +- When `true`: validators run concurrently via `Task.WhenAll`; each validator's own `Timeout` applies; aggregate fails closed if any times out (matches existing per-validator `OnError: FailClosed` semantics — "parallel" changes scheduling, not failure semantics). + +### D6 — Parallel aggregation: strict AND, no first-reject short-circuit +All validators in the parallel set must `Verdict.Allow` for the action to fire. First reject does **not** cancel other in-flight validators — operators get full per-validator visibility on every dispatch. Documented as a deliberate cost-of-information tradeoff and intentionally simpler than the cancellation-token plumbing the alternative would require. + +## OQ resolutions (locked at /plan dispatch) + +### OQ-1 — DOODS2 `.proto` sourcing: **vendor at a pinned commit** +Copy the upstream DOODS2 `.proto` into the plugin project (`src/FrigateRelay.Plugins.Doods2/Protos/`). Add the source repo URL + commit SHA + LICENSE attribution at the top of the `.proto` file as a comment. Dependabot's reach stays scoped to NuGet only. Updates are deliberate file-level swaps. Submodule rejected — second update path alongside Dependabot adds repo-shape weight for one file. + +### OQ-2 — Roboflow Testcontainers feasibility: deferred to researcher +The architect should NOT decide this. The researcher must run a five-minute `docker pull roboflow/inference` + boot check during research and document findings: +- Image exists on a public registry (yes/no, what tag). +- Boot time is under 30s (current integration-test SLO from Phase 4). +- If both pass: PR-1 ships a Testcontainers integration test; if either fails: PR-1 ships WireMock-only with a documented manual-smoke recipe in the PR description. + +### OQ-3 — `ParallelValidators` flag location: **ActionEntry only** +Per-action exclusively. No per-subscription default. Smallest surface; aligns with V3 (per-action validator scope). If an operator with many parallel-mode actions hits config bloat, revisit in v1.3 with concrete evidence — not now. + +### OQ-4 — Reject counter in parallel mode: **per-validator emission only** +Each rejecting validator emits its own `validators.rejected` counter — same shape as today's sequential mode. Dashboards already pivot by `validator` tag; no new counter needed. No aggregate `actions.rejected_by_validators` counter — keeps the v1.1 counter inventory drift-test clean. + +## Constraints reaffirmed (not negotiable) + +- **gRPC dep containment.** `Grpc.Net.Client` + `Google.Protobuf` + `Grpc.Tools` exist in `FrigateRelay.Plugins.Doods2` only. Verification: `dotnet list package --include-transitive` on `FrigateRelay.Abstractions.csproj` and `FrigateRelay.Host.csproj` shows zero `Grpc.*` entries; `git grep -nE 'Grpc\.' src/FrigateRelay.Host src/FrigateRelay.Abstractions` returns empty. +- **Backward compat.** `ParallelValidators: false` is the default; all existing v1.0/v1.1 `appsettings.json` configs work unchanged on v1.2. +- **Counter inventory.** No new counters added to `DispatcherDiagnostics` for #23. The existing `validators.rejected` already carries `validator`, `subscription`, `camera`, `action` tags. +- **Architectural invariants from CLAUDE.md.** No `App.Metrics`, `OpenTracing`, `Jaeger.*`. No `ServicePointManager`. No `.Result`/`.Wait()`. TLS skipping per-plugin only. No hard-coded IPs/hostnames. Warnings-as-errors. Test names use underscores. `[SetsRequiredMembers]` on ctors with `required init` properties. + +## What the architect should produce + +Three sequential waves, one PR per wave (mirrors Phase 13's "wave = PR" pattern): + +- **Wave 1: PR for #13** — Roboflow plugin scaffold + tests + DI registration. +- **Wave 2: PR for #14** — DOODS2 plugin scaffold + HTTP + gRPC paths + tests + DI registration. +- **Wave 3: PR for #23** — `ParallelValidators` field + host validator-execution-loop changes + integration test exercising ≥ 2 validators concurrently. + +Each wave should keep individual plans to ≤ 3 tasks each per Shipyard's standard. Phase 13's pattern produced 1–2 plans per wave; Phase 14 likely runs similar (scaffold + tests per validator; field + execution-loop + integration test for #23). diff --git a/.shipyard/phases/14/CRITIQUE.md b/.shipyard/phases/14/CRITIQUE.md new file mode 100644 index 0000000..8196ac6 --- /dev/null +++ b/.shipyard/phases/14/CRITIQUE.md @@ -0,0 +1,295 @@ +# Phase 14 Plan Critique +**Date:** 2026-05-06 +**Type:** plan-review (feasibility stress test) + +## Verdict +**READY** — All 8 plans are feasible and executable against the current codebase. File paths exist, API surfaces match, and verification commands are syntactically correct. + +## Blocking Issues +**0** — No missing dependencies, conflicting references, or impossible verification steps detected. + +--- + +## Per-Plan Findings + +### PLAN-1.1 (Roboflow plugin scaffold + DI wiring) + +**Files referenced — all paths verified to exist or parent directories confirmed:** +- `src/FrigateRelay.Plugins.CodeProjectAi/` — exemplar plugin exists. Csproj, Options, Response, Validator, PluginRegistrar all present and inspected. +- `src/FrigateRelay.Host/Configuration/ActionEntry.cs` — exists, current signature at line 30 is `internal sealed record ActionEntry(string Plugin, string? SnapshotProvider = null, IReadOnlyList? Validators = null)`. Parent directory `/src/FrigateRelay.Host/Configuration/` confirmed. +- `src/FrigateRelay.Host/HostBootstrap.cs` — exists. Validators section gate at line 133-134 is exactly as cited in RESEARCH §3.3. +- `src/FrigateRelay.Host/FrigateRelay.Host.csproj` — exists. Current plugin ProjectReferences include CodeProjectAi; pattern for new reference is established. +- `FrigateRelay.sln` — exists, 24 projects currently defined. Solution add pattern is standard. + +**API surface verification:** +- `IValidationPlugin` contract (from Abstractions) — implicit in CPAI reference; plan clones CPAI shape. +- `IPluginRegistrar` pattern — CPAI PluginRegistrar.cs inspected; seven-step ritual at lines 30-86 matches RESEARCH §1.5 description. +- `PluginRegistrationContext` — implicit in CPAI; constructor pattern clear. + +**Verification commands — all syntactically valid:** +- Build checks (`dotnet build FrigateRelay.sln -c Release`) — standard. +- Grep patterns for invariants (`git grep -nE 'App\.Metrics|...`) — all patterns are valid grep expressions. +- Solution list check (`dotnet sln FrigateRelay.sln list | grep -i Roboflow`) — standard tooling. + +**Risk assessment:** Low. CPAI exists as an exact template; the plan is a straightforward clone with Roboflow-specific config (BaseUrl, ModelId). + +--- + +### PLAN-1.2 (Roboflow validator tests + CHANGELOG) + +**Files referenced:** +- `tests/FrigateRelay.Plugins.Roboflow.Tests/` — parent directory `/tests/` exists; new subdirectory will be created. +- `tests/FrigateRelay.Plugins.CodeProjectAi.Tests/` — exemplar exists; test structure and csproj pattern inspected. +- `FrigateRelay.TestHelpers/` — exists at `/tests/`. CapturingLogger.cs confirmed present (CLAUDE.md "shared test helper" precedent). +- `CHANGELOG.md` — must exist at repo root; not explicitly verified but standard file. + +**WireMock + NSubstitute versions:** Plan defers exact version pinning to execution time ("match the version used by other test projects"). The `run-tests.sh` auto-discovery will find the new project (established pattern per CLAUDE.md). + +**Test count baseline:** RESEARCH §8 cites "242 tests" as post-Phase-13 baseline. Verified: current test run shows exactly 242 tests. Target 250 (+8 new). + +**Verification commands:** All valid. `bash .github/scripts/run-tests.sh --skip-integration` is the established CI test runner. + +**Risk assessment:** Low. Tests follow the existing CPAI pattern; no new frameworks introduced. + +--- + +### PLAN-2.1 (DOODS2 plugin scaffold + dual-transport + DI wiring) + +**Files referenced:** +- `src/FrigateRelay.Plugins.Doods2/` — new directory; parent `/src/FrigateRelay.Plugins.*/` pattern established. +- `src/FrigateRelay.Plugins.Doods2/Protos/odrpc.proto` — new file; `/Protos/` subdirectory to be created. Upstream source: `https://github.com/snowzach/doods2` (URL provided in RESEARCH §4.1). +- Existing files to modify: `HostBootstrap.cs`, `Host.csproj`, `FrigateRelay.sln` — all verified to exist. + +**gRPC package versions:** Plan cites `Grpc.Net.Client` 2.66.0, `Grpc.Tools` 2.66.0, `Google.Protobuf` 3.28.0 (RESEARCH §4.2). Plan explicitly defers to "actual current versions at PR-2 execution time" — acceptable for RESEARCH-time guesses. + +**Architectural invariant — gRPC dep containment:** Plan's Acceptance Criteria require verification that Host and Abstractions stay gRPC-free via `dotnet list` transitive-package checks (lines 119-124). This is load-bearing and enforceable. + +**Verification commands:** All valid. The proto header comment requires builder to capture actual upstream commit SHA at execution time (achievable via `git rev-parse --short HEAD` on the doods2 repo). + +**Risk assessment:** Medium (flagged in plan frontmatter). gRPC is a new dependency family for this codebase. However, the csproj containment strategy (`PrivateAssets="all"` on Grpc.Tools, `` with explicit namespace) is sound. The architectural invariant is enforceable via the listed grep/dotnet commands. + +--- + +### PLAN-2.2 (DOODS2 HTTP-transport tests) + +**Files referenced:** +- `tests/FrigateRelay.Plugins.Doods2.Tests/` — new directory; parent exists. +- csproj will mirror Roboflow test csproj (PLAN-2.2 Task 1 lines 45-54) — no new package families, just WireMock. +- Note: Plan explicitly defers gRPC server packages to PLAN-2.3 (line 52: "NO `Grpc.AspNetCore.Server` reference yet"). + +**Test count target:** 250 → 257 (+7 HTTP-path tests). Cumulative baseline verified as 242; post-Wave-1 (PLAN-1.2) becomes 250. + +**Confidence normalization assertion:** Test #1 (`ValidateAsync_Http_DetectionAboveThresholdAfterNormalization_ReturnsAllow`) explicitly tests 0-100 → 0-1 mapping (80.0 → 0.8 >= 0.5 threshold). This is the load-bearing test for DOODS2-specific gotcha (RESEARCH §7.2 note: "confidence is **0-100** scale in DOODS2, not 0-1"). + +**Verification commands:** All valid. Counter EventId assertions (7201 timeout, 7202 unavailable) are explicitly required in Task 2 Acceptance Criteria. + +**Risk assessment:** Low. WireMock pattern established by Roboflow; confidence normalization is a straightforward assertion. + +--- + +### PLAN-2.3 (DOODS2 gRPC-transport tests + CHANGELOG) + +**Files referenced:** +- `tests/FrigateRelay.Plugins.Doods2.Tests/Fixtures/InProcessDoods2GrpcServer.cs` — new fixture file; `/Fixtures/` subdirectory to be created (no existing gRPC fixtures in codebase, but parent pattern established by Mosquitto fixtures). +- Test csproj will be extended with `Grpc.AspNetCore.Server`, `Microsoft.AspNetCore.TestHost`, `Grpc.Tools` (lines 51-53). +- Vendored proto included in test build with `GrpcServices="Server"` (line 53). + +**In-process gRPC test pattern:** Plan cites RESEARCH Concern #1 (line 479-481): "gRPC test harness ergonomics in .NET 10 are unverified." Plan Task 1 explicitly requires "proof-of-shape before fanning out" — one exemplar test first, then 4 more. Fallback documented if pattern proves hostile (lines 127-130). + +**Verification commands:** Task 1 Acceptance Criteria lines 133-137 reference the `Detector.DetectorClient` generated type and in-process server setup. These are contingent on gRPC codegen succeeding, which is gated by the csproj's `` item. Verification is sound. + +**Test count target:** 257 → 262 (+5 gRPC tests). This matches RESEARCH §8 table row for PR-2. + +**Risk assessment:** Medium. In-process gRPC server pattern is unverified against this codebase's MSTest v3 + class-fixture setup. However, plan has a documented fallback (real Kestrel host on random port) if the TestHost approach fails. The exemplar test approach (Task 1) mitigates risk by proving the pattern before fanning out. + +--- + +### PLAN-3.1 (ParallelValidators field on ActionEntry + DispatchItem + converters) + +**Files referenced — all exist:** +- `src/FrigateRelay.Host/Configuration/ActionEntry.cs:30` — verified. Current record signature spans lines 30-33. +- `src/FrigateRelay.Host/Configuration/ActionEntryJsonConverter.cs` — verified. Dual-form Read (lines 32-37 string, 39-46 object), Write (lines 53-66). DTO at lines 69-72. +- `src/FrigateRelay.Host/Configuration/ActionEntryTypeConverter.cs` — verified. Single-method converter at lines 32-35. +- `src/FrigateRelay.Host/Dispatch/DispatchItem.cs:29-36` — verified. Current readonly record struct signature at lines 29-36. +- `src/FrigateRelay.Host/EventPump.cs` — verified. Exists; plan must locate `new DispatchItem` construction sites via `git grep -n` at execution time. + +**JSON converter passthrough logic:** Plan Task 1 requires updating the DTO (line 76) to include `ParallelValidators` and updating Write to emit it conditionally (lines 81-82). TypeConverter (lines 87-88) requires "no change" — the `new ActionEntry(s)` default will carry `ParallelValidators = false`. This is correct back-compat. + +**DispatchItem propagation:** Plan Task 2 cites `git grep -n 'new DispatchItem'` as the discovery mechanism. Grep is appropriate since construction sites may vary. No hardcoded line numbers assumed — plan defers to execution-time discovery. + +**Verification commands:** All valid. The cumulative test count gate (line 150) defers to "at least 262 minimum" (no new tests in Wave 3 PLAN-3.1, but Wave 2 left us at 262). + +**Risk assessment:** Low. This is straightforward plumbing — no behavior change, just field addition with default false. Back-compat is guaranteed by the default-false parameter position. + +--- + +### PLAN-3.2 (Parallel-validators branch in ChannelActionDispatcher + unit tests) + +**Files referenced:** +- `src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs:200-246` — verified. Current line range contains the validator loop (lines 209-245); plan will branch at the `if (item.Validators.Count > 0)` entry (line 200). +- New test class in `tests/FrigateRelay.Host.Tests/ChannelActionDispatcherParallelValidatorsTests.cs` — parent directory `/tests/FrigateRelay.Host.Tests/` exists; no new csproj needed. + +**Dispatcher branch logic:** Plan specifies extracting two private async methods (lines 75-103): +- `RunValidatorsSequentiallyAsync` — existing sequential loop body unchanged (back-compat invariant). +- `RunValidatorsInParallelAsync` — new `Task.WhenAll` path with strict-AND aggregation + per-validator counter emission. + +The helper `RunOneValidatorAsync` (lines 87-102) is required for span generation (matching existing lines 211-218 activity code). This is refactorable from sequential path as well, though plan allows duplication if refactor is too invasive (line 105). + +**Counter invariant:** Plan explicitly states "No new counters added" (line 107). The existing `DispatcherDiagnostics.IncrementValidatorsPassed/Rejected` calls are used from both branches. Verification command (line 169) checks that Phase 13's `CounterInventoryDriftTests` still pass — this locks the counter count to the v1.1 baseline. + +**Catch-block ordering:** Plan does not modify validator catch-blocks; each validator's own `HttpClient.Timeout` + `catch (TaskCanceledException)` returns a verdict per its config. Parallel path awaits `Task.WhenAll` which rethrows `OperationCanceledException` naturally (line 30 states this). + +**Test count target:** 262 → 268 (+6 new unit tests). Tests are in the existing Host.Tests project (no new csproj). + +**Verification commands:** Lines 172-178 check for `item.ParallelValidators` and `Task.WhenAll` usage, counter inventory, and architectural invariants. All valid. + +**Risk assessment:** Medium (flagged in frontmatter). The dispatcher branch and Task.WhenAll aggregation is the core logic of #23. However, the test suite (6 unit tests covering sequential back-compat, happy-path parallel, strict-AND, counters, timeout, cancellation) is comprehensive. The parallel path is isolated from the sequential path by design. + +--- + +### PLAN-3.3 (Parallel-validators integration test + CHANGELOG) + +**Files referenced:** +- `tests/FrigateRelay.IntegrationTests/ParallelValidatorsSliceTests.cs` — new test class; parent `/tests/FrigateRelay.IntegrationTests/` exists (verified in earlier output). Exemplar slice tests exist per plan lines 45-46 reference to `MqttToBlueIrisSliceTests.cs`. +- `CHANGELOG.md` — exists at repo root. Plan adds bullets under `[Unreleased] / ### Added` section (which will be created by PLAN-1.2 if not existing). + +**Integration test scenario:** Plan describes a concrete CPAI + Roboflow parallel-validation scenario (lines 48-65). Two WireMock stubs for validators, one for action plugin, real Mosquitto fixture. Configuration includes `ParallelValidators: true` on an action. Assertions check that both validators received requests AND action fired (happy path). + +Optional second test (line 67: "optional, only if harness is cheap") tests strict-AND rejection. Optional stretch goal (line 70): add DOODS2-HTTP as third validator. + +Minimum bar: 1 test (line 75). This pushes test count from 268 → 269 (+1 new). + +**CHANGELOG entries:** Three bullets required (lines 162-177): +- #13 (Roboflow) — added by PLAN-1.2. +- #14 (DOODS2) — added by PLAN-2.3. +- #23 (parallel validators) — added by PLAN-3.3. + +**Verification commands:** Full test suite with integration tests (no `--skip-integration` flag), test count gate ≥ 269, counter inventory drift test, architectural invariants, gRPC dep containment (STILL holds), CHANGELOG grep checks. + +**Risk assessment:** Medium. Integration tests are slower and require Testcontainers (Docker); however, the pattern is established by Phase 4+ `MqttToBlueIrisSliceTests.cs`. The test's concurrency assertion (validator request timestamps delta below sequential lower bound) requires WireMock `RequestMessage.DateTime` inspection — this is plausible but not yet verified in this codebase's integration tests. + +--- + +## Cross-Plan Analysis + +### Dependencies and Ordering + +**Sequential PR ordering (CONTEXT-14 D1):** +- Wave 1 (PR #13 — PLAN-1.1 + 1.2) — no dependencies. +- Wave 2 (PR #14 — PLAN-2.1 + 2.2 + 2.3) — depends on PR #13 merged. +- Wave 3 (PR #23 — PLAN-3.1 + 3.2 + 3.3) — depends on PR #13 + #14 merged. + +All plans respect this ordering. PLAN-3.3's integration test explicitly exercises CPAI + Roboflow concurrently (line 21-24), requiring both plugin projects to be on `main` before PR #23 opens. + +### Shared File Conflicts + +**Wave 1 files:** PLAN-1.1 and PLAN-1.2 touch disjoint files. PLAN-1.1 creates plugin source files; PLAN-1.2 creates test files and modifies CHANGELOG.md. + +**Wave 2 files:** PLAN-2.1, 2.2, 2.3 touch: +- PLAN-2.1 modifies `HostBootstrap.cs` (add DOODS2 registrar). +- PLAN-2.2 creates test project (parallel file). +- PLAN-2.3 modifies test csproj (extends PLAN-2.2's csproj) and CHANGELOG.md. + +No blocking conflicts. PLAN-2.3 extends the test csproj created by PLAN-2.2 — correct sequencing. + +**Wave 3 files:** PLAN-3.1 modifies ActionEntry, DispatchItem, converters, EventPump (plumbing). PLAN-3.2 modifies ChannelActionDispatcher (dispatcher branch) and creates tests. PLAN-3.3 creates integration test and modifies CHANGELOG.md. No conflicts; sequencing is correct (plumbing must precede dispatcher branch). + +### Hidden Dependencies Within Waves + +**Wave 2 internal ordering:** PLAN-2.1 creates Doods2Validator; PLAN-2.2 tests HTTP path; PLAN-2.3 extends csproj and tests gRPC path. Correct sequencing — no hidden dep issues. + +**Wave 3 internal ordering:** PLAN-3.1 adds field; PLAN-3.2 uses field in dispatcher; PLAN-3.3 exercises the feature end-to-end. Correct sequencing. + +### Shared Configuration (HostBootstrap.cs) + +PLAN-1.1 Task 3 and PLAN-2.1 Task 3 both modify `HostBootstrap.cs` (registrar additions). They modify the same `if (builder.Configuration.GetSection("Validators").Exists())` block: + +- PLAN-1.1 adds Roboflow registrar (line 152 in plan's template). +- PLAN-2.1 adds DOODS2 registrar (line 284 in plan's template). + +Both are **within the same block**, and PLAN-1.1 executes first (Wave 1 before Wave 2). This is correct — the Validators gate is shared by all three validator plugins. No conflict. + +--- + +## Complexity and Scale + +### Files Touched Per Plan + +| Plan | File Count | Scope | Complexity | +|------|-----------|-------|-----------| +| 1.1 | 8 | Plugin scaffold + DI wiring | Low (clone CPAI) | +| 1.2 | 4 | Tests + CHANGELOG | Low (WireMock) | +| 2.1 | 9 | Plugin scaffold + gRPC setup + DI wiring | Medium (gRPC new) | +| 2.2 | 3 | HTTP tests + (implicit test csproj extend) | Low (WireMock) | +| 2.3 | 4 | gRPC tests + CHANGELOG | Medium (in-process gRPC) | +| 3.1 | 5 | Plumbing (ActionEntry, converters, DispatchItem, EventPump) | Low (field additions) | +| 3.2 | 2 | Dispatcher branch + unit tests | Medium (Task.WhenAll logic) | +| 3.3 | 2 | Integration test + CHANGELOG | Medium (integration test setup) | + +**Largest plans:** PLAN-2.1 (9 files, medium risk due to gRPC). All others within reasonable bounds (2-8 files, mostly low risk). + +--- + +## Verification Command Validity + +All `bash` commands cited in Verification sections are syntactically valid: +- Standard `dotnet build`, `dotnet run`, `dotnet list`, `dotnet sln` commands. +- Standard `grep`, `git grep`, `find` patterns. +- Established CI script invocation (`bash .github/scripts/run-tests.sh`). +- Test filtering via `--filter` (MSTest v3 / MTP runner support). + +No typos or path issues detected. + +--- + +## Known Caveats and Risk Mitigations + +### PLAN-2.3 — In-Process gRPC Test Pattern (Unverified) + +**Concern:** gRPC test harness ergonomics in .NET 10 are unverified against this codebase's MSTest v3 + class-fixture setup (RESEARCH Concern #1). + +**Mitigation:** Plan Task 1 requires a single exemplar test before fanning out. Fallback documented: real Kestrel host on random port if TestHost + WebApplicationFactory prove hostile. + +**Verdict:** Acceptable risk. Exemplar test approach is prudent. + +### PLAN-2.1 — Vendored .proto Upstream Commit + +**Concern:** Builder must capture the actual upstream commit SHA at PR-2 execution time. + +**Mitigation:** Plan Task 1 explicitly requires the commit SHA in the proto header comment (lines 54-55, 69-70) and cites the upstream repo URL. Builder can fetch the latest commit via `git ls-remote https://github.com/snowzach/doods2 HEAD`. + +**Verdict:** Feasible. No blockers. + +### PLAN-3.2 — Task.WhenAll Aggregation (Strict-AND with No Short-Circuit) + +**Concern:** Ensures all validators run even if one rejects (CONTEXT-14 D6). + +**Verification:** Plan Task 2 test #3 (`Dispatch_ParallelValidatorsTrue_AnyValidatorRejects_ActionDoesNotExecute_AllValidatorsRan`) explicitly asserts all three validators' `ValidateAsync` was called via NSubstitute `.Received(1)` on each. This is a load-bearing assertion; if Task.WhenAll short-circuits internally, this test will fail. + +**Verdict:** Test-gated. Safe. + +--- + +## Recommendations + +1. **PLAN-2.3 exemplar test first:** Builder should verify the in-process gRPC server pattern works before committing to the full test set. If it fails, use the documented fallback. + +2. **DOODS2 0-100 → 0-1 normalization:** PLAN-2.2 Test #1 is the load-bearing assertion that this normalization works. Ensure it passes before marking the HTTP path complete. + +3. **Shared CapturingLogger precedent:** All test plans use `FrigateRelay.TestHelpers` `CapturingLogger` (not NSubstitute on `ILogger`). This pattern is consistently enforced in acceptance criteria and should be honored in all new test files. + +4. **gRPC dep containment:** PLAN-2.1, 2.2, 2.3 verification commands include `dotnet list` transitive-package checks to ensure gRPC stays contained. These are non-negotiable gates — run them at every phase checkpoint. + +5. **Counter inventory drift test:** PLAN-3.2 Acceptance Criteria (line 114) reference `CounterInventoryDriftTests`. Locate this test in the Phase 13 output (or grep `tests/FrigateRelay.Host.Tests/` for "Inventory" or "Counter" tests) to confirm the test exists and passes at each phase boundary. + +--- + +## Final Assessment + +**All 8 plans are feasible and ready for execution.** The codebase has the necessary supporting infrastructure (test helpers, existing plugin templates, established patterns). The plans respect architectural invariants (gRPC containment, no new counters unless gated, back-compat defaults). Verification commands are all valid and enforceable. Risk is well-documented and mitigated (exemplar-first approach for gRPC tests, fallback strategies). + +**Recommended next step:** Execute Wave 1 (PLAN-1.1 + 1.2) against `main`, merge PR #13, then proceed to Wave 2. + +--- + +**Verdict:** READY diff --git a/.shipyard/phases/14/RESEARCH.md b/.shipyard/phases/14/RESEARCH.md new file mode 100644 index 0000000..c410c03 --- /dev/null +++ b/.shipyard/phases/14/RESEARCH.md @@ -0,0 +1,482 @@ +# Phase 14 RESEARCH + +Codebase patterns and external-API shapes the architect needs to plan #13 (Roboflow), #14 (DOODS2), and #23 (parallel validators). + +--- + +## 1. CPAI plugin reference (clone shape for Roboflow + DOODS2) + +The architect should clone this shape verbatim — CPAI is the canonical `IValidationPlugin` exemplar. + +### 1.1 Project layout + +`src/FrigateRelay.Plugins.CodeProjectAi/`: +- `CodeProjectAiOptions.cs` — DataAnnotations-decorated options class (1 file, ~80 lines). +- `CodeProjectAiResponse.cs` — DTO for HTTP response deserialization. +- `CodeProjectAiValidator.cs` — `IValidationPlugin` implementation (~125 lines). +- `PluginRegistrar.cs` — `IPluginRegistrar` implementation (~88 lines). +- `FrigateRelay.Plugins.CodeProjectAi.csproj` — minimal csproj. + +### 1.2 csproj shape (clone for Roboflow + DOODS2 HTTP-only path) + +`src/FrigateRelay.Plugins.CodeProjectAi/FrigateRelay.Plugins.CodeProjectAi.csproj:1-24`: +```xml + + + FrigateRelay.Plugins.CodeProjectAi + FrigateRelay.Plugins.CodeProjectAi + false + true + + + + + + + + + + + + + +``` + +### 1.3 Options class shape + +`src/FrigateRelay.Plugins.CodeProjectAi/CodeProjectAiOptions.cs:43-78`: +- `[Required, Url] string BaseUrl = ""` +- `[Range(0.0, 1.0)] double MinConfidence = 0.5` +- `string[] AllowedLabels = []` (empty = any label passes) +- `ValidatorErrorMode OnError = FailClosed` (enum FailClosed/FailOpen) +- `[Range(typeof(TimeSpan), "00:00:01", "00:01:00")] TimeSpan Timeout = TimeSpan.FromSeconds(5)` +- `bool AllowInvalidCertificates = false` (per-instance TLS skip; opt-in) + +`ValidatorErrorMode` enum lives at `CodeProjectAiOptions.cs:81-88` — Roboflow + DOODS2 should each define their own enum with the same shape (or factor a shared type later, but not v1.2). + +### 1.4 Validator class shape + +`src/FrigateRelay.Plugins.CodeProjectAi/CodeProjectAiValidator.cs`: +- `public sealed partial class CodeProjectAiValidator : IValidationPlugin` (line 24) +- Constructor signature (line 36): `(string name, CodeProjectAiOptions opts, HttpClient http, ILogger logger)` +- `public string Name => _name;` (line 49) — `IValidationPlugin.Name` getter +- `public async Task ValidateAsync(EventContext ctx, SnapshotContext snapshot, CancellationToken ct)` (line 52) +- **Snapshot resolution at line 54:** `var snap = await snapshot.ResolveAsync(ctx, ct).ConfigureAwait(false);` — returns `null` if no snapshot, validator returns `Verdict.Fail("validator_no_snapshot")`. +- **HTTP call at line 61:** `_http.PostAsync("/v1/vision/detection", content, ct)` — `HttpClient.BaseAddress` set by registrar; relative path here. +- **Catch-block ordering (lines 66-84) MUST be preserved verbatim:** + - First: `catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }` — host shutdown propagation, NOT a validator failure. + - Second: `catch (TaskCanceledException ex)` — timeout. Honors `OnError`: `FailOpen` → `Verdict.Pass()`, otherwise `Verdict.Fail("validator_timeout")`. + - Third: `catch (HttpRequestException ex)` — network/non-2xx. Honors `OnError` same way; reason: `validator_unavailable: {ex.Message}`. +- **`LoggerMessage` source-generated logging at lines 115-124** — partial class `Log` with `[LoggerMessage(EventId = 7001|7002, Level=Warning)]`. Roboflow should use a different EventId range (suggest 7100s); DOODS2 a different range (7200s). Coordinate at PR-1 / PR-2 planning. + +### 1.5 PluginRegistrar shape — the registration ritual + +`src/FrigateRelay.Plugins.CodeProjectAi/PluginRegistrar.cs:30-86`: +1. Get `Validators` config section; bail if not present (line 32-33). +2. For each child (`instance` = one named entry like `cpai`, `roboflow_persons`, etc.): + - Filter by `Type` discriminator (line 37-39): `if (!string.Equals(type, "CodeProjectAi", StringComparison.Ordinal)) continue;` + - Capture `instance.Key` to a local — closure-safety (line 43). + - `AddOptions(instanceKey).Bind(instance).ValidateDataAnnotations().ValidateOnStart()` — named options bound to that section (lines 46-50). + - `AddHttpClient($"CodeProjectAi:{instanceKey}").ConfigurePrimaryHttpMessageHandler(sp => ...)` — per-instance `HttpClient` with optional TLS bypass via per-handler `SocketsHttpHandler.SslOptions.RemoteCertificateValidationCallback` (lines 53-72). NEVER use `ServicePointManager` — CLAUDE.md invariant. + - `AddKeyedSingleton(instanceKey, (sp, key) => new CodeProjectAiValidator(...))` — keyed by the named instance key (lines 75-84). The constructor receives the bound options + the named `HttpClient` with `BaseAddress` and `Timeout` set from `opts`. +3. Critical detail: `BaseAddress` and `Timeout` are set on the `HttpClient` at the keyed-singleton factory (lines 80-81), NOT inside the registrar's `ConfigurePrimaryHttpMessageHandler`. Roboflow/DOODS2 plugins must do the same — use the named-options pattern, not constructor injection of the options into the registrar. + +### 1.6 Test shape (clone for Roboflow + DOODS2 unit tests) + +`tests/FrigateRelay.Plugins.CodeProjectAi.Tests/`: +- `CodeProjectAiValidatorTests.cs` — single test file with WireMock stubs for the upstream HTTP, NSubstitute for `ILogger`, the shared `FrigateRelay.TestHelpers` `CapturingLogger` if log assertions are needed. +- Tests assertions follow the contract: allow / reject (low confidence) / reject (label not allowed) / no snapshot / timeout (Fail-Closed default) / timeout (Fail-Open) / network unavailable (Fail-Closed) / network unavailable (Fail-Open) / cancellation (host shutdown propagates `OperationCanceledException`). +- **Test count baseline:** the existing CPAI test project ships 24 tests (per the CI run output of v1.1.0). Roboflow + DOODS2 should each ship at minimum 6 tests for the same paths; suggest 8-10 each. + +--- + +## 2. Validator execution loop (the host code #23 modifies) + +### 2.1 Where the validator chain lives + +`src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs:200-246` — the per-action validator loop. Inside `ConsumeAsync` (the channel-reader loop, one task per consumer per plugin), per `DispatchItem`: + +```csharp +if (item.Validators.Count > 0) +{ + var preResolved = await initial.ResolveAsync(item.Context, ct).ConfigureAwait(false); + shared = new SnapshotContext(preResolved); + + foreach (var validator in item.Validators) + { + // validator..check span + var validatorSpanName = $"validator.{validator.Name.ToLowerInvariant()}.check"; + using var vActivity = DispatcherDiagnostics.ActivitySource.StartActivity( + validatorSpanName, ActivityKind.Internal); + vActivity?.SetTag("event.id", item.Context.EventId); + vActivity?.SetTag("validator", validator.Name); + vActivity?.SetTag("action", plugin.Name); + vActivity?.SetTag("subscription", item.Subscription); + + var verdict = await validator.ValidateAsync(item.Context, shared, ct).ConfigureAwait(false); + // ... per-verdict tag/counter update + if (verdict.Passed) DispatcherDiagnostics.IncrementValidatorsPassed(item, validator.Name); + else { + DispatcherDiagnostics.IncrementValidatorsRejected(item, validator.Name); + LogValidatorRejected(...); + goto NextItem; // short-circuits THIS action only. + } + } +} +``` + +### 2.2 What #23 must change + +The plan must: +1. Add a `bool ParallelValidators` field to `DispatchItem` (line 29-36 today) — populated from the new `ActionEntry.ParallelValidators` field at the EventPump match-time (`EventPump` constructs `DispatchItem` from `ActionEntry`). Find the EventPump construction site at PR-3 planning via `grep -n "new DispatchItem" src/FrigateRelay.Host/`. +2. Branch in `ChannelActionDispatcher.cs:209` based on `item.ParallelValidators`: + - When `false`: existing sequential `foreach` with `goto NextItem` short-circuit. + - When `true`: `Task.WhenAll(item.Validators.Select(v => RunOneValidator(v, item, shared, ct)))` — each `RunOneValidator` returns its own `Verdict` (or rethrows on host-shutdown cancellation). After all complete, AND the verdicts; if any rejected, increment counters for each rejecting validator (preserves per-validator visibility per CONTEXT-14 OQ-4) and `goto NextItem`. Activity spans for each validator still emit individually. + +### 2.3 Per-validator timeout in parallel mode + +Each validator's `HttpClient.Timeout` already enforces its own timeout — the dispatcher does NOT need to layer a `CancellationTokenSource` per validator. `Task.WhenAll` will surface each task's `TaskCanceledException` as the validator's individual `Verdict.Fail("validator_timeout")` per its own `OnError` config (the validator's own `catch (TaskCanceledException)` block returns the verdict; it does not throw to the dispatcher). This means parallel mode is *purely* a scheduling change — no new failure semantics. + +### 2.4 Counter increments (no inventory change) + +`DispatcherDiagnostics.IncrementValidatorsPassed/Rejected(item, validator.Name)` already exists (PLAN-1.1 / Phase 13). Each parallel branch calls these per-validator after `Task.WhenAll` completes. Counter inventory unchanged from Phase 13 — `CounterInventoryDriftTests` still passes without doc changes. Per CONTEXT-14 OQ-4, no aggregate `actions.rejected_by_validators` counter is added. + +### 2.5 `ActionEntry` location + +`src/FrigateRelay.Host/Configuration/ActionEntry.cs:30` — declared as `internal sealed record` in `FrigateRelay.Host.Configuration`. NOT in `FrigateRelay.Abstractions`. So `ParallelValidators` field lives in the host project, NOT the abstractions assembly. This is a host-internal concern; plugin authors don't need to know about it. Add the new field as an additional `record` parameter: + +```csharp +internal sealed record ActionEntry( + string Plugin, + string? SnapshotProvider = null, + IReadOnlyList? Validators = null, + bool ParallelValidators = false); // NEW for #23 +``` + +`ActionEntryJsonConverter` and `ActionEntryTypeConverter` (decorating the record at lines 28-29) must also be updated to pass through the new field. Find them at PR-3 planning via `find src/FrigateRelay.Host/Configuration -name "ActionEntry*.cs"`. + +`DispatchItem` (in `Host.Dispatch`, internal readonly record struct) gets the same field — `EventPump` propagates `ActionEntry.ParallelValidators` → `DispatchItem.ParallelValidators` at construction time. + +--- + +## 3. Plugin project structure & DI wiring + +### 3.1 csproj location + +All plugins go under `src/FrigateRelay.Plugins./`. New projects: +- `src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj` +- `src/FrigateRelay.Plugins.Doods2/FrigateRelay.Plugins.Doods2.csproj` + +Test projects: +- `tests/FrigateRelay.Plugins.Roboflow.Tests/FrigateRelay.Plugins.Roboflow.Tests.csproj` +- `tests/FrigateRelay.Plugins.Doods2.Tests/FrigateRelay.Plugins.Doods2.Tests.csproj` + +### 3.2 Solution wiring + +`FrigateRelay.sln` at repo root — new projects must be added via `dotnet sln add` for each. CI auto-discovers test projects via `find tests -maxdepth 2 -name '*Tests.csproj'` (per `.github/scripts/run-tests.sh`), so just dropping the test csproj at the right path is sufficient for CI. + +### 3.3 DI wiring in `HostBootstrap.cs` + +`src/FrigateRelay.Host/HostBootstrap.cs:120-138` — registrars are added explicitly to the list, gated by config-section presence. NOT reflection-based discovery. Plan must edit this file: + +```csharp +List registrars = [new FrigateRelay.Sources.FrigateMqtt.PluginRegistrar()]; +if (builder.Configuration.GetSection("BlueIris").Exists()) + registrars.Add(new FrigateRelay.Plugins.BlueIris.PluginRegistrar()); +if (builder.Configuration.GetSection("FrigateSnapshot").Exists()) + registrars.Add(new FrigateRelay.Plugins.FrigateSnapshot.PluginRegistrar()); +if (builder.Configuration.GetSection("Pushover").Exists()) + registrars.Add(new FrigateRelay.Plugins.Pushover.PluginRegistrar()); +// CPAI/Roboflow/DOODS2: only register when the top-level Validators section is present. +// Each registrar iterates that section and only acts on its own Type discriminator. +if (builder.Configuration.GetSection("Validators").Exists()) +{ + registrars.Add(new FrigateRelay.Plugins.CodeProjectAi.PluginRegistrar()); + registrars.Add(new FrigateRelay.Plugins.Roboflow.PluginRegistrar()); // NEW for #13 + registrars.Add(new FrigateRelay.Plugins.Doods2.PluginRegistrar()); // NEW for #14 +} +``` + +The `Validators` section gate is shared — all three validator plugins are registered together, each filters on its own `Type`. This avoids needing separate config sections for each validator type. + +`FrigateRelay.Host.csproj` must add `` and same for DOODS2. + +### 3.4 Config-shape contract + +`appsettings.json` for the new validators: +```json +"Validators": { + "roboflow_persons": { + "Type": "Roboflow", + "BaseUrl": "http://roboflow:9001", + "ModelId": "rfdetr-base", + "MinConfidence": 0.50, + "AllowedLabels": ["person"], + "OnError": "FailClosed", + "Timeout": "00:00:05" + }, + "doods_persons": { + "Type": "Doods2", + "Transport": "Http", + "BaseUrl": "http://doods2:8080", + "DetectorName": "default", + "MinConfidence": 0.50, + "AllowedLabels": ["person"], + "OnError": "FailClosed", + "Timeout": "00:00:05" + } +} +``` + +`ActionEntry.Validators` references these by key: `"Validators": ["cpai", "roboflow_persons"]` etc. + +--- + +## 4. gRPC integration plan (DOODS2 #14, NEW for this codebase) + +### 4.1 Vendored .proto file + +Per CONTEXT-14 OQ-1: vendor the upstream `.proto` at a pinned commit. + +DOODS2 upstream: `https://github.com/snowzach/doods2`. The relevant proto is `odrpc/odrpc.proto` (Object Detection RPC). MIT-licensed (verify at PR-2 planning by reading `LICENSE` in the repo). + +Place at: `src/FrigateRelay.Plugins.Doods2/Protos/odrpc.proto`. Add a header comment: +```proto +// Source: https://github.com/snowzach/doods2/blob//odrpc/odrpc.proto +// License: MIT (see https://github.com/snowzach/doods2/blob//LICENSE) +// Vendored at for FrigateRelay.Plugins.Doods2. +``` + +PR-2 planning must capture the actual upstream commit SHA being vendored. + +### 4.2 csproj for DOODS2 (gRPC additions) + +`src/FrigateRelay.Plugins.Doods2/FrigateRelay.Plugins.Doods2.csproj` (extends the CPAI csproj shape): +```xml + + + FrigateRelay.Plugins.Doods2 + FrigateRelay.Plugins.Doods2 + false + true + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + +``` + +`` triggers `Grpc.Tools` MSBuild codegen at build time. Generated client classes are placed in the project's namespace + the proto's `option csharp_namespace` (or `package` if none). PR-2 planning should add `option csharp_namespace = "FrigateRelay.Plugins.Doods2.Grpc";` to the vendored .proto if not already there. + +`PrivateAssets="all"` on `Grpc.Tools` ensures the codegen package is build-time only — does NOT propagate to consumers (here, the host). Combined with the plugin-only project reference, the host's transitive package list stays gRPC-free. The architectural invariant is verified by: +```bash +dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive | grep -E 'Grpc\.' || echo "PASS" +``` + +### 4.3 In-process gRPC test server pattern + +`Grpc.AspNetCore.Server` (NOT `Grpc.Net.Client`) plus `Microsoft.AspNetCore.TestHost` is the canonical .NET 10 in-process test pattern. Test csproj adds: +```xml + + + +``` + +Test setup pattern: `WebApplicationFactory` is heavyweight; for plugin tests, use `Grpc.AspNetCore.Web.GrpcWebExtensions` + minimal API host that registers a fake `Detector.DetectorBase` (the auto-generated server-base abstract class), then connect the validator's `GrpcChannel` to the in-process `HttpClient` from `WebApplicationFactory.CreateClient()` configured with the test server's `BaseAddress`. + +PR-2 planning should sketch this concretely with one full test as exemplar — the architect should NOT pre-decide the exact testing harness without verifying the .NET 10 ergonomics first. + +--- + +## 5. Testcontainers usage in this codebase + +`tests/FrigateRelay.IntegrationTests/Fixtures/MosquittoFixture.cs:1-31` — exemplar pattern: + +```csharp +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +internal sealed class MosquittoFixture : IAsyncDisposable +{ + private readonly IContainer _container; + + public MosquittoFixture() + { + var conf = "listener 1883\nallow_anonymous true\n"; + _container = new ContainerBuilder("eclipse-mosquitto:2") + .WithPortBinding(1883, true) + .WithResourceMapping(Encoding.UTF8.GetBytes(conf), "/mosquitto/config/mosquitto.conf") + .WithWaitStrategy(Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(1883)) + .Build(); + } + + public string Hostname => _container.Hostname; + public int Port => _container.GetMappedPublicPort(1883); + + public ValueTask InitializeAsync() => new(_container.StartAsync()); + public async ValueTask DisposeAsync() => await _container.DisposeAsync().ConfigureAwait(false); +} +``` + +Notes: +- Testcontainers 4.10+ requires explicit image at `ContainerBuilder` construction (parameterless ctor + chained `.WithImage(...)` is obsolete and slated for removal). +- `Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(...)` is the current API (renamed from `UntilPortIsAvailable` in 4.7.0; old name removed in a later patch). +- Image must be on Docker Hub (or another registry); CI runners pull at test-start time. Each test class should own its fixture or use class-fixture sharing — Mosquitto's startup is ~3-5s. + +`tests/FrigateRelay.IntegrationTests/MqttToBlueIrisSliceTests.cs` and similar use `MosquittoFixture` paired with WireMock for the downstream HTTP. PR-3's parallel-validator integration test should follow this pattern: real Mosquitto + WireMock for CPAI + WireMock for Roboflow (DOODS2 likely WireMock + in-process gRPC depending on which transport). + +--- + +## 6. OQ-2 result — Roboflow Testcontainers feasibility: **NOT VIABLE for CI integration tests** + +### Evidence + +- **`docker pull roboflow/inference`** → fails: `pull access denied for roboflow/inference, repository does not exist`. The plain name does not exist; the modern image is `roboflow/roboflow-inference-server-cpu`. +- **`docker pull roboflow/roboflow-inference-server-cpu:latest`** → succeeds, **16.7 GB**. Pull alone exceeds GitHub Actions Linux runner default disk-free budget (~14 GB on `ubuntu-latest`). The image is a fully-loaded ML inference server (PyTorch + ONNX runtime + multiple model archives + a web UI). +- **Boot time**: not measured — image size alone disqualifies it. A typical FastAPI+PyTorch model-loaded boot is 30-90 seconds, but the dominant cost in CI is the pull, not the boot. +- **The image likely requires API-key authentication** for Roboflow Cloud model downloads even in self-hosted mode (the published "self-hosted" images bundle a license-server check by default). This was not verified — the operator-facing config shape captured in CONTEXT-14 D2 explicitly excludes the Cloud API surface; this PR-1 should not gate on it either. + +### Recommendation + +**PR-1 ships WireMock-only unit + integration tests; no Testcontainers integration test for Roboflow.** + +PR-1's `README.md` (or the CHANGELOG entry under `Added`) should document a manual-smoke recipe for operators who want to verify against a real Roboflow Inference container locally. Suggest: + +```bash +# Example manual smoke (operator runs locally — not in CI): +docker run --rm -p 9001:9001 \ + -e ROBOFLOW_API_KEY=... \ + roboflow/roboflow-inference-server-cpu:latest + +# In another terminal: +curl -X POST http://localhost:9001//?api_key=... \ + -F file=@test.jpg +``` + +This keeps PR-1 small and CI-fast; defers any real-container coverage to operator-driven local validation. + +--- + +## 7. External APIs + +### 7.1 Roboflow Inference HTTP API (#13) + +Self-hosted endpoint: `http://:9001///` for hosted-model inference, or `/infer/object_detection` for direct ONNX/RF-DETR routing. + +Modern API (post-2024): `POST /infer/object_detection` with body: +```json +{ + "model_id": "rfdetr-base/1", + "image": { "type": "base64", "value": "" }, + "confidence": 0.5 +} +``` + +Response shape: +```json +{ + "image": { "width": 640, "height": 480 }, + "predictions": [ + { "x": 320, "y": 240, "width": 50, "height": 100, "class": "person", "confidence": 0.92, "class_id": 0 } + ], + "time": 0.143 +} +``` + +PR-1 planning must verify the exact endpoint shape against the upstream docs at `https://inference.roboflow.com/` (or whichever URL the architect picks at PR time) — the API surface evolves and v0.x → v1.x had a path change. WireMock stubs in PR-1 should mock whatever shape the implementation calls. + +### 7.2 DOODS2 HTTP API (#14) + +`POST /detect` with body: +```json +{ + "detector_name": "default", + "data": "", + "preprocess": [], + "detect": { "*": 0.5 } +} +``` + +Response: +```json +{ + "id": "...", + "detections": [ + { "top": 100, "left": 200, "bottom": 300, "right": 400, "label": "person", "confidence": 92.5 } + ] +} +``` + +Note: confidence is **0-100** scale in DOODS2, not 0-1. The plugin must normalize `confidence / 100.0` before comparing to `MinConfidence` (which is 0-1 per CPAI's contract). + +### 7.3 DOODS2 gRPC API (#14) + +The vendored `.proto` at `src/FrigateRelay.Plugins.Doods2/Protos/odrpc.proto` defines a service: +```proto +service Detector { + rpc Detect (DetectRequest) returns (DetectResponse); +} +``` +with `DetectRequest` carrying `bytes data`, `string detector_name`, `map detect` (label-confidence threshold map), and `DetectResponse` carrying `repeated Detection detections` with the same field shape as the HTTP response. + +The `Grpc.Tools` MSBuild codegen produces a `Detector.DetectorClient` typed client. The plugin's gRPC code path: +```csharp +using var channel = GrpcChannel.ForAddress(opts.BaseUrl); // or reused per-instance +var client = new Detector.DetectorClient(channel); +var response = await client.DetectAsync(request, cancellationToken: ct); +``` + +Per-instance `GrpcChannel` lifetime should match the validator instance lifetime (singleton — `AddKeyedSingleton`). `GrpcChannel` is thread-safe and HTTP/2-multiplexed; reuse is correct. + +--- + +## 8. Test count baseline + per-PR new-test minimums + +### Baseline + +Post-Phase 13 (commit `5197492` after ID-29 hotfix): **242 tests**, 0 warnings, all passing. + +Verified via: +```bash +bash .github/scripts/run-tests.sh --skip-integration | grep total: | awk '{ sum += $2 } END { print sum }' +# → 242 +``` + +### Per-PR minimum suggestions + +| PR | New project | New tests minimum | Cumulative total | +|---|---|---|---| +| PR-1 (#13 Roboflow) | `tests/FrigateRelay.Plugins.Roboflow.Tests/` | **8** (allow / reject low-confidence / reject not-allowed-label / no-snapshot / timeout-FailClosed / timeout-FailOpen / unavailable-FailClosed / cancellation) | 250 | +| PR-2 (#14 DOODS2) | `tests/FrigateRelay.Plugins.Doods2.Tests/` | **12** (HTTP path: 6 from PR-1 list × 1 + transport-config-validation × 1 = 7; gRPC path: 5 covering happy + reject + timeout + unavailable + cancellation) | 262 | +| PR-3 (#23 parallel) | `tests/FrigateRelay.Host.Tests/` (existing) + `tests/FrigateRelay.IntegrationTests/` (existing) | **6** (sequential default unchanged / parallel happy / parallel any-reject / parallel any-timeout-FailClosed / parallel cancellation propagates / per-validator counter still emitted in parallel mode) plus **1** integration test exercising ≥ 2 validators concurrently end-to-end | 269 | + +**Phase 14 final test target: 269 tests** (242 + 8 + 12 + 7). + +Architect should treat these as floors, not ceilings — additional defensive tests welcome, but each plan must hit at least its row's count. + +--- + +## Concerns flagged for the architect + +1. **gRPC test harness ergonomics in .NET 10 are unverified.** The `Grpc.AspNetCore.Server` + `WebApplicationFactory` pattern is the canonical .NET 10 approach but may need adjustment for this codebase's MSTest v3 + class-fixture pattern. PR-2 planning should sketch one complete in-process gRPC test as a proof-of-shape before fanning out to the full test set. +2. **DOODS2 `.proto` upstream commit not yet pinned.** PR-2 planning must capture the actual upstream commit SHA being vendored (`https://github.com/snowzach/doods2`, `odrpc/odrpc.proto`). Recommend the most recent commit on `master` at PR-2 planning time. +3. **Roboflow API surface volatility.** The exact endpoint shape (hosted-model URL vs direct-infer URL) and request body have changed across versions. PR-1 planning should pin against the latest stable docs at planning time and document the version in `RoboflowOptions` XML doc-comments. +4. **OQ-2 fallback documentation.** PR-1's CHANGELOG entry should explicitly note "Roboflow integration test uses WireMock; manual-smoke recipe in README" so operators understand the coverage scope. diff --git a/.shipyard/phases/14/VERIFICATION.md b/.shipyard/phases/14/VERIFICATION.md new file mode 100644 index 0000000..bf13429 --- /dev/null +++ b/.shipyard/phases/14/VERIFICATION.md @@ -0,0 +1,330 @@ +# Verification Report — Phase 14 Plan Review +**Phase:** 14 — v1.2 Inference Engines + Parallel Validation +**Date:** 2026-05-06 +**Type:** plan-review +**Verdict:** **PASS** + +--- + +## Executive Summary + +All 8 Phase 14 plans comprehensively cover the v1.2 scope defined in ROADMAP.md and PROJECT.md with high-quality acceptance criteria, concrete verification commands, and correct wave/dependency ordering. Plans are well-balanced (21 tasks across 8 plans, max 3 per plan), file conflicts are minimal and non-destructive, and the three-PR sequencing (#13 → #14 → #23) is locked correctly. + +--- + +## Coverage Matrix + +| Deliverable | Plan(s) | Status | Evidence | +|---|---|---|---| +| #13 Roboflow plugin scaffold + Options/Response/Validator | PLAN-1.1 (T1/T2) | ✓ COVERED | Files: RoboflowOptions, RoboflowResponse, RoboflowValidator, PluginRegistrar.cs. HTTP-only per CONTEXT-14 D2. Self-hosted endpoint shape. Per-instance ModelId per D3. | +| #13 Roboflow DI wiring in HostBootstrap | PLAN-1.1 (T3) | ✓ COVERED | HostBootstrap.cs + Host.csproj edited; registrar added under `Validators` config-section gate. Shares gate with CPAI/DOODS2 per CONTEXT-14. | +| #13 Roboflow WireMock unit tests + manual-smoke recipe | PLAN-1.2 (T1/T2/T3) | ✓ COVERED | 8 tests minimum (allow/reject-confidence/reject-label/no-snapshot/timeout-FC/timeout-FO/unavailable-FC/cancellation). Per OQ-2 fallback (image too large): WireMock-only + CHANGELOG manual-smoke recipe. | +| #14 DOODS2 plugin scaffold + vendored .proto | PLAN-2.1 (T1) | ✓ COVERED | Vendor odrpc.proto at pinned commit (CONTEXT-14 OQ-1) with header comment + MIT attribution. csproj with `` for codegen. gRPC.Tools PrivateAssets=all so codegen doesn't propagate. | +| #14 DOODS2 dual-transport (HTTP + gRPC) implementation | PLAN-2.1 (T2) | ✓ COVERED | Doods2Options.Transport enum (Http/Grpc), operator-selectable per instance. HTTP: POST /detect with base64 image. gRPC: DetectAsync via generated client. Both paths implemented in Doods2Validator.cs. **Key gotcha captured:** 0-100 → 0-1 confidence normalization (RESEARCH §7.2 final note). | +| #14 DOODS2 PluginRegistrar + Host DI wiring | PLAN-2.1 (T3) | ✓ COVERED | Seven-step pattern: config filter → options binding → HTTP client + gRPC channel registration → keyed validator singleton. Added to HostBootstrap under same `Validators` gate after Roboflow. | +| #14 DOODS2 HTTP-path unit tests | PLAN-2.2 (T1/T2) | ✓ COVERED | 7 tests minimum (allow/reject-confidence/reject-label/no-snapshot/timeout-FC/timeout-FO/unavailable-FC). WireMock-driven. **Assertion explicitly checks 0-100 → 0-1 normalization** (Test #1: `80.0 / 100.0 = 0.8 >= 0.5` passes). | +| #14 DOODS2 gRPC-path tests + in-process server fixture | PLAN-2.3 (T1/T2) | ✓ COVERED | 5 gRPC tests (exemplar + happy/reject/timeout/unavailable/cancellation). In-process server fixture via `Grpc.AspNetCore.Server` + `Microsoft.AspNetCore.TestHost`. One exemplar test (T1) validates pattern before fanning out. Cancellation test covers both HTTP + gRPC paths (identical catch-ordering). | +| #23 ParallelValidators field on ActionEntry | PLAN-3.1 (T1) | ✓ COVERED | `bool ParallelValidators = false` added to ActionEntry record (last positional, default false for back-compat). Updated in ActionEntryJsonConverter (Read DTO + Write paths). TypeConverter unchanged (relies on default). Full XML doc-comment on the parameter. | +| #23 ParallelValidators field on DispatchItem + propagation | PLAN-3.1 (T2) | ✓ COVERED | `bool ParallelValidators = false` added to DispatchItem struct. EventPump.cs propagates `action.ParallelValidators` to DispatchItem constructor call (named argument). Back-compat: all test-only sites continue to work unchanged. | +| #23 Dispatcher parallel branch + strict-AND aggregation | PLAN-3.2 (T1) | ✓ COVERED | Refactored `if (item.Validators.Count > 0)` to branch on `item.ParallelValidators`. Sequential path unchanged (back-compat). Parallel path runs validators via `Task.WhenAll` with strict-AND (no first-reject short-circuit per CONTEXT-14 D6). Per-validator counter emission preserved (OQ-4). | +| #23 Parallel-mode unit tests (6 tests minimum) | PLAN-3.2 (T2) | ✓ COVERED | 6 tests: sequential back-compat, happy-path, strict-AND (all validators run when one rejects), counter emission per-validator, timeout handling, host-cancellation propagation. All use NSubstitute on IValidationPlugin. | +| #23 Parallel-validators integration test (end-to-end) | PLAN-3.3 (T1) | ✓ COVERED | One minimum (CPAI + Roboflow via WireMock), with concurrency proof: assert both WireMock request timestamps are within ~100ms (well below sequential lower bound). Stretch goal: add DOODS2-HTTP if ergonomic. Real Mosquitto via Testcontainers + WireMock pattern per RESEARCH §5. | +| Three CHANGELOG entries (#13, #14, #23) | PLAN-1.2 T3 / PLAN-2.3 T2 / PLAN-3.3 T2 | ✓ COVERED | #13 includes manual-smoke recipe (per OQ-2 fallback). #14 describes both transports + gRPC-dep-containment invariant. #23 describes strict-AND + no-new-counters + example JSON config. Placed in [Unreleased] / ### Added section. | +| Architectural invariants (no App.Metrics, OpenTracing, Jaeger, ServicePointManager, .Result/.Wait()) | All plans (verification sections) | ✓ COVERED | Each plan's verification section includes `git grep` commands to verify invariants remain empty. PLAN-2.1 has load-bearing gRPC dep-containment checks (`dotnet list package --include-transitive`). | +| Test count gate: 242 baseline → ≥269 final (27 new tests) | PLAN-1.2, PLAN-2.2, PLAN-2.3, PLAN-3.2, PLAN-3.3 | ✓ COVERED | +8 Roboflow (PLAN-1.2), +7 DOODS2 HTTP (PLAN-2.2), +5 DOODS2 gRPC (PLAN-2.3), +6 parallel-unit (PLAN-3.2), +1 integration (PLAN-3.3) = 27 new. Each plan's acceptance criteria includes explicit count gate. | + +--- + +## Per-Plan Summary + +| Plan | Wave | Tasks | Risk | Status | Notes | +|---|---|---|---|---|---| +| PLAN-1.1 | 1 | 3 | Low | ✓ PASS | Roboflow scaffold. All files identified. Clear CPAI clone pattern. DI wiring under shared Validators gate. | +| PLAN-1.2 | 1 | 3 | Low | ✓ PASS | 8 WireMock unit tests. Manual-smoke recipe in CHANGELOG (OQ-2 fallback documented). Test project scaffold. | +| PLAN-2.1 | 2 | 3 | Medium | ✓ PASS | DOODS2 scaffold + dual-transport. Vendored .proto with header attribution (OQ-1). **gRPC dep containment is load-bearing — PLAN includes multiple verification gates.** 0-100 → 0-1 normalization captured in implementation spec. | +| PLAN-2.2 | 2 | 2 | Low | ✓ PASS | 7 HTTP-path WireMock tests. Test-csproj scaffold includes explicit note: "NO Grpc.AspNetCore.Server yet" (that's PLAN-2.3's job). **Test #1 explicitly demonstrates confidence normalization** — this is the load-bearing assertion. | +| PLAN-2.3 | 2 | 2 | Medium | ✓ PASS | 5 gRPC tests + in-process fixture. Exemplar test pattern proven in T1 before fanning out (addresses RESEARCH Concern #1: in-process server ergonomics unverified). Fallback documented if pattern fails. Cancellation test shared with HTTP path (both rethrow per catch-ordering). | +| PLAN-3.1 | 3 | 2 | Low | ✓ PASS | ParallelValidators field added to ActionEntry + DispatchItem. Converters updated (JSON Read/Write). EventPump propagation to DispatchItem. Default `false` enforces back-compat (no test changes required). | +| PLAN-3.2 | 3 | 2 | Medium | ✓ PASS | Dispatcher parallel branch. 6 unit tests covering sequential back-compat, happy-path, strict-AND no-short-circuit, per-validator counter emission, timeout, host-shutdown. Counter inventory unchanged (OQ-4 enforced). | +| PLAN-3.3 | 3 | 2 | Medium | ✓ PASS | 1 minimum integration test (CPAI + Roboflow parallel). Concurrency proven via WireMock timestamp assertion. CHANGELOG entry wraps #23. Stretch: DOODS2-HTTP third validator if ergonomic. | + +--- + +## File Conflict Analysis + +**Cross-plan file modifications (same file touched by multiple plans):** + +| File | Plans | Wave(s) | Conflict? | Notes | +|---|---|---|---|---| +| `FrigateRelay.sln` | PLAN-1.1, PLAN-2.1 | 1, 2 | NO | Sequential waves. PLAN-1.1 adds Roboflow project. PLAN-2.1 adds DOODS2 project. Both are `dotnet sln add` calls — additive, no conflict. | +| `src/FrigateRelay.Host/HostBootstrap.cs` | PLAN-1.1 T3, PLAN-2.1 T3 | 1, 2 | NO | Sequential waves. Both extend the `if (builder.Configuration.GetSection("Validators").Exists())` block with new registrar adds (CPAI → Roboflow → DOODS2 stacking order). Cleanly sequential. | +| `src/FrigateRelay.Host/FrigateRelay.Host.csproj` | PLAN-1.1 T3, PLAN-2.1 T3 | 1, 2 | NO | Both add `` entries to the same ``. Additive, no conflict. | +| `tests/FrigateRelay.Plugins.Doods2.Tests/FrigateRelay.Plugins.Doods2.Tests.csproj` | PLAN-2.2 T1, PLAN-2.3 T1 | 2, 2 | NO | **Same wave.** PLAN-2.2 scaffolds the csproj (base WireMock packages). PLAN-2.3 extends it with gRPC server packages (Grpc.AspNetCore.Server, Grpc.Tools server-side codegen). Dependencies listed correctly: PLAN-2.2 depends on PLAN-2.1; PLAN-2.3 depends on PLAN-2.1 and PLAN-2.2. Order is enforced. | +| `CHANGELOG.md` | PLAN-1.2 T3, PLAN-2.3 T2, PLAN-3.3 T2 | 1, 2, 3 | NO | All add bullets to the same `[Unreleased] / ### Added` section. Sequential waves ensure section exists and bullets stack cleanly. No merge conflicts if properly sequenced. | +| `src/FrigateRelay.Host/Configuration/ActionEntry.cs` | PLAN-3.1 T1 | 3 | NO | Single wave, single plan. No conflict. | +| `src/FrigateRelay.Host/Configuration/ActionEntryJsonConverter.cs` | PLAN-3.1 T1 | 3 | NO | Single wave, single plan. No conflict. | +| `src/FrigateRelay.Host/Dispatch/DispatchItem.cs` | PLAN-3.1 T2 | 3 | NO | Single wave, single plan. No conflict. | +| `src/FrigateRelay.Host/EventPump.cs` | PLAN-3.1 T2 | 3 | NO | Single wave, single plan. No conflict. | +| `src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs` | PLAN-3.2 T1 | 3 | NO | Single wave, single plan. No conflict. | + +**Verdict:** No file conflicts. All cross-plan modifications are additive (new registrar adds, new project references, new CHANGELOG bullets) or within-wave sequential (PLAN-2.2 → PLAN-2.3 on the test csproj). + +--- + +## Dependency Ordering Verification + +``` +Wave 1 (PR #13): + PLAN-1.1 (scaffold) → PLAN-1.2 (tests depend on T1) + ✓ Correct: T2 of 1.2 declares dependency on 1.1 + +Wave 2 (PR #14): + PLAN-2.1 (scaffold) → PLAN-2.2 (tests depend on 2.1) → PLAN-2.3 (tests depend on 2.1 + 2.2) + ✓ Correct: 2.2 declares dependency [2.1]; 2.3 declares [2.1, 2.2] + +Wave 3 (PR #23): + PLAN-3.1 (plumbing) → PLAN-3.2 (dispatcher, depends on 3.1) → PLAN-3.3 (integration, depends on 3.1 + 3.2) + ✓ Correct: 3.2 declares [3.1]; 3.3 declares [3.1, 3.2] + +Wave interdependencies (per CONTEXT-14 D1: Wave 2 strictly after Wave 1; Wave 3 strictly after Wave 2): + PLAN-2.1/2.2/2.3 declare dependencies [1.1, 1.2] ← shown as Wave 2 depends on Wave 1 + ✓ Correct: PLAN-2.1:5-6 reads "dependencies: [1.1, 1.2]" + PLAN-3.1/3.2/3.3 declare dependencies [1.1, 1.2, 2.1, 2.2, 2.3] (fully anchored to end of Wave 2) + ✓ Correct: PLAN-3.1:5 reads "dependencies: [1.1, 1.2, 2.1, 2.2, 2.3]" +``` + +**Verdict:** Dependency graph is correct and acyclic. No circular dependencies. Sequential waves are properly enforced. + +--- + +## Task Count Analysis + +| Plan | Task Count | Max Allowed | Status | +|---|---|---|---| +| PLAN-1.1 | 3 | 3 | ✓ AT MAX | +| PLAN-1.2 | 3 | 3 | ✓ AT MAX | +| PLAN-2.1 | 3 | 3 | ✓ AT MAX | +| PLAN-2.2 | 2 | 3 | ✓ OK | +| PLAN-2.3 | 2 | 3 | ✓ OK | +| PLAN-3.1 | 2 | 3 | ✓ OK | +| PLAN-3.2 | 2 | 3 | ✓ OK | +| PLAN-3.3 | 2 | 3 | ✓ OK | + +**Verdict:** All plans stay within the 3-task max per Shipyard standard (CLAUDE.md). Good distribution. + +--- + +## Acceptance Criteria Quality + +### Verification Commands (Spot Check) + +**PLAN-1.1 Task 3 — Solution wiring:** +```bash +dotnet build FrigateRelay.sln -c Release +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +dotnet sln FrigateRelay.sln list | grep -i 'FrigateRelay.Plugins.Roboflow' +``` +**Assessment:** Concrete, runnable commands. Clear pass/fail criteria. ✓ GOOD + +**PLAN-2.1 Task 1 — gRPC codegen + dep containment:** +```bash +dotnet build src/FrigateRelay.Plugins.Doods2/FrigateRelay.Plugins.Doods2.csproj -c Release +dotnet list src/FrigateRelay.Abstractions/FrigateRelay.Abstractions.csproj package --include-transitive | grep -E 'Grpc\.' && exit 1 || true +``` +**Assessment:** Load-bearing verification commands. Absence assertions (exit 1 on match, || true for "pass if empty") are correctly implemented. ✓ EXCELLENT + +**PLAN-2.3 Task 1 — Exemplar test pattern:** +```bash +dotnet run --project tests/FrigateRelay.Plugins.Doods2.Tests -c Release --no-build -- --filter "Grpc_DetectionAboveThreshold" +``` +**Assessment:** Specific MSTest v3 filter syntax. ✓ GOOD + +**PLAN-3.2 Task 2 — Parallel-mode unit tests:** +```bash +dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build -- --filter "ParallelValidators" +TOTAL=$(bash .github/scripts/run-tests.sh --skip-integration 2>&1 | grep -E '^total:' | awk '{ sum += $2 } END { print sum }') +[ "$TOTAL" -ge 268 ] || { echo "test count regression: $TOTAL < 268"; exit 1; } +``` +**Assessment:** Combines specific test filter with aggregate count validation. Proper shell error handling. ✓ GOOD + +### Acceptance Criteria Testability + +All acceptance criteria are **objective and testable**: +- Build commands with zero-warnings gate ✓ +- `dotnet list package --include-transitive` grep absence checks ✓ +- Test count assertions with aggregate sums ✓ +- `git grep` for architectural invariants ✓ +- WireMock request count assertions ✓ +- EventId assertions in log captures ✓ +- Timestamp delta assertions for concurrency proof ✓ + +No subjective criteria like "code is clean" or "implementation looks correct" detected. ✓ GOOD + +--- + +## Risk Assessment + +| Plan | Risk Level | Assessment | +|---|---|---| +| PLAN-1.1 | Low | Straightforward CPAI clone. HTTP-only, no new dep families. Clear DI pattern established. | +| PLAN-1.2 | Low | WireMock pattern established (CPAI precedent). Manual-smoke recipe addresses image-size constraint. | +| PLAN-2.1 | **Medium** | **Highest risk in phase.** gRPC is new dep family. Architectural invariant (containment to plugin only) is load-bearing. Plan acknowledges this: load-bearing verification commands in place, OQ-1 decision locked (vendor .proto, not submodule), .proto codegen PrivateAssets correctly specified. Mitigation: strong. | +| PLAN-2.2 | Low | WireMock HTTP path mirrors Roboflow tests. 0-100 → 0-1 normalization is captured in test spec and implementation spec. | +| PLAN-2.3 | **Medium** | In-process gRPC test server pattern unverified against this codebase's MSTest v3 + Testcontainers stack (RESEARCH Concern #1). Plan addresses: exemplar test in T1 as proof-of-shape before fanning out. Fallback documented (real Kestrel-bound server on random port). Reasonable risk mitigation. | +| PLAN-3.1 | Low | Field plumbing with default `false`. Back-compat enforced. Converters updated. No complex logic. | +| PLAN-3.2 | **Medium** | Dispatcher loop refactoring. Multi-validator concurrent execution (Task.WhenAll). Catch-block ordering critical (rethrow OperationCanceledException per host-shutdown contract). Plan captures this in spec and test coverage (Test #6). No first-reject short-circuit is non-obvious but clearly enforced (Test #3). Counter inventory must stay unchanged (OQ-4 gate in acceptance criteria). Risk is bounded by strong test coverage. | +| PLAN-3.3 | **Medium** | Integration test with real Mosquitto + WireMock + concurrency proof (timestamp assertion). Stretch goal (DOODS2-HTTP) adds complexity if pursued. Minimum scope (CPAI + Roboflow) is achievable. | + +**Overall:** Medium-risk phase driven by gRPC (new) and parallel-validator dispatcher (complex logic). Risks are acknowledged in plans and mitigated via strong verification gates, exemplar patterns, and fallback paths. ✓ ACCEPTABLE + +--- + +## Gaps and Findings + +### No Critical Gaps + +All Phase 14 requirements from ROADMAP.md (lines 437–451) are **fully covered** by at least one plan task. No deliverable is orphaned. + +### Minor Observations (Non-Blocking) + +1. **PLAN-2.3 gRPC test fallback clarity** (RESEARCH Concern #1): + - Plan documents that in-process TestHost pattern is untested against this codebase's MSTest v3 + Testcontainers combination. + - Mitigation: exemplar test in T1 + documented fallback (real Kestrel listener on random port). + - **Finding:** Strong. The fallback is realistic and well-documented. Builder has a clear escape route if in-process pattern fails. + +2. **PLAN-3.3 "stretch goal" clarity** (three-validator integration test): + - ROADMAP.md line 449 mentions "the CPAI + Roboflow combination is the smallest meaningful coverage, the CPAI + Roboflow + DOODS2 trio is the target if PR-3's test infrastructure allows it." + - PLAN-3.3 lists CPAI + Roboflow as the minimum bar, with DOODS2-HTTP as a stretch. + - **Finding:** Correctly scoped. Plan acknowledges both the minimum bar (262 test gate is met with CPAI + Roboflow) and the target (stretch goal). Acceptable tradeoff: v1.2.0 ships with full parallel-AND feature end-to-end proven with two validators; three-validator case is documented as a deferred polish item if time permits. + +3. **OQ-2 resolution in PLAN-1.2** (Roboflow Testcontainers fallback): + - CONTEXT-14 OQ-2 required the researcher to check if `roboflow/roboflow-inference-server-cpu` image exists and boots in <30s. + - PLAN-1.1 preamble states the researcher found: **NOT VIABLE** (image is 16.7 GB, exceeds GitHub Actions disk budget). + - PLAN-1.2 T3 correctly implements the OQ-2 fallback: WireMock-only + manual-smoke recipe in CHANGELOG. + - **Finding:** Correct. The fallback is documented and includes the manual-smoke recipe for operators. + +4. **CONTEXT-14 OQ-3 decision** (ParallelValidators flag location): + - CONTEXT-14 OQ-3 locks `ActionEntry`-only, no per-subscription default. + - PLAN-3.1 Task 1 implements exactly this: flag on ActionEntry record, defaults to `false`. + - No per-subscription override surface added. + - **Finding:** Correct. Smallest surface, no future burden. + +5. **CONTEXT-14 OQ-4 decision** (reject counter in parallel mode): + - OQ-4 locks: per-validator emission only (each rejecting validator emits `validators.rejected`), no aggregate counter. + - PLAN-3.2 Task 1 implements: for each rejecting validator, call `IncrementValidatorsRejected` (same counter call as sequential mode). + - Acceptance criterion: "counter inventory unchanged" (loaded in verification commands). + - **Finding:** Correct. Counter inventory is explicitly gated in PLAN-3.2 acceptance criteria (no new counters, phase 13's drift test still passes). + +--- + +## Architectural Invariants Verification + +All 8 plans include verification sections that validate: +- ✓ `dotnet build FrigateRelay.sln -c Release` zero warnings +- ✓ `git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/` empty +- ✓ `git grep -nE '\.(Result|Wait)\(' src/` empty +- ✓ `git grep -n 'ServicePointManager' src/` empty +- ✓ No hard-coded IPs/hostnames (regex checks for `192.168.*`, `10.0.0.*`, `172.16-31.*`) +- ✓ `dotnet list package --include-transitive` checks (gRPC containment in PLAN-2.1, PLAN-2.2, PLAN-2.3, PLAN-3.2, PLAN-3.3) +- ✓ `git grep -n 'Grpc\.' src/FrigateRelay.Host src/FrigateRelay.Abstractions` empty (PLAN-2.1, PLAN-2.3, PLAN-3.2, PLAN-3.3) + +**Verdict:** Architectural invariants are comprehensively gated across all plans. Load-bearing checks appear multiple times (esp. gRPC containment) to ensure no regression. ✓ EXCELLENT + +--- + +## Test Coverage Analysis + +### New Test Counts (Target: 269 = 242 baseline + 27 new) + +| Source | Count | Rationale | +|---|---|---| +| PLAN-1.2 (Roboflow WireMock) | +8 | allow / reject-low-confidence / reject-bad-label / no-snapshot / timeout-FailClosed / timeout-FailOpen / unavailable-FailClosed / cancellation | +| PLAN-2.2 (DOODS2 HTTP WireMock) | +7 | above / below threshold / bad-label / no-snapshot / timeout-FC / timeout-FO / unavailable-FC (cancellation deferred to 2.3) | +| PLAN-2.3 (DOODS2 gRPC in-process) | +5 | exemplar test + below-threshold + deadline-exceeded-FC + server-error-FC + cancellation (shared with HTTP) | +| PLAN-3.2 (ChannelActionDispatcher parallel) | +6 | sequential back-compat / all-pass / any-reject-no-short-circuit / per-validator-counter / timeout-handling / host-cancellation | +| PLAN-3.3 (integration parallel validators) | +1 | CPAI + Roboflow end-to-end (stretch: +DOODS2-HTTP) | +| **Total** | **+27** | **242 + 27 = 269 minimum** | + +Each plan's acceptance criteria includes explicit test-count gates that accumulate. The final gate (PLAN-3.3) asserts ≥269 total via `bash .github/scripts/run-tests.sh` (full integration suite). ✓ GOOD + +### Test Categorization + +- **Unit tests per validator:** 8 (Roboflow) + 12 (DOODS2 HTTP + gRPC) = 20 tests ✓ + - Covers: happy path, threshold/label filters, no-snapshot, timeout-FailClosed/FailOpen, unavailable error, cancellation. + - Use WireMock (HTTP validators) + in-process gRPC server (DOODS2 gRPC). +- **Unit tests per dispatcher:** 6 tests ✓ + - Covers: sequential back-compat, parallel happy-path, strict-AND no-short-circuit, counter emission, timeout, host-shutdown. +- **Integration test:** 1 minimum (CPAI + Roboflow end-to-end) ✓ + - Real Mosquitto + WireMock, concurrency proof via timestamp assertion. + +**Verdict:** Test coverage is comprehensive and well-structured. Validator tests are thorough (all error modes + timeout modes + cancellation). Dispatcher tests cover both sequential and parallel paths plus the critical "no first-reject short-circuit" invariant (Test #3, PLAN-3.2). ✓ EXCELLENT + +--- + +## Wave Sequencing + +### Wave 1 (PR #13 — Roboflow) +**Status:** PLAN-1.1 + PLAN-1.2 ready for review. +**Deliverable:** Roboflow `IValidationPlugin`, 8 unit tests, CHANGELOG bullet. +**Dependency:** None. Self-contained. Can execute immediately after Phase 13. + +### Wave 2 (PR #14 — DOODS2) +**Status:** PLAN-2.1 + PLAN-2.2 + PLAN-2.3 ready for review. +**Deliverable:** DOODS2 `IValidationPlugin` (HTTP + gRPC), 12 unit tests, CHANGELOG bullet. +**Dependency:** Wave 1 (PR #13 merged) — listed explicitly in each PLAN-2.x at lines 5–6. +**Risk mitigation:** Exemplar gRPC test in PLAN-2.3 T1 before full fan-out (mitigates RESEARCH Concern #1). + +### Wave 3 (PR #23 — Parallel Validators) +**Status:** PLAN-3.1 + PLAN-3.2 + PLAN-3.3 ready for review. +**Deliverable:** `ParallelValidators` field + dispatcher branch + integration test, 6 unit + 1 integration test, CHANGELOG bullet. +**Dependency:** Wave 2 (PR #14 merged) — listed explicitly in each PLAN-3.x at lines 5–6. +**Rationale:** Integration test exercises CPAI + Roboflow + (optionally) DOODS2 in parallel, so both #13 and #14 must be on `main` first. + +**Verdict:** Wave sequencing is locked correctly per CONTEXT-14 D1. ✓ PASS + +--- + +## Decision Traceability + +All key decisions from CONTEXT-14 (D1–D6, OQ-1–OQ-4) are referenced in the plans: + +| Decision | Location | Status | +|---|---|---| +| D1 — 3 sequential PRs (#13 → #14 → #23) | All wave-3 plans reference; phase-level goal in intro | ✓ Locked | +| D2 — Roboflow self-hosted only | PLAN-1.1 Context + Task 1 (RoboflowOptions doc) | ✓ Locked | +| D3 — Roboflow per-instance ModelId | PLAN-1.1 Context + Task 1 (RoboflowOptions param) | ✓ Locked | +| D4 — DOODS2 both transports (operator-selectable) | PLAN-2.1 Context + Task 2 (Doods2Options.Transport enum) | ✓ Locked | +| D5 — ParallelValidators: bool on ActionEntry (default false) | PLAN-3.1 Context + Task 1 (ActionEntry record) | ✓ Locked | +| D6 — Strict-AND, no first-reject short-circuit | PLAN-3.2 Context + Task 1 (parallel branch spec) + Task 2 Test #3 | ✓ Locked | +| OQ-1 — Vendor .proto at pinned commit (not submodule) | PLAN-2.1 Task 1 (vendoring spec + header comment) | ✓ Locked | +| OQ-2 — Roboflow Testcontainers (researcher found NOT VIABLE) | PLAN-1.2 preamble + Task 3 (fallback: WireMock + manual recipe) | ✓ Locked | +| OQ-3 — ActionEntry-only, no per-subscription default | PLAN-3.1 Context + Task 1 (ActionEntry no per-subscription field) | ✓ Locked | +| OQ-4 — Per-validator counter emission, no aggregate | PLAN-3.2 Task 1 (counter calls preserved) + Task 2 Test #4 | ✓ Locked | + +**Verdict:** All decisions are correctly propagated into plan specs. No silent deviations. ✓ EXCELLENT + +--- + +## Recommendations + +None. Phase 14 is **ready for builder execution**. All criteria are met: +1. ✓ Requirements fully covered +2. ✓ Task counts within spec +3. ✓ Dependency graph correct +4. ✓ File conflicts minimal and non-destructive +5. ✓ Acceptance criteria concrete and testable +6. ✓ Architectural invariants comprehensively gated +7. ✓ Test coverage well-structured (unit + integration) +8. ✓ Wave sequencing locked +9. ✓ Risk mitigations in place (exemplar patterns, fallbacks, load-bearing verification commands) + +--- + +## Verdict + +**✓ PASS** + +Phase 14 plans are production-ready. The architect has correctly decomposed the v1.2 scope (Roboflow + DOODS2 + ParallelValidators) into 8 well-balanced plans covering all requirements with high-quality acceptance criteria, concrete verification commands, and appropriate risk mitigations. The three-PR wave structure (PR #13 → #14 → #23) is locked per CONTEXT-14 D1, and all cross-cutting decisions (OQ-1 through OQ-4) are correctly embedded in the plan specs. + +No gaps. Builder can proceed. + diff --git a/.shipyard/phases/14/plans/PLAN-1.1.md b/.shipyard/phases/14/plans/PLAN-1.1.md new file mode 100644 index 0000000..3ab4741 --- /dev/null +++ b/.shipyard/phases/14/plans/PLAN-1.1.md @@ -0,0 +1,191 @@ +--- +phase: 14-v1.2-inference-engines-parallel-validation +plan: 1.1 +wave: 1 +dependencies: [] +must_haves: + - New project src/FrigateRelay.Plugins.Roboflow with RoboflowOptions, RoboflowResponse, RoboflowValidator, PluginRegistrar + - DI wiring in HostBootstrap.cs (registrar added under existing Validators-section gate) + - Project + reference added to FrigateRelay.sln and FrigateRelay.Host.csproj + - Build clean (warnings-as-errors) on Linux + Windows; zero new warnings +files_touched: + - src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj + - src/FrigateRelay.Plugins.Roboflow/RoboflowOptions.cs + - src/FrigateRelay.Plugins.Roboflow/RoboflowResponse.cs + - src/FrigateRelay.Plugins.Roboflow/RoboflowValidator.cs + - src/FrigateRelay.Plugins.Roboflow/PluginRegistrar.cs + - src/FrigateRelay.Host/HostBootstrap.cs + - src/FrigateRelay.Host/FrigateRelay.Host.csproj + - FrigateRelay.sln +tdd: false +risk: low +--- + +# Plan 1.1: Roboflow plugin scaffold + DI wiring (PR #13 — Roboflow Inference validator) + +## Context + +Issue #13 ships a self-hosted Roboflow Inference `IValidationPlugin`. CONTEXT-14 D2 locks scope to the self-hosted endpoint shape (`http://roboflow:9001`) — no Roboflow Hosted Cloud API in v1.2. CONTEXT-14 D3 locks per-instance `ModelId` (operators declare multiple validator instances, e.g. `roboflow_persons`, `roboflow_vehicles`, for per-camera model selection). + +The CPAI plugin is the canonical clone target (RESEARCH §1). Project layout (RESEARCH §1.1), csproj shape (RESEARCH §1.2), options shape (RESEARCH §1.3), validator shape (RESEARCH §1.4), and registrar ritual (RESEARCH §1.5) all transfer verbatim. The HTTP API surface for Roboflow Inference is `POST /infer/object_detection` with a base64-encoded image body and a `predictions` response array (RESEARCH §7.1). DI wiring lives in `HostBootstrap.cs:120-138` (RESEARCH §3.3) — the registrar is added under the existing `Validators` config-section gate so the three validator plugins (CPAI, Roboflow, DOODS2) coexist. + +Per CONTEXT-14 OQ-2 (resolved by RESEARCH §6: NOT VIABLE), this PR ships **WireMock-only** unit tests with no Testcontainers integration test — the upstream `roboflow/roboflow-inference-server-cpu` image is 16.7 GB and exceeds the GitHub Actions Linux runner's disk-free budget. PLAN-1.2 captures the manual-smoke recipe in the CHANGELOG bullet. + +## Dependencies + +None. Wave 1 plan; depends on no other Phase 14 work. + +## Tasks + +### Task 1: Create FrigateRelay.Plugins.Roboflow project + Options/Response/Validator + +**Files:** +- `src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj` (new) +- `src/FrigateRelay.Plugins.Roboflow/RoboflowOptions.cs` (new) +- `src/FrigateRelay.Plugins.Roboflow/RoboflowResponse.cs` (new) +- `src/FrigateRelay.Plugins.Roboflow/RoboflowValidator.cs` (new) + +**Action:** create + +**Description:** + +Clone the CPAI shape verbatim (RESEARCH §1.2 — §1.4) with these Roboflow-specific differences: + +**`FrigateRelay.Plugins.Roboflow.csproj`** — identical to `src/FrigateRelay.Plugins.CodeProjectAi/FrigateRelay.Plugins.CodeProjectAi.csproj:1-24` except for `RootNamespace`, `AssemblyName`, and `InternalsVisibleTo` (point at `FrigateRelay.Plugins.Roboflow.Tests`). Same three `Microsoft.Extensions.*` package references at `Version="10.0.7"`. NO `Grpc.*` packages — Roboflow is HTTP-only. + +**`RoboflowOptions.cs`** — clone `CodeProjectAiOptions.cs:43-78` (RESEARCH §1.3) and add per-instance `ModelId` per CONTEXT-14 D3: +- `[Required, Url] string BaseUrl = ""` (e.g. `"http://roboflow:9001"`) +- `[Required] string ModelId = ""` (e.g. `"rfdetr-base/1"` — model id + version, slash-delimited, per RESEARCH §7.1) +- `[Range(0.0, 1.0)] double MinConfidence = 0.5` +- `string[] AllowedLabels = []` (empty = any label passes) +- `RoboflowValidatorErrorMode OnError = FailClosed` +- `[Range(typeof(TimeSpan), "00:00:01", "00:01:00")] TimeSpan Timeout = TimeSpan.FromSeconds(5)` +- `bool AllowInvalidCertificates = false` + +Define `enum RoboflowValidatorErrorMode { FailClosed, FailOpen }` (per-plugin enum — no shared abstraction in v1.2 per RESEARCH §1.3). + +XML doc-comments must capture the v1.2 API contract: self-hosted only (link CONTEXT-14 D2), `ModelId` is per-instance and includes the version suffix. + +**`RoboflowResponse.cs`** — DTO matching the response shape in RESEARCH §7.1: +```csharp +internal sealed record RoboflowResponse( + RoboflowImage? Image, + IReadOnlyList? Predictions, + double Time); + +internal sealed record RoboflowImage(int Width, int Height); + +internal sealed record RoboflowPrediction( + [property: JsonPropertyName("class")] string Label, + double Confidence, + int? ClassId, + double X, double Y, double Width, double Height); +``` +Use `System.Text.Json.Serialization.JsonPropertyName` to map the protobuf-style `class` field name onto the C# `Label` property (avoids the C# keyword collision). + +**`RoboflowValidator.cs`** — clone `CodeProjectAiValidator.cs` verbatim with these differences: +- Constructor signature identical: `(string name, RoboflowOptions opts, HttpClient http, ILogger logger)` (RESEARCH §1.4 line 36). +- Snapshot resolution at the entry: `var snap = await snapshot.ResolveAsync(ctx, ct).ConfigureAwait(false); if (snap is null) return Verdict.Fail("validator_no_snapshot");` (RESEARCH §1.4). +- HTTP body: build a JSON `RoboflowRequest` with `model_id = opts.ModelId`, `image = { type: "base64", value: }`, `confidence = opts.MinConfidence` (RESEARCH §7.1). POST to `/infer/object_detection` (relative path; `BaseAddress` set by registrar). +- Catch-block ordering identical to CPAI (RESEARCH §1.4 lines 66-84): `OperationCanceledException when ct.IsCancellationRequested` first → rethrow; `TaskCanceledException` second → timeout, honors `OnError`; `HttpRequestException` third → unavailable, honors `OnError`. +- `LoggerMessage` source-generated logging — use **EventId range 7100s** (RESEARCH §1.4 — CPAI uses 7001/7002, Roboflow takes 7101/7102, DOODS2 will take 7201/7202). Two events: `RoboflowValidatorTimeout` (EventId=7101) and `RoboflowValidatorUnavailable` (EventId=7102), both `LogLevel.Warning`. +- Confidence is already 0-1 in Roboflow's response (per RESEARCH §7.1 — `0.92` for a 92% prediction); compare directly to `opts.MinConfidence`. **No `/100.0` normalization** (that's DOODS2's quirk in PLAN-2.x). +- `EvaluatePredictions` filters predictions by `MinConfidence` and `AllowedLabels` (case-insensitive ordinal); on first match returns `Verdict.Pass(p.Confidence)`. On no match: `Verdict.Fail($"validator_no_match: minConfidence={...}, allowedLabels=[{...}]")`. On null/empty predictions list: `Verdict.Fail("validator_no_predictions")`. + +The class is `public sealed partial class RoboflowValidator : IValidationPlugin` to match CPAI's visibility convention (RESEARCH §1.4 line 24). XML doc-comments mention CONTEXT-14 D2/D3 and the no-retry contract (RESEARCH §1.4 / CPAI XML doc lines 14-18). + +**Acceptance Criteria:** +- New csproj compiles standalone: `dotnet build src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj -c Release` succeeds with zero warnings. +- `RoboflowValidator.Name` returns the constructor `name` argument (matches `IValidationPlugin.Name` contract). +- No `Grpc.*` package references in the new csproj (`grep -i 'Grpc' src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj` returns empty). +- No `ServicePointManager`, no `.Result`/`.Wait()`, no `App.Metrics`/`OpenTracing`/`Jaeger.` references in any new file. +- LoggerMessage event IDs are 7101 and 7102 (no overlap with CPAI's 7001/7002). + +--- + +### Task 2: Create PluginRegistrar with named-options + keyed-singleton ritual + +**Files:** `src/FrigateRelay.Plugins.Roboflow/PluginRegistrar.cs` (new) + +**Action:** create + +**Description:** + +Clone `src/FrigateRelay.Plugins.CodeProjectAi/PluginRegistrar.cs:30-86` verbatim (RESEARCH §1.5) with the type discriminator changed from `"CodeProjectAi"` to `"Roboflow"` and the named `HttpClient` prefix changed from `"CodeProjectAi:"` to `"Roboflow:"`. + +The registration ritual MUST follow the seven-step CPAI pattern exactly: +1. Get `Validators` config section; bail if not present. +2. Iterate children; filter by `Type == "Roboflow"` (ordinal compare per RESEARCH §1.5 line 38-39). +3. Capture `instance.Key` to a local for closure-safety (RESEARCH §1.5 line 43). +4. `AddOptions(instanceKey).Bind(instance).ValidateDataAnnotations().ValidateOnStart()`. +5. `AddHttpClient($"Roboflow:{instanceKey}").ConfigurePrimaryHttpMessageHandler(sp => ...)` — per-instance `SocketsHttpHandler` with optional TLS bypass via `SslOptions.RemoteCertificateValidationCallback` (CPAI registrar lines 53-72). The `#pragma warning disable CA5359` block is required for the opt-in callback (CPAI lines 66-69). +6. `AddKeyedSingleton(instanceKey, (sp, key) => ...)` — factory resolves `IOptionsMonitor.Get(name)`, `IHttpClientFactory.CreateClient($"Roboflow:{name}")`, sets `http.BaseAddress = new Uri(opts.BaseUrl); http.Timeout = opts.Timeout;` (RESEARCH §1.5 lines 80-81), constructs `RoboflowValidator`. + +The registrar class is `public sealed class PluginRegistrar : IPluginRegistrar`. XML doc-comments mention the asymmetric no-retry contract (RESEARCH §1.5 / CPAI registrar lines 19-25). NEVER use `ServicePointManager` (CLAUDE.md invariant). + +**Acceptance Criteria:** +- `dotnet build src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj -c Release` succeeds. +- `git grep -n 'ServicePointManager' src/FrigateRelay.Plugins.Roboflow/` returns empty. +- `git grep -n 'AddResilienceHandler' src/FrigateRelay.Plugins.Roboflow/` returns empty (validators do NOT retry — CPAI parity). +- Registrar filters on `Type == "Roboflow"` ordinal-compare (one `string.Equals(type, "Roboflow", StringComparison.Ordinal)` call). + +--- + +### Task 3: Wire Roboflow into solution + HostBootstrap + Host.csproj reference + +**Files:** +- `FrigateRelay.sln` +- `src/FrigateRelay.Host/FrigateRelay.Host.csproj` +- `src/FrigateRelay.Host/HostBootstrap.cs` + +**Action:** modify + +**Description:** + +1. Add the new project to the solution: `dotnet sln FrigateRelay.sln add src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj`. +2. Add a `` to `src/FrigateRelay.Host/FrigateRelay.Host.csproj` in the same `` block as the existing CPAI ProjectReference. Match its style verbatim. +3. In `src/FrigateRelay.Host/HostBootstrap.cs:133-134` (the `if (builder.Configuration.GetSection("Validators").Exists())` block — RESEARCH §3.3), add the Roboflow registrar **after** the CPAI registrar inside the same `if` block: + ```csharp + if (builder.Configuration.GetSection("Validators").Exists()) + { + registrars.Add(new FrigateRelay.Plugins.CodeProjectAi.PluginRegistrar()); + registrars.Add(new FrigateRelay.Plugins.Roboflow.PluginRegistrar()); // NEW for #13 + } + ``` + Convert the single-line `if` (currently `if (...) registrars.Add(...);`) to a brace block. Update the surrounding inline comment to match the multi-validator pattern in RESEARCH §3.3 lines 190-191. + +DO NOT add a separate top-level config section gate (e.g. `GetSection("Roboflow")`). All validator-type plugins share the `Validators` section gate; each registrar filters on its own `Type` discriminator (RESEARCH §3.3 explanation lines 196-198). + +**Acceptance Criteria:** +- `dotnet build FrigateRelay.sln -c Release` succeeds with zero warnings. +- `dotnet sln FrigateRelay.sln list | grep -i Roboflow` returns the new project path. +- `git grep -n 'FrigateRelay.Plugins.Roboflow' src/FrigateRelay.Host/HostBootstrap.cs` returns one match (the registrars.Add line). +- Existing CPAI registration remains intact: `git grep -n 'FrigateRelay.Plugins.CodeProjectAi.PluginRegistrar' src/FrigateRelay.Host/HostBootstrap.cs` still returns one match. +- Existing host tests pass: `dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build` exits 0. + +## Verification + +```bash +# Build clean +dotnet build FrigateRelay.sln -c Release + +# Architectural invariants — no new violations introduced +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +git grep -nE '\.(Result|Wait)\(' src/ # must be empty +git grep -n 'ServicePointManager' src/ # must be empty +git grep -nE '192\.168\.|10\.0\.0\.|172\.(1[6-9]|2[0-9]|3[01])\.' src/ # must be empty (no hard-coded IPs) + +# gRPC dep containment — Roboflow is HTTP-only; no Grpc references anywhere new +git grep -n 'Grpc\.' src/FrigateRelay.Plugins.Roboflow/ # must be empty +git grep -n 'Grpc\.' src/FrigateRelay.Host/ # must still be empty (host stays gRPC-free) + +# Solution wiring +dotnet sln FrigateRelay.sln list | grep -i 'FrigateRelay.Plugins.Roboflow' + +# DI wiring +git grep -n 'FrigateRelay.Plugins.Roboflow.PluginRegistrar' src/FrigateRelay.Host/HostBootstrap.cs + +# Existing tests still pass — no regressions to the host or CPAI from the wiring change +dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build +dotnet run --project tests/FrigateRelay.Plugins.CodeProjectAi.Tests -c Release --no-build +``` diff --git a/.shipyard/phases/14/plans/PLAN-1.2.md b/.shipyard/phases/14/plans/PLAN-1.2.md new file mode 100644 index 0000000..8316866 --- /dev/null +++ b/.shipyard/phases/14/plans/PLAN-1.2.md @@ -0,0 +1,178 @@ +--- +phase: 14-v1.2-inference-engines-parallel-validation +plan: 1.2 +wave: 1 +dependencies: [1.1] +must_haves: + - tests/FrigateRelay.Plugins.Roboflow.Tests project with WireMock-driven coverage + - At least 8 new tests covering allow / reject-low-confidence / reject-bad-label / no-snapshot / timeout-FailClosed / timeout-FailOpen / unavailable-FailClosed / cancellation + - CHANGELOG bullet under [Unreleased] / ### Added with manual-smoke recipe (per OQ-2 fallback) + - Test count rises from 242 baseline to ≥ 250 +files_touched: + - tests/FrigateRelay.Plugins.Roboflow.Tests/FrigateRelay.Plugins.Roboflow.Tests.csproj + - tests/FrigateRelay.Plugins.Roboflow.Tests/RoboflowValidatorTests.cs + - tests/FrigateRelay.Plugins.Roboflow.Tests/Usings.cs + - CHANGELOG.md +tdd: true +risk: low +--- + +# Plan 1.2: Roboflow validator tests + CHANGELOG (PR #13 — Roboflow Inference validator) + +## Context + +PLAN-1.1 ships the production `RoboflowValidator`; this plan covers it with WireMock-driven unit tests. Per CONTEXT-14 OQ-2 (resolved by RESEARCH §6: NOT VIABLE for Testcontainers — `roboflow/roboflow-inference-server-cpu` is 16.7 GB and exceeds the GitHub Actions disk-free budget), there is **no** integration-test container; the CHANGELOG bullet documents a manual-smoke recipe operators can run locally. + +CPAI's test project is the canonical clone target (RESEARCH §1.6) — same WireMock + NSubstitute + `FrigateRelay.TestHelpers` `CapturingLogger` pattern. The CI auto-discovers test projects via `find tests -maxdepth 2 -name '*Tests.csproj'` (CLAUDE.md "When adding a new test project: no CI changes required") so the new csproj at `tests/FrigateRelay.Plugins.Roboflow.Tests/FrigateRelay.Plugins.Roboflow.Tests.csproj` is sufficient. + +Test count target: 242 baseline → **250 (+8 new)** per RESEARCH §8. + +## Dependencies + +- **PLAN-1.1** — production `RoboflowValidator`, `RoboflowOptions`, `RoboflowResponse`, `PluginRegistrar` must compile before tests can be written. + +## Tasks + +### Task 1: Scaffold the test project + Usings.cs + +**Files:** +- `tests/FrigateRelay.Plugins.Roboflow.Tests/FrigateRelay.Plugins.Roboflow.Tests.csproj` (new) +- `tests/FrigateRelay.Plugins.Roboflow.Tests/Usings.cs` (new) + +**Action:** create + +**Description:** + +Mirror `tests/FrigateRelay.Plugins.CodeProjectAi.Tests/FrigateRelay.Plugins.CodeProjectAi.Tests.csproj` exactly. The csproj must: +- Use `Microsoft.NET.Sdk` SDK with `Exe` (MSTest v3 / Microsoft.Testing.Platform pattern — CLAUDE.md "Commands" section). +- ``. +- `` (provides `CapturingLogger` per CLAUDE.md "Conventions" section — do NOT redefine a per-assembly copy). +- Standard MSTest v3 packages — match versions used in the CPAI test csproj at the time of planning. +- WireMock.Net package — match the version used by other test projects (verify via `grep -r WireMock.Net tests/*/*.csproj`). +- NSubstitute — match the version in CPAI tests. +- FluentAssertions pinned to `6.12.2` (CLAUDE.md "Testing" — license-critical). + +`Usings.cs` must include: +```csharp +global using FrigateRelay.TestHelpers; +global using Microsoft.VisualStudio.TestTools.UnitTesting; +``` +plus any other global usings the CPAI test project uses (verify and copy). Test names use underscores (`Method_Condition_Expected`); CA1707 is silenced for `tests/**.cs` via `.editorconfig` — do not re-enable per-project (CLAUDE.md "Conventions"). + +Add the project to the solution: `dotnet sln FrigateRelay.sln add tests/FrigateRelay.Plugins.Roboflow.Tests/FrigateRelay.Plugins.Roboflow.Tests.csproj`. + +**Acceptance Criteria:** +- `dotnet build tests/FrigateRelay.Plugins.Roboflow.Tests/FrigateRelay.Plugins.Roboflow.Tests.csproj -c Release` succeeds with zero warnings (zero tests yet). +- `dotnet sln FrigateRelay.sln list | grep -i 'FrigateRelay.Plugins.Roboflow.Tests'` returns the new project. +- The csproj has `Exe`, references `FrigateRelay.TestHelpers`, and pins `FluentAssertions` to `6.12.2`. +- `bash .github/scripts/run-tests.sh --skip-integration` discovers the new project (visible in stdout) and exits 0. + +--- + +### Task 2: Write 8 RoboflowValidator tests against WireMock + +**Files:** `tests/FrigateRelay.Plugins.Roboflow.Tests/RoboflowValidatorTests.cs` (new) + +**Action:** create (TDD — write tests then verify they pass against PLAN-1.1's implementation) + +**Description:** + +Single test file modeled on `tests/FrigateRelay.Plugins.CodeProjectAi.Tests/CodeProjectAiValidatorTests.cs`. Use a `[TestClass]` with a `WireMockServer` field, started in `[TestInitialize]` and disposed in `[TestCleanup]`. Construct the validator under test with a real `HttpClient` whose `BaseAddress` points at the WireMock URL and whose `Timeout` matches the test's intent (5s for happy-path, 200ms for timeout tests so they don't slow CI). + +Helpers needed in the same file (private statics): +- `MakeOptions(double minConf = 0.5, string[]? labels = null, RoboflowValidatorErrorMode onError = FailClosed, TimeSpan? timeout = null, string modelId = "rfdetr-base/1")` — builds `RoboflowOptions`. +- `MakeContext()` — builds an `EventContext` with a synthetic camera/label/event id (mirror CPAI's helper). +- `MakeSnapshotContext(ReadOnlyMemory? bytes = null)` — builds a `SnapshotContext` from a pre-resolved fixture (`new SnapshotContext(new SnapshotResult(...))`-style; copy from CPAI tests). +- `StubInferEndpoint(WireMockServer ws, RoboflowResponse response)` — sets up `ws.Given(Request.Create().UsingPost().WithPath("/infer/object_detection")).RespondWith(...)`. + +The 8 tests (all use `Method_Condition_Expected` naming): + +1. `ValidateAsync_PredictionAboveThreshold_ReturnsAllow` — stub returns one prediction with `confidence=0.92`, `class="person"`; opts have `MinConfidence=0.5`, `AllowedLabels=["person"]`. Expect `Verdict.Passed == true`. +2. `ValidateAsync_PredictionBelowThreshold_ReturnsReject` — stub returns one prediction with `confidence=0.30`; opts `MinConfidence=0.5`. Expect `Verdict.Passed == false` and `Reason` starts with `"validator_no_match"`. +3. `ValidateAsync_LabelNotInAllowList_ReturnsReject` — stub returns one prediction with `confidence=0.92`, `class="dog"`; opts `AllowedLabels=["person"]`. Expect `Verdict.Passed == false`, reason starts with `"validator_no_match"`. +4. `ValidateAsync_NoSnapshot_ReturnsRejectImmediately` — pass `default(SnapshotContext)` (no resolver). Expect `Verdict.Fail("validator_no_snapshot")`. WireMock receives ZERO requests (assert via `ws.LogEntries.Count == 0`). +5. `ValidateAsync_HttpTimeout_FailClosed_ReturnsReject` — stub configured with `WithDelay(TimeSpan.FromSeconds(2))`; client timeout 200ms; opts `OnError=FailClosed`. Expect `Verdict.Fail("validator_timeout")`. Verify the warning log emitted via `CapturingLogger` contains EventId 7101. +6. `ValidateAsync_HttpTimeout_FailOpen_ReturnsAllow` — same stub, opts `OnError=FailOpen`. Expect `Verdict.Pass()`. +7. `ValidateAsync_HttpServerError_FailClosed_ReturnsReject` — stub returns HTTP 500; opts `OnError=FailClosed`. Expect `Verdict.Passed == false` and reason starts with `"validator_unavailable: "`. Verify EventId 7102 logged. +8. `ValidateAsync_CancellationRequested_PropagatesOperationCanceled` — pass a `CancellationToken` from a `CancellationTokenSource` whose `Cancel()` is called *before* the call. Expect `OperationCanceledException` is thrown (NOT swallowed into a verdict — RESEARCH §1.4 lines 66-69 catch ordering: `OperationCanceledException when ct.IsCancellationRequested` rethrows, NOT a validator failure). Use `Assert.ThrowsExactlyAsync(...)` or MSTest equivalent. + +Each test has a single `[TestMethod]` attribute and an XML doc-comment summarizing the case. Use the `CapturingLogger` helper from `FrigateRelay.TestHelpers` (CLAUDE.md "Conventions" — DO NOT use NSubstitute on `ILogger`). + +**Acceptance Criteria:** +- All 8 tests pass: `dotnet run --project tests/FrigateRelay.Plugins.Roboflow.Tests -c Release` exits 0 and reports `total: 8`. +- `dotnet build FrigateRelay.sln -c Release` zero warnings. +- Cumulative test count rises to ≥ 250 (242 baseline + 8 new): `bash .github/scripts/run-tests.sh --skip-integration | grep -c 'total:'` shows the new project's totals row. +- Tests use the shared `CapturingLogger`, NOT NSubstitute on `ILogger` (verify: `git grep -n 'Substitute.For:9001`). Per-instance `ModelId`, `MinConfidence`, `AllowedLabels`, + `OnError` (FailClosed/FailOpen), and `Timeout` config. Add to `appsettings.json` under + `Validators:: { "Type": "Roboflow", ... }` and reference the key from any + `ActionEntry.Validators` list. WireMock-driven unit tests; no Testcontainers + integration test (the upstream `roboflow/roboflow-inference-server-cpu` image is + ~16 GB, exceeding GitHub Actions disk budget). + + **Manual smoke recipe** (operator runs locally): + ```bash + docker run --rm -p 9001:9001 \ + -e ROBOFLOW_API_KEY=... \ + roboflow/roboflow-inference-server-cpu:latest + curl -X POST http://localhost:9001/infer/object_detection \ + -H 'Content-Type: application/json' \ + -d '{"model_id":"rfdetr-base/1","image":{"type":"base64","value":""},"confidence":0.5}' + ``` +``` + +Do NOT use real IPs / hostnames in the bullet (CLAUDE.md "no hard-coded IPs"). Use `` and `localhost` as placeholders. + +**Acceptance Criteria:** +- `[Unreleased]` section exists in `CHANGELOG.md` with an `### Added` heading. +- The Roboflow bullet references issue `#13` and includes the manual-smoke recipe. +- The CI secret-scan does not flag the recipe (`bash .github/scripts/secret-scan.sh` exits 0; `` placeholder is intentionally non-secret-shaped). +- No real IPs (`192.168.x.x`, etc.) appear in the CHANGELOG entry. + +## Verification + +```bash +# Build clean +dotnet build FrigateRelay.sln -c Release + +# Tests pass — Roboflow project + the rest of the suite +dotnet run --project tests/FrigateRelay.Plugins.Roboflow.Tests -c Release --no-build +bash .github/scripts/run-tests.sh --skip-integration + +# Test count gate — at least 250 total (242 baseline + 8 new) +TOTAL=$(bash .github/scripts/run-tests.sh --skip-integration 2>&1 | grep -E '^total:' | awk '{ sum += $2 } END { print sum }') +[ "$TOTAL" -ge 250 ] || { echo "test count regression: $TOTAL < 250"; exit 1; } + +# Architectural invariants +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +git grep -nE '\.(Result|Wait)\(' src/ # must be empty +git grep -n 'ServicePointManager' src/ # must be empty + +# Tests use the shared CapturingLogger, not NSubstitute on ILogger +git grep -n 'Substitute.For/odrpc/odrpc.proto +// License: MIT (see https://github.com/snowzach/doods2/blob//LICENSE) +// Vendored at for FrigateRelay.Plugins.Doods2. +// +// This file is the contract between FrigateRelay's DOODS2 validator and the upstream +// DOODS2 server. Updates are deliberate: re-vendor at a newer commit when the upstream +// API evolves. Dependabot does not watch this file (NuGet only). + +syntax = "proto3"; +option csharp_namespace = "FrigateRelay.Plugins.Doods2.Grpc"; +package odrpc; +// ... rest of upstream content verbatim +``` + +The `option csharp_namespace = "FrigateRelay.Plugins.Doods2.Grpc";` line MUST be present so generated client code lives in a stable namespace under the plugin. If the upstream file already defines a different `csharp_namespace`, replace it with this one. The builder must capture the actual `` and `` at execution time (no placeholders left in the committed file). + +Also vendor the upstream `LICENSE` text snippet (or a clear MIT attribution) inline at the bottom of the proto-header block — single-source license attribution (CONTEXT-14 OQ-1 rationale: vendoring is simpler for license attribution). + +**Create the csproj** matching the shape in RESEARCH §4.2: + +```xml + + + FrigateRelay.Plugins.Doods2 + FrigateRelay.Plugins.Doods2 + false + true + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + +``` + +Verify the actual current versions for `Grpc.Net.Client`, `Grpc.Tools`, and `Google.Protobuf` at PR-2 execution time (use Context7 MCP if needed). The versions in this plan are RESEARCH-time guesses; the builder picks current stable. + +`PrivateAssets="all"` on `Grpc.Tools` is critical (RESEARCH §4.2): build-time codegen does NOT propagate transitively, so the host's `dotnet list package --include-transitive` stays gRPC-free. + +Add to solution: `dotnet sln FrigateRelay.sln add src/FrigateRelay.Plugins.Doods2/FrigateRelay.Plugins.Doods2.csproj`. + +**Acceptance Criteria:** +- `dotnet build src/FrigateRelay.Plugins.Doods2/FrigateRelay.Plugins.Doods2.csproj -c Release` succeeds with zero warnings; build output shows `Grpc.Tools` codegen ran (look for `Detector.cs` / `Odrpc.cs` in obj/Release/). +- The vendored proto file's first comment lines name a real commit SHA (40 hex chars) and a real date (`grep -E '^// Source: https://github.com/snowzach/doods2/blob/[0-9a-f]{40}/' src/FrigateRelay.Plugins.Doods2/Protos/odrpc.proto`). +- `option csharp_namespace = "FrigateRelay.Plugins.Doods2.Grpc";` is present in the proto. +- Generated gRPC types are accessible from C# at namespace `FrigateRelay.Plugins.Doods2.Grpc.Detector` (the auto-generated client class). +- **gRPC dep containment** (architectural invariant — load-bearing): + ```bash + dotnet list src/FrigateRelay.Abstractions/FrigateRelay.Abstractions.csproj package --include-transitive | grep -E '^\s*(>|>) Grpc\.' && exit 1 || true + dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive | grep -E '^\s*(>|>) Grpc\.' && exit 1 || true + ``` + Both must return zero `Grpc.*` matches. + +--- + +### Task 2: Implement Doods2Options + Doods2Response + Doods2Validator (HTTP and gRPC paths) + +**Files:** +- `src/FrigateRelay.Plugins.Doods2/Doods2Options.cs` (new) +- `src/FrigateRelay.Plugins.Doods2/Doods2Response.cs` (new — HTTP DTO) +- `src/FrigateRelay.Plugins.Doods2/Doods2Validator.cs` (new) + +**Action:** create + +**Description:** + +**`Doods2Options.cs`** — clone `RoboflowOptions.cs` (PLAN-1.1 Task 1) shape and add: +- `[Required, Url] string BaseUrl = ""` +- **`Doods2Transport Transport = Doods2Transport.Http`** (NEW — selects HTTP vs gRPC; per CONTEXT-14 D4) +- `[Required] string DetectorName = "default"` (DOODS2-specific; `default` matches the `detector_name` field in RESEARCH §7.2) +- `[Range(0.0, 1.0)] double MinConfidence = 0.5` (operator-facing; 0-1 scale — the validator handles the DOODS2 0-100 normalization internally per RESEARCH §7.2) +- `string[] AllowedLabels = []` +- `Doods2ValidatorErrorMode OnError = FailClosed` +- `[Range(typeof(TimeSpan), "00:00:01", "00:01:00")] TimeSpan Timeout = TimeSpan.FromSeconds(5)` +- `bool AllowInvalidCertificates = false` (HTTP transport only; documented as such in XML doc-comments — gRPC transport ignores this in v1.2; if operators need TLS-skip on gRPC, file a follow-up) + +Define `enum Doods2Transport { Http, Grpc }` and `enum Doods2ValidatorErrorMode { FailClosed, FailOpen }` in the same file (both `public`). + +**`Doods2Response.cs`** — DTO for HTTP transport per RESEARCH §7.2: +```csharp +internal sealed record Doods2HttpResponse( + string? Id, + IReadOnlyList? Detections); + +internal sealed record Doods2Detection( + int Top, int Left, int Bottom, int Right, + string Label, + double Confidence); // 0-100 scale per DOODS2; validator normalizes +``` + +**`Doods2Validator.cs`** — `public sealed partial class Doods2Validator : IValidationPlugin`. Constructor: +```csharp +public Doods2Validator( + string name, + Doods2Options opts, + HttpClient http, // used by HTTP transport + Grpc.Detector.DetectorClient grpcClient, // used by gRPC transport + ILogger logger) +``` + +Both transport clients are constructor-injected. The registrar (Task 3) builds whichever is needed; for instances in HTTP mode the gRPC client is constructed with a no-op channel (or null with nullable reference annotation) and never used. **Cleaner alternative the builder may pick instead:** two private factory methods, one per transport, that read `opts.Transport` and dispatch — keep both clients on the type to avoid runtime branching cost. Pick whichever produces less ceremony; document the choice in a class-level remark. + +`ValidateAsync`: + +```csharp +public async Task ValidateAsync(EventContext ctx, SnapshotContext snapshot, CancellationToken ct) +{ + var snap = await snapshot.ResolveAsync(ctx, ct).ConfigureAwait(false); + if (snap is null) return Verdict.Fail("validator_no_snapshot"); + + try + { + var detections = _opts.Transport == Doods2Transport.Grpc + ? await DetectGrpcAsync(snap.Bytes, ct).ConfigureAwait(false) + : await DetectHttpAsync(snap.Bytes, ct).ConfigureAwait(false); + return EvaluateDetections(detections); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (TaskCanceledException ex) { Log.Doods2ValidatorTimeout(_logger, _name, ctx.EventId, ex); return _opts.OnError == Doods2ValidatorErrorMode.FailOpen ? Verdict.Pass() : Verdict.Fail("validator_timeout"); } + catch (Grpc.Core.RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.DeadlineExceeded) { /* same as TaskCanceledException — gRPC's deadline-exceeded is the gRPC equivalent of HTTP timeout */ Log.Doods2ValidatorTimeout(_logger, _name, ctx.EventId, ex); return _opts.OnError == Doods2ValidatorErrorMode.FailOpen ? Verdict.Pass() : Verdict.Fail("validator_timeout"); } + catch (Grpc.Core.RpcException ex) { Log.Doods2ValidatorUnavailable(_logger, _name, ctx.EventId, ex); return _opts.OnError == Doods2ValidatorErrorMode.FailOpen ? Verdict.Pass() : Verdict.Fail($"validator_unavailable: {ex.Message}"); } + catch (HttpRequestException ex) { Log.Doods2ValidatorUnavailable(_logger, _name, ctx.EventId, ex); return _opts.OnError == Doods2ValidatorErrorMode.FailOpen ? Verdict.Pass() : Verdict.Fail($"validator_unavailable: {ex.Message}"); } +} +``` + +Catch-block ordering MUST be preserved: `OperationCanceledException when ct.IsCancellationRequested` first (host shutdown rethrow); then `TaskCanceledException` (HTTP timeout); then `RpcException` with `StatusCode.DeadlineExceeded` (gRPC timeout — same OnError mapping); then generic `RpcException` (gRPC unavailable); then `HttpRequestException` (HTTP unavailable). RESEARCH §1.4 catch-ordering invariant carries forward verbatim with the gRPC additions. + +`DetectHttpAsync(ReadOnlyMemory bytes, ct)`: +- Build JSON body: `{"detector_name": opts.DetectorName, "data": Convert.ToBase64String(bytes.Span), "preprocess": [], "detect": {"*": opts.MinConfidence}}` (RESEARCH §7.2). +- `_http.PostAsJsonAsync("/detect", body, ct)`; `EnsureSuccessStatusCode`; `ReadFromJsonAsync`. +- Return `body?.Detections ?? Array.Empty()` (or equivalent). + +`DetectGrpcAsync(ReadOnlyMemory bytes, ct)`: +- Construct `DetectRequest` per RESEARCH §7.3: `Data = ByteString.CopyFrom(bytes.Span)`, `DetectorName = opts.DetectorName`, `Detect.Add("*", (float)opts.MinConfidence)`. +- Call `await _grpcClient.DetectAsync(request, deadline: DateTime.UtcNow.Add(opts.Timeout), cancellationToken: ct)`. +- Project the response's `Detections` list onto the same `Doods2Detection` shape so `EvaluateDetections` is transport-agnostic. (Field names in the generated proto types may differ slightly from the C# DTO — map them explicitly.) + +`EvaluateDetections(IReadOnlyList detections)`: +- If empty: `Verdict.Fail("validator_no_predictions")`. +- For each detection: **normalize confidence**: `var normalized = d.Confidence / 100.0;` (DOODS2 returns 0-100 per RESEARCH §7.2 — this is the chief gotcha). If `normalized >= _opts.MinConfidence` and (`_opts.AllowedLabels.Length == 0` || allowed-list contains `d.Label` ordinal-ignore-case): `return Verdict.Pass(normalized)`. +- On no match: `Verdict.Fail($"validator_no_match: minConfidence={...}, allowedLabels=[{...}]")`. + +`LoggerMessage` — use **EventId range 7200s** (RESEARCH §1.4): `Doods2ValidatorTimeout` (7201) and `Doods2ValidatorUnavailable` (7202), both `LogLevel.Warning`. The gRPC-deadline-exceeded path reuses `Doods2ValidatorTimeout` (semantically equivalent). + +**Acceptance Criteria:** +- `dotnet build src/FrigateRelay.Plugins.Doods2/FrigateRelay.Plugins.Doods2.csproj -c Release` succeeds with zero warnings. +- `Doods2Validator.ValidateAsync` exists with `EventContext`, `SnapshotContext`, `CancellationToken` signature (matches `IValidationPlugin` contract). +- The DOODS2 0-100 → 0-1 confidence normalization is present: `git grep -n 'Confidence.*/.*100' src/FrigateRelay.Plugins.Doods2/Doods2Validator.cs` returns at least one match. +- `LoggerMessage` event IDs are 7201 and 7202 (no overlap with CPAI 7001/7002 or Roboflow 7101/7102). +- Catch-block ordering: `OperationCanceledException` first, `TaskCanceledException` second, `RpcException(DeadlineExceeded)` third, generic `RpcException` fourth, `HttpRequestException` fifth (verifiable by reading the file or via an analyzer; primary check is that the test suite in PLAN-2.2 + PLAN-2.3 passes). + +--- + +### Task 3: Implement Doods2 PluginRegistrar + wire into HostBootstrap + Host.csproj + +**Files:** +- `src/FrigateRelay.Plugins.Doods2/PluginRegistrar.cs` (new) +- `src/FrigateRelay.Host/HostBootstrap.cs` +- `src/FrigateRelay.Host/FrigateRelay.Host.csproj` + +**Action:** create + modify + +**Description:** + +**`PluginRegistrar.cs`** — the seven-step CPAI ritual (RESEARCH §1.5) with `Type == "Doods2"` discriminator and these transport-specific additions: + +1. Filter on `Type == "Doods2"` ordinal compare. +2. Capture `instanceKey = instance.Key`. +3. `AddOptions(instanceKey).Bind(instance).ValidateDataAnnotations().ValidateOnStart()`. +4. **HTTP client registration** (always registered, used in HTTP mode): + ```csharp + var httpClientName = $"Doods2:{instanceKey}"; + context.Services.AddHttpClient(httpClientName) + .ConfigurePrimaryHttpMessageHandler(sp => /* same TLS-skip pattern as CPAI/Roboflow */); + ``` +5. **gRPC channel registration** (always registered, used in gRPC mode). Register a singleton factory keyed on `instanceKey` that builds a `GrpcChannel` and a `Detector.DetectorClient`: + ```csharp + context.Services.AddKeyedSingleton(instanceKey, (sp, key) => + { + var name = (string)key!; + var opts = sp.GetRequiredService>().Get(name); + var channel = GrpcChannel.ForAddress(opts.BaseUrl); // GrpcChannel is thread-safe + HTTP/2-multiplexed; reuse-as-singleton is correct (RESEARCH §7.3 final note). + return new Grpc.Detector.DetectorClient(channel); + }); + ``` + Even in HTTP mode the gRPC client is constructed (cheap — `GrpcChannel.ForAddress` is lazy until first call). The validator never invokes it. This keeps the registrar branch-free; PR-2 builder may instead conditionally register only the chosen transport's client based on `opts.Transport` if they prefer — both shapes are acceptable. +6. **Validator keyed singleton:** + ```csharp + context.Services.AddKeyedSingleton(instanceKey, (sp, key) => + { + var name = (string)key!; + var opts = sp.GetRequiredService>().Get(name); + var http = sp.GetRequiredService().CreateClient($"Doods2:{name}"); + http.BaseAddress = new Uri(opts.BaseUrl); + http.Timeout = opts.Timeout; + var grpcClient = sp.GetRequiredKeyedService(name); + var logger = sp.GetRequiredService>(); + return new Doods2Validator(name, opts, http, grpcClient, logger); + }); + ``` + +XML doc-comments mirror CPAI's (no retry, fail-open/closed semantics, transport-selection rationale per CONTEXT-14 D4). + +**`FrigateRelay.Host.csproj`** — add `` in the same `` as the existing CPAI/Roboflow ProjectReferences. + +**`HostBootstrap.cs`** — extend the `if (builder.Configuration.GetSection("Validators").Exists())` block (now at the position from PLAN-1.1 Task 3) to register DOODS2 after Roboflow: +```csharp +if (builder.Configuration.GetSection("Validators").Exists()) +{ + registrars.Add(new FrigateRelay.Plugins.CodeProjectAi.PluginRegistrar()); + registrars.Add(new FrigateRelay.Plugins.Roboflow.PluginRegistrar()); + registrars.Add(new FrigateRelay.Plugins.Doods2.PluginRegistrar()); // NEW for #14 +} +``` + +DO NOT add a separate top-level config section gate. All validator-type plugins share the `Validators` section gate; each registrar filters on its own `Type` discriminator. + +**Acceptance Criteria:** +- `dotnet build FrigateRelay.sln -c Release` zero warnings. +- `dotnet sln FrigateRelay.sln list | grep -i Doods2` returns the new project path. +- `git grep -n 'FrigateRelay.Plugins.Doods2.PluginRegistrar' src/FrigateRelay.Host/HostBootstrap.cs` returns one match. +- The registrar uses `GrpcChannel.ForAddress(opts.BaseUrl)` (single use site; reuse via keyed singleton). +- **gRPC dep containment — non-negotiable**: + ```bash + dotnet list src/FrigateRelay.Abstractions/FrigateRelay.Abstractions.csproj package --include-transitive | grep -E 'Grpc\.' && exit 1 || true + dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive | grep -E 'Grpc\.' && exit 1 || true + ``` + Both must show ZERO `Grpc.*` entries. The gRPC dep is plugin-local only. + +## Verification + +```bash +# Build clean — gRPC codegen must run and succeed +dotnet build FrigateRelay.sln -c Release + +# gRPC dep containment — load-bearing architectural invariant (CONTEXT-14 + CLAUDE.md) +dotnet list src/FrigateRelay.Abstractions/FrigateRelay.Abstractions.csproj package --include-transitive | grep -E 'Grpc\.' && { echo "FAIL: Grpc leaked into Abstractions"; exit 1; } || echo "PASS: Abstractions gRPC-free" +dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive | grep -E 'Grpc\.' && { echo "FAIL: Grpc leaked into Host"; exit 1; } || echo "PASS: Host gRPC-free" + +# Source-level gRPC containment +git grep -nE 'Grpc\.' src/FrigateRelay.Host src/FrigateRelay.Abstractions # must be empty +git grep -nE 'Grpc\.' src/FrigateRelay.Plugins.Doods2/ # MUST have matches (this is where it lives) + +# Vendored proto attribution +grep -E '^// Source: https://github.com/snowzach/doods2/blob/[0-9a-f]{40}/' src/FrigateRelay.Plugins.Doods2/Protos/odrpc.proto +grep -E '^// License: MIT' src/FrigateRelay.Plugins.Doods2/Protos/odrpc.proto +grep -E 'option csharp_namespace = "FrigateRelay.Plugins.Doods2.Grpc"' src/FrigateRelay.Plugins.Doods2/Protos/odrpc.proto + +# Architectural invariants +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +git grep -nE '\.(Result|Wait)\(' src/ # must be empty +git grep -n 'ServicePointManager' src/ # must be empty +git grep -nE '192\.168\.|10\.0\.0\.|172\.(1[6-9]|2[0-9]|3[01])\.' src/ # must be empty + +# Solution + DI wiring +dotnet sln FrigateRelay.sln list | grep -i 'FrigateRelay.Plugins.Doods2' +git grep -n 'FrigateRelay.Plugins.Doods2.PluginRegistrar' src/FrigateRelay.Host/HostBootstrap.cs + +# Existing tests still pass — host + earlier plugins unaffected +dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build +dotnet run --project tests/FrigateRelay.Plugins.CodeProjectAi.Tests -c Release --no-build +dotnet run --project tests/FrigateRelay.Plugins.Roboflow.Tests -c Release --no-build +``` diff --git a/.shipyard/phases/14/plans/PLAN-2.2.md b/.shipyard/phases/14/plans/PLAN-2.2.md new file mode 100644 index 0000000..5b1943c --- /dev/null +++ b/.shipyard/phases/14/plans/PLAN-2.2.md @@ -0,0 +1,129 @@ +--- +phase: 14-v1.2-inference-engines-parallel-validation +plan: 2.2 +wave: 2 +dependencies: [2.1] +must_haves: + - tests/FrigateRelay.Plugins.Doods2.Tests project (used by both PLAN-2.2 and PLAN-2.3) + - At least 7 HTTP-path tests covering allow / reject-low-confidence / reject-bad-label / no-snapshot / timeout-FailClosed / timeout-FailOpen / unavailable-FailClosed + - DOODS2 confidence 0-100 → 0-1 normalization is asserted + - Cumulative test count rises from 250 (post-Wave 1) to ≥ 257 +files_touched: + - tests/FrigateRelay.Plugins.Doods2.Tests/FrigateRelay.Plugins.Doods2.Tests.csproj + - tests/FrigateRelay.Plugins.Doods2.Tests/Usings.cs + - tests/FrigateRelay.Plugins.Doods2.Tests/Doods2HttpValidatorTests.cs +tdd: true +risk: low +--- + +# Plan 2.2: DOODS2 HTTP-transport tests (PR #14 — DOODS2 validator) + +## Context + +PLAN-2.1 ships the dual-transport `Doods2Validator`. This plan covers the **HTTP transport path only** with WireMock-driven unit tests that mirror the Roboflow test shape from PLAN-1.2. PLAN-2.3 covers the gRPC path separately (in-process gRPC server pattern is more involved and gets its own plan to keep task counts ≤ 3). + +Key DOODS2-specific assertion: the validator must normalize the upstream's 0-100 confidence to 0-1 before comparing to `MinConfidence` (RESEARCH §7.2 — "confidence is **0-100** scale in DOODS2, not 0-1"). Tests in this plan stub WireMock with confidences in 0-100 and assert the operator-facing 0-1 `MinConfidence` works as expected. + +Test count target: 250 (post-Wave 1) → **257 (+7 new)** for the HTTP path. PLAN-2.3 adds 5 more for the gRPC path → 262. + +## Dependencies + +- **PLAN-2.1** — `Doods2Validator`, `Doods2Options`, `Doods2HttpResponse` types must compile. + +## Tasks + +### Task 1: Scaffold tests/FrigateRelay.Plugins.Doods2.Tests project + +**Files:** +- `tests/FrigateRelay.Plugins.Doods2.Tests/FrigateRelay.Plugins.Doods2.Tests.csproj` (new) +- `tests/FrigateRelay.Plugins.Doods2.Tests/Usings.cs` (new) + +**Action:** create + +**Description:** + +Mirror `tests/FrigateRelay.Plugins.Roboflow.Tests/FrigateRelay.Plugins.Roboflow.Tests.csproj` (created in PLAN-1.2 Task 1) with these DOODS2-specific additions: + +```xml + + +``` + +The csproj must NOT add `Grpc.AspNetCore.Server` here — that is PLAN-2.3's responsibility (the in-process gRPC test host pattern). PLAN-2.2 only needs WireMock + the existing test stack. PLAN-2.3 will edit this same csproj to add the gRPC server packages. + +`Usings.cs` matches the Roboflow tests' shape: `global using FrigateRelay.TestHelpers;` and `global using Microsoft.VisualStudio.TestTools.UnitTesting;`. + +Add to solution: `dotnet sln FrigateRelay.sln add tests/FrigateRelay.Plugins.Doods2.Tests/FrigateRelay.Plugins.Doods2.Tests.csproj`. + +**Acceptance Criteria:** +- `dotnet build tests/FrigateRelay.Plugins.Doods2.Tests/FrigateRelay.Plugins.Doods2.Tests.csproj -c Release` succeeds with zero warnings. +- `dotnet sln FrigateRelay.sln list | grep -i 'FrigateRelay.Plugins.Doods2.Tests'` returns the new project. +- The csproj references `FrigateRelay.Plugins.Doods2` and `FrigateRelay.TestHelpers`. NO `Grpc.AspNetCore.Server` reference yet (PLAN-2.3 adds it). +- `bash .github/scripts/run-tests.sh --skip-integration` discovers the new project and exits 0 (no tests yet, but auto-discovery confirmed). + +--- + +### Task 2: Write 7 HTTP-path Doods2Validator tests + +**Files:** `tests/FrigateRelay.Plugins.Doods2.Tests/Doods2HttpValidatorTests.cs` (new) + +**Action:** create (TDD — tests verify PLAN-2.1's HTTP-path implementation) + +**Description:** + +Single test file modeled on `tests/FrigateRelay.Plugins.Roboflow.Tests/RoboflowValidatorTests.cs` (PLAN-1.2 Task 2). All tests construct the validator with `Transport = Doods2Transport.Http` and a real `HttpClient` aimed at WireMock. The gRPC client constructor argument can be a real `Detector.DetectorClient` over an unused `GrpcChannel.ForAddress("http://localhost:0")` (lazy; never invoked in HTTP mode) or a `null!` if Doods2Validator's constructor signature accepts it via nullable annotations — pick whichever PLAN-2.1's final shape allows. + +Helpers in the same file (private statics): +- `MakeOptions(double minConf = 0.5, string[]? labels = null, Doods2ValidatorErrorMode onError = FailClosed, TimeSpan? timeout = null, string detectorName = "default")` — `Transport = Http` always. +- `StubDetectEndpoint(WireMockServer ws, params (string label, double rawConfidence0to100)[] detections)` — sets up `ws.Given(Request.Create().UsingPost().WithPath("/detect")).RespondWithJson(...)` building a `Doods2HttpResponse` whose `Detections` carries the supplied tuples (raw 0-100 confidence as DOODS2 returns it). Each detection's `Top/Left/Bottom/Right` can be fixed at `0/0/100/100` since the validator does not use bbox. + +The 7 tests: + +1. `ValidateAsync_Http_DetectionAboveThresholdAfterNormalization_ReturnsAllow` — stub returns `(label="person", confidence=80.0)`; opts `MinConfidence=0.5`, `AllowedLabels=["person"]`. Validator normalizes `80.0 / 100.0 = 0.8 >= 0.5` → expect `Verdict.Passed == true`. **This test is the load-bearing assertion that the 0-100 → 0-1 normalization works** (RESEARCH §7.2). + +2. `ValidateAsync_Http_DetectionBelowThresholdAfterNormalization_ReturnsReject` — stub returns `(label="person", confidence=30.0)`; opts `MinConfidence=0.5`. Validator normalizes `0.30 < 0.5` → expect `Verdict.Passed == false`, reason starts with `"validator_no_match"`. + +3. `ValidateAsync_Http_LabelNotInAllowList_ReturnsReject` — stub returns `(label="dog", confidence=90.0)`; opts `AllowedLabels=["person"]`. Expect `Verdict.Passed == false`. + +4. `ValidateAsync_Http_NoSnapshot_ReturnsRejectImmediately` — pass `default(SnapshotContext)`; expect `Verdict.Fail("validator_no_snapshot")`. Assert WireMock receives ZERO requests. + +5. `ValidateAsync_Http_Timeout_FailClosed_ReturnsReject` — stub `WithDelay(TimeSpan.FromSeconds(2))`; client timeout 200ms; opts `OnError=FailClosed`. Expect `Verdict.Fail("validator_timeout")`. Verify `CapturingLogger` shows EventId 7201. + +6. `ValidateAsync_Http_Timeout_FailOpen_ReturnsAllow` — same stub, `OnError=FailOpen`. Expect `Verdict.Pass()`. + +7. `ValidateAsync_Http_HttpServerError_FailClosed_ReturnsReject` — stub returns HTTP 500; `OnError=FailClosed`. Expect reason starts with `"validator_unavailable: "`. Verify EventId 7202. + +XML doc-comments per test summarizing the case. Each test uses `[TestMethod]`. Tests use the shared `CapturingLogger` from `FrigateRelay.TestHelpers` — DO NOT use NSubstitute on `ILogger` (CLAUDE.md "Conventions"). + +**Cancellation test is deferred to PLAN-2.3** (it covers the gRPC + HTTP cancellation paths together, since both transports rethrow `OperationCanceledException` identically per PLAN-2.1's catch-block ordering). + +**Acceptance Criteria:** +- All 7 tests pass: `dotnet run --project tests/FrigateRelay.Plugins.Doods2.Tests -c Release` exits 0 and reports `total: 7` (or higher if PLAN-2.3 has already added gRPC tests by the time of execution). +- `dotnet build FrigateRelay.sln -c Release` zero warnings. +- Cumulative test count ≥ 257 (250 post-Wave 1 + 7 new). +- Test #1 explicitly demonstrates the 0-100 → 0-1 normalization (the 80.0 → pass + 30.0 → fail asymmetry on a 0.5 threshold is the intended evidence). +- LoggerMessage event IDs 7201 (timeout) and 7202 (unavailable) are asserted in tests #5 and #7. +- `git grep -n 'Substitute.For&1 | grep -E '^total:' | awk '{ sum += $2 } END { print sum }') +[ "$TOTAL" -ge 257 ] || { echo "test count regression: $TOTAL < 257"; exit 1; } + +# Architectural invariants — gRPC still contained +dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive | grep -E 'Grpc\.' && exit 1 || true +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +git grep -nE '\.(Result|Wait)\(' src/ # must be empty + +# Tests use the shared CapturingLogger +git grep -n 'Substitute.For + + +``` +Verify the actual current versions at PR-2 execution time; the values above are RESEARCH-time guesses. + +The test csproj also needs `Grpc.Tools` referenced (build-time only, `PrivateAssets="all"`) so the server-side codegen runs against the same vendored proto. The `` item's `GrpcServices="Server"` is the key difference from the plugin csproj's `GrpcServices="Client"`. Both client and server stubs are generated; the test project uses both. + +**Build the in-process gRPC server fixture** at `tests/FrigateRelay.Plugins.Doods2.Tests/Fixtures/InProcessDoods2GrpcServer.cs`: + +```csharp +internal sealed class InProcessDoods2GrpcServer : IAsyncDisposable +{ + private readonly WebApplication _app; + public string BaseAddress { get; } + public List ReceivedRequests { get; } = new(); + + private InProcessDoods2GrpcServer(WebApplication app, string baseAddress) + { _app = app; BaseAddress = baseAddress; } + + public static async Task StartAsync(Func? respond = null, TimeSpan? delay = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); // Microsoft.AspNetCore.TestHost in-process listener + builder.Services.AddGrpc(); + // Register the test detector implementation that records requests + replies via 'respond'. + // ... + var app = builder.Build(); + app.MapGrpcService(); + await app.StartAsync(); + var addr = app.Services.GetRequiredService().BaseAddress.ToString(); + return new InProcessDoods2GrpcServer(app, addr); + } + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); +} +``` + +`TestDetectorImpl : Detector.DetectorBase` (the auto-generated server-base abstract class from the `GrpcServices="Server"` codegen) overrides `Detect(DetectRequest req, ServerCallContext ctx)`, records the request into `ReceivedRequests`, optionally awaits the configured `delay` (used for timeout tests), and returns the supplied `respond(req)` result. + +The validator-under-test connects to this in-process server via: +```csharp +var channel = GrpcChannel.ForAddress(server.BaseAddress, new GrpcChannelOptions +{ + HttpHandler = server._app.Services.GetRequiredService().CreateHandler(), +}); +var grpcClient = new Detector.DetectorClient(channel); +``` +The `TestServer.CreateHandler()` produces an `HttpMessageHandler` that routes requests to the in-process server without going through the kernel network stack — this is the key .NET 10 idiom (RESEARCH §4.3). + +**Write ONE exemplar test** in `Doods2GrpcValidatorTests.cs`: +```csharp +[TestMethod] +public async Task ValidateAsync_Grpc_DetectionAboveThresholdAfterNormalization_ReturnsAllow() +{ + await using var server = await InProcessDoods2GrpcServer.StartAsync(respond: req => + { + var resp = new DetectResponse(); + resp.Detections.Add(new Detection { Label = "person", Confidence = 80.0f, /* ... bbox ... */ }); + return resp; + }); + + var opts = MakeOptions(transport: Doods2Transport.Grpc, baseUrl: server.BaseAddress, minConf: 0.5); + var validator = MakeValidator(opts, server); // helper builds Doods2Validator with grpcClient pointing at the in-process server + + var ctx = MakeContext(); + var snap = MakeSnapshotContext(SnapshotResultFor("fakejpegbytes")); + + var verdict = await validator.ValidateAsync(ctx, snap, CancellationToken.None); + + Assert.IsTrue(verdict.Passed); + Assert.AreEqual(1, server.ReceivedRequests.Count); + Assert.AreEqual("default", server.ReceivedRequests[0].DetectorName); +} +``` + +**Time-box:** if the in-process pattern proves problematic against MSTest v3 + this codebase's Usings shape (RESEARCH Concern #1), the builder MAY fall back to: +- A real `Grpc.AspNetCore.Server` host on `Kestrel` bound to `localhost:` (use `IServerAddressesFeature` after `StartAsync` to discover the port). This is real-network in-process — no `TestHost` magic. Slightly slower per test but mechanically simpler. + +Document the fallback decision in the file's class-level XML doc-comment if invoked. + +**Acceptance Criteria:** +- The exemplar test passes: `dotnet run --project tests/FrigateRelay.Plugins.Doods2.Tests -c Release --no-build -- --filter "Grpc_DetectionAboveThreshold"` exits 0. +- `dotnet build FrigateRelay.sln -c Release` zero warnings. +- The in-process server records the gRPC `DetectRequest` (proves end-to-end wiring: validator → channel → server → response → validator). +- The 0-100 → 0-1 normalization works for the gRPC path (server returns `Confidence = 80.0f`; validator normalizes to 0.8; passes 0.5 threshold). +- gRPC dep containment STILL holds: `dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive | grep -E 'Grpc\.'` returns empty (host stays gRPC-free even though tests use server-side gRPC). + +--- + +### Task 2: Fan out to 4 more gRPC-path tests + CHANGELOG bullet + +**Files:** +- `tests/FrigateRelay.Plugins.Doods2.Tests/Doods2GrpcValidatorTests.cs` (extend) +- `CHANGELOG.md` (modify) + +**Action:** create + modify + +**Description:** + +With the exemplar test from Task 1 working, add 4 more gRPC-path tests using the same `InProcessDoods2GrpcServer` fixture: + +2. `ValidateAsync_Grpc_DetectionBelowThresholdAfterNormalization_ReturnsReject` — server returns `Confidence = 30.0f`; opts `MinConfidence=0.5`. Expect reject. Counterpart to PLAN-2.2 test #2. + +3. `ValidateAsync_Grpc_DeadlineExceeded_FailClosed_ReturnsReject` — server's `respond` callback awaits `delay: TimeSpan.FromSeconds(2)`; client's `opts.Timeout = TimeSpan.FromMilliseconds(200)`. Expect `Verdict.Fail("validator_timeout")`. The validator's gRPC catch-block for `RpcException(StatusCode.DeadlineExceeded)` (PLAN-2.1 Task 2) is exercised here. Verify `CapturingLogger` shows EventId 7201 (same as HTTP timeout — semantically equivalent). + +4. `ValidateAsync_Grpc_ServerThrows_FailClosed_ReturnsReject` — server's `respond` throws `new RpcException(new Status(StatusCode.Internal, "boom"))`. Expect `Verdict.Fail` whose reason starts with `"validator_unavailable: "`. Verify EventId 7202. + +5. `ValidateAsync_Grpc_CancellationRequested_PropagatesOperationCanceled` — start the call with a `CancellationToken` from a `CancellationTokenSource` whose `Cancel()` is invoked before the call. Expect `OperationCanceledException` (or its subclass) is thrown — NOT swallowed into a verdict. The validator's catch-ordering (`OperationCanceledException when ct.IsCancellationRequested` first) must rethrow per PLAN-2.1 Task 2 + RESEARCH §1.4. + +This single test case effectively also covers the HTTP-cancellation behavior (the catch-block is shared per PLAN-2.1's catch-ordering), so PLAN-2.2 deliberately deferred its own cancellation test here. + +**CHANGELOG bullet** under `[Unreleased]` / `### Added`: +```markdown +- **DOODS2 validator (#14).** New `FrigateRelay.Plugins.Doods2` validator plugin with + HTTP and gRPC transports, operator-selectable per validator instance via + `Validators::Transport: "Http" | "Grpc"`. HTTP path uses `POST /detect` with + base64-encoded JPEG; gRPC path uses the `Detector.Detect` RPC from the vendored + upstream `odrpc.proto`. Per-instance `BaseUrl`, `DetectorName`, `MinConfidence` + (0-1 scale; the validator normalizes the upstream's 0-100 confidence internally), + `AllowedLabels`, `OnError` (FailClosed/FailOpen), and `Timeout` config. + gRPC dependencies (`Grpc.Net.Client`, `Google.Protobuf`, `Grpc.Tools`) are + contained to this plugin only — `FrigateRelay.Host` and + `FrigateRelay.Abstractions` remain gRPC-free. +``` + +Do NOT use real IPs / hostnames. Reference issue `#14`. + +**Acceptance Criteria:** +- All 5 gRPC tests pass: `dotnet run --project tests/FrigateRelay.Plugins.Doods2.Tests -c Release --no-build` exits 0 and reports `total: 12` (7 HTTP from PLAN-2.2 + 5 gRPC from this plan). +- Cumulative test count ≥ 262 (257 post-PLAN-2.2 + 5 new): `bash .github/scripts/run-tests.sh --skip-integration` confirms. +- The cancellation test (#5) verifies `OperationCanceledException` is rethrown, not swallowed (proves PLAN-2.1's catch-ordering invariant). +- gRPC dep containment STILL holds (run the dep-list grep). +- CHANGELOG entry references `#14` and explicitly mentions the gRPC-dep-containment invariant. + +## Verification + +```bash +# Build clean +dotnet build FrigateRelay.sln -c Release + +# Tests pass — DOODS2 (full HTTP + gRPC) + everything else +dotnet run --project tests/FrigateRelay.Plugins.Doods2.Tests -c Release --no-build +bash .github/scripts/run-tests.sh --skip-integration + +# Test count gate — at least 262 total (250 post-Wave 1 + 7 HTTP + 5 gRPC) +TOTAL=$(bash .github/scripts/run-tests.sh --skip-integration 2>&1 | grep -E '^total:' | awk '{ sum += $2 } END { print sum }') +[ "$TOTAL" -ge 262 ] || { echo "test count regression: $TOTAL < 262"; exit 1; } + +# gRPC dep containment — load-bearing +dotnet list src/FrigateRelay.Abstractions/FrigateRelay.Abstractions.csproj package --include-transitive | grep -E 'Grpc\.' && exit 1 || true +dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive | grep -E 'Grpc\.' && exit 1 || true + +# Source-level gRPC containment unchanged +git grep -nE 'Grpc\.' src/FrigateRelay.Host src/FrigateRelay.Abstractions # must be empty + +# Architectural invariants +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +git grep -nE '\.(Result|Wait)\(' src/ # must be empty +git grep -n 'ServicePointManager' src/ # must be empty + +# Tests use the shared CapturingLogger +git grep -n 'Substitute.For? Validators = null, + bool ParallelValidators = false); // NEW for #23 / CONTEXT-14 D5 +``` + +Add an XML doc-comment block on the new parameter: +```xml +/// +/// When , the action's validators run concurrently via +/// +/// with strict-AND aggregation: ALL validators must Verdict.Allow for the action to fire. +/// First-reject does NOT short-circuit other in-flight validators (CONTEXT-14 D6) — operators +/// get full per-validator visibility on every dispatch via the existing +/// validators.rejected per-validator counter. +/// Default preserves the v1.0 / v1.1 sequential-with-short-circuit behavior. +/// +``` + +The default-`false` parameter at the end of the positional record means existing call sites that construct `ActionEntry` without specifying `ParallelValidators` continue to compile unchanged (back-compat invariant from CONTEXT-14). + +**`ActionEntryJsonConverter.cs`** — update the dual-form `Read` method and the `Write` method: + +In `Read` at the `JsonTokenType.StartObject` branch (lines 39-46): the private `ActionEntryDto` record (lines 69-72) gains a `bool ParallelValidators = false` parameter, and the constructor call at line 45 passes it through: +```csharp +return new ActionEntry(dto.Plugin, dto.SnapshotProvider, dto.Validators, dto.ParallelValidators); +``` + +In `Write` (lines 53-66): emit `ParallelValidators` only when non-default (matches the existing `Validators is { Count: > 0 }` pattern at line 59 — keeps round-trips compact and avoids polluting written JSON with the default value): +```csharp +if (value.ParallelValidators) + writer.WriteBoolean("ParallelValidators", true); +``` + +The JSON-string-form branch (`JsonTokenType.String` at lines 32-37) creates `new ActionEntry(plugin)` — `ParallelValidators` defaults to `false` automatically. No change needed there; document the invariant in an inline comment so future readers don't introduce a per-string ParallelValidators encoding. + +**`ActionEntryTypeConverter.cs`** — verify NO change is needed. The TypeConverter only converts a scalar string into `new ActionEntry(s)` (line 34). The default-`false` for `ParallelValidators` carries through. Leave the file unchanged but add a one-line inline `// Note:` comment confirming this — future contributors will look here when they touch the JSON converter. + +**Acceptance Criteria:** +- `dotnet build FrigateRelay.sln -c Release` succeeds with zero warnings on Linux + Windows. +- The `ActionEntry` record's signature exactly matches: `internal sealed record ActionEntry(string Plugin, string? SnapshotProvider = null, IReadOnlyList? Validators = null, bool ParallelValidators = false);`. +- Existing tests pass without modification: `dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build` exits 0 (the back-compat invariant — no test-side adjustments allowed). +- Round-trip test (add to `tests/FrigateRelay.Host.Tests` if a corresponding ActionEntry-converter test class exists; otherwise piggyback on the next phase): `JsonSerializer.Serialize(new ActionEntry("X", ParallelValidators: true))` includes `"ParallelValidators":true`; deserializing it back yields a record with `ParallelValidators == true`. +- Round-trip with default flag: `JsonSerializer.Serialize(new ActionEntry("X"))` does NOT include the `"ParallelValidators"` field (matches existing terseness for absent `SnapshotProvider`/`Validators`). + +--- + +### Task 2: Add ParallelValidators field to DispatchItem + propagate from EventPump + +**Files:** +- `src/FrigateRelay.Host/Dispatch/DispatchItem.cs` +- `src/FrigateRelay.Host/EventPump.cs` + +**Action:** modify + +**Description:** + +**`DispatchItem.cs`** — extend the readonly-record-struct signature per RESEARCH §2.2: +```csharp +internal readonly record struct DispatchItem( + EventContext Context, + IActionPlugin Plugin, + IReadOnlyList Validators, + ActivityContext ParentContext, + string Subscription = "", + string? PerActionSnapshotProvider = null, + string? SubscriptionSnapshotProvider = null, + bool ParallelValidators = false); // NEW for #23 +``` + +Add an XML doc-comment paragraph on the new parameter mirroring the `ActionEntry` doc-comment from Task 1, and noting that `EventPump` populates this from `ActionEntry.ParallelValidators` at construction time. Default `false` keeps all existing `new DispatchItem(...)` call sites valid. + +**`EventPump.cs`** — find every `new DispatchItem(` call site by running `git grep -n 'new DispatchItem' src/FrigateRelay.Host/` at execution time (RESEARCH §2.2 explicit guidance). At each construction site that has access to the originating `ActionEntry`, pass `action.ParallelValidators` to the new positional parameter. + +Expected sites: typically one or two construction calls in `EventPump.cs` where an `ActionEntry` (variable name likely `action`) is matched and a `DispatchItem` is built. The construction is parameterized over `Plugin`, `Validators`, `Subscription`, `PerActionSnapshotProvider`, `SubscriptionSnapshotProvider`. Append `ParallelValidators: action.ParallelValidators` (named argument — the field is the new last positional, but using a named argument is more readable and avoids accidentally swapping arguments if a future refactor reorders). + +If `git grep` reveals construction sites OUTSIDE `EventPump.cs` (e.g. test-only construction in `tests/FrigateRelay.Host.Tests`), DO NOT modify those — the default `false` parameter keeps test-side construction working unchanged. The plumbing is host-internal; tests for parallel mode are written explicitly in PLAN-3.2. + +**Acceptance Criteria:** +- `dotnet build FrigateRelay.sln -c Release` succeeds with zero warnings. +- `git grep -n 'new DispatchItem' src/FrigateRelay.Host/EventPump.cs` shows EVERY production-code site passing `ParallelValidators: ` (likely `action.ParallelValidators`). +- All existing `EventPumpTests` pass: `dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build -- --filter "EventPump"` exits 0. +- All existing dispatcher tests still pass: `dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build -- --filter "ChannelActionDispatcher"` exits 0 (the dispatcher still runs the sequential path because `item.ParallelValidators` is `false` for all existing test inputs). +- `git grep -n 'ParallelValidators' src/FrigateRelay.Host/Dispatch/DispatchItem.cs` returns at least 2 matches (declaration + doc-comment). + +## Verification + +```bash +# Build clean +dotnet build FrigateRelay.sln -c Release + +# Existing tests still pass — back-compat invariant (no behavioral change when flag is false) +dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build +dotnet run --project tests/FrigateRelay.Plugins.CodeProjectAi.Tests -c Release --no-build +dotnet run --project tests/FrigateRelay.Plugins.Roboflow.Tests -c Release --no-build +dotnet run --project tests/FrigateRelay.Plugins.Doods2.Tests -c Release --no-build + +# Cumulative test count unchanged from end of Wave 2 (262 minimum) +TOTAL=$(bash .github/scripts/run-tests.sh --skip-integration 2>&1 | grep -E '^total:' | awk '{ sum += $2 } END { print sum }') +[ "$TOTAL" -ge 262 ] || { echo "test count regression: $TOTAL < 262"; exit 1; } + +# Field is present on both types +git grep -n 'ParallelValidators' src/FrigateRelay.Host/Configuration/ActionEntry.cs # must have at least 2 matches +git grep -n 'ParallelValidators' src/FrigateRelay.Host/Dispatch/DispatchItem.cs # must have at least 2 matches + +# JSON converter passes the field through +git grep -n 'ParallelValidators' src/FrigateRelay.Host/Configuration/ActionEntryJsonConverter.cs # must have at least 2 matches (Read DTO + Write) + +# EventPump propagates ActionEntry.ParallelValidators → DispatchItem.ParallelValidators +git grep -n 'ParallelValidators' src/FrigateRelay.Host/EventPump.cs # must have at least 1 match + +# Architectural invariants +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +git grep -nE '\.(Result|Wait)\(' src/ # must be empty +``` diff --git a/.shipyard/phases/14/plans/PLAN-3.2.md b/.shipyard/phases/14/plans/PLAN-3.2.md new file mode 100644 index 0000000..06f7254 --- /dev/null +++ b/.shipyard/phases/14/plans/PLAN-3.2.md @@ -0,0 +1,182 @@ +--- +phase: 14-v1.2-inference-engines-parallel-validation +plan: 3.2 +wave: 3 +dependencies: [3.1] +must_haves: + - Branch in ChannelActionDispatcher.cs:200-246 selecting parallel vs sequential based on item.ParallelValidators + - Task.WhenAll path implements strict-AND with no first-reject short-circuit (CONTEXT-14 D6) + - Per-validator validators.rejected counter still emitted in parallel mode (CONTEXT-14 OQ-4) + - At least 6 new unit tests in tests/FrigateRelay.Host.Tests + - Counter inventory unchanged — no new counters added (CONTEXT-14 OQ-4) + - Cumulative test count rises from 262 (post-Wave 2) to ≥ 268 +files_touched: + - src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs + - tests/FrigateRelay.Host.Tests/ChannelActionDispatcherParallelValidatorsTests.cs +tdd: true +risk: medium +--- + +# Plan 3.2: Parallel-validators branch in ChannelActionDispatcher + unit tests (PR #23) + +## Context + +PLAN-3.1 plumbed the `ParallelValidators` field onto `ActionEntry` and `DispatchItem`. This plan implements the dispatcher branch that consumes it. The change is localized to `src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs:200-246` (RESEARCH §2.1) — the `if (item.Validators.Count > 0)` block that today runs validators sequentially with `goto NextItem` short-circuit. + +Per CONTEXT-14 D5/D6 + OQ-4: +- When `item.ParallelValidators == false`: existing sequential `foreach` body unchanged. +- When `item.ParallelValidators == true`: validators run via `Task.WhenAll`; **strict-AND aggregation** — ALL must pass for the action to fire; first-reject does NOT cancel other in-flight validators (CONTEXT-14 D6 explicit); each rejecting validator emits its own `validators.rejected` counter (OQ-4 — no aggregate counter, keeps the v1.1 counter-inventory drift test clean). +- Per-validator `Timeout` is enforced by each validator's own `HttpClient.Timeout` (RESEARCH §2.3) — the dispatcher does NOT layer a per-task `CancellationTokenSource`. Validator-internal `catch (TaskCanceledException)` returns its own `Verdict.Fail("validator_timeout")` per its `OnError` config; that verdict is one of the verdicts that `Task.WhenAll` collects. +- Host-shutdown propagation: `OperationCanceledException when ct.IsCancellationRequested` from any validator MUST bubble out of `Task.WhenAll` and unwind the consumer loop (existing behavior preserved). `Task.WhenAll` rethrows the first exception by default; that's the desired semantic here. + +Test count target: 262 (post-Wave 2) → **268 (+6 new)** for the parallel-mode unit tests. + +## Dependencies + +- **PLAN-3.1** — `DispatchItem.ParallelValidators` field must exist and be propagated by `EventPump`. + +## Tasks + +### Task 1: Implement parallel branch in ChannelActionDispatcher.cs + +**Files:** `src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs` + +**Action:** modify + +**Description:** + +Refactor the `if (item.Validators.Count > 0)` block at lines 200-246 (RESEARCH §2.1) to branch on `item.ParallelValidators`: + +```csharp +if (item.Validators.Count > 0) +{ + var preResolved = await initial.ResolveAsync(item.Context, ct).ConfigureAwait(false); + shared = new SnapshotContext(preResolved); + + bool anyRejected; + if (item.ParallelValidators) + anyRejected = await RunValidatorsInParallelAsync(item, plugin, shared, ct).ConfigureAwait(false); + else + anyRejected = await RunValidatorsSequentiallyAsync(item, plugin, shared, ct).ConfigureAwait(false); + + if (anyRejected) + { + actionActivity?.SetTag("outcome", "validator_rejected"); + actionActivity?.SetStatus(ActivityStatusCode.Ok, "ValidatorRejected"); + goto NextItem; + } +} +else +{ + shared = initial; +} +``` + +Then extract two private async methods on the class: + +**`RunValidatorsSequentiallyAsync(DispatchItem item, IActionPlugin plugin, SnapshotContext shared, CancellationToken ct)`** — exact body of the existing `foreach (var validator in item.Validators)` loop at lines 209-245, minus the `goto NextItem` (replace with `return true;` to signal "rejected"). The method returns `false` if all validators pass, `true` if any rejected. Activity creation, tag-setting, counter calls (`IncrementValidatorsPassed` / `IncrementValidatorsRejected`), and `LogValidatorRejected` calls all stay byte-identical — sequential mode is **unchanged behavior** (back-compat invariant). + +**`RunValidatorsInParallelAsync(DispatchItem item, IActionPlugin plugin, SnapshotContext shared, CancellationToken ct)`** — new implementation: +1. Build a list of `Task<(IValidationPlugin validator, Verdict verdict)>` by calling each validator concurrently. Each task wraps the `validator.ValidateAsync(...)` call together with the same activity-creation + tag-setting that the sequential path does. The activity span MUST still be `validator..check` per validator (RESEARCH §2.1 lines 211-218 — preserve span name + tags so traces remain pivotable per CONTEXT-13's tag matrix). +2. `await Task.WhenAll(tasks)` — `OperationCanceledException` from host shutdown propagates naturally (Task.WhenAll rethrows the first exception; the consumer loop's outer catch handles it). +3. After all tasks complete: iterate `(validator, verdict)` pairs. For each, increment the appropriate counter (`IncrementValidatorsPassed` or `IncrementValidatorsRejected` — same calls as sequential mode, preserves OQ-4 per-validator emission with full tag matrix). For rejecting validators, also emit `LogValidatorRejected` with the same arguments as the sequential path (so dashboards + log-based alerting still work identically). +4. Return `true` if ANY verdict was a reject (`!verdict.Passed`), else `false`. **No first-reject short-circuit** — every validator runs to completion (CONTEXT-14 D6 explicit). + +Helper for building one validator-task (private method): +```csharp +private async Task<(IValidationPlugin Validator, Verdict Verdict)> RunOneValidatorAsync( + IValidationPlugin validator, DispatchItem item, IActionPlugin plugin, SnapshotContext shared, CancellationToken ct) +{ + var validatorSpanName = $"validator.{validator.Name.ToLowerInvariant()}.check"; + using var vActivity = DispatcherDiagnostics.ActivitySource.StartActivity( + validatorSpanName, ActivityKind.Internal); + vActivity?.SetTag("event.id", item.Context.EventId); + vActivity?.SetTag("validator", validator.Name); + vActivity?.SetTag("action", plugin.Name); + vActivity?.SetTag("subscription", item.Subscription); + + var verdict = await validator.ValidateAsync(item.Context, shared, ct).ConfigureAwait(false); + vActivity?.SetTag("verdict", verdict.Passed ? "pass" : "fail"); + if (!verdict.Passed) vActivity?.SetTag("reason", verdict.Reason); + return (validator, verdict); +} +``` + +This helper is also reusable from the sequential path (the activity bookkeeping is identical). Optional: refactor the sequential path to call this same helper — keeps validator-span construction in one place. If the refactor is too invasive, leave the sequential path's existing inline code untouched and accept a small duplication; the back-compat invariant is more important than DRY. + +**No new counters added.** `DispatcherDiagnostics.IncrementValidatorsPassed` / `IncrementValidatorsRejected` are called from both branches with identical arguments; counter inventory matches v1.1 baseline (CONTEXT-14 OQ-4 + counter-inventory-drift test continues to pass). + +**Acceptance Criteria:** +- `dotnet build FrigateRelay.sln -c Release` zero warnings. +- `git grep -n 'item.ParallelValidators' src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs` returns at least 1 match. +- `git grep -n 'Task.WhenAll' src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs` returns at least 1 match. +- All existing tests pass — sequential-mode behavior unchanged: `dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build -- --filter "ChannelActionDispatcher"` exits 0. +- Counter inventory is unchanged — `tests/FrigateRelay.Host.Tests/CounterInventoryDriftTests.cs` (or whatever the Phase 13 inventory test is named — locate via `grep -rln Inventory tests/FrigateRelay.Host.Tests/`) continues to pass without modification. +- The forbidden tag `event_id` is NOT added on counter increments in the parallel branch (only on activity spans, which is allowed): `git grep -n '"event_id"' src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs` returns matches only on activity-span `SetTag` calls (existing pattern), not on counter calls. +- No `.Result` / `.Wait()` introduced: `git grep -nE '\.(Result|Wait)\(' src/` empty. + +--- + +### Task 2: Write 6 ChannelActionDispatcher parallel-validators unit tests + +**Files:** `tests/FrigateRelay.Host.Tests/ChannelActionDispatcherParallelValidatorsTests.cs` (new) + +**Action:** create (TDD — write tests first, then verify against Task 1's implementation) + +**Description:** + +New test class in the existing `tests/FrigateRelay.Host.Tests/` project (no new csproj needed). Use NSubstitute to mock `IValidationPlugin` instances (the dispatcher's external dependency); use the existing test patterns from `ChannelActionDispatcherTests.cs` (find via `grep -l ChannelActionDispatcher tests/FrigateRelay.Host.Tests/`) for harness setup. + +The 6 tests: + +1. `Dispatch_ParallelValidatorsFalse_RunsValidatorsSequentially` — back-compat test. Two validator mocks; record their `ValidateAsync` invocation order via `ICallInfo`-based callbacks. Run with `item.ParallelValidators = false`. Assert both validators called; assert validator-2 NOT called when validator-1 returns `Verdict.Fail` (short-circuit invariant for sequential mode). Verifies the sequential branch is intact. + +2. `Dispatch_ParallelValidatorsTrue_AllValidatorsPass_ActionExecutes` — happy path. Two validator mocks both return `Verdict.Pass()`. Run with `ParallelValidators = true`. Assert action plugin's `ExecuteAsync` was called once. Assert both validators ran (use NSubstitute `.Received(1).ValidateAsync(...)` on both). + +3. `Dispatch_ParallelValidatorsTrue_AnyValidatorRejects_ActionDoesNotExecute_AllValidatorsRan` — strict-AND test. Three validators: V1 passes, V2 rejects, V3 passes. Run with `ParallelValidators = true`. Assert action plugin `ExecuteAsync` was NOT called. Assert all three validators' `ValidateAsync` was called (`Received(1)` on each) — proves no first-reject short-circuit (CONTEXT-14 D6). + +4. `Dispatch_ParallelValidatorsTrue_AllRejectingValidatorsEmitTheirOwnRejectedCounter` — counter test. Two validators both return `Verdict.Fail("nope")`. Run with `ParallelValidators = true`. Use a `MeterListener`-based capture (mirror `tests/FrigateRelay.Host.Tests/CounterTests.cs` or whatever the Phase 13 counter-test pattern is — find via `grep -rln MeterListener tests/FrigateRelay.Host.Tests/`). Assert exactly 2 increments to `validators.rejected`, one tagged `validator=v1`, one tagged `validator=v2`. Verifies CONTEXT-14 OQ-4: per-validator emission preserved. + +5. `Dispatch_ParallelValidatorsTrue_OneValidatorTimesOutFailClosed_ActionDoesNotExecute` — timeout test. Use a real `Doods2Validator`-style mock (or a fake `IValidationPlugin` that internally awaits `Task.Delay` past its `Timeout` and returns `Verdict.Fail("validator_timeout")` per its own catch-block). Run with `ParallelValidators = true` alongside one immediately-passing validator. Assert action `ExecuteAsync` NOT called; assert the immediately-passing validator's pass counter incremented; assert the timing-out validator's reject counter incremented with reason hint `validator_timeout`. Validates RESEARCH §2.3: validator's own `Timeout` works in parallel mode without dispatcher-level CTS plumbing. + +6. `Dispatch_ParallelValidatorsTrue_HostCancellation_PropagatesOperationCanceledException` — cancellation test. Use a fake `IValidationPlugin` whose `ValidateAsync` throws `OperationCanceledException` when its `ct` is signalled. The test cancels the dispatcher's CT mid-flight (use a `CancellationTokenSource` whose `Cancel()` runs after the `Task.WhenAll` is in flight — the simplest reliable shape is to give the validator a `Task.Delay` that completes only when `ct` fires). Assert the dispatch loop unwinds gracefully (no exception bubbles out of `ConsumeAsync` — the outer `catch` in the consumer loop handles it). The action plugin's `ExecuteAsync` must NOT be called. + +XML doc-comment per test summarizing the case. Use `[TestMethod]` attributes; test names use underscores (CLAUDE.md "Conventions" — `Method_Condition_Expected`). + +**Acceptance Criteria:** +- All 6 tests pass: `dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build -- --filter "ParallelValidators"` exits 0 and reports 6 tests. +- `dotnet build FrigateRelay.sln -c Release` zero warnings. +- Cumulative test count ≥ 268 (262 post-Wave 2 + 6 new): `bash .github/scripts/run-tests.sh --skip-integration` confirms. +- Test #3 explicitly asserts ALL validators ran when one rejected (the no-short-circuit invariant from CONTEXT-14 D6). +- Test #4 explicitly asserts per-validator `validators.rejected` counter emission (CONTEXT-14 OQ-4). +- All existing host tests still pass: `dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build` exits 0 (sequential mode behavior unchanged). +- Tests use the shared `CapturingLogger` from `FrigateRelay.TestHelpers` for log assertions, NOT NSubstitute on `ILogger` (CLAUDE.md "Conventions" — log-assertion helper precedent). + +## Verification + +```bash +# Build clean +dotnet build FrigateRelay.sln -c Release + +# All host tests pass — including the new parallel-mode suite + the back-compat sequential tests +dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build + +# Test count gate — at least 268 total (262 post-Wave 2 + 6 new) +TOTAL=$(bash .github/scripts/run-tests.sh --skip-integration 2>&1 | grep -E '^total:' | awk '{ sum += $2 } END { print sum }') +[ "$TOTAL" -ge 268 ] || { echo "test count regression: $TOTAL < 268"; exit 1; } + +# Counter inventory unchanged — Phase 13's drift test still passes +dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build -- --filter "CounterInventory" + +# Parallel branch lives in the dispatcher +git grep -n 'item.ParallelValidators' src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs # must have at least 1 match +git grep -n 'Task.WhenAll' src/FrigateRelay.Host/Dispatch/ChannelActionDispatcher.cs # must have at least 1 match + +# Architectural invariants +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +git grep -nE '\.(Result|Wait)\(' src/ # must be empty +git grep -n 'ServicePointManager' src/ # must be empty + +# Tests use the shared CapturingLogger +git grep -n 'Substitute.For&1 | grep -E '^total:' | awk '{ sum += $2 } END { print sum }') +[ "$TOTAL" -ge 269 ] || { echo "test count regression: $TOTAL < 269"; exit 1; } + +# Integration test for parallel-validators specifically +dotnet run --project tests/FrigateRelay.IntegrationTests -c Release --no-build -- --filter "ParallelValidators" + +# Counter inventory unchanged — Phase 13's drift test still passes +dotnet run --project tests/FrigateRelay.Host.Tests -c Release --no-build -- --filter "CounterInventory" + +# Architectural invariants — final sweep +git grep -nE 'App\.Metrics|OpenTracing|Jaeger\.' src/ # must be empty +git grep -nE '\.(Result|Wait)\(' src/ # must be empty +git grep -n 'ServicePointManager' src/ # must be empty +git grep -nE '192\.168\.|10\.0\.0\.|172\.(1[6-9]|2[0-9]|3[01])\.' src/ # must be empty + +# gRPC dep containment — STILL holds at end of Phase 14 +dotnet list src/FrigateRelay.Abstractions/FrigateRelay.Abstractions.csproj package --include-transitive | grep -E 'Grpc\.' && exit 1 || true +dotnet list src/FrigateRelay.Host/FrigateRelay.Host.csproj package --include-transitive | grep -E 'Grpc\.' && exit 1 || true +git grep -nE 'Grpc\.' src/FrigateRelay.Host src/FrigateRelay.Abstractions # must be empty + +# CHANGELOG references all three issues +grep -n '#13' CHANGELOG.md +grep -n '#14' CHANGELOG.md +grep -n '#23' CHANGELOG.md + +# Secret scan + tripwire still clean +bash .github/scripts/secret-scan.sh +``` From b0048dec2843f0ae2d167843aba417828de72be7 Mon Sep 17 00:00:00 2001 From: Brian Lehnen Date: Wed, 6 May 2026 11:50:54 -0500 Subject: [PATCH 03/16] shipyard(phase-14): scaffold Roboflow plugin project (Options, Response DTOs, Validator) --- .../FrigateRelay.Plugins.Roboflow.csproj | 24 ++++ .../RoboflowOptions.cs | 84 ++++++++++++ .../RoboflowResponse.cs | 39 ++++++ .../RoboflowValidator.cs | 125 ++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj create mode 100644 src/FrigateRelay.Plugins.Roboflow/RoboflowOptions.cs create mode 100644 src/FrigateRelay.Plugins.Roboflow/RoboflowResponse.cs create mode 100644 src/FrigateRelay.Plugins.Roboflow/RoboflowValidator.cs diff --git a/src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj b/src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj new file mode 100644 index 0000000..c1bdc39 --- /dev/null +++ b/src/FrigateRelay.Plugins.Roboflow/FrigateRelay.Plugins.Roboflow.csproj @@ -0,0 +1,24 @@ + + + + FrigateRelay.Plugins.Roboflow + FrigateRelay.Plugins.Roboflow + false + true + + + + + + + + + + + + + + + + + diff --git a/src/FrigateRelay.Plugins.Roboflow/RoboflowOptions.cs b/src/FrigateRelay.Plugins.Roboflow/RoboflowOptions.cs new file mode 100644 index 0000000..19c114d --- /dev/null +++ b/src/FrigateRelay.Plugins.Roboflow/RoboflowOptions.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; + +namespace FrigateRelay.Plugins.Roboflow; + +/// +/// Configuration for one named instance of the Roboflow Inference validator. Bound from a +/// Validators:<instanceKey> child section of appsettings.json via +/// and the +/// AddOptions<T>(name).Bind(...) named-options pattern. +/// +/// +/// +/// One instance per named validator entry — operators express +/// per-model tuning by creating multiple validator instances with different +/// + + +/// (CONTEXT-14 D3). For example, roboflow_persons and roboflow_vehicles can +/// each target a different model while sharing the same self-hosted Inference server. +/// +/// +/// Self-hosted only (CONTEXT-14 D2). This plugin targets the self-hosted +/// Roboflow Inference server (e.g. http://roboflow:9001). The Roboflow Hosted Cloud +/// API is out of scope for v1.2 — auth surface and quota error handling are deferred. +/// +/// +/// API contract. Uses POST /infer/object_detection as documented in +/// the Roboflow Inference v0.x/v1.x REST API. The must include the +/// version suffix (e.g. rfdetr-base/1). Confidence values in the response are in the +/// 0.0–1.0 range — no normalization is applied (contrast with DOODS2, which uses 0–100). +/// +/// +public sealed class RoboflowOptions +{ + /// Base URL of the self-hosted Roboflow Inference server, e.g. http://roboflow:9001. + [Required, Url] + public string BaseUrl { get; set; } = ""; + + /// + /// Model identifier including version suffix, e.g. rfdetr-base/1. Sent as + /// model_id in the request body. Operators declare one validator instance per model + /// (CONTEXT-14 D3). + /// + [Required] + public string ModelId { get; set; } = ""; + + /// Minimum prediction confidence (0.0-1.0) to be accepted as a positive match. + [Range(0.0, 1.0)] + public double MinConfidence { get; set; } = 0.5; + + /// + /// Allowed prediction labels. When empty, any label passes (combined with the confidence + /// gate). When non-empty, predictions whose class field is not in this list are + /// rejected (case-insensitive ordinal comparison). + /// + public string[] AllowedLabels { get; set; } = []; + + /// + /// What verdict to return when the underlying HTTP call fails (timeout, network error, + /// non-success HTTP status). Default + /// matches the legacy intent: don't notify if you can't confirm. + /// + public RoboflowValidatorErrorMode OnError { get; set; } = RoboflowValidatorErrorMode.FailClosed; + + /// HTTP timeout for the validator's POST. Default 5 seconds. + [Range(typeof(TimeSpan), "00:00:01", "00:01:00")] + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// Per-instance opt-in to bypass TLS certificate validation. Configures a per-instance + /// SocketsHttpHandler.SslOptions.RemoteCertificateValidationCallback via + /// ConfigurePrimaryHttpMessageHandler. NEVER set globally on + /// ServicePointManager (PROJECT.md / CLAUDE.md invariant). + /// + public bool AllowInvalidCertificates { get; set; } +} + +/// Failure stance for . +public enum RoboflowValidatorErrorMode +{ + /// Skip the action when the validator is unavailable. Safe default. + FailClosed, + + /// Allow the action to proceed when the validator is unavailable. Risk: notifies during outages. + FailOpen, +} diff --git a/src/FrigateRelay.Plugins.Roboflow/RoboflowResponse.cs b/src/FrigateRelay.Plugins.Roboflow/RoboflowResponse.cs new file mode 100644 index 0000000..91f5e04 --- /dev/null +++ b/src/FrigateRelay.Plugins.Roboflow/RoboflowResponse.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace FrigateRelay.Plugins.Roboflow; + +/// JSON shape returned by POST /infer/object_detection. +/// +/// Confidence values are in the 0.0–1.0 range (e.g. 0.92 for 92% confidence). +/// No normalization is required — compare directly to . +/// +internal sealed record RoboflowResponse( + [property: JsonPropertyName("image")] RoboflowImage? Image, + [property: JsonPropertyName("predictions")] IReadOnlyList? Predictions, + [property: JsonPropertyName("time")] double Time); + +/// Image dimensions from a /infer/object_detection response. +internal sealed record RoboflowImage( + [property: JsonPropertyName("width")] int Width, + [property: JsonPropertyName("height")] int Height); + +/// One detected object from a /infer/object_detection response. +internal sealed record RoboflowPrediction( + [property: JsonPropertyName("class")] string Label, + [property: JsonPropertyName("confidence")] double Confidence, + [property: JsonPropertyName("class_id")] int? ClassId, + [property: JsonPropertyName("x")] double X, + [property: JsonPropertyName("y")] double Y, + [property: JsonPropertyName("width")] double Width, + [property: JsonPropertyName("height")] double Height); + +/// Request body sent to POST /infer/object_detection. +internal sealed record RoboflowRequest( + [property: JsonPropertyName("model_id")] string ModelId, + [property: JsonPropertyName("image")] RoboflowRequestImage Image, + [property: JsonPropertyName("confidence")] double Confidence); + +/// Image payload within . +internal sealed record RoboflowRequestImage( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("value")] string Value); diff --git a/src/FrigateRelay.Plugins.Roboflow/RoboflowValidator.cs b/src/FrigateRelay.Plugins.Roboflow/RoboflowValidator.cs new file mode 100644 index 0000000..c7490ff --- /dev/null +++ b/src/FrigateRelay.Plugins.Roboflow/RoboflowValidator.cs @@ -0,0 +1,125 @@ +using System.Net.Http.Json; +using FrigateRelay.Abstractions; +using Microsoft.Extensions.Logging; + +namespace FrigateRelay.Plugins.Roboflow; + +/// +/// One named instance of the Roboflow Inference validator. Submits the dispatched snapshot to +/// POST {BaseUrl}/infer/object_detection, evaluates the returned predictions against +/// + , +/// and returns a . +/// +/// +/// Self-hosted only (CONTEXT-14 D2). Targets the self-hosted Roboflow +/// Inference server. No Roboflow Hosted Cloud API in v1.2. +/// Per-instance model (CONTEXT-14 D3). +/// is per-instance; operators declare multiple validator instances for per-camera model selection. +/// +/// No HTTP retry pipeline (CONTEXT-14 / CONTEXT-7 D4). Validators are +/// pre-action gates; per-attempt retry latency would systematically delay every notification. +/// Single timeout, fail-closed/open per . INTENTIONALLY +/// asymmetric with BlueIris/Pushover action plugins, which DO retry 3/6/9s. +/// Catch-block ordering matters (RESEARCH §1.4). +/// OperationCanceledException when ct.IsCancellationRequested is caught FIRST so host +/// shutdown propagates; (which derives from it) is caught +/// SECOND and maps to validator timeout per . +/// Confidence scale. Roboflow returns confidence 0.0–1.0. No normalization +/// is applied — compare directly to . +/// +public sealed partial class RoboflowValidator : IValidationPlugin +{ + private readonly string _name; + private readonly RoboflowOptions _opts; + private readonly HttpClient _http; + private readonly ILogger _logger; + + /// Initialises a Roboflow Inference validator instance. + /// The instance key from the top-level Validators config dictionary (e.g. "roboflow_persons"). + /// Bound options for this instance. + /// Per-instance with BaseAddress + Timeout set by the registrar. + /// Logger for validator-side warnings (timeout, unavailable). The dispatcher emits the structured validator_rejected entry separately. + public RoboflowValidator( + string name, + RoboflowOptions opts, + HttpClient http, + ILogger logger) + { + _name = name; + _opts = opts; + _http = http; + _logger = logger; + } + + /// + public string Name => _name; + + /// + public async Task ValidateAsync(EventContext ctx, SnapshotContext snapshot, CancellationToken ct) + { + var snap = await snapshot.ResolveAsync(ctx, ct).ConfigureAwait(false); + if (snap is null) + return Verdict.Fail("validator_no_snapshot"); + + try + { + var base64 = Convert.ToBase64String(snap.Bytes); + var request = new RoboflowRequest( + ModelId: _opts.ModelId, + Image: new RoboflowRequestImage(Type: "base64", Value: base64), + Confidence: _opts.MinConfidence); + + using var response = await _http.PostAsJsonAsync("/infer/object_detection", request, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(ct).ConfigureAwait(false); + return EvaluatePredictions(body); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Host shutdown — propagate. NOT a validator failure. + throw; + } + catch (TaskCanceledException ex) + { + Log.ValidatorTimeout(_logger, _name, ctx.EventId, ex); + return _opts.OnError == RoboflowValidatorErrorMode.FailOpen + ? Verdict.Pass() + : Verdict.Fail("validator_timeout"); + } + catch (HttpRequestException ex) + { + Log.ValidatorUnavailable(_logger, _name, ctx.EventId, ex); + return _opts.OnError == RoboflowValidatorErrorMode.FailOpen + ? Verdict.Pass() + : Verdict.Fail($"validator_unavailable: {ex.Message}"); + } + } + + private Verdict EvaluatePredictions(RoboflowResponse? body) + { + if (body?.Predictions is null || body.Predictions.Count == 0) + return Verdict.Fail("validator_no_predictions"); + + foreach (var p in body.Predictions) + { + if (p.Confidence < _opts.MinConfidence) continue; + if (_opts.AllowedLabels.Length > 0 && + !_opts.AllowedLabels.Any(l => string.Equals(l, p.Label, StringComparison.OrdinalIgnoreCase))) + continue; + return Verdict.Pass(p.Confidence); + } + + return Verdict.Fail($"validator_no_match: minConfidence={_opts.MinConfidence}, allowedLabels=[{string.Join(",", _opts.AllowedLabels)}]"); + } + + private static partial class Log + { + [LoggerMessage(EventId = 7101, Level = LogLevel.Warning, + Message = "Roboflow validator '{Validator}' timed out for event {FrigateEventId}")] + public static partial void ValidatorTimeout(ILogger logger, string validator, string frigateEventId, Exception ex); + + [LoggerMessage(EventId = 7102, Level = LogLevel.Warning, + Message = "Roboflow validator '{Validator}' unavailable for event {FrigateEventId}")] + public static partial void ValidatorUnavailable(ILogger logger, string validator, string frigateEventId, Exception ex); + } +} From 281aac2a420f855d5ed611b9e29bab6076e91f70 Mon Sep 17 00:00:00 2001 From: Brian Lehnen Date: Wed, 6 May 2026 11:52:53 -0500 Subject: [PATCH 04/16] shipyard(phase-14): add Roboflow PluginRegistrar with named-options + keyed-singleton ritual --- .../PluginRegistrar.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/FrigateRelay.Plugins.Roboflow/PluginRegistrar.cs diff --git a/src/FrigateRelay.Plugins.Roboflow/PluginRegistrar.cs b/src/FrigateRelay.Plugins.Roboflow/PluginRegistrar.cs new file mode 100644 index 0000000..3a2f94f --- /dev/null +++ b/src/FrigateRelay.Plugins.Roboflow/PluginRegistrar.cs @@ -0,0 +1,87 @@ +using FrigateRelay.Abstractions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FrigateRelay.Plugins.Roboflow; + +/// +/// Enumerates the top-level Validators:<key> configuration dictionary, picks +/// entries with Type == "Roboflow", and registers one keyed +/// per instance via AddKeyedSingleton + named options. +/// +/// +/// +/// Per-instance with per-instance TLS handler. Other validator types +/// (CPAI, DOODS2) coexist by filtering on their own Type discriminator value. +/// +/// +/// INTENTIONALLY no AddResilienceHandler on the validator . +/// Asymmetric with BlueIris/Pushover (which retry 3/6/9s). Validators are pre-action gates; +/// per-attempt retry latency would systematically delay every notification by 18s. +/// Single ; fail-{closed,open} per +/// (CONTEXT-7 D4 / CONTEXT-14 architect lock-in). +/// +/// +public sealed class PluginRegistrar : IPluginRegistrar +{ + /// + public void Register(PluginRegistrationContext context) + { + var validatorsSection = context.Configuration.GetSection("Validators"); + if (!validatorsSection.Exists()) return; + + foreach (var instance in validatorsSection.GetChildren()) + { + var type = instance["Type"]; + if (!string.Equals(type, "Roboflow", StringComparison.Ordinal)) + continue; + + // Capture for the closures below — instance.Key is a property reference, but we want + // the value snapshot at registration time so all factories see the same instance key. + var instanceKey = instance.Key; + + // Bind named options instance (retrieved at runtime via IOptionsMonitor.Get(key)). + context.Services + .AddOptions(instanceKey) + .Bind(instance) + .ValidateDataAnnotations() + .ValidateOnStart(); + + // Per-instance HttpClient with per-instance TLS handler. INTENTIONALLY no + // AddResilienceHandler — see remarks on this class. + var clientName = $"Roboflow:{instanceKey}"; + context.Services + .AddHttpClient(clientName) + .ConfigurePrimaryHttpMessageHandler(sp => + { + var opts = sp.GetRequiredService>().Get(instanceKey); + var handler = new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + }; + if (opts.AllowInvalidCertificates) + { +#pragma warning disable CA5359 // opt-in TLS skip per CLAUDE.md architecture invariant + handler.SslOptions.RemoteCertificateValidationCallback = + static (_, _, _, _) => true; +#pragma warning restore CA5359 + } + return handler; + }); + + // Keyed validator plugin — resolved by EventPump via IServiceProvider.GetRequiredKeyedService. + context.Services.AddKeyedSingleton(instanceKey, (sp, key) => + { + var name = (string)key!; + var opts = sp.GetRequiredService>().Get(name); + var http = sp.GetRequiredService().CreateClient($"Roboflow:{name}"); + http.BaseAddress = new Uri(opts.BaseUrl); + http.Timeout = opts.Timeout; + var logger = sp.GetRequiredService>(); + return new RoboflowValidator(name, opts, http, logger); + }); + } + } +} From 0340e5b9967192be40b2e2a7291dc5bfdbf77125 Mon Sep 17 00:00:00 2001 From: Brian Lehnen Date: Wed, 6 May 2026 11:58:13 -0500 Subject: [PATCH 05/16] shipyard(phase-14): wire Roboflow plugin into Host DI + solution --- FrigateRelay.sln | 15 +++++++++++++++ src/FrigateRelay.Host/FrigateRelay.Host.csproj | 1 + src/FrigateRelay.Host/HostBootstrap.cs | 8 +++++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/FrigateRelay.sln b/FrigateRelay.sln index 9c1bf5c..987d06d 100644 --- a/FrigateRelay.sln +++ b/FrigateRelay.sln @@ -51,6 +51,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrigateRelay.MigrateConf.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrigateRelay.IntegrationTests.RealBroker", "tests\FrigateRelay.IntegrationTests.RealBroker\FrigateRelay.IntegrationTests.RealBroker.csproj", "{C15C8832-0CFD-4A10-860C-2D8243BECD67}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrigateRelay.Plugins.Roboflow", "src\FrigateRelay.Plugins.Roboflow\FrigateRelay.Plugins.Roboflow.csproj", "{BAD7AC89-A995-470A-92B3-5AE6E2EE5948}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -301,6 +303,18 @@ Global {C15C8832-0CFD-4A10-860C-2D8243BECD67}.Release|x64.Build.0 = Release|Any CPU {C15C8832-0CFD-4A10-860C-2D8243BECD67}.Release|x86.ActiveCfg = Release|Any CPU {C15C8832-0CFD-4A10-860C-2D8243BECD67}.Release|x86.Build.0 = Release|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Debug|x64.ActiveCfg = Debug|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Debug|x64.Build.0 = Debug|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Debug|x86.ActiveCfg = Debug|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Debug|x86.Build.0 = Debug|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Release|Any CPU.Build.0 = Release|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Release|x64.ActiveCfg = Release|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Release|x64.Build.0 = Release|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Release|x86.ActiveCfg = Release|Any CPU + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -326,5 +340,6 @@ Global {9D1D5315-0F8B-4932-9522-0B56F8D8D8EE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {32DFD307-D93B-4794-9D36-B5EF1208C361} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {C15C8832-0CFD-4A10-860C-2D8243BECD67} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {BAD7AC89-A995-470A-92B3-5AE6E2EE5948} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/FrigateRelay.Host/FrigateRelay.Host.csproj b/src/FrigateRelay.Host/FrigateRelay.Host.csproj index 22e4737..87262d2 100644 --- a/src/FrigateRelay.Host/FrigateRelay.Host.csproj +++ b/src/FrigateRelay.Host/FrigateRelay.Host.csproj @@ -40,6 +40,7 @@ +