|
|
|
|
@@ -1,11 +1,14 @@
|
|
|
|
|
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
|
|
|
|
|
|
|
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
|
|
|
using StellaOps.HybridLogicalClock;
|
|
|
|
|
using StellaOps.Timeline.Core.Replay;
|
|
|
|
|
|
|
|
|
|
namespace StellaOps.Timeline.WebService.Endpoints;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Replay endpoints for deterministic replay of event sequences.
|
|
|
|
|
/// Sprint: SPRINT_20260107_006_005 Task RB-005
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static class ReplayEndpoints
|
|
|
|
|
{
|
|
|
|
|
@@ -15,8 +18,7 @@ public static class ReplayEndpoints
|
|
|
|
|
public static void MapReplayEndpoints(this IEndpointRouteBuilder app)
|
|
|
|
|
{
|
|
|
|
|
var group = app.MapGroup("/api/v1/timeline")
|
|
|
|
|
.WithTags("Replay")
|
|
|
|
|
.WithOpenApi();
|
|
|
|
|
.WithTags("Replay");
|
|
|
|
|
|
|
|
|
|
group.MapPost("/{correlationId}/replay", InitiateReplayAsync)
|
|
|
|
|
.WithName("InitiateReplay")
|
|
|
|
|
@@ -29,11 +31,16 @@ public static class ReplayEndpoints
|
|
|
|
|
group.MapPost("/replay/{replayId}/cancel", CancelReplayAsync)
|
|
|
|
|
.WithName("CancelReplay")
|
|
|
|
|
.WithDescription("Cancel an in-progress replay operation");
|
|
|
|
|
|
|
|
|
|
group.MapDelete("/replay/{replayId}", DeleteReplayAsync)
|
|
|
|
|
.WithName("DeleteReplay")
|
|
|
|
|
.WithDescription("Delete/cancel a replay operation");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static async Task<Results<Accepted<ReplayInitiatedResponse>, BadRequest<string>>> InitiateReplayAsync(
|
|
|
|
|
string correlationId,
|
|
|
|
|
ReplayRequest request,
|
|
|
|
|
ITimelineReplayOrchestrator orchestrator,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
// Validate request
|
|
|
|
|
@@ -42,53 +49,117 @@ public static class ReplayEndpoints
|
|
|
|
|
return TypedResults.BadRequest("Correlation ID is required");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO: Integrate with StellaOps.Replay.Core
|
|
|
|
|
// For now, return a stub response
|
|
|
|
|
var replayId = Guid.NewGuid().ToString("N")[..16];
|
|
|
|
|
// Convert API request to domain request
|
|
|
|
|
var domainRequest = new Core.Replay.ReplayRequest
|
|
|
|
|
{
|
|
|
|
|
Mode = request.Mode,
|
|
|
|
|
FromHlc = ParseHlc(request.FromHlc),
|
|
|
|
|
ToHlc = ParseHlc(request.ToHlc)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await Task.CompletedTask; // Placeholder for actual implementation
|
|
|
|
|
// Initiate replay via orchestrator
|
|
|
|
|
var operation = await orchestrator.InitiateReplayAsync(
|
|
|
|
|
correlationId,
|
|
|
|
|
domainRequest,
|
|
|
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
return TypedResults.Accepted(
|
|
|
|
|
$"/api/v1/timeline/replay/{replayId}",
|
|
|
|
|
$"/api/v1/timeline/replay/{operation.ReplayId}",
|
|
|
|
|
new ReplayInitiatedResponse
|
|
|
|
|
{
|
|
|
|
|
ReplayId = replayId,
|
|
|
|
|
CorrelationId = correlationId,
|
|
|
|
|
Mode = request.Mode,
|
|
|
|
|
Status = "INITIATED",
|
|
|
|
|
EstimatedDurationMs = 5000
|
|
|
|
|
ReplayId = operation.ReplayId,
|
|
|
|
|
CorrelationId = operation.CorrelationId,
|
|
|
|
|
Mode = operation.Mode,
|
|
|
|
|
Status = MapStatus(operation.Status),
|
|
|
|
|
EstimatedDurationMs = EstimateDuration(operation.TotalEvents)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static async Task<Results<Ok<ReplayStatusResponse>, NotFound>> GetReplayStatusAsync(
|
|
|
|
|
string replayId,
|
|
|
|
|
ITimelineReplayOrchestrator orchestrator,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
// TODO: Integrate with replay state store
|
|
|
|
|
await Task.CompletedTask;
|
|
|
|
|
var operation = await orchestrator.GetReplayStatusAsync(replayId, cancellationToken)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (operation is null)
|
|
|
|
|
{
|
|
|
|
|
return TypedResults.NotFound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return TypedResults.Ok(new ReplayStatusResponse
|
|
|
|
|
{
|
|
|
|
|
ReplayId = replayId,
|
|
|
|
|
Status = "IN_PROGRESS",
|
|
|
|
|
Progress = 0.5,
|
|
|
|
|
EventsProcessed = 50,
|
|
|
|
|
TotalEvents = 100,
|
|
|
|
|
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5)
|
|
|
|
|
ReplayId = operation.ReplayId,
|
|
|
|
|
CorrelationId = operation.CorrelationId,
|
|
|
|
|
Mode = operation.Mode,
|
|
|
|
|
Status = MapStatus(operation.Status),
|
|
|
|
|
Progress = operation.Progress,
|
|
|
|
|
EventsProcessed = operation.EventsProcessed,
|
|
|
|
|
TotalEvents = operation.TotalEvents,
|
|
|
|
|
StartedAt = operation.StartedAt,
|
|
|
|
|
CompletedAt = operation.CompletedAt,
|
|
|
|
|
OriginalDigest = operation.OriginalDigest,
|
|
|
|
|
ReplayDigest = operation.ReplayDigest,
|
|
|
|
|
DeterministicMatch = operation.DeterministicMatch,
|
|
|
|
|
Error = operation.Error
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static async Task<Results<Ok, NotFound>> CancelReplayAsync(
|
|
|
|
|
string replayId,
|
|
|
|
|
ITimelineReplayOrchestrator orchestrator,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
// TODO: Integrate with replay cancellation
|
|
|
|
|
await Task.CompletedTask;
|
|
|
|
|
return TypedResults.Ok();
|
|
|
|
|
var cancelled = await orchestrator.CancelReplayAsync(replayId, cancellationToken)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
return cancelled ? TypedResults.Ok() : TypedResults.NotFound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static async Task<Results<Ok, NotFound>> DeleteReplayAsync(
|
|
|
|
|
string replayId,
|
|
|
|
|
ITimelineReplayOrchestrator orchestrator,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
// Delete is the same as cancel for now
|
|
|
|
|
var deleted = await orchestrator.CancelReplayAsync(replayId, cancellationToken)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
return deleted ? TypedResults.Ok() : TypedResults.NotFound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static HlcTimestamp? ParseHlc(string? hlcString)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(hlcString))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return HlcTimestamp.TryParse(hlcString, out var hlc) ? hlc : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string MapStatus(ReplayStatus status) => status switch
|
|
|
|
|
{
|
|
|
|
|
ReplayStatus.Initiated => "INITIATED",
|
|
|
|
|
ReplayStatus.InProgress => "IN_PROGRESS",
|
|
|
|
|
ReplayStatus.Completed => "COMPLETED",
|
|
|
|
|
ReplayStatus.Failed => "FAILED",
|
|
|
|
|
ReplayStatus.Cancelled => "CANCELLED",
|
|
|
|
|
_ => "UNKNOWN"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static long EstimateDuration(int eventCount)
|
|
|
|
|
{
|
|
|
|
|
// Rough estimate: 10ms per event + 500ms overhead
|
|
|
|
|
return (eventCount * 10) + 500;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DTOs
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Request for initiating a replay operation.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed record ReplayRequest
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
@@ -107,23 +178,104 @@ public sealed record ReplayRequest
|
|
|
|
|
public string? ToHlc { get; init; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Response when a replay operation is initiated.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed record ReplayInitiatedResponse
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The unique replay operation ID.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public required string ReplayId { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The correlation ID being replayed.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public required string CorrelationId { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The replay mode.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public required string Mode { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Current status of the replay.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public required string Status { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Estimated duration in milliseconds.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public long EstimatedDurationMs { get; init; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Response containing replay operation status.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed record ReplayStatusResponse
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The unique replay operation ID.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public required string ReplayId { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The correlation ID being replayed.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? CorrelationId { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The replay mode.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? Mode { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Current status: INITIATED, IN_PROGRESS, COMPLETED, FAILED, CANCELLED.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public required string Status { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Progress from 0.0 to 1.0.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public double Progress { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Number of events processed so far.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public int EventsProcessed { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Total number of events to process.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public int TotalEvents { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// When the replay started.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public DateTimeOffset StartedAt { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// When the replay completed (if finished).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public DateTimeOffset? CompletedAt { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// SHA-256 digest of the original event chain.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? OriginalDigest { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// SHA-256 digest of the replayed event chain.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? ReplayDigest { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Whether the replay produced deterministic output.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public bool? DeterministicMatch { get; init; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Error message if replay failed.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? Error { get; init; }
|
|
|
|
|
}
|
|
|
|
|
|