using System.Net; using System.Net.Http.Json; using System.Reflection; using CareFix.Core; namespace CareFix.Agent; /// Long-polls the CareFix server over outbound HTTPS and runs jobs locally. public sealed class AgentWorker(AgentConfig cfg, JobRunner runner, IHttpClientFactory http, ILogger log) : BackgroundService { private static readonly string Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0"; private HttpClient Client() { var c = http.CreateClient("carefix"); c.BaseAddress = new Uri(cfg.ServerUrl.TrimEnd('/') + "/"); c.DefaultRequestHeaders.Add(AgentProtocol.HospitalHeader, cfg.HospitalCode); c.DefaultRequestHeaders.Add(AgentProtocol.KeyHeader, cfg.AgentKey); return c; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { log.LogInformation("CareFix Agent {Version} started for {Hospital}, server {Server}", Version, cfg.HospitalCode, cfg.ServerUrl); var slots = new SemaphoreSlim(cfg.MaxParallelJobs, cfg.MaxParallelJobs); var backoff = TimeSpan.FromSeconds(2); var poll = new AgentPoll(Version, Environment.MachineName); while (!stoppingToken.IsCancellationRequested) { await slots.WaitAsync(stoppingToken); var released = false; try { using var client = Client(); using var resp = await client.PostAsJsonAsync("api/agent/poll", poll, AgentProtocol.Json, stoppingToken); if (resp.StatusCode == HttpStatusCode.NoContent) { backoff = TimeSpan.FromSeconds(2); continue; } if (resp.StatusCode == HttpStatusCode.Unauthorized) { log.LogError("The server rejected the agent key for {Hospital}. Generate a new key in CareFix and run 'CareFix.Agent.exe configure'.", cfg.HospitalCode); await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); continue; } resp.EnsureSuccessStatusCode(); var job = await resp.Content.ReadFromJsonAsync(AgentProtocol.Json, stoppingToken); backoff = TimeSpan.FromSeconds(2); if (job is null) continue; released = true; // the job task releases the slot _ = Task.Run(async () => { try { await HandleAsync(job, stoppingToken); } finally { slots.Release(); } }, CancellationToken.None); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { log.LogWarning("Cannot reach the CareFix server ({Message}). Retrying in {Seconds}s.", ex.Message, (int)backoff.TotalSeconds); await Task.Delay(backoff, stoppingToken); backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 60)); } finally { if (!released) slots.Release(); } } } private async Task HandleAsync(AgentJob job, CancellationToken ct) { var result = await runner.RunAsync(job, ct); // Reporting the result matters most after a change, so retry a few times. for (var attempt = 1; attempt <= 5; attempt++) { try { using var client = Client(); using var resp = await client.PostAsJsonAsync($"api/agent/jobs/{job.JobId}/result", result, AgentProtocol.Json, ct); if (resp.IsSuccessStatusCode || resp.StatusCode == HttpStatusCode.Conflict) return; log.LogWarning("Server returned {Status} for job {JobId} result", (int)resp.StatusCode, job.JobId); } catch (Exception ex) when (ex is not OperationCanceledException) { log.LogWarning("Could not send result for job {JobId}: {Message}", job.JobId, ex.Message); } await Task.Delay(TimeSpan.FromSeconds(2 * attempt), ct); } log.LogError("Gave up sending the result of job {JobId} ({Kind}). The server will show it as unknown.", job.JobId, job.Kind); } }