using System.Text.Json; using ChangeT50.Agent; return await AgentProgram.RunAsync(args); internal static class AgentProgram { public static async Task RunAsync(string[] args) { if (args.Length == 0 || args[0] is "help" or "-h" or "--help") { PrintUsage(); return args.Length == 0 ? 1 : 0; } try { if (args[0] == "contract-test") { await ContractSelfTest.RunAsync(); return 0; } if (args[0] is not ("run-once" or "run" or "person-query")) { throw new ArgumentException($"Unknown command '{args[0]}'."); } var options = ParseOptions(args); if (args[0] == "person-query") { var externalNo = Value(args, "--external-no") ?? throw new ArgumentException("person-query requires --external-no."); using var personHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; var person = await new AgentApiClient(personHttp, options).GetPersonAsync(externalNo); Console.WriteLine(JsonSerializer.Serialize(person, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower })); return person.Found ? 0 : 3; } if (args[0] == "run") { return await RunLoopAsync(options, Integer(Value(args, "--interval-seconds"), 2)); } var summary = await RunOnceAsync(options, CancellationToken.None); PrintSummary(summary); return 0; } catch (Exception exception) { Console.Error.WriteLine($"FAIL: {exception.GetType().Name}: {exception.Message}"); return 2; } } private static async Task RunLoopAsync(AgentOptions options, int intervalSeconds) { intervalSeconds = Math.Clamp(intervalSeconds, 1, 300); using var cancellation = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; cancellation.Cancel(); }; var consecutiveFailures = 0; while (!cancellation.IsCancellationRequested) { try { var summary = await RunOnceAsync(options, cancellation.Token); PrintSummary(summary); consecutiveFailures = 0; await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellation.Token); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { break; } catch (Exception exception) { consecutiveFailures++; Console.Error.WriteLine($"Agent cycle failed ({consecutiveFailures}): {exception.GetType().Name}: {exception.Message}"); var retrySeconds = Math.Min(60, intervalSeconds * (1 << Math.Min(5, consecutiveFailures - 1))); await Task.Delay(TimeSpan.FromSeconds(retrySeconds), cancellation.Token); } } return 0; } private static async Task RunOnceAsync(AgentOptions options, CancellationToken cancellationToken) { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; var client = new AgentApiClient(http, options); var journal = new LocalJournal(options.JournalPath); using IChangeT50Provider provider = options.Mode == "native" ? new NativeChangeT50Provider(options) : new SimulatedChangeT50Provider(); var runner = new AgentRunner(client, provider, journal); return await runner.RunOnceAsync(cancellationToken); } private static void PrintSummary(AgentRunSummary summary) { Console.WriteLine(JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower })); } private static AgentOptions ParseOptions(IReadOnlyList args) { var baseUrl = Value(args, "--base-url") ?? Environment.GetEnvironmentVariable("ONE_CARD_AGENT_BASE_URL") ?? "http://127.0.0.1:8000"; var provider = Value(args, "--provider") ?? Environment.GetEnvironmentVariable("ONE_CARD_AGENT_PROVIDER") ?? "change_t50"; var device = Value(args, "--device") ?? Environment.GetEnvironmentVariable("ONE_CARD_AGENT_DEVICE") ?? string.Empty; var secret = Value(args, "--secret") ?? Environment.GetEnvironmentVariable("ONE_CARD_AGENT_HMAC_SECRET") ?? string.Empty; var mode = (Value(args, "--mode") ?? Environment.GetEnvironmentVariable("CHANGE_T50_MODE") ?? "simulate").ToLowerInvariant(); var journal = Value(args, "--journal") ?? Environment.GetEnvironmentVariable("CHANGE_T50_JOURNAL_PATH") ?? DefaultJournalPath(); var dll = Value(args, "--dll") ?? Environment.GetEnvironmentVariable("CHANGE_T50_DLL_PATH"); var serviceUrl = Environment.GetEnvironmentVariable("CHANGE_T50_WEBSERVICE_URL"); var terminalId = Integer(Environment.GetEnvironmentVariable("CHANGE_T50_TERMINAL_ID"), 0); var messageBytes = Integer(Environment.GetEnvironmentVariable("CHANGE_T50_UPLOAD_MESSAGE_BYTES"), 0); if (device.Length == 0 || secret.Length == 0) { throw new ArgumentException("ONE_CARD_AGENT_DEVICE and ONE_CARD_AGENT_HMAC_SECRET are required."); } if (mode is not ("simulate" or "native")) { throw new ArgumentException("--mode must be simulate or native."); } return new AgentOptions(new Uri(baseUrl), provider, device, secret, mode, journal, dll, serviceUrl, terminalId, messageBytes); } private static string? Value(IReadOnlyList args, string option) { for (var index = 1; index < args.Count; index++) { if (args[index] != option) { continue; } if (index + 1 >= args.Count) { throw new ArgumentException($"Option {option} requires a value."); } return args[index + 1]; } return null; } private static int Integer(string? value, int fallback) => int.TryParse(value, out var result) ? result : fallback; private static string DefaultJournalPath() { if (OperatingSystem.IsWindows()) { var root = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); return Path.Combine(root, "Cpt", "ChangeT50Agent", "journal.json"); } return Path.Combine(AppContext.BaseDirectory, "runtime", "change-t50-agent-journal.json"); } private static void PrintUsage() { Console.WriteLine("Change T5.0 Windows Agent"); Console.WriteLine(); Console.WriteLine(" contract-test Verify all 10 REST paths and the PHP/.NET HMAC vector without money or hardware."); Console.WriteLine(" run-once Send heartbeat, process new tasks, and recover existing uploads once."); Console.WriteLine(" run Continuously poll and process tasks; use Windows Task Scheduler to start at boot."); Console.WriteLine(" person-query Query one person through the standard REST contract (--external-no required)."); Console.WriteLine(); Console.WriteLine("Required environment: ONE_CARD_AGENT_DEVICE, ONE_CARD_AGENT_HMAC_SECRET."); Console.WriteLine("Native-only environment: CHANGE_T50_DLL_PATH, CHANGE_T50_WEBSERVICE_URL,"); Console.WriteLine("CHANGE_T50_TERMINAL_ID, and vendor-confirmed CHANGE_T50_UPLOAD_MESSAGE_BYTES."); } }