Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion DotNet/Automation.Link/Models/AutomationRunStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,26 @@ public enum AutomationRunStatus
Running,
Cancelled,
Succeeded,
Failed
Failed,
LiveWindowOpen,
ReportFinalization
}

public static class AutomationRunStatusExtensions
{
public static bool IsTerminal(this AutomationRunStatus status)
=> status is AutomationRunStatus.Succeeded
or AutomationRunStatus.Failed
or AutomationRunStatus.Cancelled;

public static bool IsCancellable(this AutomationRunStatus status)
=> status is AutomationRunStatus.Queued
or AutomationRunStatus.Running
or AutomationRunStatus.LiveWindowOpen
or AutomationRunStatus.ReportFinalization;

public static bool IsInProgress(this AutomationRunStatus status)
=> status is AutomationRunStatus.Running
or AutomationRunStatus.LiveWindowOpen
or AutomationRunStatus.ReportFinalization;
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@
if (summary == null)
return NotFound(new { error = $"Run {runId} not found." });

var isTerminal = summary.Status is AutomationRunStatus.Succeeded
or AutomationRunStatus.Failed
or AutomationRunStatus.Cancelled;
var isTerminal = summary.Status.IsTerminal();

return Ok(new RunStatusResponse
{
Expand All @@ -90,6 +88,92 @@
});
}

public sealed class LivePatientEventRequest
{
public string? PatientId { get; set; }
public string? Notes { get; set; }
public string? Source { get; set; }
}

[HttpPost("{runId:guid}/events/admit")]
public async Task<IActionResult> Admit(Guid runId, [FromBody] LivePatientEventRequest? request, CancellationToken cancellationToken)
{
return await ExecuteLiveInjectAsync(
runId,
() => runManager.InjectAdmitAsync(
runId,
request?.PatientId,
string.IsNullOrWhiteSpace(request?.Source) ? "API" : request!.Source!.Trim(),
request?.Notes,
cancellationToken),
cancellationToken);
}

[HttpPost("{runId:guid}/events/discharge")]
public async Task<IActionResult> Discharge(Guid runId, [FromBody] LivePatientEventRequest? request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request?.PatientId))
return BadRequest(new { error = "patientId is required." });

return await ExecuteLiveInjectAsync(
runId,
() => runManager.InjectDischargeAsync(
runId,
request.PatientId,
string.IsNullOrWhiteSpace(request.Source) ? "API" : request.Source.Trim(),
request.Notes,
cancellationToken),
cancellationToken);
}

[HttpGet("{runId:guid}/events")]
public async Task<IActionResult> GetEvents(Guid runId, CancellationToken cancellationToken)
{
if (await runManager.GetRunAsync(runId, cancellationToken) == null)
return NotFound(new { error = $"Run {runId} not found." });

var events = await runManager.GetLiveEventsAsync(runId, cancellationToken);
return Ok(events);
}

[HttpGet("{runId:guid}/patient-state")]
public async Task<IActionResult> GetPatientState(Guid runId, CancellationToken cancellationToken)
{
if (await runManager.GetRunAsync(runId, cancellationToken) == null)
return NotFound(new { error = $"Run {runId} not found." });

var state = await runManager.GetLivePatientStateAsync(runId, cancellationToken);
return Ok(new
{
admitted = state.Admitted,
dischargedDuringWindow = state.DischargedDuringWindow,
expectedPopulation = state.ExpectedPopulation,
acceptingInjections = state.AcceptingInjections,
windowStartUtc = state.WindowStartUtc,
windowEndUtc = state.WindowEndUtc,
reportGenerationTimeUtc = state.ReportGenerationTimeUtc
});
}

private async Task<IActionResult> ExecuteLiveInjectAsync(
Guid runId,
Func<Task<PatientStateEvent>> action,
CancellationToken cancellationToken)
{
if (await runManager.GetRunAsync(runId, cancellationToken) == null)
return NotFound(new { error = $"Run {runId} not found." });

try
{
var evt = await action();
return Ok(evt);
}
catch (LiveInjectionException ex)
{
return StatusCode(ex.StatusCode, new { error = ex.Message });
}
}

