feature: In-App Simulation Runner (Backend)
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 8m32s

This commit is contained in:
voltsrage
2026-08-06 01:52:53 +08:00
parent 943d41339c
commit 24f45851e9
83 changed files with 3974 additions and 120 deletions
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public class AlertExplanation public class AlertExplanation
{ {
public List<ScoreContributor> ScoreContributors { get; set; } = new(); public List<ScoreContributor> ScoreContributors { get; set; } = new();
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record AlertResponse( public record AlertResponse(
Guid Id, Guid Id,
Guid EncounterId, Guid EncounterId,
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error); public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error);
public record ApiError(string Message, string Code); public record ApiError(string Message, string Code);
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize); public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
@@ -1 +1,3 @@
namespace VigilCare.Simulation;
public record BatchIngestRequest(List<IngestObservationRequest> Observations); public record BatchIngestRequest(List<IngestObservationRequest> Observations);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record CreateMedicationAdministrationRequest( public record CreateMedicationAdministrationRequest(
string DrugName, decimal Dose, string DoseUnit, string Route, string DrugName, decimal Dose, string DoseUnit, string Route,
DateTimeOffset? AdministeredAt, string AdministeredBy); DateTimeOffset? AdministeredAt, string AdministeredBy);
@@ -1 +1,3 @@
namespace VigilCare.Simulation;
public record CreateOrderRequest(string OrderType, string Description, string OrderedBy); public record CreateOrderRequest(string OrderType, string Description, string OrderedBy);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record EncounterResponse( public record EncounterResponse(
Guid Id, Guid PatientId, string EncounterType, string Status, Guid Id, Guid PatientId, string EncounterType, string Status,
string Department, string AttendingPhysician, string? RoomBed, string Department, string AttendingPhysician, string? RoomBed,
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record GcsResponse( public record GcsResponse(
int EyeScore, int VerbalScore, int MotorScore, int EyeScore, int VerbalScore, int MotorScore,
int TotalScore, string Classification, DateTimeOffset CalculatedAt); int TotalScore, string Classification, DateTimeOffset CalculatedAt);
@@ -1,5 +1,6 @@
namespace VigilCare.Simulation;
public record IngestObservationRequest( public record IngestObservationRequest(
string ObservationCode, decimal Value, string Unit, string ObservationCode, decimal Value, string Unit,
string Source, DateTimeOffset RecordedAt, string? IdempotencyKey); string Source, DateTimeOffset RecordedAt, string? IdempotencyKey);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record SimLoginRequest(string Username, string Password); public record SimLoginRequest(string Username, string Password);
public record SimLoginResponse( public record SimLoginResponse(
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record News2Response( public record News2Response(
Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree, Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree,
int RespRateScore, int Spo2Score, int SystolicBpScore, int RespRateScore, int Spo2Score, int SystolicBpScore,
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record OpenEncounterRequest( public record OpenEncounterRequest(
string EncounterType, string Department, string AttendingPhysician, string EncounterType, string Department, string AttendingPhysician,
@@ -1 +1,3 @@
namespace VigilCare.Simulation;
public record OrderResponse(Guid Id, string Description, string Status); public record OrderResponse(Guid Id, string Description, string Status);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record PatientResponse( public record PatientResponse(
Guid Id, string Mrn, string FirstName, string LastName, Guid Id, string Mrn, string FirstName, string LastName,
DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt); DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record QsofaResponse(int ActiveCriteria);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record RecordOrderResultRequest(string? ResultSummary);
@@ -1,2 +1,4 @@
namespace VigilCare.Simulation;
public record RegisterPatientRequest( public record RegisterPatientRequest(
string FirstName, string LastName, DateOnly DateOfBirth, string Gender); string FirstName, string LastName, DateOnly DateOfBirth, string Gender);
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record SepsisBundleElementResponse(string Status); public record SepsisBundleElementResponse(string Status);
public record SepsisBundleResponse( public record SepsisBundleResponse(
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record SofaResponse( public record SofaResponse(
int TotalScore, int TotalScore,
int RespiratoryScore, int CoagulationScore, int LiverScore, int RespiratoryScore, int CoagulationScore, int LiverScore,
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public record SofaStalenessResponse( public record SofaStalenessResponse(
IReadOnlyList<string> StaleComponents, IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents, IReadOnlyList<string> MissingComponents,
@@ -2,6 +2,8 @@ using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
namespace VigilCare.Simulation;
public class VigilCareApiClient public class VigilCareApiClient
{ {
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
@@ -0,0 +1,10 @@
namespace VigilCare.Simulation;
/// <summary>
/// Optional post-cluster polling hook. Console hosts display scores/alerts;
/// the API host passes null — polling is a display concern.
/// </summary>
public interface IApiPoller
{
Task PollAndDisplayAsync(Guid encounterId, string simTime);
}
@@ -0,0 +1,31 @@
namespace VigilCare.Simulation;
public interface IReplayObserver
{
void Header(string name, string? description);
void Info(string message);
void Event(string simTime, string description);
void Waiting(double deltaMinutes, int delayMs);
void Warn(string message);
void Error(string message);
void DryRun(string message);
void Completed(ReplayResult result);
/// <summary>Fired after each cluster so hosts can report progress.</summary>
void Progress(double offsetMinutes, int clusterIndex, int clusterCount);
}
public sealed class NullReplayObserver : IReplayObserver
{
public static readonly NullReplayObserver Instance = new();
public void Header(string name, string? description) { }
public void Info(string message) { }
public void Event(string simTime, string description) { }
public void Waiting(double deltaMinutes, int delayMs) { }
public void Warn(string message) { }
public void Error(string message) { }
public void DryRun(string message) { }
public void Completed(ReplayResult result) { }
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) { }
}
@@ -1,13 +1,23 @@
namespace VigilCare.Simulation;
public class ReplayEngine public class ReplayEngine
{ {
private readonly VigilCareApiClient _client; private readonly VigilCareApiClient _client;
private readonly ApiPoller? _poller; private readonly IApiPoller? _poller;
private readonly IReplayObserver _observer;
private readonly Func<Guid, CancellationToken, Task>? _onPatientRegistered;
private DateTimeOffset _scenarioStartTime; private DateTimeOffset _scenarioStartTime;
public ReplayEngine(VigilCareApiClient client, ApiPoller? poller) public ReplayEngine(
VigilCareApiClient client,
IApiPoller? poller,
IReplayObserver? observer = null,
Func<Guid, CancellationToken, Task>? onPatientRegistered = null)
{ {
_client = client; _client = client;
_poller = poller; _poller = poller;
_observer = observer ?? NullReplayObserver.Instance;
_onPatientRegistered = onPatientRegistered;
} }
public async Task<ReplayResult> RunAsync( public async Task<ReplayResult> RunAsync(
@@ -18,7 +28,7 @@ public class ReplayEngine
_scenarioStartTime = startTime; _scenarioStartTime = startTime;
// --- Phase 1: Setup --- // --- Phase 1: Setup ---
SimulatorConsole.Header(scenario.Scenario.Name, scenario.Scenario.Description); _observer.Header(scenario.Scenario.Name, scenario.Scenario.Description);
if (options.DryRun) if (options.DryRun)
{ {
@@ -26,27 +36,27 @@ public class ReplayEngine
{ {
result.EncounterId = options.ExistingEncounterId.Value; result.EncounterId = options.ExistingEncounterId.Value;
if (options.Target == ReplayTarget.Gateway) if (options.Target == ReplayTarget.Gateway)
SimulatorConsole.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}"); _observer.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}");
else else
SimulatorConsole.DryRun($"Would use existing encounter {result.EncounterId}"); _observer.DryRun($"Would use existing encounter {result.EncounterId}");
} }
else else
{ {
SimulatorConsole.DryRun("Would register patient: " + _observer.DryRun("Would register patient: " +
$"{scenario.Patient.FirstName} {scenario.Patient.LastName}"); $"{scenario.Patient.FirstName} {scenario.Patient.LastName}");
SimulatorConsole.DryRun("Would open encounter: " + _observer.DryRun("Would open encounter: " +
$"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}"); $"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}");
} }
} }
else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue) else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue)
{ {
result.EncounterId = options.ExistingEncounterId.Value; result.EncounterId = options.ExistingEncounterId.Value;
SimulatorConsole.Info($"Gateway mode — using existing encounter {result.EncounterId}"); _observer.Info($"Gateway mode — using existing encounter {result.EncounterId}");
} }
else if (options.ExistingEncounterId.HasValue) else if (options.ExistingEncounterId.HasValue)
{ {
result.EncounterId = options.ExistingEncounterId.Value; result.EncounterId = options.ExistingEncounterId.Value;
SimulatorConsole.Info($"Using existing encounter {result.EncounterId}"); _observer.Info($"Using existing encounter {result.EncounterId}");
} }
else else
{ {
@@ -56,6 +66,9 @@ public class ReplayEngine
DateOnly.Parse(scenario.Patient.DateOfBirth), DateOnly.Parse(scenario.Patient.DateOfBirth),
scenario.Patient.Gender)); scenario.Patient.Gender));
if (_onPatientRegistered is not null)
await _onPatientRegistered(patient.Id, ct);
var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest( var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest(
scenario.Encounter.EncounterType, scenario.Encounter.EncounterType,
DepartmentMapper.ToApiDepartment(scenario.Encounter.Department), DepartmentMapper.ToApiDepartment(scenario.Encounter.Department),
@@ -63,8 +76,8 @@ public class ReplayEngine
scenario.Encounter.RoomBed, scenario.Encounter.RoomBed,
scenario.Encounter.AdmissionReason)); scenario.Encounter.AdmissionReason));
SimulatorConsole.Info($"Patient registered: {patient.Id} ({patient.Mrn})"); _observer.Info($"Patient registered: {patient.Id} ({patient.Mrn})");
SimulatorConsole.Info($"Encounter opened: {encounter.Id} ({encounter.Status})"); _observer.Info($"Encounter opened: {encounter.Id} ({encounter.Status})");
result.PatientId = patient.Id; result.PatientId = patient.Id;
result.EncounterId = encounter.Id; result.EncounterId = encounter.Id;
} }
@@ -76,15 +89,16 @@ public class ReplayEngine
.OrderBy(g => g.Key) .OrderBy(g => g.Key)
.ToList(); .ToList();
foreach (var cluster in clusters) for (var clusterIndex = 0; clusterIndex < clusters.Count; clusterIndex++)
{ {
var cluster = clusters[clusterIndex];
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var deltaMinutes = cluster.Key - lastOffset; var deltaMinutes = cluster.Key - lastOffset;
if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun) if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun)
{ {
var delayMs = (int)(deltaMinutes * 60_000 / options.Speed); var delayMs = (int)(deltaMinutes * 60_000 / options.Speed);
SimulatorConsole.Wait(deltaMinutes, delayMs); _observer.Waiting(deltaMinutes, delayMs);
await Task.Delay(delayMs, ct); await Task.Delay(delayMs, ct);
} }
@@ -116,6 +130,7 @@ public class ReplayEngine
} }
lastOffset = cluster.Key; lastOffset = cluster.Key;
_observer.Progress(cluster.Key, clusterIndex, clusters.Count);
if (options.Poll && !options.DryRun && _poller is not null) if (options.Poll && !options.DryRun && _poller is not null)
{ {
@@ -136,10 +151,10 @@ public class ReplayEngine
result.HadExpectedOutcomes = true; result.HadExpectedOutcomes = true;
result.OutcomeFailures.AddRange(failures); result.OutcomeFailures.AddRange(failures);
foreach (var failure in failures) foreach (var failure in failures)
SimulatorConsole.Error(failure); _observer.Error(failure);
} }
SimulatorConsole.Summary(result); _observer.Completed(result);
return result; return result;
} }
@@ -160,7 +175,7 @@ public class ReplayEngine
var unit = evt.Data.GetProperty("unit").GetString()!; var unit = evt.Data.GetProperty("unit").GetString()!;
var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual"; var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual";
SimulatorConsole.Event(simTime, $"{code} {value} {unit}"); _observer.Event(simTime, $"{code} {value} {unit}");
result.ObservationsSent++; result.ObservationsSent++;
if (!options.DryRun) if (!options.DryRun)
@@ -185,7 +200,7 @@ public class ReplayEngine
if (options.DryRun) if (options.DryRun)
{ {
SimulatorConsole.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}"); _observer.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}");
return; return;
} }
@@ -194,12 +209,12 @@ public class ReplayEngine
if (sent) if (sent)
{ {
SimulatorConsole.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent"); _observer.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent");
result.MedicationsSent++; result.MedicationsSent++;
} }
else else
{ {
SimulatorConsole.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)"); _observer.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)");
result.MedicationsSkipped++; result.MedicationsSkipped++;
} }
} }
@@ -215,7 +230,7 @@ public class ReplayEngine
if (options.DryRun) if (options.DryRun)
{ {
SimulatorConsole.DryRun($"[{simTime}] ORDER {orderType}: {description}"); _observer.DryRun($"[{simTime}] ORDER {orderType}: {description}");
return; return;
} }
@@ -224,12 +239,12 @@ public class ReplayEngine
if (placed) if (placed)
{ {
SimulatorConsole.Event(simTime, $"ORDER {description} placed"); _observer.Event(simTime, $"ORDER {description} placed");
result.OrdersPlaced++; result.OrdersPlaced++;
} }
else else
{ {
SimulatorConsole.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'"); _observer.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'");
} }
} }
@@ -242,7 +257,7 @@ public class ReplayEngine
if (options.DryRun) if (options.DryRun)
{ {
SimulatorConsole.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}"); _observer.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}");
return; return;
} }
@@ -251,12 +266,12 @@ public class ReplayEngine
if (ok) if (ok)
{ {
SimulatorConsole.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted"); _observer.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted");
result.OrdersResulted++; result.OrdersResulted++;
} }
else else
{ {
SimulatorConsole.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted"); _observer.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted");
} }
} }
@@ -285,7 +300,7 @@ public class ReplayEngine
if (options.DryRun) if (options.DryRun)
{ {
SimulatorConsole.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}"); _observer.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}");
return; return;
} }
@@ -296,8 +311,8 @@ public class ReplayEngine
var ok = await _client.TryAcknowledgeAlertAsync( var ok = await _client.TryAcknowledgeAlertAsync(
result.EncounterId, alertType, clinicianId, note, waitForAlert, ct); result.EncounterId, alertType, clinicianId, note, waitForAlert, ct);
if (ok) if (ok)
SimulatorConsole.Event(simTime, $"ACK {alertType} by {clinicianId}"); _observer.Event(simTime, $"ACK {alertType} by {clinicianId}");
else else
SimulatorConsole.Warn($"[{simTime}] ACK failed — no open {alertType} alert found"); _observer.Warn($"[{simTime}] ACK failed — no open {alertType} alert found");
} }
} }
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public enum ReplayTarget { Central, Gateway } public enum ReplayTarget { Central, Gateway }
public record ReplayOptions( public record ReplayOptions(
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public class ReplayResult public class ReplayResult
{ {
public string ScenarioId { get; } public string ScenarioId { get; }
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public static class DepartmentMapper public static class DepartmentMapper
{ {
private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase)
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public static class ExpectedOutcomeValidator public static class ExpectedOutcomeValidator
{ {
private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8); private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8);
@@ -1,5 +1,7 @@
using System.Text.Json; using System.Text.Json;
namespace VigilCare.Simulation;
public record ScenarioFile( public record ScenarioFile(
ScenarioMeta Scenario, ScenarioMeta Scenario,
ScenarioPatient Patient, ScenarioPatient Patient,
@@ -0,0 +1,59 @@
using System.Text.Json;
namespace VigilCare.Simulation;
public static class ScenarioLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static ScenarioFile Load(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Scenario file not found: {path}");
var json = File.ReadAllText(path);
var scenario = JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize: {path}");
return scenario with
{
Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList()
};
}
/// <summary>
/// Enumerates <c>*.json</c> in <paramref name="directory"/>, skips files that
/// fail to deserialize, and returns pairs sorted by <see cref="ScenarioMeta.Id"/>.
/// </summary>
public static IReadOnlyList<(ScenarioFile Scenario, string Path)> LoadAll(string directory)
{
if (!Directory.Exists(directory))
return Array.Empty<(ScenarioFile, string)>();
var results = new List<(ScenarioFile Scenario, string Path)>();
foreach (var path in Directory.EnumerateFiles(directory, "*.json")
.Where(p => !string.Equals(
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{
try
{
results.Add((Load(path), path));
}
catch
{
// Skip files that fail to deserialize — catalogue must stay resilient.
}
}
return results
.OrderBy(r => r.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
@@ -1,3 +1,5 @@
namespace VigilCare.Simulation;
public static class ScenarioValidator public static class ScenarioValidator
{ {
private static readonly HashSet<string> ValidCodes = new() private static readonly HashSet<string> ValidCodes = new()
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>VigilCare.Simulation</RootNamespace>
<Copyright>Copyright (c) 2024-2026 voltsrage. All Rights Reserved.</Copyright>
<Authors>voltsrage</Authors>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>
</Project>
@@ -1 +0,0 @@
public record QsofaResponse(int ActiveCriteria);
@@ -1 +0,0 @@
public record RecordOrderResultRequest(string? ResultSummary);
@@ -1,4 +1,5 @@
using System.CommandLine; using System.CommandLine;
using VigilCare.Simulation;
public static class DryRunCommand public static class DryRunCommand
{ {
@@ -21,7 +22,7 @@ public static class DryRunCommand
return; return;
} }
var engine = new ReplayEngine(client: null!, poller: null); var engine = new ReplayEngine(client: null!, poller: null, new ConsoleReplayObserver());
await engine.RunAsync(scenario, new ReplayOptions(DryRun: true)); await engine.RunAsync(scenario, new ReplayOptions(DryRun: true));
}, fileArg); }, fileArg);
@@ -1,4 +1,5 @@
using System.CommandLine; using System.CommandLine;
using VigilCare.Simulation;
public static class ReplayAllCommand public static class ReplayAllCommand
{ {
@@ -38,7 +39,7 @@ public static class ReplayAllCommand
await client.LoginAsync(username, password); await client.LoginAsync(username, password);
SimulatorConsole.Info("Authenticated."); SimulatorConsole.Info("Authenticated.");
var engine = new ReplayEngine(client, poller: null); var engine = new ReplayEngine(client, poller: null, new ConsoleReplayObserver());
var results = new List<ReplayResult>(); var results = new List<ReplayResult>();
foreach (var file in files) foreach (var file in files)
@@ -1,4 +1,5 @@
using System.CommandLine; using System.CommandLine;
using VigilCare.Simulation;
public static class ReplayCommand public static class ReplayCommand
{ {
@@ -86,7 +87,7 @@ public static class ReplayCommand
} }
var poller = poll ? new ApiPoller(client) : null; var poller = poll ? new ApiPoller(client) : null;
var engine = new ReplayEngine(client, poller); var engine = new ReplayEngine(client, poller, new ConsoleReplayObserver());
var options = new ReplayOptions( var options = new ReplayOptions(
speed, poll, pollInterval, DryRun: false, speed, poll, pollInterval, DryRun: false,
Target: gateway ? ReplayTarget.Gateway : ReplayTarget.Central, Target: gateway ? ReplayTarget.Gateway : ReplayTarget.Central,
@@ -1,4 +1,5 @@
using System.CommandLine; using System.CommandLine;
using VigilCare.Simulation;
public static class ValidateCommand public static class ValidateCommand
{ {
@@ -1,6 +1,7 @@
using System.CommandLine; using System.CommandLine;
using System.Text.Json; using System.Text.Json;
using Spectre.Console; using Spectre.Console;
using VigilCare.Simulation;
public static class MimicGenerateCommand public static class MimicGenerateCommand
{ {
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using VigilCare.Simulation;
public record MimicGenerateOptions( public record MimicGenerateOptions(
int? MaxHours = null, int? MaxHours = null,
@@ -0,0 +1,33 @@
using VigilCare.Simulation;
public sealed class ConsoleReplayObserver : IReplayObserver
{
public void Header(string name, string? description) =>
SimulatorConsole.Header(name, description);
public void Info(string message) =>
SimulatorConsole.Info(message);
public void Event(string simTime, string description) =>
SimulatorConsole.Event(simTime, description);
public void Waiting(double deltaMinutes, int delayMs) =>
SimulatorConsole.Wait(deltaMinutes, delayMs);
public void Warn(string message) =>
SimulatorConsole.Warn(message);
public void Error(string message) =>
SimulatorConsole.Error(message);
public void DryRun(string message) =>
SimulatorConsole.DryRun(message);
public void Completed(ReplayResult result) =>
SimulatorConsole.Summary(result);
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount)
{
// Console progress is already visible via Event/Wait; no extra output.
}
}
@@ -1,4 +1,5 @@
using Spectre.Console; using Spectre.Console;
using VigilCare.Simulation;
public static class SimulatorConsole public static class SimulatorConsole
{ {
+3 -1
View File
@@ -1,4 +1,6 @@
public class ApiPoller using VigilCare.Simulation;
public class ApiPoller : IApiPoller
{ {
private readonly VigilCareApiClient _client; private readonly VigilCareApiClient _client;
private readonly HashSet<Guid> _seenAlertIds = new(); private readonly HashSet<Guid> _seenAlertIds = new();
@@ -1,3 +1,5 @@
using VigilCare.Simulation;
public record PollResult( public record PollResult(
News2Response? News2, News2Response? News2,
GcsResponse? Gcs, GcsResponse? Gcs,
@@ -1,26 +0,0 @@
using System.Text.Json;
public static class ScenarioLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static ScenarioFile Load(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Scenario file not found: {path}");
var json = File.ReadAllText(path);
var scenario = JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize: {path}");
return scenario with
{
Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList()
};
}
}
@@ -19,4 +19,8 @@
<PackageReference Include="Spectre.Console" Version="0.49.1" /> <PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj" />
</ItemGroup>
</Project> </Project>
+6
View File
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway", "Vi
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway.Tests", "VigilCare.WardGateway.Tests\VigilCare.WardGateway.Tests.csproj", "{1C6D261E-488B-4541-8995-12C5029441A6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway.Tests", "VigilCare.WardGateway.Tests\VigilCare.WardGateway.Tests.csproj", "{1C6D261E-488B-4541-8995-12C5029441A6}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.Simulation.Core", "VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj", "{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -54,5 +56,9 @@ Global
{1C6D261E-488B-4541-8995-12C5029441A6}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C6D261E-488B-4541-8995-12C5029441A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.Build.0 = Release|Any CPU {1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.Build.0 = Release|Any CPU
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
@@ -3,6 +3,7 @@ using FluentAssertions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis; using StackExchange.Redis;
using VigilCare.Simulation;
[Collection("Integration")] [Collection("Integration")]
public class ClinicalRefactorEndToEndTests : IAsyncLifetime public class ClinicalRefactorEndToEndTests : IAsyncLifetime
@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using StackExchange.Redis; using StackExchange.Redis;
@@ -24,6 +26,12 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
public static int RabbitPort { get; } = public static int RabbitPort { get; } =
int.TryParse(Environment.GetEnvironmentVariable("RabbitMq__Port"), out var p) ? p : 5674; int.TryParse(Environment.GetEnvironmentVariable("RabbitMq__Port"), out var p) ? p : 5674;
public static string SimulationScenarioDirectory { get; } = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "Fixtures", "Scenarios"));
public TestSimulationClientFactory SimulationClientFactory =>
Services.GetRequiredService<TestSimulationClientFactory>();
// Override configuration to point at a test database — never run tests against // Override configuration to point at a test database — never run tests against
// the development database; a botched rollback could corrupt seed data. // the development database; a botched rollback could corrupt seed data.
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -62,6 +70,16 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
}); });
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false); config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
// Win over appsettings.Testing.json catalogue path / concurrency defaults.
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Simulation:Enabled"] = "true",
["Simulation:ScenarioDirectory"] = SimulationScenarioDirectory,
["Simulation:MaxConcurrentRuns"] = "2",
["Simulation:MaxSpeed"] = "600",
["Simulation:RunHistoryLimit"] = "50",
});
}); });
builder.ConfigureServices(services => builder.ConfigureServices(services =>
@@ -77,6 +95,15 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
.AddScheme<AuthenticationSchemeOptions, TestingAuthHandler>( .AddScheme<AuthenticationSchemeOptions, TestingAuthHandler>(
TestingAuthHandler.SchemeName, _ => { }); TestingAuthHandler.SchemeName, _ => { });
}); });
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ISimulationClientFactory>();
services.AddSingleton<TestSimulationClientFactory>(_ =>
new TestSimulationClientFactory(this));
services.AddSingleton<ISimulationClientFactory>(sp =>
sp.GetRequiredService<TestSimulationClientFactory>());
});
} }
public async Task InitializeAsync() public async Task InitializeAsync()
@@ -0,0 +1,44 @@
{
"scenario": {
"id": "minimal-sim-01",
"name": "Minimal Simulation Fixture",
"description": "Three observation clusters for Phase 36 CI tests.",
"durationMinutes": 2,
"tags": ["test", "minimal"]
},
"patient": {
"firstName": "Sim",
"lastName": "Fixture",
"dateOfBirth": "1980-01-15",
"gender": "Female"
},
"encounter": {
"department": "GeneralMedicine",
"encounterType": "Inpatient",
"attendingPhysician": "Dr. Test",
"roomBed": "T-1",
"admissionReason": "Simulation fixture"
},
"events": [
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 72, "unit": "bpm", "source": "Manual" }
},
{
"offsetMinutes": 0,
"type": "observation",
"data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Manual" }
},
{
"offsetMinutes": 1,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 74, "unit": "bpm", "source": "Manual" }
},
{
"offsetMinutes": 2,
"type": "observation",
"data": { "code": "HEART_RATE", "value": 70, "unit": "bpm", "source": "Manual" }
}
]
}
@@ -35,6 +35,7 @@ public static class DbResetHelper
DELETE FROM alert_thresholds; DELETE FROM alert_thresholds;
DELETE FROM clinical_audit_logs; DELETE FROM clinical_audit_logs;
DELETE FROM clinical_users; DELETE FROM clinical_users;
DELETE FROM simulation_runs;
DELETE FROM patients; DELETE FROM patients;
"); ");
return; return;
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using VigilCare.Simulation;
public static class ScenarioReplayHelper public static class ScenarioReplayHelper
{ {
@@ -0,0 +1,152 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Integration")]
public class SimulationEndpointTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
public SimulationEndpointTests(ApiFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
_fixture.SimulationClientFactory.HangOnCreate = false;
_fixture.SimulationClientFactory.FailOnCreate = false;
_client.DefaultRequestHeaders.Remove("X-Test-Role");
_client.DefaultRequestHeaders.Remove("X-Test-User-Id");
_client.AsAdmin();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Config_ReturnsEnabledFalse_WhenDisabled()
{
using var factory = _fixture.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Simulation:Enabled"] = "false",
});
});
});
var client = factory.CreateClient();
client.AsAdmin();
var resp = await client.GetAsync("/api/v1/simulation/config");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("data").GetProperty("enabled").GetBoolean().Should().BeFalse();
}
[Fact]
public async Task Scenarios_WhenDisabled_Returns404()
{
using var factory = _fixture.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Simulation:Enabled"] = "false",
});
});
});
var client = factory.CreateClient();
client.AsAdmin();
var resp = await client.GetAsync("/api/v1/simulation/scenarios");
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Scenarios_AsNurse_Returns200()
{
_client.AsNurse();
var resp = await _client.GetAsync("/api/v1/simulation/scenarios");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
var items = body.GetProperty("data");
items.GetArrayLength().Should().BeGreaterThan(0);
items[0].GetProperty("id").GetString().Should().Be("minimal-sim-01");
}
[Fact]
public async Task StartRun_AsIntegrationRole_Returns403()
{
_client.AsIntegration();
var resp = await _client.PostAsJsonAsync("/api/v1/simulation/runs", new
{
scenarioId = "minimal-sim-01",
speed = 600
});
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task StartRun_WritesAuditLog()
{
_client.AsPhysician();
var resp = await _client.PostAsJsonAsync("/api/v1/simulation/runs", new
{
scenarioId = "minimal-sim-01",
speed = 600
});
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
var runId = body.GetProperty("data").GetProperty("runId").GetGuid();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var audit = await db.ClinicalAuditLogs
.Where(a => a.Action == AuditAction.SimulationRunStarted && a.EntityId == runId)
.SingleOrDefaultAsync();
audit.Should().NotBeNull();
audit!.EntityType.Should().Be("SimulationRun");
}
[Fact]
public async Task Config_WhenEnabled_ReturnsLimits()
{
_client.AsNurse();
var resp = await _client.GetAsync("/api/v1/simulation/config");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
var data = body.GetProperty("data");
data.GetProperty("enabled").GetBoolean().Should().BeTrue();
data.GetProperty("maxSpeed").GetDouble().Should().Be(600);
data.GetProperty("maxConcurrentRuns").GetInt32().Should().Be(2);
}
}
@@ -0,0 +1,197 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StackExchange.Redis;
[Collection("Integration")]
public class SimulationRunnerTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private ISimulationRunner _runner = null!;
private TestSimulationClientFactory _clientFactory = null!;
public SimulationRunnerTests(ApiFixture fixture) => _fixture = fixture;
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
_runner = _fixture.Services.GetRequiredService<ISimulationRunner>();
_clientFactory = _fixture.SimulationClientFactory;
_clientFactory.HangOnCreate = false;
_clientFactory.FailOnCreate = false;
foreach (var run in _runner.ListRuns())
_runner.Cancel(run.RunId);
}
public Task DisposeAsync()
{
_clientFactory.HangOnCreate = false;
_clientFactory.FailOnCreate = false;
return Task.CompletedTask;
}
[Fact]
public async Task Start_UnknownScenario_Returns422()
{
var act = () => _runner.StartAsync("does-not-exist", 60, "tester", CancellationToken.None);
var ex = await act.Should().ThrowAsync<ValidationException>();
ex.Which.ErrorCode.Should().Be("SIMULATION_SCENARIO_UNKNOWN");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.SimulationRuns.CountAsync()).Should().Be(0);
}
[Fact]
public async Task Start_SpeedAboveMax_Returns422()
{
var act = () => _runner.StartAsync("minimal-sim-01", 601, "tester", CancellationToken.None);
var ex = await act.Should().ThrowAsync<ValidationException>();
ex.Which.ErrorCode.Should().Be("SIMULATION_SPEED_INVALID");
}
[Fact]
public async Task Start_AtConcurrencyLimit_Returns409()
{
_clientFactory.HangOnCreate = true;
await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
var act = () => _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.ErrorCode.Should().Be("SIMULATION_CONCURRENCY_LIMIT");
foreach (var run in _runner.ListRuns())
_runner.Cancel(run.RunId);
await WaitForAsync(() => _runner.ListRuns().All(r =>
r.Status is SimulationRunStatus.Cancelled or SimulationRunStatus.Failed
or SimulationRunStatus.Completed));
}
[Fact]
public async Task Start_CreatesPatientMarkedSimulated()
{
var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None);
var completed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed);
completed.PatientId.Should().NotBeNull();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var patient = await db.Patients.SingleAsync(p => p.Id == completed.PatientId);
patient.IsSimulated.Should().BeTrue();
}
[Fact]
public async Task Run_ProgressAdvances()
{
var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None);
await WaitForAsync(() =>
{
var current = _runner.GetRun(state.RunId);
return current is not null && current.LastOffsetMinutes > 0;
});
var mid = _runner.GetRun(state.RunId)!;
mid.LastOffsetMinutes.Should().BeGreaterThan(0);
var completed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed);
completed.ProgressPercent.Should().Be(100);
completed.ObservationsSent.Should().Be(4);
}
[Fact]
public async Task Stop_CancelsRun_StatusCancelled()
{
_clientFactory.HangOnCreate = true;
var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
_runner.Cancel(state.RunId).Should().BeTrue();
var cancelled = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled);
cancelled.Status.Should().Be(SimulationRunStatus.Cancelled);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(await db.Observations.CountAsync()).Should().Be(0);
}
[Fact]
public async Task Stop_AlreadyCompleted_IsNoOpSuccess()
{
var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None);
await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed);
_runner.Cancel(state.RunId).Should().BeTrue();
var after = _runner.GetRun(state.RunId)!;
after.Status.Should().Be(SimulationRunStatus.Completed);
}
[Fact]
public async Task Run_Failure_RecordsFailureReason()
{
_clientFactory.FailOnCreate = true;
var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None);
var failed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Failed);
failed.FailureReason.Should().NotBeNullOrWhiteSpace();
failed.FailureReason.Should().Contain("Login failed");
}
[Fact]
public async Task Shutdown_CancelsActiveRuns()
{
_clientFactory.HangOnCreate = true;
var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None);
var hosted = (IHostedService)_fixture.Services.GetRequiredService<SimulationRunner>();
await hosted.StopAsync(CancellationToken.None);
var after = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled);
after.Status.Should().Be(SimulationRunStatus.Cancelled);
}
private async Task<SimulationRunState> WaitForRunAsync(
Guid runId, SimulationRunStatus expected, TimeSpan? timeout = null)
{
try
{
await WaitForAsync(() => _runner.GetRun(runId)?.Status == expected, timeout);
}
catch (TimeoutException)
{
var actual = _runner.GetRun(runId);
throw new TimeoutException(
$"Expected run {runId} status {expected}, but was {actual?.Status}. " +
$"FailureReason={actual?.FailureReason}");
}
return _runner.GetRun(runId)!;
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
while (DateTimeOffset.UtcNow < deadline)
{
if (condition())
return;
await Task.Delay(25);
}
throw new TimeoutException("Condition was not met within the timeout.");
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc.Testing;
using VigilCare.Simulation;
/// <summary>
/// Loopback client for WebApplicationFactory tests. Uses X-Test-Role INTEGRATION
/// instead of JWT login (Testing auth scheme ignores Bearer tokens).
/// Uses Server.CreateHandler() to avoid TestServer re-entrancy deadlocks.
/// </summary>
public sealed class TestSimulationClientFactory : ISimulationClientFactory
{
private readonly WebApplicationFactory<Program> _factory;
public TestSimulationClientFactory(WebApplicationFactory<Program> factory) =>
_factory = factory;
/// <summary>When true, CreateAsync blocks until cancelled — for concurrency/shutdown tests.</summary>
public bool HangOnCreate { get; set; }
/// <summary>When true, CreateAsync throws — for failure-path tests.</summary>
public bool FailOnCreate { get; set; }
public async Task<VigilCareApiClient> CreateAsync(CancellationToken ct = default)
{
if (FailOnCreate)
throw new HttpRequestException("Login failed (401 Unauthorized): invalid credentials");
if (HangOnCreate)
await Task.Delay(Timeout.Infinite, ct);
var http = new HttpClient(_factory.Server.CreateHandler(), disposeHandler: true)
{
BaseAddress = _factory.Server.BaseAddress ?? new Uri("http://localhost"),
};
http.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION");
return new VigilCareApiClient(http);
}
}
@@ -26,11 +26,11 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" /> <ProjectReference Include="..\VigilCareClinicalAPI\VigilCareClinicalAPI.csproj" />
<ProjectReference Include="..\VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="..\VigilCare.Simulator\Scenarios\ScenarioFile.cs" Link="Scenarios\ScenarioFile.cs" /> <None Include="Fixtures\Scenarios\**\*.json" CopyToOutputDirectory="PreserveNewest" />
<Compile Include="..\VigilCare.Simulator\Scenarios\ScenarioValidator.cs" Link="Scenarios\ScenarioValidator.cs" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -18,4 +18,5 @@ public static class ClinicalPermissions
public const string AuditRead = "audit:read"; public const string AuditRead = "audit:read";
public const string UsersAdmin = "users:admin"; public const string UsersAdmin = "users:admin";
public const string AlertsFeedback = "alerts:feedback"; public const string AlertsFeedback = "alerts:feedback";
public const string SimulationRun = "simulation:run";
} }
@@ -17,6 +17,7 @@ public static class ClinicalRolePermissionMap
ClinicalPermissions.OrdersWrite, ClinicalPermissions.OrdersWrite,
ClinicalPermissions.MedicationsWrite, ClinicalPermissions.MedicationsWrite,
ClinicalPermissions.AlertsFeedback, ClinicalPermissions.AlertsFeedback,
ClinicalPermissions.SimulationRun,
}, },
[ClinicalRole.Physician] = new(StringComparer.Ordinal) [ClinicalRole.Physician] = new(StringComparer.Ordinal)
{ {
@@ -33,6 +34,7 @@ public static class ClinicalRolePermissionMap
ClinicalPermissions.OrdersWrite, ClinicalPermissions.OrdersWrite,
ClinicalPermissions.MedicationsWrite, ClinicalPermissions.MedicationsWrite,
ClinicalPermissions.AlertsFeedback, ClinicalPermissions.AlertsFeedback,
ClinicalPermissions.SimulationRun,
}, },
[ClinicalRole.Admin] = new(StringComparer.Ordinal) [ClinicalRole.Admin] = new(StringComparer.Ordinal)
{ {
@@ -54,6 +56,7 @@ public static class ClinicalRolePermissionMap
ClinicalPermissions.AuditRead, ClinicalPermissions.AuditRead,
ClinicalPermissions.UsersAdmin, ClinicalPermissions.UsersAdmin,
ClinicalPermissions.AlertsFeedback, ClinicalPermissions.AlertsFeedback,
ClinicalPermissions.SimulationRun,
}, },
[ClinicalRole.Integration] = new(StringComparer.Ordinal) [ClinicalRole.Integration] = new(StringComparer.Ordinal)
{ {
@@ -61,6 +64,10 @@ public static class ClinicalRolePermissionMap
ClinicalPermissions.EncountersWrite, ClinicalPermissions.EncountersWrite,
ClinicalPermissions.ObservationsIngest, ClinicalPermissions.ObservationsIngest,
ClinicalPermissions.MedicationsWrite, ClinicalPermissions.MedicationsWrite,
// Phase 36 — simulation runner (Integration) must place orders and ack
// alerts so sepsis-bundle / alert_ack scenario timelines are complete.
ClinicalPermissions.OrdersWrite,
ClinicalPermissions.AlertsAcknowledge,
ClinicalPermissions.FhirIngest, ClinicalPermissions.FhirIngest,
ClinicalPermissions.FhirRead, ClinicalPermissions.FhirRead,
}, },
@@ -0,0 +1,27 @@
public class SimulationOptions
{
public const string Section = "Simulation";
/// <summary>Master switch. When false, no simulation endpoints or services are registered.</summary>
public bool Enabled { get; set; } = false;
/// <summary>Directory containing scenario JSON files.</summary>
public string ScenarioDirectory { get; set; } = "Scenarios";
/// <summary>Base address the runner posts to (the API's own address).</summary>
public string LoopbackBaseUrl { get; set; } = "http://localhost:5270";
/// <summary>Service account the runner authenticates as.</summary>
public string RunnerUsername { get; set; } = "simulation.runner";
public string RunnerPassword { get; set; } = null!;
/// <summary>Concurrent scenario runs allowed (Phase 38 ward population needs &gt; 1).</summary>
public int MaxConcurrentRuns { get; set; } = 8;
/// <summary>Upper bound on replay speed multiplier requested by a client.</summary>
public double MaxSpeed { get; set; } = 600;
/// <summary>Completed runs retained in the in-memory registry.</summary>
public int RunHistoryLimit { get; set; } = 50;
}
@@ -0,0 +1,194 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
/// <summary>
/// In-app scenario catalogue and run control for clinical testing sessions.
/// </summary>
[ApiController]
[Route("api/v1/simulation")]
[Produces("application/json")]
[Authorize]
public class SimulationController : ControllerBase
{
private readonly SimulationOptions _options;
private readonly ISimulationRunner? _runner;
private readonly IScenarioCatalog? _catalog;
private readonly ICurrentUserService _currentUser;
private readonly IAuditService _audit;
public SimulationController(
IOptions<SimulationOptions> options,
IServiceProvider services,
ICurrentUserService currentUser,
IAuditService audit)
{
_options = options.Value;
_runner = services.GetService<ISimulationRunner>();
_catalog = services.GetService<IScenarioCatalog>();
_currentUser = currentUser;
_audit = audit;
}
/// <summary>
/// Feature-detect simulation availability without requiring simulation:run.
/// Returns enabled=false when the feature is off (never 404).
/// </summary>
[HttpGet("config")]
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
[ProducesResponseType(typeof(ApiResponse<SimulationConfigResponse>), StatusCodes.Status200OK)]
public IActionResult GetConfig()
{
if (!_options.Enabled)
return Ok(ApiResponse<SimulationConfigResponse>.Ok(new SimulationConfigResponse(Enabled: false)));
return Ok(ApiResponse<SimulationConfigResponse>.Ok(new SimulationConfigResponse(
Enabled: true,
MaxSpeed: _options.MaxSpeed,
MaxConcurrentRuns: _options.MaxConcurrentRuns)));
}
/// <summary>
/// Lists available scenario files from the configured scenario directory.
/// </summary>
[HttpGet("scenarios")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<List<ScenarioSummaryResponse>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public IActionResult ListScenarios()
{
EnsureEnabled();
var items = _catalog!.ListScenarios()
.Select(s => new ScenarioSummaryResponse(
s.Scenario.Id,
s.Scenario.Name,
s.Scenario.Description,
s.Scenario.DurationMinutes,
s.Scenario.Tags,
s.Encounter.Department,
s.Events.Count,
s.ExpectedOutcomes?.Count ?? 0))
.ToList();
return Ok(ApiResponse<List<ScenarioSummaryResponse>>.Ok(items));
}
/// <summary>
/// Starts a background scenario replay.
/// </summary>
[HttpPost("runs")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> StartRun(
[FromBody] StartSimulationRunRequest req, CancellationToken ct)
{
EnsureEnabled();
var userId = _currentUser.UserId?.ToString()
?? throw new ValidationException("Authenticated user id is required.", "SIMULATION_USER_REQUIRED");
var state = await _runner!.StartAsync(req.ScenarioId, req.Speed, userId, ct);
await _audit.WriteAsync(
AuditAction.SimulationRunStarted,
"SimulationRun",
state.RunId,
newValue: new
{
state.ScenarioId,
state.ScenarioName,
state.Speed,
StartedBy = userId,
});
return StatusCode(201, ApiResponse<SimulationRunResponse>.Created(ToResponse(state)));
}
/// <summary>
/// Lists active and recent simulation runs from the in-memory registry.
/// </summary>
[HttpGet("runs")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<List<SimulationRunResponse>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public IActionResult ListRuns()
{
EnsureEnabled();
var items = _runner!.ListRuns().Select(ToResponse).ToList();
return Ok(ApiResponse<List<SimulationRunResponse>>.Ok(items));
}
/// <summary>
/// Gets one simulation run by id.
/// </summary>
[HttpGet("runs/{runId:guid}")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public IActionResult GetRun(Guid runId)
{
EnsureEnabled();
var state = _runner!.GetRun(runId)
?? throw new NotFoundException($"Simulation run '{runId}' was not found.");
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
}
/// <summary>
/// Stops an in-flight run. Idempotent — stopping a finished run succeeds as a no-op.
/// </summary>
[HttpPost("runs/{runId:guid}/stop")]
[AuthorizePermission(ClinicalPermissions.SimulationRun)]
[ProducesResponseType(typeof(ApiResponse<SimulationRunResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> StopRun(Guid runId)
{
EnsureEnabled();
if (!_runner!.Cancel(runId))
throw new NotFoundException($"Simulation run '{runId}' was not found.");
var state = _runner.GetRun(runId)
?? throw new NotFoundException($"Simulation run '{runId}' was not found.");
await _audit.WriteAsync(
AuditAction.SimulationRunStopped,
"SimulationRun",
runId,
newValue: new
{
state.ScenarioId,
state.Status,
StoppedBy = _currentUser.UserId?.ToString(),
});
return Ok(ApiResponse<SimulationRunResponse>.Ok(ToResponse(state)));
}
private void EnsureEnabled()
{
if (!_options.Enabled || _runner is null || _catalog is null)
throw new NotFoundException("Simulation endpoints are not available.");
}
private static SimulationRunResponse ToResponse(SimulationRunState state) =>
new(
state.RunId,
state.ScenarioId,
state.ScenarioName,
state.Status.ToDbString(),
state.Speed,
state.PatientId,
state.EncounterId,
state.PatientDisplayName,
state.StartedAt,
state.ElapsedRealSeconds,
state.LastOffsetMinutes,
state.TotalOffsetMinutes,
state.ProgressPercent,
state.ObservationsSent,
state.MedicationsSent,
state.OrdersPlaced,
state.FailureReason);
}
@@ -37,6 +37,7 @@ public class AppDbContext : DbContext
public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>(); public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>();
public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>(); public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<SimulationRun> SimulationRuns => Set<SimulationRun>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -25,6 +25,7 @@ public class PatientConfiguration : IEntityTypeConfiguration<Patient>
.HasColumnName("name_search_token") .HasColumnName("name_search_token")
.HasMaxLength(64); .HasMaxLength(64);
builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(p => p.IsSimulated).HasColumnName("is_simulated").HasDefaultValue(false);
// MRN uses exact-match unique index — MRN lookups are always equality checks, // MRN uses exact-match unique index — MRN lookups are always equality checks,
// never LIKE/ILIKE. A B-tree unique index satisfies O(log n) point lookup. // never LIKE/ILIKE. A B-tree unique index satisfies O(log n) point lookup.
@@ -33,5 +34,9 @@ public class PatientConfiguration : IEntityTypeConfiguration<Patient>
// at this scale (pg_trgm GIN would be warranted at >500k patients). // at this scale (pg_trgm GIN would be warranted at >500k patients).
builder.HasIndex(p => p.Mrn).IsUnique(); builder.HasIndex(p => p.Mrn).IsUnique();
builder.HasIndex(p => p.NameSearchToken); builder.HasIndex(p => p.NameSearchToken);
// Filtered index keeps Phase 38 simulated-patient purge cheap.
builder.HasIndex(p => p.IsSimulated)
.HasDatabaseName("IX_Patients_IsSimulated")
.HasFilter("is_simulated = true");
} }
} }
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class SimulationRunConfiguration : IEntityTypeConfiguration<SimulationRun>
{
public void Configure(EntityTypeBuilder<SimulationRun> builder)
{
builder.ToTable("simulation_runs");
builder.HasKey(r => r.Id);
builder.Property(r => r.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(r => r.ScenarioId).HasColumnName("scenario_id").HasMaxLength(100).IsRequired();
builder.Property(r => r.ScenarioName).HasColumnName("scenario_name").HasMaxLength(200).IsRequired();
builder.Property(r => r.Speed).HasColumnName("speed");
builder.Property(r => r.Status).HasColumnName("status").HasMaxLength(20).IsRequired()
.HasConversion(
v => v.ToDbString(),
v => SimulationRunStatusExtensions.FromDbString(v));
builder.Property(r => r.PatientId).HasColumnName("patient_id");
builder.Property(r => r.EncounterId).HasColumnName("encounter_id");
builder.Property(r => r.StartedByUserId).HasColumnName("started_by_user_id").HasMaxLength(100).IsRequired();
builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.CompletedAt).HasColumnName("completed_at");
builder.Property(r => r.ObservationsSent).HasColumnName("observations_sent");
builder.Property(r => r.MedicationsSent).HasColumnName("medications_sent");
builder.Property(r => r.OrdersPlaced).HasColumnName("orders_placed");
builder.Property(r => r.LastOffsetMinutes).HasColumnName("last_offset_minutes");
builder.Property(r => r.TotalOffsetMinutes).HasColumnName("total_offset_minutes");
builder.Property(r => r.FailureReason).HasColumnName("failure_reason").HasMaxLength(2000);
builder.HasIndex(r => new { r.Status, r.StartedAt })
.IsDescending(false, true)
.HasDatabaseName("IX_simulation_runs_status_started_at");
}
}
+75 -39
View File
@@ -2,48 +2,84 @@ using Microsoft.EntityFrameworkCore;
public static class UserSeeder public static class UserSeeder
{ {
public static async Task SeedAsync(AppDbContext db) public static readonly Guid SimulationRunnerUserId =
Guid.Parse("55555555-5555-5555-5555-555555555555");
public static async Task SeedAsync(
AppDbContext db,
bool simulationEnabled = false,
string? simulationRunnerPassword = null)
{ {
if (await db.ClinicalUsers.AnyAsync()) if (!await db.ClinicalUsers.AnyAsync())
{
db.ClinicalUsers.AddRange(
new ClinicalUser
{
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
Username = "nurse.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
DisplayName = "Demo Nurse",
Role = ClinicalRole.Nurse,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
Username = "physician.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
DisplayName = "Dr. Demo Physician",
Role = ClinicalRole.Physician,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
Username = "admin.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
DisplayName = "Demo Admin",
Role = ClinicalRole.Admin,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("44444444-4444-4444-4444-444444444444"),
Username = "integration.mirth",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"),
DisplayName = "Mirth Connect",
Role = ClinicalRole.Integration,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
if (simulationEnabled)
await EnsureSimulationRunnerAsync(db, simulationRunnerPassword);
}
/// <summary>
/// Seeds the loopback simulation runner account when Simulation:Enabled.
/// Idempotent — safe to call on an existing database that already has demo users.
/// </summary>
public static async Task EnsureSimulationRunnerAsync(
AppDbContext db, string? password)
{
if (await db.ClinicalUsers.AnyAsync(u => u.Username == "simulation.runner"))
return; return;
db.ClinicalUsers.AddRange( if (string.IsNullOrWhiteSpace(password))
new ClinicalUser throw new InvalidOperationException(
{ "Simulation:Enabled requires Simulation:RunnerPassword to seed simulation.runner.");
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
Username = "nurse.demo", db.ClinicalUsers.Add(new ClinicalUser
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"), {
DisplayName = "Demo Nurse", Id = SimulationRunnerUserId,
Role = ClinicalRole.Nurse, Username = "simulation.runner",
CreatedAt = DateTimeOffset.UtcNow PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
}, DisplayName = "Simulation Runner",
new ClinicalUser Role = ClinicalRole.Integration,
{ CreatedAt = DateTimeOffset.UtcNow
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), });
Username = "physician.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
DisplayName = "Dr. Demo Physician",
Role = ClinicalRole.Physician,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
Username = "admin.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
DisplayName = "Demo Admin",
Role = ClinicalRole.Admin,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("44444444-4444-4444-4444-444444444444"),
Username = "integration.mirth",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"),
DisplayName = "Mirth Connect",
Role = ClinicalRole.Integration,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
@@ -16,5 +16,8 @@ public class Patient
public string Status { get; set; } = "active"; public string Status { get; set; } = "active";
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
/// <summary>True when this patient was created by the simulation runner. Never set for ingested clinical data.</summary>
public bool IsSimulated { get; set; }
public ICollection<Encounter> Encounters { get; set; } = new List<Encounter>(); public ICollection<Encounter> Encounters { get; set; } = new List<Encounter>();
} }
@@ -0,0 +1,19 @@
public class SimulationRun
{
public Guid Id { get; set; }
public string ScenarioId { get; set; } = null!;
public string ScenarioName { get; set; } = null!;
public double Speed { get; set; }
public SimulationRunStatus Status { get; set; }
public Guid? PatientId { get; set; }
public Guid? EncounterId { get; set; }
public string StartedByUserId { get; set; } = null!;
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public int ObservationsSent { get; set; }
public int MedicationsSent { get; set; }
public int OrdersPlaced { get; set; }
public double LastOffsetMinutes { get; set; }
public double TotalOffsetMinutes { get; set; }
public string? FailureReason { get; set; }
}
@@ -14,6 +14,8 @@ public enum AuditAction
AlertFeedbackSubmitted, AlertFeedbackSubmitted,
UserLogout, UserLogout,
TokenRefreshed, TokenRefreshed,
SimulationRunStarted,
SimulationRunStopped,
} }
public static class AuditActionExtensions public static class AuditActionExtensions
@@ -34,6 +36,8 @@ public static class AuditActionExtensions
AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED", AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED",
AuditAction.UserLogout => "USER_LOGOUT", AuditAction.UserLogout => "USER_LOGOUT",
AuditAction.TokenRefreshed => "TOKEN_REFRESHED", AuditAction.TokenRefreshed => "TOKEN_REFRESHED",
AuditAction.SimulationRunStarted => "SIMULATION_RUN_STARTED",
AuditAction.SimulationRunStopped => "SIMULATION_RUN_STOPPED",
_ => throw new ArgumentOutOfRangeException(nameof(a)) _ => throw new ArgumentOutOfRangeException(nameof(a))
}; };
@@ -53,6 +57,8 @@ public static class AuditActionExtensions
"ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted, "ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted,
"USER_LOGOUT" => AuditAction.UserLogout, "USER_LOGOUT" => AuditAction.UserLogout,
"TOKEN_REFRESHED" => AuditAction.TokenRefreshed, "TOKEN_REFRESHED" => AuditAction.TokenRefreshed,
"SIMULATION_RUN_STARTED" => AuditAction.SimulationRunStarted,
"SIMULATION_RUN_STOPPED" => AuditAction.SimulationRunStopped,
_ => throw new ArgumentOutOfRangeException(nameof(v)) _ => throw new ArgumentOutOfRangeException(nameof(v))
}; };
} }
@@ -0,0 +1,24 @@
public enum SimulationRunStatus { Pending, Running, Completed, Cancelled, Failed }
public static class SimulationRunStatusExtensions
{
public static string ToDbString(this SimulationRunStatus s) => s switch
{
SimulationRunStatus.Pending => "PENDING",
SimulationRunStatus.Running => "RUNNING",
SimulationRunStatus.Completed => "COMPLETED",
SimulationRunStatus.Cancelled => "CANCELLED",
SimulationRunStatus.Failed => "FAILED",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static SimulationRunStatus FromDbString(string v) => v switch
{
"PENDING" => SimulationRunStatus.Pending,
"RUNNING" => SimulationRunStatus.Running,
"COMPLETED" => SimulationRunStatus.Completed,
"CANCELLED" => SimulationRunStatus.Cancelled,
"FAILED" => SimulationRunStatus.Failed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown simulation run status: '{v}'")
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSimulationSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_simulated",
table: "patients",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "simulation_runs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
scenario_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
scenario_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
speed = table.Column<double>(type: "double precision", nullable: false),
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
encounter_id = table.Column<Guid>(type: "uuid", nullable: true),
started_by_user_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
observations_sent = table.Column<int>(type: "integer", nullable: false),
medications_sent = table.Column<int>(type: "integer", nullable: false),
orders_placed = table.Column<int>(type: "integer", nullable: false),
last_offset_minutes = table.Column<double>(type: "double precision", nullable: false),
total_offset_minutes = table.Column<double>(type: "double precision", nullable: false),
failure_reason = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_simulation_runs", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_Patients_IsSimulated",
table: "patients",
column: "is_simulated",
filter: "is_simulated = true");
migrationBuilder.CreateIndex(
name: "IX_simulation_runs_status_started_at",
table: "simulation_runs",
columns: new[] { "status", "started_at" },
descending: new[] { false, true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "simulation_runs");
migrationBuilder.DropIndex(
name: "IX_Patients_IsSimulated",
table: "patients");
migrationBuilder.DropColumn(
name: "is_simulated",
table: "patients");
}
}
}
@@ -1166,6 +1166,12 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnType("character varying(10)") .HasColumnType("character varying(10)")
.HasColumnName("gender"); .HasColumnName("gender");
b.Property<bool>("IsSimulated")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_simulated");
b.Property<string>("LastName") b.Property<string>("LastName")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -1193,6 +1199,10 @@ namespace VigilCareClinicalAPI.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("IsSimulated")
.HasDatabaseName("IX_Patients_IsSimulated")
.HasFilter("is_simulated = true");
b.HasIndex("Mrn") b.HasIndex("Mrn")
.IsUnique(); .IsUnique();
@@ -1524,6 +1534,92 @@ namespace VigilCareClinicalAPI.Migrations
}); });
}); });
modelBuilder.Entity("SimulationRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("FailureReason")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("failure_reason");
b.Property<double>("LastOffsetMinutes")
.HasColumnType("double precision")
.HasColumnName("last_offset_minutes");
b.Property<int>("MedicationsSent")
.HasColumnType("integer")
.HasColumnName("medications_sent");
b.Property<int>("ObservationsSent")
.HasColumnType("integer")
.HasColumnName("observations_sent");
b.Property<int>("OrdersPlaced")
.HasColumnType("integer")
.HasColumnName("orders_placed");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("ScenarioId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("scenario_id");
b.Property<string>("ScenarioName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("scenario_name");
b.Property<double>("Speed")
.HasColumnType("double precision")
.HasColumnName("speed");
b.Property<DateTimeOffset>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("StartedByUserId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("started_by_user_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<double>("TotalOffsetMinutes")
.HasColumnType("double precision")
.HasColumnName("total_offset_minutes");
b.HasKey("Id");
b.HasIndex("Status", "StartedAt")
.IsDescending(false, true)
.HasDatabaseName("IX_simulation_runs_status_started_at");
b.ToTable("simulation_runs", (string)null);
});
modelBuilder.Entity("SofaScore", b => modelBuilder.Entity("SofaScore", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -0,0 +1,35 @@
public record SimulationConfigResponse(
bool Enabled,
double? MaxSpeed = null,
int? MaxConcurrentRuns = null);
public record ScenarioSummaryResponse(
string Id,
string Name,
string? Description,
int? DurationMinutes,
IReadOnlyList<string>? Tags,
string Department,
int EventCount,
int ExpectedOutcomeCount);
public record StartSimulationRunRequest(string ScenarioId, double Speed = 60);
public record SimulationRunResponse(
Guid RunId,
string ScenarioId,
string ScenarioName,
string Status,
double Speed,
Guid? PatientId,
Guid? EncounterId,
string PatientDisplayName,
DateTimeOffset StartedAt,
double ElapsedRealSeconds,
double LastOffsetMinutes,
double TotalOffsetMinutes,
double ProgressPercent,
int ObservationsSent,
int MedicationsSent,
int OrdersPlaced,
string? FailureReason);
+34 -1
View File
@@ -167,6 +167,23 @@ try
builder.Services.Configure<AlertQualityOptions>( builder.Services.Configure<AlertQualityOptions>(
builder.Configuration.GetSection(AlertQualityOptions.Section)); builder.Configuration.GetSection(AlertQualityOptions.Section));
builder.Services.Configure<SimulationOptions>(
builder.Configuration.GetSection(SimulationOptions.Section));
var simulationOptions = builder.Configuration
.GetSection(SimulationOptions.Section).Get<SimulationOptions>() ?? new();
if (simulationOptions.Enabled)
{
builder.Services.AddHttpClient("simulation-loopback", c =>
c.BaseAddress = new Uri(simulationOptions.LoopbackBaseUrl));
builder.Services.AddSingleton<ISimulationClientFactory, SimulationClientFactory>();
builder.Services.AddSingleton<IScenarioCatalog, ScenarioCatalog>();
builder.Services.AddSingleton<SimulationRunner>();
builder.Services.AddSingleton<ISimulationRunner>(sp => sp.GetRequiredService<SimulationRunner>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<SimulationRunner>());
}
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("Dashboard", policy => options.AddPolicy("Dashboard", policy =>
@@ -345,6 +362,13 @@ try
|| args.Contains("create-admin") || args.Contains("create-admin")
|| args.Contains("register-gateway"); || args.Contains("register-gateway");
if (simulationOptions.Enabled && !isCliCommand)
{
Log.Warning(
"Simulation mode ENABLED — scenario replay endpoints are exposed. " +
"Do not run this configuration against real patient data.");
}
// Demo data — including the seeded demo users with well-known passwords — // Demo data — including the seeded demo users with well-known passwords —
// must never be created in production. Seeding:EnableDemoData defaults to // must never be created in production. Seeding:EnableDemoData defaults to
// true so local development and the existing verification scripts are // true so local development and the existing verification scripts are
@@ -360,7 +384,16 @@ try
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>(); var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DataSeeder.SeedAsync(db, redis); await DataSeeder.SeedAsync(db, redis);
await GatewayRegistrySeeder.SeedAsync(db); await GatewayRegistrySeeder.SeedAsync(db);
await UserSeeder.SeedAsync(db); await UserSeeder.SeedAsync(
db,
simulationEnabled: simulationOptions.Enabled,
simulationRunnerPassword: simulationOptions.RunnerPassword);
}
else if (simulationOptions.Enabled && !isCliCommand && !app.Environment.IsEnvironment("Testing"))
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await UserSeeder.EnsureSimulationRunnerAsync(db, simulationOptions.RunnerPassword);
} }
if (!app.Environment.IsEnvironment("Testing")) if (!app.Environment.IsEnvironment("Testing"))
@@ -0,0 +1,6 @@
using VigilCare.Simulation;
public interface ISimulationClientFactory
{
Task<VigilCareApiClient> CreateAsync(CancellationToken ct = default);
}
@@ -0,0 +1,39 @@
using VigilCare.Simulation;
public sealed class RunStateReplayObserver : IReplayObserver
{
private readonly SimulationRunState _state;
public RunStateReplayObserver(SimulationRunState state) => _state = state;
public void Header(string name, string? description) { }
public void Info(string message) { }
public void Event(string simTime, string description) =>
_state.NoteEvent(description);
public void Waiting(double deltaMinutes, int delayMs) { }
public void Warn(string message) { }
public void Error(string message) { }
public void DryRun(string message) { }
public void Completed(ReplayResult result) => SyncFromResult(result);
public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) =>
_state.UpdateOffset(offsetMinutes);
public void SyncFromResult(ReplayResult result)
{
_state.ApplyResultCounters(
result.ObservationsSent,
result.MedicationsSent,
result.OrdersPlaced);
_state.SetIds(
result.PatientId == Guid.Empty ? null : result.PatientId,
result.EncounterId == Guid.Empty ? null : result.EncounterId);
}
}
@@ -0,0 +1,79 @@
using VigilCare.Simulation;
public interface IScenarioCatalog
{
IReadOnlyList<ScenarioFile> ListScenarios();
ScenarioFile? GetById(string scenarioId);
}
public sealed class ScenarioCatalog : IScenarioCatalog
{
private readonly string _directory;
private readonly object _gate = new();
private IReadOnlyList<(ScenarioFile Scenario, string Path, DateTime LastWriteUtc)> _entries =
Array.Empty<(ScenarioFile, string, DateTime)>();
public ScenarioCatalog(Microsoft.Extensions.Options.IOptions<SimulationOptions> options)
{
_directory = options.Value.ScenarioDirectory;
}
public IReadOnlyList<ScenarioFile> ListScenarios()
{
RefreshIfNeeded();
return _entries.Select(e => e.Scenario).ToList();
}
public ScenarioFile? GetById(string scenarioId)
{
RefreshIfNeeded();
return _entries
.Select(e => e.Scenario)
.FirstOrDefault(s => string.Equals(
s.Scenario.Id, scenarioId, StringComparison.OrdinalIgnoreCase));
}
private void RefreshIfNeeded()
{
lock (_gate)
{
if (!Directory.Exists(_directory))
{
_entries = Array.Empty<(ScenarioFile, string, DateTime)>();
return;
}
var disk = Directory.EnumerateFiles(_directory, "*.json")
.Where(p => !string.Equals(
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
.Select(p => (Path: p, LastWriteUtc: File.GetLastWriteTimeUtc(p)))
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
.ToList();
var unchanged = _entries.Count == disk.Count
&& _entries.Zip(disk, (cached, onDisk) =>
cached.Path == onDisk.Path && cached.LastWriteUtc == onDisk.LastWriteUtc)
.All(eq => eq);
if (unchanged)
return;
var loaded = new List<(ScenarioFile Scenario, string Path, DateTime LastWriteUtc)>();
foreach (var file in disk)
{
try
{
loaded.Add((ScenarioLoader.Load(file.Path), file.Path, file.LastWriteUtc));
}
catch
{
// Skip corrupt files — catalogue must stay resilient.
}
}
_entries = loaded
.OrderBy(e => e.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Options;
using VigilCare.Simulation;
public sealed class SimulationClientFactory : ISimulationClientFactory
{
private readonly IHttpClientFactory _http;
private readonly SimulationOptions _options;
public SimulationClientFactory(IHttpClientFactory http, IOptions<SimulationOptions> options)
{
_http = http;
_options = options.Value;
}
public async Task<VigilCareApiClient> CreateAsync(CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_options.RunnerPassword))
throw new InvalidOperationException(
"Simulation:RunnerPassword is required when Simulation:Enabled is true.");
var http = _http.CreateClient("simulation-loopback");
var client = new VigilCareApiClient(http);
await client.LoginAsync(_options.RunnerUsername, _options.RunnerPassword);
return client;
}
}
@@ -0,0 +1,126 @@
public sealed class SimulationRunState
{
private readonly object _gate = new();
private DateTimeOffset? _completedAt;
public Guid RunId { get; init; }
public string ScenarioId { get; init; } = null!;
public string ScenarioName { get; init; } = null!;
public double Speed { get; init; }
public string StartedByUserId { get; init; } = null!;
public DateTimeOffset StartedAt { get; init; }
public double TotalOffsetMinutes { get; init; }
public string PatientDisplayName { get; init; } = null!;
public SimulationRunStatus Status { get; private set; } = SimulationRunStatus.Pending;
public Guid? PatientId { get; private set; }
public Guid? EncounterId { get; private set; }
public int ObservationsSent { get; private set; }
public int MedicationsSent { get; private set; }
public int OrdersPlaced { get; private set; }
public double LastOffsetMinutes { get; private set; }
public double ProgressPercent { get; private set; }
public string? FailureReason { get; private set; }
public double ElapsedRealSeconds
{
get
{
lock (_gate)
{
var end = _completedAt ?? DateTimeOffset.UtcNow;
return (end - StartedAt).TotalSeconds;
}
}
}
public void MarkRunning()
{
lock (_gate) Status = SimulationRunStatus.Running;
}
public void SetIds(Guid? patientId, Guid? encounterId)
{
lock (_gate)
{
if (patientId.HasValue) PatientId = patientId;
if (encounterId.HasValue) EncounterId = encounterId;
}
}
public void UpdateOffset(double offsetMinutes)
{
lock (_gate)
{
LastOffsetMinutes = offsetMinutes;
ProgressPercent = TotalOffsetMinutes <= 0
? 100
: Math.Clamp(offsetMinutes / TotalOffsetMinutes * 100.0, 0, 100);
}
}
public void ApplyResultCounters(int observationsSent, int medicationsSent, int ordersPlaced)
{
lock (_gate)
{
ObservationsSent = observationsSent;
MedicationsSent = medicationsSent;
OrdersPlaced = ordersPlaced;
}
}
public void NoteEvent(string description)
{
lock (_gate)
{
if (description.StartsWith("MEDICATION", StringComparison.Ordinal))
MedicationsSent++;
else if (description.StartsWith("ORDER ", StringComparison.Ordinal))
OrdersPlaced++;
else if (!description.StartsWith("ORDER_RESULT", StringComparison.Ordinal)
&& !description.StartsWith("ACK ", StringComparison.Ordinal))
ObservationsSent++;
}
}
public void MarkTerminal(SimulationRunStatus status, string? failureReason = null)
{
lock (_gate)
{
Status = status;
FailureReason = failureReason;
_completedAt = DateTimeOffset.UtcNow;
if (status == SimulationRunStatus.Completed)
ProgressPercent = 100;
}
}
public SimulationRunState Snapshot()
{
lock (_gate)
{
var copy = new SimulationRunState
{
RunId = RunId,
ScenarioId = ScenarioId,
ScenarioName = ScenarioName,
Speed = Speed,
StartedByUserId = StartedByUserId,
StartedAt = StartedAt,
TotalOffsetMinutes = TotalOffsetMinutes,
PatientDisplayName = PatientDisplayName,
};
copy.Status = Status;
copy.PatientId = PatientId;
copy.EncounterId = EncounterId;
copy.ObservationsSent = ObservationsSent;
copy.MedicationsSent = MedicationsSent;
copy.OrdersPlaced = OrdersPlaced;
copy.LastOffsetMinutes = LastOffsetMinutes;
copy.ProgressPercent = ProgressPercent;
copy.FailureReason = FailureReason;
copy._completedAt = _completedAt;
return copy;
}
}
}
@@ -0,0 +1,269 @@
using System.Collections.Concurrent;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using VigilCare.Simulation;
public interface ISimulationRunner
{
IReadOnlyList<SimulationRunState> ListRuns();
SimulationRunState? GetRun(Guid runId);
Task<SimulationRunState> StartAsync(
string scenarioId, double speed, string startedByUserId, CancellationToken ct);
bool Cancel(Guid runId);
}
public sealed class SimulationRunner : ISimulationRunner, IHostedService
{
private readonly ConcurrentDictionary<Guid, RunContext> _runs = new();
private readonly ISimulationClientFactory _clientFactory;
private readonly IScenarioCatalog _catalog;
private readonly IServiceScopeFactory _scopeFactory;
private readonly SimulationOptions _options;
private readonly ILogger<SimulationRunner> _logger;
public SimulationRunner(
ISimulationClientFactory clientFactory,
IScenarioCatalog catalog,
IServiceScopeFactory scopeFactory,
IOptions<SimulationOptions> options,
ILogger<SimulationRunner> logger)
{
_clientFactory = clientFactory;
_catalog = catalog;
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
}
public IReadOnlyList<SimulationRunState> ListRuns() =>
_runs.Values
.Select(c => c.State.Snapshot())
.OrderByDescending(s => s.StartedAt)
.ToList();
public SimulationRunState? GetRun(Guid runId) =>
_runs.TryGetValue(runId, out var ctx) ? ctx.State.Snapshot() : null;
public async Task<SimulationRunState> StartAsync(
string scenarioId, double speed, string startedByUserId, CancellationToken ct)
{
if (!_options.Enabled)
throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED");
if (string.IsNullOrWhiteSpace(scenarioId))
throw new ValidationException("scenarioId is required.", "SIMULATION_SCENARIO_REQUIRED");
if (speed <= 0 || speed > _options.MaxSpeed)
throw new ValidationException(
$"speed must be > 0 and <= {_options.MaxSpeed}.", "SIMULATION_SPEED_INVALID");
var scenario = _catalog.GetById(scenarioId)
?? throw new ValidationException(
$"Unknown scenario '{scenarioId}'.", "SIMULATION_SCENARIO_UNKNOWN");
var activeCount = _runs.Values.Count(c =>
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running);
if (activeCount >= _options.MaxConcurrentRuns)
throw new ConflictException(
$"Maximum concurrent simulation runs ({_options.MaxConcurrentRuns}) reached.",
"SIMULATION_CONCURRENCY_LIMIT");
var totalOffset = scenario.Events.Count == 0
? 0
: scenario.Events.Max(e => e.OffsetMinutes);
var runId = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
var state = new SimulationRunState
{
RunId = runId,
ScenarioId = scenario.Scenario.Id,
ScenarioName = scenario.Scenario.Name,
Speed = speed,
StartedByUserId = startedByUserId,
StartedAt = startedAt,
TotalOffsetMinutes = totalOffset,
PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}",
};
await PersistNewRunAsync(state, ct);
var cts = new CancellationTokenSource();
var ctx = new RunContext(state, cts, scenario);
if (!_runs.TryAdd(runId, ctx))
throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED");
_ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None);
return state.Snapshot();
}
public bool Cancel(Guid runId)
{
if (!_runs.TryGetValue(runId, out var ctx))
return false;
if (ctx.State.Status is SimulationRunStatus.Completed
or SimulationRunStatus.Cancelled
or SimulationRunStatus.Failed)
return true;
ctx.Cts.Cancel();
return true;
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public async Task StopAsync(CancellationToken cancellationToken)
{
foreach (var ctx in _runs.Values)
{
if (ctx.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running)
ctx.Cts.Cancel();
}
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline
&& _runs.Values.Any(c =>
c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running))
{
await Task.Delay(50, cancellationToken);
}
}
private async Task ExecuteAsync(RunContext ctx, CancellationToken _)
{
var runId = ctx.State.RunId;
ctx.State.MarkRunning();
await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Running);
try
{
var client = await _clientFactory.CreateAsync(ctx.Cts.Token);
var observer = new RunStateReplayObserver(ctx.State);
var engine = new ReplayEngine(
client,
poller: null,
observer,
onPatientRegistered: (patientId, ct) => MarkPatientSimulatedAsync(patientId, ct));
var result = await engine.RunAsync(
ctx.Scenario,
new ReplayOptions(Speed: ctx.State.Speed, Poll: false),
ctx.Cts.Token);
observer.SyncFromResult(result);
ctx.State.MarkTerminal(SimulationRunStatus.Completed);
await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Completed);
}
catch (OperationCanceledException)
{
ctx.State.MarkTerminal(SimulationRunStatus.Cancelled);
await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Cancelled);
_logger.LogInformation("Simulation run {RunId} cancelled", runId);
}
catch (Exception ex)
{
ctx.State.MarkTerminal(SimulationRunStatus.Failed, ex.Message);
await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Failed, ex.Message);
_logger.LogError(ex, "Simulation run {RunId} failed", runId);
}
finally
{
ctx.Cts.Dispose();
TrimHistory();
}
}
private async Task MarkPatientSimulatedAsync(Guid patientId, CancellationToken ct)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var patient = await db.Patients.FirstOrDefaultAsync(p => p.Id == patientId, ct);
if (patient is null)
return;
patient.IsSimulated = true;
await db.SaveChangesAsync(ct);
}
private async Task PersistNewRunAsync(SimulationRunState state, CancellationToken ct)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.SimulationRuns.Add(new SimulationRun
{
Id = state.RunId,
ScenarioId = state.ScenarioId,
ScenarioName = state.ScenarioName,
Speed = state.Speed,
Status = SimulationRunStatus.Pending,
StartedByUserId = state.StartedByUserId,
StartedAt = state.StartedAt,
TotalOffsetMinutes = state.TotalOffsetMinutes,
});
await db.SaveChangesAsync(ct);
}
private async Task UpdateRunRowAsync(
SimulationRunState state,
SimulationRunStatus status,
string? failureReason = null)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var row = await db.SimulationRuns.FirstOrDefaultAsync(r => r.Id == state.RunId);
if (row is null)
return;
row.Status = status;
row.PatientId = state.PatientId;
row.EncounterId = state.EncounterId;
row.ObservationsSent = state.ObservationsSent;
row.MedicationsSent = state.MedicationsSent;
row.OrdersPlaced = state.OrdersPlaced;
row.LastOffsetMinutes = state.LastOffsetMinutes;
row.TotalOffsetMinutes = state.TotalOffsetMinutes;
row.FailureReason = failureReason ?? state.FailureReason;
if (status is SimulationRunStatus.Completed
or SimulationRunStatus.Cancelled
or SimulationRunStatus.Failed)
{
row.CompletedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist simulation run {RunId} status {Status}",
state.RunId, status);
}
}
private void TrimHistory()
{
var terminal = _runs.Values
.Where(c => c.State.Status is SimulationRunStatus.Completed
or SimulationRunStatus.Cancelled
or SimulationRunStatus.Failed)
.OrderByDescending(c => c.State.StartedAt)
.Skip(_options.RunHistoryLimit)
.ToList();
foreach (var old in terminal)
_runs.TryRemove(old.State.RunId, out _);
}
private sealed class RunContext(
SimulationRunState state,
CancellationTokenSource cts,
ScenarioFile scenario)
{
public SimulationRunState State { get; } = state;
public CancellationTokenSource Cts { get; } = cts;
public ScenarioFile Scenario { get; } = scenario;
}
}
@@ -46,6 +46,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" /> <ProjectReference Include="..\VigilCare.ClinicalContracts\VigilCare.ClinicalContracts.csproj" />
<ProjectReference Include="..\VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -39,5 +39,10 @@
"LogListAccess": true "LogListAccess": true
}, },
"Swagger": { "Enabled": false }, "Swagger": { "Enabled": false },
"Seeding": { "EnableDemoData": false } "Seeding": { "EnableDemoData": false },
"Simulation": {
// Patient-safety gate: never expose scenario replay against real care data.
// Flip only for dedicated training/staging environments with synthetic patients.
"Enabled": false
}
} }
@@ -22,5 +22,15 @@
"DataLake": { "DataLake": {
"FlushCount": 3, "FlushCount": 3,
"FlushIntervalSeconds": 10 "FlushIntervalSeconds": 10
},
"Simulation": {
"Enabled": true,
"ScenarioDirectory": "../VigilCare.Simulator/Scenarios/List",
"LoopbackBaseUrl": "http://localhost:5270",
"RunnerUsername": "simulation.runner",
"RunnerPassword": "DemoSimulation1!",
"MaxConcurrentRuns": 8,
"MaxSpeed": 600,
"RunHistoryLimit": 50
} }
} }
+9
View File
@@ -228,5 +228,14 @@
}, },
"Seeding": { "Seeding": {
"EnableDemoData": true "EnableDemoData": true
},
"Simulation": {
"Enabled": false,
"ScenarioDirectory": "Scenarios",
"LoopbackBaseUrl": "http://localhost:5270",
"RunnerUsername": "simulation.runner",
"MaxConcurrentRuns": 8,
"MaxSpeed": 600,
"RunHistoryLimit": 50
} }
} }