19 KiB
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.
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=trueeverywhere — 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 inboundDelmiaRecipeDownloadcontract — keepSystem.Text.Jsondefault 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
<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
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
{
"ScadaBridge": {
"BaseUrls": "http://localhost:9000",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
Step 4: Create the test csproj
<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:
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:
// 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; }
}
// RecipeDownloadResult.cs
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal sealed class RecipeDownloadResult
{
public bool Result { get; set; }
public string? ResultText { get; set; }
}
// 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
NotifierConfigline 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
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))]toNotifierJsonContextif 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:
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, secondConnected 200 Result=true→ Ok. - first
Connected 200 Result=false→ not Ok, reason fromResultText, second sender never called. - first
Connected 500→ not Ok, reason contains500, second never called. - all
ConnectFailed→ not Ok, reason containsunreachable.
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. innerSocketException) orTaskCanceledException/OperationCanceledExceptionfrom timeout →AttemptOutcome(ConnectFailed, 0, null, ex.Message).- Got a response →
Connected, withStatusCode; on 2xx parse body intoRecipeDownloadResult(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 hadX-API-Keyheader 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), returns0. - 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:
ArgParser.Parse(args)→ on error:DiagLog,Reporter.Report(false, error).- Load config; if no
BaseUrls→ report false "no BaseUrls configured". ResolveApiKey; if null → report false "API key not configured (SCADABRIDGE_API_KEY)".- Build
HttpClient { Timeout = TimeSpan.FromSeconds(cfg.TimeoutSeconds) }+HttpRecipeSender. await Notifier.RunAsync(...); log per-attempt diagnostics;Reporter.Report(result.Ok, result.Reason).- Wrap in try/catch → on unexpected error:
DiagLog+ report false with the message. stdout only ever getsYES/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-vd03happen on a Windows host; the managed build/test in every task is the cross-platform gate here.