diff --git a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs
new file mode 100644
index 00000000..f2b3a9de
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs
@@ -0,0 +1,39 @@
+using System.Globalization;
+
+namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
+
+///
+/// Best-effort diagnostics: writes UTC-timestamped lines to stderr and, when a log path is configured,
+/// appends them to a file (relative to the exe). Never throws — diagnostics must not break the YES/NO path.
+///
+internal sealed class DiagLog(string? logPath)
+{
+ private readonly string? _logPath = string.IsNullOrWhiteSpace(logPath) ? null : logPath;
+
+ public void Write(string message)
+ {
+ var line = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} {message}");
+ Console.Error.WriteLine(line);
+
+ if (_logPath is null)
+ {
+ return;
+ }
+
+ try
+ {
+ var fullPath = Path.IsPathRooted(_logPath) ? _logPath : Path.Combine(AppContext.BaseDirectory, _logPath);
+ var dir = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ File.AppendAllText(fullPath, line + Environment.NewLine);
+ }
+ catch
+ {
+ // diagnostics are best-effort; swallow file errors so the YES/NO contract is unaffected
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs
index 7d6959e6..28a458eb 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs
@@ -2,5 +2,72 @@ namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal static class Program
{
- public static int Main(string[] args) => 0; // replaced in Task 7
+ public static async Task Main(string[] args)
+ {
+ var stdout = Console.Out;
+
+ // Config first, so the diagnostic log can honour the configured LogPath even for early failures.
+ NotifierConfig cfg;
+ try
+ {
+ cfg = ConfigLoader.LoadFromDefaultFile();
+ }
+ catch (Exception ex)
+ {
+ new DiagLog(null).Write("config load failed: " + ex.Message);
+ return Reporter.Report(false, "config load failed: " + ex.Message, stdout);
+ }
+
+ var log = new DiagLog(cfg.ScadaBridge.LogPath);
+
+ try
+ {
+ var parse = ArgParser.Parse(args);
+ if (!parse.Ok)
+ {
+ log.Write("arg error: " + parse.Error);
+ return Reporter.Report(false, parse.Error!, stdout);
+ }
+
+ var baseUrls = ConfigLoader.SplitBaseUrls(cfg.ScadaBridge.BaseUrls);
+ if (baseUrls.Length == 0)
+ {
+ log.Write("no BaseUrls configured");
+ return Reporter.Report(false, "no BaseUrls configured", stdout);
+ }
+
+ var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable);
+ if (apiKey is null)
+ {
+ log.Write("API key not configured (SCADABRIDGE_API_KEY)");
+ return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY)", stdout);
+ }
+
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) };
+ var sender = new LoggingRecipeSender(new HttpRecipeSender(http, apiKey), log);
+
+ log.Write($"notifying {baseUrls.Length} URL(s) for machine {parse.Payload!.MachineCode}");
+ var result = await Notifier.RunAsync(baseUrls, parse.Payload, sender, CancellationToken.None);
+ log.Write($"result: ok={result.Ok} reason={result.Reason}");
+ return Reporter.Report(result.Ok, result.Reason, stdout);
+ }
+ catch (Exception ex)
+ {
+ log.Write("unexpected error: " + ex);
+ return Reporter.Report(false, "unexpected error: " + ex.Message, stdout);
+ }
+ }
+
+ /// Decorates a sender to emit a per-attempt diagnostic line; keeps stdout reserved for the YES/NO contract.
+ private sealed class LoggingRecipeSender(IRecipeSender inner, DiagLog log) : IRecipeSender
+ {
+ public async Task SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct)
+ {
+ var outcome = await inner.SendAsync(baseUrl, payload, ct);
+ log.Write(outcome.Kind == AttemptKind.ConnectFailed
+ ? $"attempt {baseUrl}: connect failed: {outcome.Error}"
+ : $"attempt {baseUrl}: HTTP {outcome.StatusCode}");
+ return outcome;
+ }
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs
new file mode 100644
index 00000000..e8419785
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs
@@ -0,0 +1,22 @@
+namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
+
+///
+/// Maps an outcome to the legacy WWNotifier stdout contract: YES + exit 0 on success,
+/// NO followed by a reason line + exit -1 on failure. The writer is injected so stdout is the
+/// only thing Delmia ever parses (LF-terminated, deterministic across platforms).
+///
+internal static class Reporter
+{
+ public static int Report(bool ok, string reason, TextWriter stdout)
+ {
+ if (ok)
+ {
+ stdout.Write("YES\n");
+ return 0;
+ }
+
+ stdout.Write("NO\n");
+ stdout.Write(reason);
+ return -1;
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs
new file mode 100644
index 00000000..88c65ce7
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs
@@ -0,0 +1,24 @@
+using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
+
+namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
+
+public class ReporterTests
+{
+ [Fact]
+ public void Success_writes_YES_and_returns_0()
+ {
+ using var sw = new StringWriter();
+ var code = Reporter.Report(true, "", sw);
+ Assert.Equal("YES\n", sw.ToString());
+ Assert.Equal(0, code);
+ }
+
+ [Fact]
+ public void Failure_writes_NO_then_reason_and_returns_minus1()
+ {
+ using var sw = new StringWriter();
+ var code = Reporter.Report(false, "boom", sw);
+ Assert.Equal("NO\nboom", sw.ToString());
+ Assert.Equal(-1, code);
+ }
+}