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

19 KiB
Raw Blame History

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=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

<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 bothdotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests. Expected: both succeed.

Step 7: Commitgit 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 NotifierConfig line and add it in Task 4.

Step 4: Run → passes.

Step 5: Commitfeat(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: Commitfeat(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: Commitfeat(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, 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: Commitfeat(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 HttpRequestExceptionConnectFailed.
  • 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: Commitfeat(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: Commitfeat(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 verificationdotnet 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: Commitdocs(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.