Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Replay.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62536;http://localhost:62538"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Audit.ReplayToken\StellaOps.Audit.ReplayToken.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AuditPack\StellaOps.AuditPack.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
|
||||
333
src/Replay/StellaOps.Replay.WebService/VerdictReplayEndpoints.cs
Normal file
333
src/Replay/StellaOps.Replay.WebService/VerdictReplayEndpoints.cs
Normal file
@@ -0,0 +1,333 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictReplayEndpoints.cs
|
||||
// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay
|
||||
// Task: T5 — Verdict replay API endpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
|
||||
namespace StellaOps.Replay.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering verdict replay endpoints.
|
||||
/// </summary>
|
||||
public static class VerdictReplayEndpoints
|
||||
{
|
||||
private const string ReplayVerdictPolicy = "replay.verdict";
|
||||
|
||||
/// <summary>
|
||||
/// Maps verdict replay endpoints to the application.
|
||||
/// </summary>
|
||||
public static void MapVerdictReplayEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/v1/replay/verdict")
|
||||
.WithTags("Verdict Replay");
|
||||
|
||||
// POST /v1/replay/verdict - Execute verdict replay
|
||||
group.MapPost("/", ExecuteReplayAsync)
|
||||
.WithName("ExecuteVerdictReplay")
|
||||
.Produces<VerdictReplayResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /v1/replay/verdict/verify - Verify replay eligibility
|
||||
group.MapPost("/verify", VerifyEligibilityAsync)
|
||||
.WithName("VerifyReplayEligibility")
|
||||
.Produces<ReplayEligibilityResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// GET /v1/replay/verdict/{manifestId}/status - Get replay status
|
||||
group.MapGet("/{manifestId}/status", GetReplayStatusAsync)
|
||||
.WithName("GetReplayStatus")
|
||||
.Produces<ReplayStatusResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /v1/replay/verdict/compare - Compare two replay executions
|
||||
group.MapPost("/compare", CompareReplayResultsAsync)
|
||||
.WithName("CompareReplayResults")
|
||||
.Produces<ReplayComparisonResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<VerdictReplayResponse>, NotFound, ProblemHttpResult>> ExecuteReplayAsync(
|
||||
HttpContext httpContext,
|
||||
VerdictReplayRequest request,
|
||||
IReplayExecutor replayExecutor,
|
||||
IAuditBundleReader bundleReader,
|
||||
IVerdictReplayPredicate replayPredicate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Read the audit bundle
|
||||
var readRequest = new AuditBundleReadRequest { BundlePath = request.BundlePath };
|
||||
var bundleResult = await bundleReader.ReadAsync(readRequest, ct);
|
||||
if (!bundleResult.Success || bundleResult.Manifest is null)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "bundle_read_failed",
|
||||
detail: bundleResult.Error ?? "Failed to read audit bundle");
|
||||
}
|
||||
|
||||
// Check eligibility
|
||||
var eligibility = replayPredicate.Evaluate(bundleResult.Manifest, request.CurrentInputState);
|
||||
if (!eligibility.IsEligible)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "replay_not_eligible",
|
||||
detail: string.Join("; ", eligibility.Reasons));
|
||||
}
|
||||
|
||||
// Create isolated context and execute replay
|
||||
var options = new IsolatedReplayContextOptions
|
||||
{
|
||||
CleanupOnDispose = true,
|
||||
EnforceOffline = true,
|
||||
EvaluationTime = request.EvaluationTime
|
||||
};
|
||||
|
||||
using var context = new IsolatedReplayContext(options);
|
||||
var initResult = await context.InitializeAsync(bundleResult, ct);
|
||||
if (!initResult.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "context_init_failed",
|
||||
detail: initResult.Error ?? "Failed to initialize replay context");
|
||||
}
|
||||
|
||||
var execOptions = new ReplayExecutionOptions
|
||||
{
|
||||
FailOnInputDrift = request.FailOnInputDrift,
|
||||
DetailedDriftDetection = request.DetailedDriftDetection,
|
||||
StrictMode = request.StrictMode
|
||||
};
|
||||
|
||||
var result = await replayExecutor.ExecuteAsync(context, bundleResult.Manifest, execOptions, ct);
|
||||
|
||||
// Generate divergence report if needed
|
||||
ReplayDivergenceReport? divergenceReport = null;
|
||||
if (result.Success && !result.VerdictMatches)
|
||||
{
|
||||
var originalResult = new ReplayExecutionResult
|
||||
{
|
||||
Success = true,
|
||||
OriginalVerdictDigest = bundleResult.Manifest.VerdictDigest,
|
||||
OriginalDecision = bundleResult.Manifest.Decision
|
||||
};
|
||||
divergenceReport = replayPredicate.CompareDivergence(originalResult, result);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new VerdictReplayResponse
|
||||
{
|
||||
Success = result.Success,
|
||||
Status = result.Status.ToString(),
|
||||
VerdictMatches = result.VerdictMatches,
|
||||
DecisionMatches = result.DecisionMatches,
|
||||
OriginalVerdictDigest = result.OriginalVerdictDigest,
|
||||
ReplayedVerdictDigest = result.ReplayedVerdictDigest,
|
||||
OriginalDecision = result.OriginalDecision,
|
||||
ReplayedDecision = result.ReplayedDecision,
|
||||
Drifts = result.Drifts.Select(d => new DriftItemDto
|
||||
{
|
||||
Type = d.Type.ToString(),
|
||||
Field = d.Field,
|
||||
Expected = d.Expected,
|
||||
Actual = d.Actual,
|
||||
Message = d.Message
|
||||
}).ToList(),
|
||||
DivergenceSummary = divergenceReport?.Summary,
|
||||
DurationMs = result.DurationMs,
|
||||
EvaluatedAt = result.EvaluatedAt,
|
||||
Error = result.Error
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ReplayEligibilityResponse>, ProblemHttpResult>> VerifyEligibilityAsync(
|
||||
HttpContext httpContext,
|
||||
VerifyEligibilityRequest request,
|
||||
IAuditBundleReader bundleReader,
|
||||
IVerdictReplayPredicate replayPredicate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var readRequest = new AuditBundleReadRequest { BundlePath = request.BundlePath };
|
||||
var bundleResult = await bundleReader.ReadAsync(readRequest, ct);
|
||||
if (!bundleResult.Success || bundleResult.Manifest is null)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "bundle_read_failed",
|
||||
detail: bundleResult.Error ?? "Failed to read audit bundle");
|
||||
}
|
||||
|
||||
var eligibility = replayPredicate.Evaluate(bundleResult.Manifest, request.CurrentInputState);
|
||||
|
||||
return TypedResults.Ok(new ReplayEligibilityResponse
|
||||
{
|
||||
IsEligible = eligibility.IsEligible,
|
||||
Reasons = eligibility.Reasons.ToList(),
|
||||
Warnings = eligibility.Warnings.ToList(),
|
||||
ConfidenceScore = eligibility.ConfidenceScore,
|
||||
ExpectedStatus = eligibility.ExpectedOutcome?.ExpectedStatus.ToString(),
|
||||
ExpectedDecision = eligibility.ExpectedOutcome?.ExpectedDecision,
|
||||
Rationale = eligibility.ExpectedOutcome?.Rationale
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Results<Ok<ReplayStatusResponse>, NotFound>> GetReplayStatusAsync(
|
||||
string manifestId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// In production, this would look up stored replay results
|
||||
// For now, return a placeholder
|
||||
return Task.FromResult<Results<Ok<ReplayStatusResponse>, NotFound>>(
|
||||
TypedResults.Ok(new ReplayStatusResponse
|
||||
{
|
||||
ManifestId = manifestId,
|
||||
LastReplayedAt = null,
|
||||
TotalReplays = 0,
|
||||
SuccessfulReplays = 0,
|
||||
FailedReplays = 0,
|
||||
Status = "not_replayed"
|
||||
}));
|
||||
}
|
||||
|
||||
private static Task<Results<Ok<ReplayComparisonResponse>, ProblemHttpResult>> CompareReplayResultsAsync(
|
||||
HttpContext httpContext,
|
||||
ReplayComparisonRequest request,
|
||||
IVerdictReplayPredicate replayPredicate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Create execution results from the request data
|
||||
var original = new ReplayExecutionResult
|
||||
{
|
||||
Success = true,
|
||||
OriginalVerdictDigest = request.OriginalVerdictDigest,
|
||||
OriginalDecision = request.OriginalDecision
|
||||
};
|
||||
|
||||
var replayed = new ReplayExecutionResult
|
||||
{
|
||||
Success = true,
|
||||
ReplayedVerdictDigest = request.ReplayedVerdictDigest,
|
||||
ReplayedDecision = request.ReplayedDecision,
|
||||
Drifts = request.Drifts?.Select(d => new DriftItem
|
||||
{
|
||||
Type = Enum.TryParse<DriftType>(d.Type, out var t) ? t : DriftType.Other,
|
||||
Field = d.Field,
|
||||
Expected = d.Expected,
|
||||
Actual = d.Actual,
|
||||
Message = d.Message
|
||||
}).ToList() ?? []
|
||||
};
|
||||
|
||||
var report = replayPredicate.CompareDivergence(original, replayed);
|
||||
|
||||
return Task.FromResult<Results<Ok<ReplayComparisonResponse>, ProblemHttpResult>>(
|
||||
TypedResults.Ok(new ReplayComparisonResponse
|
||||
{
|
||||
HasDivergence = report.HasDivergence,
|
||||
OverallSeverity = report.OverallSeverity.ToString(),
|
||||
Summary = report.Summary,
|
||||
Divergences = report.Divergences.Select(d => new DivergenceItemDto
|
||||
{
|
||||
Category = d.Category.ToString(),
|
||||
Field = d.Field,
|
||||
OriginalValue = d.OriginalValue,
|
||||
ReplayedValue = d.ReplayedValue,
|
||||
Severity = d.Severity.ToString(),
|
||||
Explanation = d.Explanation
|
||||
}).ToList()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
public record VerdictReplayRequest(
|
||||
string BundlePath,
|
||||
ReplayInputState? CurrentInputState = null,
|
||||
DateTimeOffset? EvaluationTime = null,
|
||||
bool FailOnInputDrift = false,
|
||||
bool DetailedDriftDetection = true,
|
||||
bool StrictMode = false);
|
||||
|
||||
public record VerdictReplayResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public bool VerdictMatches { get; init; }
|
||||
public bool DecisionMatches { get; init; }
|
||||
public string? OriginalVerdictDigest { get; init; }
|
||||
public string? ReplayedVerdictDigest { get; init; }
|
||||
public string? OriginalDecision { get; init; }
|
||||
public string? ReplayedDecision { get; init; }
|
||||
public required List<DriftItemDto> Drifts { get; init; }
|
||||
public string? DivergenceSummary { get; init; }
|
||||
public long DurationMs { get; init; }
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public record DriftItemDto
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public string? Field { get; init; }
|
||||
public string? Expected { get; init; }
|
||||
public string? Actual { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
public record VerifyEligibilityRequest(
|
||||
string BundlePath,
|
||||
ReplayInputState? CurrentInputState = null);
|
||||
|
||||
public record ReplayEligibilityResponse
|
||||
{
|
||||
public bool IsEligible { get; init; }
|
||||
public required List<string> Reasons { get; init; }
|
||||
public required List<string> Warnings { get; init; }
|
||||
public double ConfidenceScore { get; init; }
|
||||
public string? ExpectedStatus { get; init; }
|
||||
public string? ExpectedDecision { get; init; }
|
||||
public string? Rationale { get; init; }
|
||||
}
|
||||
|
||||
public record ReplayStatusResponse
|
||||
{
|
||||
public required string ManifestId { get; init; }
|
||||
public DateTimeOffset? LastReplayedAt { get; init; }
|
||||
public int TotalReplays { get; init; }
|
||||
public int SuccessfulReplays { get; init; }
|
||||
public int FailedReplays { get; init; }
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
public record ReplayComparisonRequest(
|
||||
string OriginalVerdictDigest,
|
||||
string OriginalDecision,
|
||||
string ReplayedVerdictDigest,
|
||||
string ReplayedDecision,
|
||||
List<DriftItemDto>? Drifts = null);
|
||||
|
||||
public record ReplayComparisonResponse
|
||||
{
|
||||
public bool HasDivergence { get; init; }
|
||||
public required string OverallSeverity { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public required List<DivergenceItemDto> Divergences { get; init; }
|
||||
}
|
||||
|
||||
public record DivergenceItemDto
|
||||
{
|
||||
public required string Category { get; init; }
|
||||
public required string Field { get; init; }
|
||||
public string? OriginalValue { get; init; }
|
||||
public string? ReplayedValue { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public string? Explanation { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user