diff --git a/docs/plans/2026-06-26-delmia-recipe-notifier.md b/docs/plans/2026-06-26-delmia-recipe-notifier.md new file mode 100644 index 00000000..24ce53c4 --- /dev/null +++ b/docs/plans/2026-06-26-delmia-recipe-notifier.md @@ -0,0 +1,429 @@ +# Delmia Recipe-Download Notifier — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Build `ZB.MOM.WW.ScadaBridge.DelmiaNotifier` — a compact Native-AOT Windows console app that DELMIA shells out to per recipe download, which POSTs to the ScadaBridge Inbound API `DelmiaRecipeDownload` method and reports the legacy `YES`/`NO` + exit-code contract. + +**Architecture:** Single `Program.cs` orchestrates four testable pieces (arg parser, config loader, notifier failover loop behind an `IRecipeSender` seam, result reporter). Zero third-party NuGet deps; `System.Text.Json` source generator for AOT-safe (de)serialization; raw `HttpClient`. Comma-list of base URLs with **connect-failure-only** failover. + +**Tech Stack:** .NET 10, C#, Native AOT (`win-x64`), `System.Text.Json` source-gen, xUnit. Central package management (`Directory.Packages.props`); no `Directory.Build.props`. + +Design doc: [`2026-06-26-delmia-recipe-notifier-design.md`](2026-06-26-delmia-recipe-notifier-design.md). + +**Cross-cutting conventions (apply to every task):** +- TDD: failing test → run (fails) → minimal code → run (passes) → commit. +- Run a single test: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests --filter "FullyQualifiedName~"`. +- Build the two new projects only (fast): `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. +- `TreatWarningsAsErrors=true` everywhere — AOT/trim analyzer warnings are build failures; fix, don't suppress. +- Native AOT cannot be published from macOS; all tasks here are managed build/test only (cross-platform). The win-x64 AOT exe is produced on Windows (Task 8). +- JSON keys are **PascalCase** (`MachineCode`, `Result`, …) to match the inbound `DelmiaRecipeDownload` contract — keep `System.Text.Json` default naming (no camelCase policy). + +--- + +### Task 1: Scaffold the src + test projects + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (foundation) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj` +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/appsettings.json` +- Create: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj` +- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (add both `` lines under the matching `/src/` and `/tests/` folders) + +**Step 1: Create the src csproj** + +```xml + + + Exe + net10.0 + enable + enable + true + WWNotifier + true + true + + + + + + + + + +``` + +**Step 2: Create a stub `Program.cs`** + +```csharp +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +internal static class Program +{ + public static int Main(string[] args) => 0; // replaced in Task 7 +} +``` + +**Step 3: Create `appsettings.json`** + +```json +{ + "ScadaBridge": { + "BaseUrls": "http://localhost:9000", + "TimeoutSeconds": 30, + "LogPath": "logs/delmia-notifier.log" + } +} +``` + +**Step 4: Create the test csproj** + +```xml + + + net10.0 + enable + enable + true + false + + + + + + + + + + + + + + +``` + +**Step 5: Add both projects to `ZB.MOM.WW.ScadaBridge.slnx`** — one `` under ``, one `` under ``. + +**Step 6: Build both** — `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. Expected: both succeed. + +**Step 7: Commit** — `git add … && git commit -m "feat(delmia-notifier): scaffold DelmiaNotifier src + test projects"` + +--- + +### Task 2: DTOs + JSON source-generation context + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 3, Task 4 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/RecipeDownload.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/RecipeDownloadResult.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierJsonContext.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/JsonContractTests.cs` + +**Step 1: Failing test** — assert `RecipeDownload` serializes to PascalCase keys and `RecipeDownloadResult` round-trips: + +```csharp +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +public class JsonContractTests +{ + [Fact] + public void RecipeDownload_serializes_pascalcase() + { + var json = JsonSerializer.Serialize( + new RecipeDownload { MachineCode = "Z28061", DownloadPath = @"C:\r.nc", + WorkOrderNumber = "W1", PartNumber = "P1", JobStepNumber = "0100", Username = "op" }, + NotifierJsonContext.Default.RecipeDownload); + Assert.Contains("\"MachineCode\":\"Z28061\"", json); + Assert.Contains("\"DownloadPath\"", json); + } + + [Fact] + public void RecipeDownloadResult_deserializes_pascalcase() + { + var r = JsonSerializer.Deserialize("{\"Result\":true,\"ResultText\":\"ok\"}", + NotifierJsonContext.Default.RecipeDownloadResult); + Assert.True(r!.Result); + Assert.Equal("ok", r.ResultText); + } +} +``` + +**Step 2: Run → fails** (types don't exist). + +**Step 3: Implement** the two DTOs and the context: + +```csharp +// RecipeDownload.cs +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; +internal sealed class RecipeDownload +{ + public string? MachineCode { get; set; } + public string? DownloadPath { get; set; } + public string? WorkOrderNumber { get; set; } + public string? PartNumber { get; set; } + public string? JobStepNumber { get; set; } + public string? Username { get; set; } +} +``` +```csharp +// RecipeDownloadResult.cs +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; +internal sealed class RecipeDownloadResult +{ + public bool Result { get; set; } + public string? ResultText { get; set; } +} +``` +```csharp +// NotifierJsonContext.cs +using System.Text.Json.Serialization; +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +[JsonSerializable(typeof(RecipeDownload))] +[JsonSerializable(typeof(RecipeDownloadResult))] +[JsonSerializable(typeof(NotifierConfig))] // added in Task 4 +internal partial class NotifierJsonContext : JsonSerializerContext; +``` +> If Task 4 isn't done yet, temporarily omit the `NotifierConfig` line and add it in Task 4. + +**Step 4: Run → passes.** + +**Step 5: Commit** — `feat(delmia-notifier): recipe DTOs + JSON source-gen context` + +--- + +### Task 3: Command-line argument parser + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2, Task 4 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ArgParser.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ArgParserTests.cs` + +**Behavior:** parse `-d/--downloadpath -m/--machine -w/--workorder -p/--partnumber` (required) + `-s/--seqop -u/--username` (optional) into a `RecipeDownload`. Return a discriminated result: success(payload) or error(message). Unknown flag or missing required → error with a human reason. + +**Step 1: Failing tests** + +```csharp +using ZB.MOM.WW.ScadaBridge.DelmiaNotifier; + +public class ArgParserTests +{ + [Fact] + public void Parses_all_flags() + { + var r = ArgParser.Parse(new[] { "-m","Z28061","-d",@"C:\r.nc","-w","W1","-p","P1","-s","0100","-u","op" }); + Assert.True(r.Ok); + Assert.Equal("Z28061", r.Payload!.MachineCode); + Assert.Equal("0100", r.Payload.JobStepNumber); + Assert.Equal("op", r.Payload.Username); + } + + [Fact] + public void Missing_required_returns_error() + { + var r = ArgParser.Parse(new[] { "-m","Z28061","-d",@"C:\r.nc","-w","W1" }); // no -p + Assert.False(r.Ok); + Assert.Contains("partnumber", r.Error, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Optional_flags_may_be_omitted() + { + var r = ArgParser.Parse(new[] { "-m","Z","-d","x","-w","W","-p","P" }); + Assert.True(r.Ok); + Assert.Null(r.Payload!.Username); + Assert.Null(r.Payload.JobStepNumber); + } + + [Fact] + public void Unknown_flag_returns_error() + { + var r = ArgParser.Parse(new[] { "-z","x" }); + Assert.False(r.Ok); + } +} +``` + +**Step 2: Run → fails.** + +**Step 3: Implement `ArgParser`** — a `ParseResult` record (`bool Ok`, `RecipeDownload? Payload`, `string? Error`) and a `Parse(string[])` that walks pairs, maps short+long flags, validates the four required fields, and returns the first missing/unknown as the error. Keep it allocation-light and reflection-free (AOT-safe). + +**Step 4: Run → passes.** + +**Step 5: Commit** — `feat(delmia-notifier): CLI arg parser with required/optional validation` + +--- + +### Task 4: Config loader + API key + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2, Task 3 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierConfig.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ConfigLoader.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs` +- (Add `[JsonSerializable(typeof(NotifierConfig))]` to `NotifierJsonContext` if not already present.) + +**Behavior:** `NotifierConfig` POCO (`ScadaBridge` section: `BaseUrls` string, `TimeoutSeconds` int = 30, `LogPath` string?). `ConfigLoader.Load(string jsonText)` deserializes via `NotifierJsonContext`. A helper `SplitBaseUrls(string?)` → trimmed, non-empty `string[]`. `ResolveApiKey(Func envGet)` reads `SCADABRIDGE_API_KEY` (inject the env accessor for testability). + +**Step 1: Failing tests** — comma split (`"a, b ,,c"` → `[a,b,c]`), default `TimeoutSeconds == 30` when omitted, empty/whitespace key → null, present key → value. + +**Step 2: Run → fails.** + +**Step 3: Implement** POCO + loader. `Load` uses `JsonSerializer.Deserialize(jsonText, NotifierJsonContext.Default.NotifierConfig)`. Separate file read (`File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))`) into a thin wrapper so the parse logic is unit-tested from a string. + +**Step 4: Run → passes.** + +**Step 5: Commit** — `feat(delmia-notifier): config loader + SCADABRIDGE_API_KEY resolution` + +--- + +### Task 5: Notifier failover loop (behind `IRecipeSender` seam) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (core logic; needs Task 2) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/IRecipeSender.cs` (seam: `AttemptKind`, `AttemptOutcome`, `IRecipeSender`) +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Notifier.cs` (the failover loop + `NotifyResult`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/NotifierTests.cs` + +**Seam:** +```csharp +namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; +internal enum AttemptKind { Connected, ConnectFailed } +internal sealed record AttemptOutcome(AttemptKind Kind, int StatusCode, RecipeDownloadResult? Body, string? Error); +internal interface IRecipeSender +{ + Task SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct); +} +``` + +**Loop rule (connect-failure-only failover):** iterate base URLs in order; `ConnectFailed` → record error, continue; `Connected` → authoritative, stop: +- 2xx + `Result == true` → success. +- 2xx + `Result == false` → failure(`ResultText`). +- non-2xx → failure(`HTTP `). +- All `ConnectFailed` → failure(`all URLs unreachable: `). + +**Step 1: Failing tests** using a fake `IRecipeSender` (a queue of scripted outcomes), assert `NotifyResult` (Ok + Reason): +- first URL `ConnectFailed`, second `Connected 200 Result=true` → Ok. +- first `Connected 200 Result=false` → not Ok, reason from `ResultText`, **second sender never called**. +- first `Connected 500` → not Ok, reason contains `500`, second never called. +- all `ConnectFailed` → not Ok, reason contains `unreachable`. + +**Step 2: Run → fails.** + +**Step 3: Implement** `Notifier.RunAsync(string[] baseUrls, RecipeDownload payload, IRecipeSender sender, CancellationToken ct)` returning `NotifyResult(bool Ok, string Reason)`. + +**Step 4: Run → passes.** + +**Step 5: Commit** — `feat(delmia-notifier): connect-failure-only failover loop` + +--- + +### Task 6: Real `HttpClient` sender (outcome classification) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (needs Task 2, Task 5) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs` + +**Behavior:** implements `IRecipeSender` over an injected `HttpClient`. `POST {baseUrl.TrimEnd('/')}/api/DelmiaRecipeDownload`, header `X-API-Key: `, body = `RecipeDownload` JSON via `NotifierJsonContext`. Classification: +- `HttpRequestException` (incl. inner `SocketException`) or `TaskCanceledException`/`OperationCanceledException` from timeout → `AttemptOutcome(ConnectFailed, 0, null, ex.Message)`. +- Got a response → `Connected`, with `StatusCode`; on 2xx parse body into `RecipeDownloadResult` (tolerate parse failure → `Body=null`, treated as failure by the loop). + +**Step 1: Failing tests** with a fake `HttpMessageHandler` (`Func`): +- handler throws `HttpRequestException` → `ConnectFailed`. +- handler returns 200 `{"Result":true,"ResultText":""}` → `Connected`, 200, `Body.Result == true`; assert request had `X-API-Key` header and path `/api/DelmiaRecipeDownload`. +- handler returns 500 → `Connected`, 500. + +**Step 2: Run → fails.** + +**Step 3: Implement** `HttpRecipeSender`. + +**Step 4: Run → passes.** + +**Step 5: Commit** — `feat(delmia-notifier): HttpClient recipe sender with connect-failure classification` + +--- + +### Task 7: `Program.Main` wiring + result reporter + logger + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (needs Tasks 2–6) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs` (maps outcome → stdout + exit code) +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs` (stderr + optional file) +- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs` + +**Reporter contract (drop-in parity):** success → write `YES` to a `TextWriter`, return `0`. Failure → write `NO` then the reason line, return `-1`. (Inject the `TextWriter` so tests assert the exact lines.) + +**Step 1: Failing tests** for `Reporter.Report(bool ok, string reason, TextWriter stdout)`: +- ok → stdout is `"YES"` (+ newline), returns `0`. +- not ok with reason `"boom"` → stdout is `"NO\nboom"`, returns `-1`. + +**Step 2: Run → fails.** + +**Step 3: Implement** `Reporter`, `DiagLog` (writes timestamped lines to `Console.Error` and, if `LogPath` set, appends to the file — create parent dir; never throw), and wire `Program.Main`: +1. `ArgParser.Parse(args)` → on error: `DiagLog`, `Reporter.Report(false, error)`. +2. Load config; if no `BaseUrls` → report false "no BaseUrls configured". +3. `ResolveApiKey`; if null → report false "API key not configured (SCADABRIDGE_API_KEY)". +4. Build `HttpClient { Timeout = TimeSpan.FromSeconds(cfg.TimeoutSeconds) }` + `HttpRecipeSender`. +5. `await Notifier.RunAsync(...)`; log per-attempt diagnostics; `Reporter.Report(result.Ok, result.Reason)`. +6. Wrap in try/catch → on unexpected error: `DiagLog` + report false with the message. **stdout only ever gets `YES`/`NO`+reason.** + +**Step 4: Run → passes** (reporter tests; full-build the two projects). + +**Step 5: Commit** — `feat(delmia-notifier): Program wiring, YES/NO reporter, diagnostics log` + +--- + +### Task 8: README, publish/AOT instructions, final verification + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 2–7 docs portion (verification depends on all) + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md` +- Test: (none new) — run the full suite. + +**Step 1: Write `README.md`** — purpose (Delmia → ScadaBridge `DelmiaRecipeDownload`), CLI flags table, `appsettings.json` schema, the `SCADABRIDGE_API_KEY` env var, the `YES`/`NO` + exit-code contract, and the **publish** command: +``` +# On Windows (Native AOT can't cross-compile from macOS/Linux): +dotnet publish src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -c Release -r win-x64 +# → WWNotifier.exe (self-contained, single native file) + appsettings.json +``` +Plus the manual smoke test (`curl`/run against `wonder-app-vd03` `/api/DelmiaRecipeDownload`). + +**Step 2: Final verification** — `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. Expected: build clean (no warnings, since warnings-as-errors), all tests pass. + +**Step 3: Commit** — `docs(delmia-notifier): README + publish/AOT instructions` + +--- + +## Notes for the executor + +- Do **not** add NuGet packages to the src project — BCL only (AOT-clean). The test project uses only the standard xUnit set already in `Directory.Packages.props`. +- If any task needs a file not listed in its `Files:` block, that's a plan defect — surface it. +- The AOT native publish + live smoke against `wonder-app-vd03` happen on a Windows host; the managed build/test in every task is the cross-platform gate here. diff --git a/docs/plans/2026-06-26-delmia-recipe-notifier.md.tasks.json b/docs/plans/2026-06-26-delmia-recipe-notifier.md.tasks.json new file mode 100644 index 00000000..d03430e9 --- /dev/null +++ b/docs/plans/2026-06-26-delmia-recipe-notifier.md.tasks.json @@ -0,0 +1,14 @@ +{ + "planPath": "docs/plans/2026-06-26-delmia-recipe-notifier.md", + "tasks": [ + {"id": 11, "subject": "Task 1: Scaffold src + test projects", "status": "pending"}, + {"id": 12, "subject": "Task 2: DTOs + JSON source-gen context", "status": "pending", "blockedBy": [11]}, + {"id": 13, "subject": "Task 3: CLI arg parser", "status": "pending", "blockedBy": [11]}, + {"id": 14, "subject": "Task 4: Config loader + API key", "status": "pending", "blockedBy": [11]}, + {"id": 15, "subject": "Task 5: Failover loop (IRecipeSender seam)", "status": "pending", "blockedBy": [12]}, + {"id": 16, "subject": "Task 6: HttpClient recipe sender", "status": "pending", "blockedBy": [12, 15]}, + {"id": 17, "subject": "Task 7: Program wiring + reporter + logger", "status": "pending", "blockedBy": [13, 14, 15, 16]}, + {"id": 18, "subject": "Task 8: README + publish/AOT + final verify", "status": "pending", "blockedBy": [17]} + ], + "lastUpdated": "2026-06-26" +}