using System.Text.Json; using CareFix.Api.Infrastructure; using CareFix.Api.Options; using CareFix.Core; using Dapper; using Microsoft.Extensions.Options; namespace CareFix.Api.Hospitals; /// /// Agent channel for on-prem hospitals. The server writes a job; the hospital's CareFix Agent (outbound HTTPS only) /// picks it up, runs it locally with its own credentials and posts the result. No inbound port is opened at the hospital, /// and the hospital's SQL passwords never leave the hospital. /// public sealed class AgentHospitalExecutor(ControlDb db, IOptions options) : IHospitalExecutor { private const int PickupSeconds = 45; private static readonly TimeSpan OfflineAfter = TimeSpan.FromMinutes(2); private sealed class JobRow { public string State { get; set; } = ""; public string? ResultJson { get; set; } public int? ErrorStatus { get; set; } public string? Error { get; set; } public DateTime ExpiresAt { get; set; } } private async Task CallAsync(int hospitalId, string kind, object payload, TimeSpan wait, CancellationToken ct) { await using var c = await db.OpenAsync(ct); var lastSeen = await c.ExecuteScalarAsync("SELECT LastSeenAt FROM CF_AGENT WHERE HospitalId = @hospitalId", new { hospitalId }); if (lastSeen is null) throw AppException.BadRequest("No CareFix Agent has connected for this hospital yet. Generate an agent key under Hospitals and install the agent on the hospital's server."); if (DateTime.UtcNow - lastSeen.Value > OfflineAfter) throw new AppException(503, $"The CareFix Agent at this hospital is offline (last seen {lastSeen.Value:dd MMM HH:mm} UTC). Ask the hospital IT team to check the \"CareFix Agent\" Windows service."); var jobId = await c.ExecuteScalarAsync(""" INSERT CF_AGENT_JOB (HospitalId, Kind, PayloadJson, ExpiresAt) OUTPUT INSERTED.JobId VALUES (@hospitalId, @kind, @p, DATEADD(SECOND, @PickupSeconds, SYSUTCDATETIME())) """, new { hospitalId, kind, p = JsonSerializer.Serialize(payload, AgentProtocol.Json), PickupSeconds }); var deadline = DateTime.UtcNow + wait; while (true) { await Task.Delay(400, ct); var r = await c.QuerySingleAsync("SELECT State, ResultJson, ErrorStatus, Error, ExpiresAt FROM CF_AGENT_JOB WHERE JobId = @jobId", new { jobId }); if (r.State == "Done") { // Results can contain unmasked patient data: keep them only as long as needed to hand over. await c.ExecuteAsync("UPDATE CF_AGENT_JOB SET ResultJson = NULL WHERE JobId = @jobId", new { jobId }); using var doc = JsonDocument.Parse(r.ResultJson ?? "null"); return doc.RootElement.Clone(); } if (r.State == "Failed") throw new AppException(r.ErrorStatus ?? 400, r.Error ?? "The agent reported an error."); if (r.State == "Pending" && DateTime.UtcNow > r.ExpiresAt) { var n = await c.ExecuteAsync("UPDATE CF_AGENT_JOB SET State = 'Expired' WHERE JobId = @jobId AND State = 'Pending'", new { jobId }); if (n == 1) throw new AppException(504, "The CareFix Agent did not pick up the request in time. It may be busy or losing its connection. Try again."); continue; // taken just now } if (r.State == "Taken" && DateTime.UtcNow > deadline) { throw new AppException(504, kind == AgentProtocol.Kinds.ApplySteps ? "The agent started the change but did not report back in time, so the result is unknown. Check carefix.CF_CHANGE_LOG at the hospital before doing anything else. Running the fix again is safe: every step checks the old value first." : "The agent did not finish in time. Try again, or narrow the query."); } } } private TimeSpan SelectWait => TimeSpan.FromSeconds(options.Value.Safety.SelectTimeoutSeconds + PickupSeconds + 15); public async Task RunSelectAsync(int h, string sql, CancellationToken ct) { var e = await CallAsync(h, AgentProtocol.Kinds.Select, new SelectPayload(sql), SelectWait, ct); var dto = e.Deserialize(AgentProtocol.Json) ?? throw new AppException(502, "The agent returned an empty result."); return new QueryResult(dto.Columns, dto.Rows.Select(r => r.Select(AgentProtocol.ToPlain).ToArray()).ToList(), dto.Truncated, dto.DurationMs); } public async Task AnyRowsAsync(int h, string sql, string pkValue, CancellationToken ct) => (await CallAsync(h, AgentProtocol.Kinds.AnyRows, new AnyRowsPayload(sql, pkValue), SelectWait, ct)).GetBoolean(); public async Task GetValueAsync(int h, string table, string pkColumn, string pkValue, string column, CancellationToken ct) => (await CallAsync(h, AgentProtocol.Kinds.GetValue, new ValuePayload(table, pkColumn, pkValue, column), SelectWait, ct)) .Deserialize(AgentProtocol.Json)!; public async Task?> GetRowAsync(int h, string table, string pkColumn, string pkValue, CancellationToken ct) { var e = await CallAsync(h, AgentProtocol.Kinds.GetRow, new RowPayload(table, pkColumn, pkValue), SelectWait, ct); if (e.ValueKind == JsonValueKind.Null) return null; return e.EnumerateObject().ToDictionary(p => p.Name, p => AgentProtocol.ToPlain(p.Value)); } public async Task ApplyStepsAsync(int h, IReadOnlyList steps, string ticketRef, CancellationToken ct) => (await CallAsync(h, AgentProtocol.Kinds.ApplySteps, new ApplyPayload(steps.ToList(), ticketRef), TimeSpan.FromSeconds(PickupSeconds + 90), ct)).GetInt32(); public async Task> ReadSchemaAsync(int h, CancellationToken ct) => (await CallAsync(h, AgentProtocol.Kinds.ReadSchema, new { }, TimeSpan.FromSeconds(PickupSeconds + 180), ct)) .Deserialize>(AgentProtocol.Json)!; public async Task TestAsync(int h, CancellationToken ct) => await CallAsync(h, AgentProtocol.Kinds.Test, new { }, SelectWait, ct); }