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);
}
}