Files
ScadaBridge/docs/plans/2026-06-26-delmia-recipe-notifier.md
T

430 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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~<Name>"`.
- 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 `<Project Path=...>` lines under the matching `/src/` and `/tests/` folders)
**Step 1: Create the src csproj**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>WWNotifier</AssemblyName>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- No <RuntimeIdentifier> here: pass -r win-x64 at publish (Task 8) so build/test stay cross-platform. -->
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
```
**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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />
</ItemGroup>
</Project>
```
**Step 5: Add both projects to `ZB.MOM.WW.ScadaBridge.slnx`** — one `<Project Path="src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />` under `<Folder Name="/src/">`, one `<Project Path="tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj" />` under `<Folder Name="/tests/">`.
**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<string,string?> 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<AttemptOutcome> 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 <status>`).
- All `ConnectFailed` → failure(`all URLs unreachable: <lastError>`).
**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: <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<HttpRequestMessage, HttpResponseMessage>`):
- 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 26)
**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 27 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.