public sealed class StartScenarioApiRequest
{
/// <summary>Saved scenario id to launch.</summary>
Expand Down
71 changes: 66 additions & 5 deletions DotNet/Automation.UI/Controllers/RunsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,7 @@ public async Task<IActionResult> Export(Guid id, CancellationToken cancellationT
// Export is only meaningful once the run has stopped collecting data;
// exporting an in-flight run would race the polling loop and yield
// half-populated domain snapshots.
if (run.Status is not AutomationRunStatus.Succeeded
and not AutomationRunStatus.Failed
and not AutomationRunStatus.Cancelled)
if (!run.Status.IsTerminal())
{
return Conflict(new { error = "Run must be completed (Succeeded, Failed, or Cancelled) before it can be exported." });
}
Expand Down Expand Up @@ -273,6 +271,69 @@ public class RunActionRequest
public Guid Id { get; set; }
}

public class LiveInjectRequest
{
public Guid Id { get; set; }
public string? PatientId { get; set; }
public string? Notes { get; set; }
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AdmitJson([FromBody] LiveInjectRequest request, CancellationToken cancellationToken = default)
{
if (request?.Id == null || request.Id == Guid.Empty)
return BadRequest(new { error = "Missing run ID" });

try
{
var evt = await runManager.InjectAdmitAsync(request.Id, request.PatientId, "UI", request.Notes, cancellationToken);
return Ok(evt);
}
catch (LiveInjectionException ex)
{
return StatusCode(ex.StatusCode, new { error = ex.Message });
}
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DischargeJson([FromBody] LiveInjectRequest request, CancellationToken cancellationToken = default)
{
if (request?.Id == null || request.Id == Guid.Empty)
return BadRequest(new { error = "Missing run ID" });
if (string.IsNullOrWhiteSpace(request.PatientId))
return BadRequest(new { error = "patientId is required." });

try
{
var evt = await runManager.InjectDischargeAsync(request.Id, request.PatientId, "UI", request.Notes, cancellationToken);
return Ok(evt);
}
catch (LiveInjectionException ex)
{
return StatusCode(ex.StatusCode, new { error = ex.Message });
}
}

[HttpGet]
public async Task<IActionResult> LiveEvents(Guid id, CancellationToken cancellationToken)
{
if (await runManager.GetRunAsync(id, cancellationToken) == null)
return NotFound();

return Json(await runManager.GetLiveEventsAsync(id, cancellationToken));
}

[HttpGet]
public async Task<IActionResult> LivePatientState(Guid id, CancellationToken cancellationToken)
{
if (await runManager.GetRunAsync(id, cancellationToken) == null)
return NotFound();

return Json(await runManager.GetLivePatientStateAsync(id, cancellationToken));
}

[HttpGet]
public async Task<IActionResult> PipelineSnapshot(Guid id, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -516,9 +577,9 @@ private async Task<List<AutomationRunSummary>> GetActiveRunsAsync(
: (await runManager.GetRunsPageAsync(1, pageSize, "createdAt", true, cancellationToken)).Runs;

return activeRunSummaries
.Where(r => r is { Status: AutomationRunStatus.Queued or AutomationRunStatus.Running })
.Where(r => r is not null && (r.Status.IsCancellable() || r.Status.IsInProgress()))
.Select(r => r!)
.Concat(activeRunsSource.Where(r => r.Status is AutomationRunStatus.Queued or AutomationRunStatus.Running))
.Concat(activeRunsSource.Where(r => r.Status.IsCancellable() || r.Status.IsInProgress()))
.GroupBy(r => r.RunId)
.Select(g => g.First())
.OrderByDescending(r => r.CreatedAt)
Expand Down
27 changes: 27 additions & 0 deletions DotNet/Automation.UI/Models/LivePatientStateSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Automation.UI.Models;

public sealed class LivePatientStateSnapshot
{
public IReadOnlyList<string> Admitted { get; init; } = [];
public IReadOnlyList<string> DischargedDuringWindow { get; init; } = [];
public IReadOnlyList<string> ExpectedPopulation { get; init; } = [];
public bool AcceptingInjections { get; init; }
public DateTimeOffset? WindowStartUtc { get; init; }
public DateTimeOffset? WindowEndUtc { get; init; }
public DateTimeOffset? ReportGenerationTimeUtc { get; init; }
}

public sealed class LiveSimulationDiagnostics
{
public DateTimeOffset? WindowStartUtc { get; init; }
public DateTimeOffset? WindowEndUtc { get; init; }
public DateTimeOffset? ReportGenerationTimeUtc { get; init; }
public List<PatientStateEvent> EventLog { get; init; } = [];
public List<string> CurrentlyAdmitted { get; init; } = [];
public List<string> DischargedDuringWindow { get; init; } = [];
public List<string> ExpectedPopulation { get; init; } = [];
public List<string> ActualPopulation { get; init; } = [];
public bool? InclusionPassed { get; init; }
public List<string> MissingFromReport { get; init; } = [];
public List<string> UnexpectedInReport { get; init; } = [];
}
7 changes: 7 additions & 0 deletions DotNet/Automation.UI/Models/PatientEventType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Automation.UI.Models;

public enum PatientEventType
{
Admit,
Discharge
}
12 changes: 12 additions & 0 deletions DotNet/Automation.UI/Models/PatientStateEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Automation.UI.Models;

public sealed class PatientStateEvent
{
public Guid EventId { get; init; }
public Guid RunId { get; init; }
public string PatientId { get; init; } = "";
public PatientEventType EventType { get; init; }
public DateTimeOffset TimestampUtc { get; init; }
public string? Source { get; init; }
public string? Notes { get; init; }
}
17 changes: 17 additions & 0 deletions DotNet/Automation.UI/Models/StartScenarioRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ public class StartScenarioRequest : IValidatableObject
/// </summary>
public Guid? OrganizationResourceMapTemplateId { get; set; }

/// <summary>
/// When true, a ScheduledReport run holds a short live window and accepts
/// Admit/Discharge injections before finalizing the report.
/// </summary>
public bool IsLiveSimulation { get; set; }

/// <summary>Live reporting window length in minutes (typically 5, 10, or 15).</summary>
[Range(1, 60)]
public int? ReportingWindowMinutes { get; set; }

/// <summary>Optional number of generated patients to admit when the live window opens.</summary>
[Range(0, 10000)]
public int? SeedPatientCount { get; set; }

/// <summary>
/// Cross-field validation. Rejects inverted report windows
/// (<see cref="ReportPeriodStart"/> &gt; <see cref="ReportPeriodEnd"/>) at the request
Expand Down Expand Up @@ -133,6 +147,9 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
QueryPlanTemplateId = scenario.QueryPlanTemplateId,
NormalizationSuiteId = scenario.NormalizationSuiteId,
OrganizationResourceMapTemplateId = scenario.OrganizationResourceMapTemplateId,
IsLiveSimulation = scenario.IsLiveSimulation,
ReportingWindowMinutes = scenario.ReportingWindowMinutes,
SeedPatientCount = scenario.SeedPatientCount,
};

private static string SerializeScenarioConfiguration(TestScenarioDefinition scenario)
Expand Down
18 changes: 18 additions & 0 deletions DotNet/Automation.UI/Models/TestScenarioDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,24 @@ public class TestScenarioDefinition
/// </summary>
public DateTimeOffset? ReportPeriodEnd { get; set; }

// ----- Live Scheduled Simulation -----

/// <summary>
/// When true, a ScheduledReport run holds a short live window and accepts
/// Admit/Discharge injections before finalizing the report.
/// </summary>
public bool IsLiveSimulation { get; set; }

/// <summary>
/// Live reporting window length in minutes (typically 5, 10, or 15).
/// </summary>
public int ReportingWindowMinutes { get; set; } = 10;

/// <summary>
/// Optional number of generated patients to admit when the live window opens.
/// </summary>
public int SeedPatientCount { get; set; }

// ----- Imported Patients (supplemental, separate from cohorts/random pool) -----

/// <summary>
Expand Down
1 change: 1 addition & 0 deletions DotNet/Automation.UI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ static IReadOnlyCollection<string> BuildValidAudiences(string configuredAudience
builder.Services.AddSignalR();
builder.Services.AddSingleton<RunSnapshotOrchestrator>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RunSnapshotOrchestrator>());
builder.Services.AddSingleton<ILivePatientEventInjector, LivePatientEventInjector>();
builder.Services.AddSingleton<IAutomationRunManager, AutomationRunManager>();
builder.Services.AddSingleton<PatientReplacementManager>();
builder.Services.AddSingleton<IRunExportService, RunExportService>();
Expand Down
Loading
Loading