audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

66
src/Timeline/AGENTS.md Normal file
View File

@@ -0,0 +1,66 @@
# Timeline Module - AGENTS.md
## Module Overview
The Timeline module provides a unified event timeline service for querying, replaying, and exporting HLC-ordered events across all StellaOps services.
## Roles Expected
- **Backend Engineer**: Implement API endpoints, query services, and replay orchestration
- **QA Engineer**: Create integration tests with Testcontainers PostgreSQL
## Key Documentation
Before working on this module, read:
1. [CLAUDE.md](../../CLAUDE.md) - Code quality rules (especially 8.2 TimeProvider, 8.18 DateTimeOffset, 8.19 HLC)
2. [docs/modules/eventing/event-envelope-schema.md](../../docs/modules/eventing/event-envelope-schema.md)
3. [docs/modules/scheduler/hlc-ordering.md](../../docs/modules/scheduler/hlc-ordering.md)
## Working Agreements
### Directory Ownership
- **WebService**: `src/Timeline/StellaOps.Timeline.WebService/`
- **Core Library**: `src/Timeline/__Libraries/StellaOps.Timeline.Core/`
- **Tests**: `src/Timeline/__Tests/`
### Dependencies
- Depends on: `StellaOps.Eventing`, `StellaOps.HybridLogicalClock`, `StellaOps.Replay.Core`
- Consumed by: UI Console (Timeline view), CLI (replay commands)
### API Conventions
1. All endpoints under `/api/v1/timeline`
2. HLC timestamps returned as sortable strings
3. Pagination via `limit`, `offset`, and `nextCursor`
4. Export produces NDJSON by default
### Testing Requirements
1. Unit tests with `[Trait("Category", "Unit")]`
2. Integration tests with `[Trait("Category", "Integration")]`
3. Use `FakeTimeProvider` for deterministic tests
4. Use Testcontainers for PostgreSQL integration tests
## Module-Specific Rules
### HLC Ordering
- All queries return events ordered by HLC timestamp (ascending)
- Critical path analysis uses wall-clock duration but references HLC for linking
- Replay must preserve HLC ordering
### Export Format
```ndjson
{"event_id":"abc123","t_hlc":"1704585600000:0:node1","correlation_id":"scan-1","kind":"ENQUEUE",...}
{"event_id":"def456","t_hlc":"1704585600001:0:node1","correlation_id":"scan-1","kind":"EXECUTE",...}
```
### Replay Contract
- Replay is read-only (dry-run mode)
- Verify mode compares replayed state to stored state
- Replay operations are idempotent

View File

@@ -0,0 +1,25 @@
# Timeline WebService Module Charter
## Mission
- Provide timeline API endpoints and service orchestration.
## Responsibilities
- Implement HTTP endpoints and request validation.
- Orchestrate persistence and timeline workflows.
- Enforce deterministic ordering in responses.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/timeline-indexer/architecture.md
- docs/modules/timeline-indexer/README.md
## Working Agreement
- Deterministic ordering and invariant formatting.
- Use TimeProvider and IGuidGenerator where timestamps or IDs are created.
- Propagate CancellationToken for async operations.
## Testing Strategy
- API tests for validation and error handling.
- Determinism checks for response ordering.

View File

@@ -0,0 +1,157 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace StellaOps.Timeline.WebService.Authorization;
/// <summary>
/// Middleware for authorizing timeline access based on tenant/correlation ownership.
/// </summary>
public sealed class TimelineAuthorizationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TimelineAuthorizationMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TimelineAuthorizationMiddleware"/> class.
/// </summary>
public TimelineAuthorizationMiddleware(
RequestDelegate next,
ILogger<TimelineAuthorizationMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Invokes the middleware.
/// </summary>
public async Task InvokeAsync(
HttpContext context,
ITimelineAuthorizationService authorizationService)
{
// Skip authorization for health endpoints
if (context.Request.Path.StartsWithSegments("/health"))
{
await _next(context);
return;
}
// Extract correlation ID from route if present
var correlationId = ExtractCorrelationId(context);
if (!string.IsNullOrEmpty(correlationId))
{
var user = context.User;
var tenantId = user.FindFirstValue("tenant_id");
if (string.IsNullOrEmpty(tenantId))
{
_logger.LogWarning("No tenant_id claim found for timeline access");
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Unauthorized: Missing tenant claim");
return;
}
var hasAccess = await authorizationService.HasAccessToCorrelationAsync(
tenantId,
correlationId,
context.RequestAborted);
if (!hasAccess)
{
_logger.LogWarning(
"Tenant {TenantId} denied access to correlation {CorrelationId}",
tenantId,
correlationId);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Forbidden: No access to this correlation");
return;
}
// Log audit trail
_logger.LogInformation(
"Tenant {TenantId} accessed correlation {CorrelationId} via {Method} {Path}",
tenantId,
correlationId,
context.Request.Method,
context.Request.Path);
}
await _next(context);
}
private static string? ExtractCorrelationId(HttpContext context)
{
// Try to get from route values
if (context.Request.RouteValues.TryGetValue("correlationId", out var routeValue))
{
return routeValue?.ToString();
}
return null;
}
}
/// <summary>
/// Service for authorizing timeline access.
/// </summary>
public interface ITimelineAuthorizationService
{
/// <summary>
/// Checks if a tenant has access to a correlation ID.
/// </summary>
Task<bool> HasAccessToCorrelationAsync(
string tenantId,
string correlationId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation that allows all access.
/// Production should integrate with Authority service.
/// </summary>
public sealed class DefaultTimelineAuthorizationService : ITimelineAuthorizationService
{
private readonly ILogger<DefaultTimelineAuthorizationService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultTimelineAuthorizationService"/> class.
/// </summary>
public DefaultTimelineAuthorizationService(ILogger<DefaultTimelineAuthorizationService> logger)
{
_logger = logger;
}
/// <inheritdoc/>
public Task<bool> HasAccessToCorrelationAsync(
string tenantId,
string correlationId,
CancellationToken cancellationToken = default)
{
// Default: allow all access
// Production: check correlation -> tenant mapping in database or Authority
_logger.LogDebug(
"Authorization check: tenant={TenantId}, correlation={CorrelationId} -> allowed (default)",
tenantId,
correlationId);
return Task.FromResult(true);
}
}
/// <summary>
/// Extension methods for timeline authorization.
/// </summary>
public static class TimelineAuthorizationExtensions
{
/// <summary>
/// Adds timeline authorization middleware to the pipeline.
/// </summary>
public static IApplicationBuilder UseTimelineAuthorization(this IApplicationBuilder app)
{
return app.UseMiddleware<TimelineAuthorizationMiddleware>();
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using Microsoft.AspNetCore.Http.HttpResults;
using StellaOps.Timeline.Core;
namespace StellaOps.Timeline.WebService.Endpoints;
/// <summary>
/// Export endpoints for timeline bundles.
/// </summary>
public static class ExportEndpoints
{
/// <summary>
/// Maps export endpoints.
/// </summary>
public static void MapExportEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/timeline")
.WithTags("Export")
.WithOpenApi();
group.MapPost("/{correlationId}/export", ExportTimelineAsync)
.WithName("ExportTimeline")
.WithDescription("Export timeline events as NDJSON bundle with optional DSSE signing");
group.MapGet("/export/{exportId}", GetExportStatusAsync)
.WithName("GetExportStatus")
.WithDescription("Get the status of an export operation");
group.MapGet("/export/{exportId}/download", DownloadExportAsync)
.WithName("DownloadExport")
.WithDescription("Download the completed export bundle");
}
private static async Task<Results<Accepted<ExportInitiatedResponse>, BadRequest<string>>> ExportTimelineAsync(
string correlationId,
ExportRequest request,
ITimelineQueryService queryService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(correlationId))
{
return TypedResults.BadRequest("Correlation ID is required");
}
// Validate the correlation exists
var result = await queryService.GetByCorrelationIdAsync(correlationId, new TimelineQueryOptions { Limit = 1 }, cancellationToken);
if (result.Events.Count == 0)
{
return TypedResults.BadRequest($"No events found for correlation ID: {correlationId}");
}
// TODO: Queue export job
var exportId = Guid.NewGuid().ToString("N")[..16];
return TypedResults.Accepted(
$"/api/v1/timeline/export/{exportId}",
new ExportInitiatedResponse
{
ExportId = exportId,
CorrelationId = correlationId,
Format = request.Format,
SignBundle = request.SignBundle,
Status = "INITIATED",
EstimatedEventCount = result.TotalCount
});
}
private static async Task<Results<Ok<ExportStatusResponse>, NotFound>> GetExportStatusAsync(
string exportId,
CancellationToken cancellationToken)
{
// TODO: Integrate with export state store
await Task.CompletedTask;
return TypedResults.Ok(new ExportStatusResponse
{
ExportId = exportId,
Status = "COMPLETED",
Format = "ndjson",
EventCount = 100,
FileSizeBytes = 45678,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
CompletedAt = DateTimeOffset.UtcNow.AddSeconds(-30)
});
}
private static async Task<Results<FileStreamHttpResult, NotFound>> DownloadExportAsync(
string exportId,
CancellationToken cancellationToken)
{
// TODO: Integrate with export storage
await Task.CompletedTask;
// Return stub for now - real implementation would stream from storage
var stubContent = """
{"event_id":"abc123","correlation_id":"scan-1","kind":"ENQUEUE"}
{"event_id":"def456","correlation_id":"scan-1","kind":"EXECUTE"}
""";
var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(stubContent));
return TypedResults.File(
stream,
contentType: "application/x-ndjson",
fileDownloadName: $"timeline-{exportId}.ndjson");
}
}
// DTOs
public sealed record ExportRequest
{
/// <summary>
/// Export format: "ndjson" or "json".
/// </summary>
public string Format { get; init; } = "ndjson";
/// <summary>
/// Whether to DSSE-sign the bundle.
/// </summary>
public bool SignBundle { get; init; } = false;
/// <summary>
/// Optional HLC range start.
/// </summary>
public string? FromHlc { get; init; }
/// <summary>
/// Optional HLC range end.
/// </summary>
public string? ToHlc { get; init; }
}
public sealed record ExportInitiatedResponse
{
public required string ExportId { get; init; }
public required string CorrelationId { get; init; }
public required string Format { get; init; }
public bool SignBundle { get; init; }
public required string Status { get; init; }
public long EstimatedEventCount { get; init; }
}
public sealed record ExportStatusResponse
{
public required string ExportId { get; init; }
public required string Status { get; init; }
public required string Format { get; init; }
public long EventCount { get; init; }
public long FileSizeBytes { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string? Error { get; init; }
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using Microsoft.Extensions.Diagnostics.HealthChecks;
using StellaOps.Eventing.Storage;
namespace StellaOps.Timeline.WebService.Endpoints;
/// <summary>
/// Health check endpoints.
/// </summary>
public static class HealthEndpoints
{
/// <summary>
/// Maps health check endpoints.
/// </summary>
public static void MapHealthEndpoints(this IEndpointRouteBuilder app)
{
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
}
}
/// <summary>
/// Health check for timeline service.
/// </summary>
public sealed class TimelineHealthCheck : IHealthCheck
{
private readonly ITimelineEventStore _eventStore;
/// <summary>
/// Initializes a new instance of the <see cref="TimelineHealthCheck"/> class.
/// </summary>
public TimelineHealthCheck(ITimelineEventStore eventStore)
{
_eventStore = eventStore;
}
/// <inheritdoc/>
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// Simple check - try to count events for a nonexistent correlation
// This validates database connectivity
await _eventStore.CountByCorrelationIdAsync("__health_check__", cancellationToken);
return HealthCheckResult.Healthy("Timeline service is healthy");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Timeline service is unhealthy", ex);
}
}
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using Microsoft.AspNetCore.Http.HttpResults;
namespace StellaOps.Timeline.WebService.Endpoints;
/// <summary>
/// Replay endpoints for deterministic replay of event sequences.
/// </summary>
public static class ReplayEndpoints
{
/// <summary>
/// Maps replay endpoints.
/// </summary>
public static void MapReplayEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/timeline")
.WithTags("Replay")
.WithOpenApi();
group.MapPost("/{correlationId}/replay", InitiateReplayAsync)
.WithName("InitiateReplay")
.WithDescription("Initiate deterministic replay for a correlation ID");
group.MapGet("/replay/{replayId}", GetReplayStatusAsync)
.WithName("GetReplayStatus")
.WithDescription("Get the status of a replay operation");
group.MapPost("/replay/{replayId}/cancel", CancelReplayAsync)
.WithName("CancelReplay")
.WithDescription("Cancel an in-progress replay operation");
}
private static async Task<Results<Accepted<ReplayInitiatedResponse>, BadRequest<string>>> InitiateReplayAsync(
string correlationId,
ReplayRequest request,
CancellationToken cancellationToken)
{
// Validate request
if (string.IsNullOrWhiteSpace(correlationId))
{
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];
await Task.CompletedTask; // Placeholder for actual implementation
return TypedResults.Accepted(
$"/api/v1/timeline/replay/{replayId}",
new ReplayInitiatedResponse
{
ReplayId = replayId,
CorrelationId = correlationId,
Mode = request.Mode,
Status = "INITIATED",
EstimatedDurationMs = 5000
});
}
private static async Task<Results<Ok<ReplayStatusResponse>, NotFound>> GetReplayStatusAsync(
string replayId,
CancellationToken cancellationToken)
{
// TODO: Integrate with replay state store
await Task.CompletedTask;
return TypedResults.Ok(new ReplayStatusResponse
{
ReplayId = replayId,
Status = "IN_PROGRESS",
Progress = 0.5,
EventsProcessed = 50,
TotalEvents = 100,
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5)
});
}
private static async Task<Results<Ok, NotFound>> CancelReplayAsync(
string replayId,
CancellationToken cancellationToken)
{
// TODO: Integrate with replay cancellation
await Task.CompletedTask;
return TypedResults.Ok();
}
}
// DTOs
public sealed record ReplayRequest
{
/// <summary>
/// Replay mode: "dry-run" or "verify".
/// </summary>
public string Mode { get; init; } = "dry-run";
/// <summary>
/// HLC to replay from (optional).
/// </summary>
public string? FromHlc { get; init; }
/// <summary>
/// HLC to replay to (optional).
/// </summary>
public string? ToHlc { get; init; }
}
public sealed record ReplayInitiatedResponse
{
public required string ReplayId { get; init; }
public required string CorrelationId { get; init; }
public required string Mode { get; init; }
public required string Status { get; init; }
public long EstimatedDurationMs { get; init; }
}
public sealed record ReplayStatusResponse
{
public required string ReplayId { get; init; }
public required string Status { get; init; }
public double Progress { get; init; }
public int EventsProcessed { get; init; }
public int TotalEvents { get; init; }
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string? Error { get; init; }
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using Microsoft.AspNetCore.Http.HttpResults;
using StellaOps.HybridLogicalClock;
using StellaOps.Timeline.Core;
namespace StellaOps.Timeline.WebService.Endpoints;
/// <summary>
/// Timeline query endpoints.
/// </summary>
public static class TimelineEndpoints
{
/// <summary>
/// Maps timeline query endpoints.
/// </summary>
public static void MapTimelineEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/timeline")
.WithTags("Timeline")
.WithOpenApi();
group.MapGet("/{correlationId}", GetTimelineAsync)
.WithName("GetTimeline")
.WithDescription("Get events for a correlation ID, ordered by HLC timestamp");
group.MapGet("/{correlationId}/critical-path", GetCriticalPathAsync)
.WithName("GetCriticalPath")
.WithDescription("Get the critical path (longest latency stages) for a correlation");
}
private static async Task<Results<Ok<TimelineResponse>, NotFound>> GetTimelineAsync(
string correlationId,
ITimelineQueryService queryService,
int? limit,
int? offset,
string? fromHlc,
string? toHlc,
string? services,
string? kinds,
CancellationToken cancellationToken)
{
var options = new TimelineQueryOptions
{
Limit = limit ?? 100,
Offset = offset ?? 0,
FromHlc = !string.IsNullOrEmpty(fromHlc) ? HlcTimestamp.Parse(fromHlc) : null,
ToHlc = !string.IsNullOrEmpty(toHlc) ? HlcTimestamp.Parse(toHlc) : null,
Services = !string.IsNullOrEmpty(services)
? services.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
: null,
Kinds = !string.IsNullOrEmpty(kinds)
? kinds.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
: null
};
var result = await queryService.GetByCorrelationIdAsync(correlationId, options, cancellationToken);
if (result.Events.Count == 0)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(new TimelineResponse
{
CorrelationId = correlationId,
Events = result.Events.Select(e => new TimelineEventDto
{
EventId = e.EventId,
THlc = e.THlc.ToSortableString(),
TsWall = e.TsWall,
Service = e.Service,
Kind = e.Kind,
Payload = e.Payload,
EngineVersion = new EngineVersionDto(
e.EngineVersion.EngineName,
e.EngineVersion.Version,
e.EngineVersion.SourceDigest)
}).ToList(),
TotalCount = result.TotalCount,
HasMore = result.HasMore,
NextCursor = result.NextCursor
});
}
private static async Task<Results<Ok<CriticalPathResponse>, NotFound>> GetCriticalPathAsync(
string correlationId,
ITimelineQueryService queryService,
CancellationToken cancellationToken)
{
var result = await queryService.GetCriticalPathAsync(correlationId, cancellationToken);
if (result.Stages.Count == 0)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(new CriticalPathResponse
{
CorrelationId = result.CorrelationId,
TotalDurationMs = result.TotalDuration.TotalMilliseconds,
Stages = result.Stages.Select(s => new CriticalPathStageDto
{
Stage = s.Stage,
Service = s.Service,
DurationMs = s.Duration.TotalMilliseconds,
Percentage = s.Percentage,
FromHlc = s.FromHlc.ToSortableString(),
ToHlc = s.ToHlc.ToSortableString()
}).ToList()
});
}
}
// DTOs
public sealed record TimelineResponse
{
public required string CorrelationId { get; init; }
public required IReadOnlyList<TimelineEventDto> Events { get; init; }
public long TotalCount { get; init; }
public bool HasMore { get; init; }
public string? NextCursor { get; init; }
}
public sealed record TimelineEventDto
{
public required string EventId { get; init; }
public required string THlc { get; init; }
public required DateTimeOffset TsWall { get; init; }
public required string Service { get; init; }
public required string Kind { get; init; }
public required string Payload { get; init; }
public required EngineVersionDto EngineVersion { get; init; }
}
public sealed record EngineVersionDto(string EngineName, string Version, string SourceDigest);
public sealed record CriticalPathResponse
{
public required string CorrelationId { get; init; }
public double TotalDurationMs { get; init; }
public required IReadOnlyList<CriticalPathStageDto> Stages { get; init; }
}
public sealed record CriticalPathStageDto
{
public required string Stage { get; init; }
public required string Service { get; init; }
public double DurationMs { get; init; }
public double Percentage { get; init; }
public required string FromHlc { get; init; }
public required string ToHlc { get; init; }
}

View File

@@ -0,0 +1,42 @@
using StellaOps.Eventing;
using StellaOps.Timeline.Core;
using StellaOps.Timeline.WebService.Endpoints;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddStellaOpsEventing(builder.Configuration);
builder.Services.AddTimelineServices(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "StellaOps Timeline API",
Version = "v1",
Description = "Unified event timeline API for querying, replaying, and exporting HLC-ordered events"
});
});
builder.Services.AddHealthChecks()
.AddCheck<TimelineHealthCheck>("timeline");
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Map endpoints
app.MapTimelineEndpoints();
app.MapReplayEndpoints();
app.MapExportEndpoints();
app.MapHealthEndpoints();
app.Run();

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Timeline.WebService</RootNamespace>
<Description>StellaOps Timeline Service - Unified event timeline API</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,24 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"StellaOps": "Debug"
}
},
"AllowedHosts": "*",
"Eventing": {
"ServiceName": "Timeline",
"UseInMemoryStore": false,
"ConnectionString": "",
"SignEvents": false,
"EnableOutbox": false
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://+:8080"
}
}
}
}

View File

@@ -0,0 +1,536 @@
openapi: 3.1.0
info:
title: StellaOps Timeline API
version: 1.0.0
description: |
Unified event timeline API for querying, replaying, and exporting HLC-ordered events
across all StellaOps services.
license:
name: AGPL-3.0-or-later
url: https://www.gnu.org/licenses/agpl-3.0.html
contact:
name: StellaOps
url: https://stellaops.io
servers:
- url: /api/v1
description: Timeline API v1
tags:
- name: Timeline
description: Query timeline events
- name: Replay
description: Deterministic replay operations
- name: Export
description: Export timeline bundles
paths:
/timeline/{correlationId}:
get:
summary: Get timeline events
description: Returns events for a correlation ID, ordered by HLC timestamp
operationId: getTimeline
tags:
- Timeline
parameters:
- name: correlationId
in: path
required: true
schema:
type: string
description: The correlation ID to query
- name: limit
in: query
schema:
type: integer
default: 100
minimum: 1
maximum: 1000
description: Maximum number of events to return
- name: offset
in: query
schema:
type: integer
default: 0
minimum: 0
description: Number of events to skip
- name: services
in: query
schema:
type: string
description: Comma-separated list of services to filter by
- name: kinds
in: query
schema:
type: string
description: Comma-separated list of event kinds to filter by
- name: fromHlc
in: query
schema:
type: string
description: Start of HLC range (inclusive)
- name: toHlc
in: query
schema:
type: string
description: End of HLC range (inclusive)
responses:
'200':
description: Timeline events
content:
application/json:
schema:
$ref: '#/components/schemas/TimelineResponse'
'404':
description: No events found for correlation ID
/timeline/{correlationId}/critical-path:
get:
summary: Get critical path
description: Returns the critical path (longest latency stages) for a correlation
operationId: getCriticalPath
tags:
- Timeline
parameters:
- name: correlationId
in: path
required: true
schema:
type: string
description: The correlation ID to analyze
responses:
'200':
description: Critical path analysis
content:
application/json:
schema:
$ref: '#/components/schemas/CriticalPathResponse'
'404':
description: No events found for correlation ID
/timeline/{correlationId}/replay:
post:
summary: Initiate replay
description: Initiate deterministic replay for a correlation ID
operationId: initiateReplay
tags:
- Replay
parameters:
- name: correlationId
in: path
required: true
schema:
type: string
description: The correlation ID to replay
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ReplayRequest'
responses:
'202':
description: Replay initiated
content:
application/json:
schema:
$ref: '#/components/schemas/ReplayInitiatedResponse'
'400':
description: Invalid request
/timeline/replay/{replayId}:
get:
summary: Get replay status
description: Get the status of a replay operation
operationId: getReplayStatus
tags:
- Replay
parameters:
- name: replayId
in: path
required: true
schema:
type: string
description: The replay operation ID
responses:
'200':
description: Replay status
content:
application/json:
schema:
$ref: '#/components/schemas/ReplayStatusResponse'
'404':
description: Replay not found
/timeline/{correlationId}/export:
post:
summary: Export timeline
description: Export timeline events as NDJSON bundle with optional DSSE signing
operationId: exportTimeline
tags:
- Export
parameters:
- name: correlationId
in: path
required: true
schema:
type: string
description: The correlation ID to export
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExportRequest'
responses:
'202':
description: Export initiated
content:
application/json:
schema:
$ref: '#/components/schemas/ExportInitiatedResponse'
'400':
description: Invalid request or no events found
/timeline/export/{exportId}:
get:
summary: Get export status
description: Get the status of an export operation
operationId: getExportStatus
tags:
- Export
parameters:
- name: exportId
in: path
required: true
schema:
type: string
description: The export operation ID
responses:
'200':
description: Export status
content:
application/json:
schema:
$ref: '#/components/schemas/ExportStatusResponse'
'404':
description: Export not found
/timeline/export/{exportId}/download:
get:
summary: Download export
description: Download the completed export bundle
operationId: downloadExport
tags:
- Export
parameters:
- name: exportId
in: path
required: true
schema:
type: string
description: The export operation ID
responses:
'200':
description: Export bundle
content:
application/x-ndjson:
schema:
type: string
format: binary
'404':
description: Export not found or not completed
components:
schemas:
TimelineResponse:
type: object
required:
- correlationId
- events
properties:
correlationId:
type: string
description: The correlation ID queried
events:
type: array
items:
$ref: '#/components/schemas/TimelineEvent'
totalCount:
type: integer
format: int64
description: Total number of events for this correlation
hasMore:
type: boolean
description: Whether there are more results
nextCursor:
type: string
description: Cursor for next page (HLC of last event)
TimelineEvent:
type: object
required:
- eventId
- tHlc
- tsWall
- service
- kind
- payload
- engineVersion
properties:
eventId:
type: string
description: Deterministic event ID (SHA-256 hash)
tHlc:
type: string
description: HLC timestamp in sortable format
tsWall:
type: string
format: date-time
description: Wall-clock time (ISO 8601)
service:
type: string
description: Service that emitted the event
kind:
type: string
description: Event kind (ENQUEUE, EXECUTE, etc.)
payload:
type: string
description: RFC 8785 canonicalized JSON payload
engineVersion:
$ref: '#/components/schemas/EngineVersion'
EngineVersion:
type: object
required:
- engineName
- version
- sourceDigest
properties:
engineName:
type: string
description: Engine/service name
version:
type: string
description: Engine version
sourceDigest:
type: string
description: Source/assembly digest
CriticalPathResponse:
type: object
required:
- correlationId
- stages
properties:
correlationId:
type: string
description: The correlation ID analyzed
totalDurationMs:
type: number
format: double
description: Total duration in milliseconds
stages:
type: array
items:
$ref: '#/components/schemas/CriticalPathStage'
CriticalPathStage:
type: object
required:
- stage
- service
- fromHlc
- toHlc
properties:
stage:
type: string
description: Stage label (e.g., "ENQUEUE -> EXECUTE")
service:
type: string
description: Service where stage occurred
durationMs:
type: number
format: double
description: Duration in milliseconds
percentage:
type: number
format: double
description: Percentage of total duration
fromHlc:
type: string
description: HLC at start of stage
toHlc:
type: string
description: HLC at end of stage
ReplayRequest:
type: object
properties:
mode:
type: string
enum:
- dry-run
- verify
default: dry-run
description: Replay mode
fromHlc:
type: string
description: HLC to replay from (optional)
toHlc:
type: string
description: HLC to replay to (optional)
ReplayInitiatedResponse:
type: object
required:
- replayId
- correlationId
- mode
- status
properties:
replayId:
type: string
description: Unique replay operation ID
correlationId:
type: string
description: The correlation ID being replayed
mode:
type: string
description: Replay mode
status:
type: string
description: Initial status (INITIATED)
estimatedDurationMs:
type: integer
format: int64
description: Estimated duration in milliseconds
ReplayStatusResponse:
type: object
required:
- replayId
- status
properties:
replayId:
type: string
description: Unique replay operation ID
status:
type: string
enum:
- INITIATED
- IN_PROGRESS
- COMPLETED
- FAILED
description: Current status
progress:
type: number
format: double
description: Progress (0.0 to 1.0)
eventsProcessed:
type: integer
description: Number of events processed
totalEvents:
type: integer
description: Total number of events
startedAt:
type: string
format: date-time
description: Start time
completedAt:
type: string
format: date-time
description: Completion time (if completed)
error:
type: string
description: Error message (if failed)
ExportRequest:
type: object
properties:
format:
type: string
enum:
- ndjson
- json
default: ndjson
description: Export format
signBundle:
type: boolean
default: false
description: Whether to DSSE-sign the bundle
fromHlc:
type: string
description: HLC range start (optional)
toHlc:
type: string
description: HLC range end (optional)
ExportInitiatedResponse:
type: object
required:
- exportId
- correlationId
- format
- status
properties:
exportId:
type: string
description: Unique export operation ID
correlationId:
type: string
description: The correlation ID being exported
format:
type: string
description: Export format
signBundle:
type: boolean
description: Whether bundle is signed
status:
type: string
description: Initial status (INITIATED)
estimatedEventCount:
type: integer
format: int64
description: Estimated number of events
ExportStatusResponse:
type: object
required:
- exportId
- status
- format
properties:
exportId:
type: string
description: Unique export operation ID
status:
type: string
enum:
- INITIATED
- IN_PROGRESS
- COMPLETED
- FAILED
description: Current status
format:
type: string
description: Export format
eventCount:
type: integer
format: int64
description: Number of events exported
fileSizeBytes:
type: integer
format: int64
description: Size of export file
createdAt:
type: string
format: date-time
description: Creation time
completedAt:
type: string
format: date-time
description: Completion time (if completed)
error:
type: string
description: Error message (if failed)

View File

@@ -0,0 +1,25 @@
# Timeline Core Module Charter
## Mission
- Provide timeline core models and deterministic ordering logic.
## Responsibilities
- Define timeline domain models and validation rules.
- Implement ordering and partitioning logic for timeline events.
- Keep serialization deterministic and invariant.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/timeline-indexer/architecture.md
- docs/modules/timeline-indexer/README.md
## Working Agreement
- Deterministic ordering and invariant formatting.
- Use TimeProvider and IGuidGenerator where timestamps or IDs are created.
- Propagate CancellationToken for async operations.
## Testing Strategy
- Unit tests for ordering, validation, and serialization.
- Determinism tests for stable outputs.

View File

@@ -0,0 +1,193 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using StellaOps.Eventing.Models;
using StellaOps.HybridLogicalClock;
namespace StellaOps.Timeline.Core.Export;
/// <summary>
/// Interface for building timeline export bundles.
/// </summary>
public interface ITimelineBundleBuilder
{
/// <summary>
/// Initiates an export operation for a correlation ID.
/// </summary>
/// <param name="correlationId">The correlation ID to export.</param>
/// <param name="request">Export request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The initiated export operation.</returns>
Task<ExportOperation> InitiateExportAsync(
string correlationId,
ExportRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the status of an export operation.
/// </summary>
/// <param name="exportId">The export operation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The export operation status, or null if not found.</returns>
Task<ExportOperation?> GetExportStatusAsync(
string exportId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the export bundle content.
/// </summary>
/// <param name="exportId">The export operation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The export bundle stream, or null if not found/not ready.</returns>
Task<ExportBundle?> GetExportBundleAsync(
string exportId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for initiating an export operation.
/// </summary>
public sealed record ExportRequest
{
/// <summary>
/// Export format: "ndjson" or "json".
/// </summary>
public string Format { get; init; } = "ndjson";
/// <summary>
/// Whether to DSSE-sign the bundle.
/// </summary>
public bool SignBundle { get; init; } = false;
/// <summary>
/// Optional HLC range start.
/// </summary>
public HlcTimestamp? FromHlc { get; init; }
/// <summary>
/// Optional HLC range end.
/// </summary>
public HlcTimestamp? ToHlc { get; init; }
/// <summary>
/// Whether to include full payloads.
/// </summary>
public bool IncludePayloads { get; init; } = true;
}
/// <summary>
/// Represents an export operation.
/// </summary>
public sealed record ExportOperation
{
/// <summary>
/// Unique export operation ID.
/// </summary>
public required string ExportId { get; init; }
/// <summary>
/// The correlation ID being exported.
/// </summary>
public required string CorrelationId { get; init; }
/// <summary>
/// Export format.
/// </summary>
public required string Format { get; init; }
/// <summary>
/// Whether bundle is signed.
/// </summary>
public bool SignBundle { get; init; }
/// <summary>
/// Current status.
/// </summary>
public required ExportStatus Status { get; init; }
/// <summary>
/// Number of events exported.
/// </summary>
public int EventCount { get; init; }
/// <summary>
/// Size of export file in bytes.
/// </summary>
public long FileSizeBytes { get; init; }
/// <summary>
/// Creation time.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Completion time (if completed).
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// Error message (if failed).
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Export operation status.
/// </summary>
public enum ExportStatus
{
/// <summary>
/// Export has been initiated.
/// </summary>
Initiated,
/// <summary>
/// Export is in progress.
/// </summary>
InProgress,
/// <summary>
/// Export completed successfully.
/// </summary>
Completed,
/// <summary>
/// Export failed.
/// </summary>
Failed
}
/// <summary>
/// Represents an export bundle.
/// </summary>
public sealed record ExportBundle
{
/// <summary>
/// The export operation ID.
/// </summary>
public required string ExportId { get; init; }
/// <summary>
/// Content type (e.g., "application/x-ndjson").
/// </summary>
public required string ContentType { get; init; }
/// <summary>
/// Suggested filename.
/// </summary>
public required string FileName { get; init; }
/// <summary>
/// Bundle content stream.
/// </summary>
public required Stream Content { get; init; }
/// <summary>
/// Bundle size in bytes.
/// </summary>
public long SizeBytes { get; init; }
/// <summary>
/// DSSE signature (if signed).
/// </summary>
public string? DsseSignature { get; init; }
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Eventing.Models;
using StellaOps.Eventing.Signing;
using StellaOps.Eventing.Storage;
using StellaOps.Timeline.Core.Telemetry;
namespace StellaOps.Timeline.Core.Export;
/// <summary>
/// Implementation of <see cref="ITimelineBundleBuilder"/>.
/// </summary>
public sealed class TimelineBundleBuilder : ITimelineBundleBuilder
{
private readonly ITimelineEventStore _eventStore;
private readonly IEventSigner? _eventSigner;
private readonly TimelineMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TimelineBundleBuilder> _logger;
// In-memory store for export operations (production would use PostgreSQL + object storage)
private readonly ConcurrentDictionary<string, ExportOperation> _operations = new();
private readonly ConcurrentDictionary<string, byte[]> _bundles = new();
/// <summary>
/// Initializes a new instance of the <see cref="TimelineBundleBuilder"/> class.
/// </summary>
public TimelineBundleBuilder(
ITimelineEventStore eventStore,
TimelineMetrics metrics,
TimeProvider timeProvider,
ILogger<TimelineBundleBuilder> logger,
IEventSigner? eventSigner = null)
{
_eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_eventSigner = eventSigner;
}
/// <inheritdoc/>
public async Task<ExportOperation> InitiateExportAsync(
string correlationId,
ExportRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
ArgumentNullException.ThrowIfNull(request);
var exportId = GenerateExportId();
var now = _timeProvider.GetUtcNow();
var operation = new ExportOperation
{
ExportId = exportId,
CorrelationId = correlationId,
Format = request.Format,
SignBundle = request.SignBundle,
Status = ExportStatus.Initiated,
CreatedAt = now
};
_operations[exportId] = operation;
_logger.LogInformation(
"Initiated export {ExportId} for correlation {CorrelationId}",
exportId,
correlationId);
// Start export in background
_ = Task.Run(() => ExecuteExportAsync(exportId, correlationId, request, cancellationToken), cancellationToken);
return operation;
}
/// <inheritdoc/>
public Task<ExportOperation?> GetExportStatusAsync(
string exportId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(exportId);
_operations.TryGetValue(exportId, out var operation);
return Task.FromResult(operation);
}
/// <inheritdoc/>
public Task<ExportBundle?> GetExportBundleAsync(
string exportId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(exportId);
if (!_operations.TryGetValue(exportId, out var operation))
{
return Task.FromResult<ExportBundle?>(null);
}
if (operation.Status != ExportStatus.Completed)
{
return Task.FromResult<ExportBundle?>(null);
}
if (!_bundles.TryGetValue(exportId, out var content))
{
return Task.FromResult<ExportBundle?>(null);
}
var bundle = new ExportBundle
{
ExportId = exportId,
ContentType = operation.Format == "ndjson" ? "application/x-ndjson" : "application/json",
FileName = $"timeline-{operation.CorrelationId}-{exportId}.{operation.Format}",
Content = new MemoryStream(content),
SizeBytes = content.Length
};
return Task.FromResult<ExportBundle?>(bundle);
}
private async Task ExecuteExportAsync(
string exportId,
string correlationId,
ExportRequest request,
CancellationToken cancellationToken)
{
try
{
// Update status to in-progress
UpdateOperation(exportId, op => op with { Status = ExportStatus.InProgress });
// Get events
IReadOnlyList<TimelineEvent> events;
if (request.FromHlc.HasValue && request.ToHlc.HasValue)
{
events = await _eventStore.GetByHlcRangeAsync(
correlationId,
request.FromHlc.Value,
request.ToHlc.Value,
cancellationToken).ConfigureAwait(false);
}
else
{
events = await _eventStore.GetByCorrelationIdAsync(
correlationId,
limit: 100000,
offset: 0,
cancellationToken).ConfigureAwait(false);
}
// Build bundle content
byte[] content;
if (request.Format == "ndjson")
{
content = BuildNdjsonBundle(events, request.IncludePayloads);
}
else
{
content = BuildJsonBundle(events, request.IncludePayloads);
}
// Sign if requested
string? signature = null;
if (request.SignBundle && _eventSigner != null)
{
signature = await _eventSigner.SignAsync(content, cancellationToken).ConfigureAwait(false);
}
// Store bundle
_bundles[exportId] = content;
// Update final status
var now = _timeProvider.GetUtcNow();
UpdateOperation(exportId, op => op with
{
Status = ExportStatus.Completed,
EventCount = events.Count,
FileSizeBytes = content.Length,
CompletedAt = now
});
_metrics.RecordExport(request.Format, request.SignBundle, content.Length, events.Count);
_logger.LogInformation(
"Completed export {ExportId}: {EventCount} events, {Size} bytes",
exportId,
events.Count,
content.Length);
}
catch (Exception ex)
{
_logger.LogError(ex, "Export {ExportId} failed", exportId);
UpdateOperation(exportId, op => op with
{
Status = ExportStatus.Failed,
CompletedAt = _timeProvider.GetUtcNow(),
Error = ex.Message
});
}
}
private static byte[] BuildNdjsonBundle(IReadOnlyList<TimelineEvent> events, bool includePayloads)
{
var sb = new StringBuilder();
foreach (var evt in events)
{
var line = new
{
event_id = evt.EventId,
t_hlc = evt.THlc.ToSortableString(),
ts_wall = evt.TsWall.ToString("O", CultureInfo.InvariantCulture),
correlation_id = evt.CorrelationId,
service = evt.Service,
kind = evt.Kind,
payload = includePayloads ? evt.Payload : null,
payload_digest = Convert.ToHexString(evt.PayloadDigest).ToLowerInvariant(),
engine_version = new
{
name = evt.EngineVersion.EngineName,
version = evt.EngineVersion.Version,
digest = evt.EngineVersion.SourceDigest
},
schema_version = evt.SchemaVersion
};
sb.AppendLine(JsonSerializer.Serialize(line, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
}));
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static byte[] BuildJsonBundle(IReadOnlyList<TimelineEvent> events, bool includePayloads)
{
var bundle = new
{
events = events.Select(evt => new
{
event_id = evt.EventId,
t_hlc = evt.THlc.ToSortableString(),
ts_wall = evt.TsWall.ToString("O", CultureInfo.InvariantCulture),
correlation_id = evt.CorrelationId,
service = evt.Service,
kind = evt.Kind,
payload = includePayloads ? evt.Payload : null,
payload_digest = Convert.ToHexString(evt.PayloadDigest).ToLowerInvariant(),
engine_version = new
{
name = evt.EngineVersion.EngineName,
version = evt.EngineVersion.Version,
digest = evt.EngineVersion.SourceDigest
},
schema_version = evt.SchemaVersion
}).ToList(),
metadata = new
{
event_count = events.Count,
exported_at = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
}
};
return JsonSerializer.SerializeToUtf8Bytes(bundle, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
}
private void UpdateOperation(string exportId, Func<ExportOperation, ExportOperation> update)
{
if (_operations.TryGetValue(exportId, out var current))
{
_operations[exportId] = update(current);
}
}
private static string GenerateExportId()
{
return Guid.NewGuid().ToString("N")[..16];
}
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using StellaOps.Eventing.Models;
using StellaOps.HybridLogicalClock;
namespace StellaOps.Timeline.Core;
/// <summary>
/// Interface for querying timeline events.
/// </summary>
public interface ITimelineQueryService
{
/// <summary>
/// Gets events for a correlation ID, ordered by HLC timestamp.
/// </summary>
Task<TimelineQueryResult> GetByCorrelationIdAsync(
string correlationId,
TimelineQueryOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the critical path (longest latency stages) for a correlation.
/// </summary>
Task<CriticalPathResult> GetCriticalPathAsync(
string correlationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets events filtered by service.
/// </summary>
Task<TimelineQueryResult> GetByServiceAsync(
string service,
HlcTimestamp? fromHlc = null,
int limit = 100,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for timeline queries.
/// </summary>
public sealed record TimelineQueryOptions
{
/// <summary>
/// Maximum number of events to return.
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Number of events to skip.
/// </summary>
public int Offset { get; init; } = 0;
/// <summary>
/// Filter by services (optional).
/// </summary>
public IReadOnlyList<string>? Services { get; init; }
/// <summary>
/// Filter by event kinds (optional).
/// </summary>
public IReadOnlyList<string>? Kinds { get; init; }
/// <summary>
/// Start of HLC range (inclusive).
/// </summary>
public HlcTimestamp? FromHlc { get; init; }
/// <summary>
/// End of HLC range (inclusive).
/// </summary>
public HlcTimestamp? ToHlc { get; init; }
}
/// <summary>
/// Result of a timeline query.
/// </summary>
public sealed record TimelineQueryResult
{
/// <summary>
/// The events matching the query.
/// </summary>
public required IReadOnlyList<TimelineEvent> Events { get; init; }
/// <summary>
/// Total count (for pagination).
/// </summary>
public long TotalCount { get; init; }
/// <summary>
/// Whether there are more results.
/// </summary>
public bool HasMore { get; init; }
/// <summary>
/// Cursor for next page (HLC of last event).
/// </summary>
public string? NextCursor { get; init; }
}
/// <summary>
/// Critical path analysis result.
/// </summary>
public sealed record CriticalPathResult
{
/// <summary>
/// The correlation ID analyzed.
/// </summary>
public required string CorrelationId { get; init; }
/// <summary>
/// Total duration from first to last event.
/// </summary>
public TimeSpan TotalDuration { get; init; }
/// <summary>
/// The stages in the critical path.
/// </summary>
public required IReadOnlyList<CriticalPathStage> Stages { get; init; }
}
/// <summary>
/// A stage in the critical path.
/// </summary>
public sealed record CriticalPathStage
{
/// <summary>
/// Stage name (e.g., "ENQUEUE -> EXECUTE").
/// </summary>
public required string Stage { get; init; }
/// <summary>
/// Service where this stage occurred.
/// </summary>
public required string Service { get; init; }
/// <summary>
/// Duration of this stage.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Percentage of total duration.
/// </summary>
public double Percentage { get; init; }
/// <summary>
/// HLC at start of stage.
/// </summary>
public required HlcTimestamp FromHlc { get; init; }
/// <summary>
/// HLC at end of stage.
/// </summary>
public required HlcTimestamp ToHlc { get; init; }
}

View File

@@ -0,0 +1,50 @@
-- Migration: 20260107_002_create_critical_path_view
-- Purpose: Create materialized view for critical path computation
-- Create materialized view for critical path analysis
CREATE MATERIALIZED VIEW timeline.critical_path AS
WITH ordered_events AS (
SELECT
correlation_id,
event_id,
t_hlc,
ts_wall,
service,
kind,
LAG(t_hlc) OVER (PARTITION BY correlation_id ORDER BY t_hlc) as prev_hlc,
LAG(ts_wall) OVER (PARTITION BY correlation_id ORDER BY t_hlc) as prev_ts,
LAG(kind) OVER (PARTITION BY correlation_id ORDER BY t_hlc) as prev_kind,
LAG(event_id) OVER (PARTITION BY correlation_id ORDER BY t_hlc) as prev_event_id
FROM timeline.events
)
SELECT
correlation_id,
prev_kind || ' -> ' || kind as stage,
prev_event_id as from_event_id,
event_id as to_event_id,
prev_hlc as from_hlc,
t_hlc as to_hlc,
EXTRACT(EPOCH FROM (ts_wall - prev_ts)) * 1000 as duration_ms,
service
FROM ordered_events
WHERE prev_hlc IS NOT NULL;
-- Create indexes for efficient queries
CREATE UNIQUE INDEX idx_critical_path_corr_from_hlc
ON timeline.critical_path (correlation_id, from_hlc);
CREATE INDEX idx_critical_path_duration
ON timeline.critical_path (correlation_id, duration_ms DESC);
-- Function to refresh materialized view for a specific correlation
CREATE OR REPLACE FUNCTION timeline.refresh_critical_path()
RETURNS void AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY timeline.critical_path;
END;
$$ LANGUAGE plpgsql;
-- Comments for documentation
COMMENT ON MATERIALIZED VIEW timeline.critical_path IS 'Pre-computed critical path stages for performance analysis';
COMMENT ON COLUMN timeline.critical_path.stage IS 'Transition label: prev_kind -> current_kind';
COMMENT ON COLUMN timeline.critical_path.duration_ms IS 'Wall-clock duration between events in milliseconds';

View File

@@ -0,0 +1,167 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using StellaOps.Eventing.Models;
using StellaOps.HybridLogicalClock;
namespace StellaOps.Timeline.Core.Replay;
/// <summary>
/// Interface for orchestrating deterministic replay of timeline events.
/// </summary>
public interface ITimelineReplayOrchestrator
{
/// <summary>
/// Initiates a replay operation for a correlation ID.
/// </summary>
/// <param name="correlationId">The correlation ID to replay.</param>
/// <param name="request">Replay request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The initiated replay operation.</returns>
Task<ReplayOperation> InitiateReplayAsync(
string correlationId,
ReplayRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the status of a replay operation.
/// </summary>
/// <param name="replayId">The replay operation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The replay operation status, or null if not found.</returns>
Task<ReplayOperation?> GetReplayStatusAsync(
string replayId,
CancellationToken cancellationToken = default);
/// <summary>
/// Cancels an in-progress replay operation.
/// </summary>
/// <param name="replayId">The replay operation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if cancelled, false if not found or already completed.</returns>
Task<bool> CancelReplayAsync(
string replayId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for initiating a replay operation.
/// </summary>
public sealed record ReplayRequest
{
/// <summary>
/// Replay mode: "dry-run" or "verify".
/// </summary>
public string Mode { get; init; } = "dry-run";
/// <summary>
/// Optional HLC to replay from.
/// </summary>
public HlcTimestamp? FromHlc { get; init; }
/// <summary>
/// Optional HLC to replay to.
/// </summary>
public HlcTimestamp? ToHlc { get; init; }
}
/// <summary>
/// Represents a replay operation.
/// </summary>
public sealed record ReplayOperation
{
/// <summary>
/// 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>
/// Replay mode.
/// </summary>
public required string Mode { get; init; }
/// <summary>
/// Current status.
/// </summary>
public required ReplayStatus Status { get; init; }
/// <summary>
/// Progress (0.0 to 1.0).
/// </summary>
public double Progress { get; init; }
/// <summary>
/// Number of events processed.
/// </summary>
public int EventsProcessed { get; init; }
/// <summary>
/// Total number of events.
/// </summary>
public int TotalEvents { get; init; }
/// <summary>
/// Start time.
/// </summary>
public DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Completion time (if completed).
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// Error message (if failed).
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Original output digest (for verify mode).
/// </summary>
public string? OriginalDigest { get; init; }
/// <summary>
/// Replayed output digest (for verify mode).
/// </summary>
public string? ReplayDigest { get; init; }
/// <summary>
/// Whether the replay matched the original (for verify mode).
/// </summary>
public bool? DeterministicMatch { get; init; }
}
/// <summary>
/// Replay operation status.
/// </summary>
public enum ReplayStatus
{
/// <summary>
/// Replay has been initiated but not started.
/// </summary>
Initiated,
/// <summary>
/// Replay is in progress.
/// </summary>
InProgress,
/// <summary>
/// Replay completed successfully.
/// </summary>
Completed,
/// <summary>
/// Replay failed.
/// </summary>
Failed,
/// <summary>
/// Replay was cancelled.
/// </summary>
Cancelled
}

View File

@@ -0,0 +1,294 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Eventing.Models;
using StellaOps.Eventing.Storage;
using StellaOps.HybridLogicalClock;
using StellaOps.Timeline.Core.Telemetry;
namespace StellaOps.Timeline.Core.Replay;
/// <summary>
/// Implementation of <see cref="ITimelineReplayOrchestrator"/>.
/// </summary>
public sealed class TimelineReplayOrchestrator : ITimelineReplayOrchestrator
{
private readonly ITimelineEventStore _eventStore;
private readonly TimelineMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TimelineReplayOrchestrator> _logger;
// In-memory store for replay operations (production would use PostgreSQL)
private readonly ConcurrentDictionary<string, ReplayOperation> _operations = new();
/// <summary>
/// Initializes a new instance of the <see cref="TimelineReplayOrchestrator"/> class.
/// </summary>
public TimelineReplayOrchestrator(
ITimelineEventStore eventStore,
TimelineMetrics metrics,
TimeProvider timeProvider,
ILogger<TimelineReplayOrchestrator> logger)
{
_eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<ReplayOperation> InitiateReplayAsync(
string correlationId,
ReplayRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
ArgumentNullException.ThrowIfNull(request);
var replayId = GenerateReplayId();
var now = _timeProvider.GetUtcNow();
// Get events to determine total count
var events = await GetEventsForReplayAsync(correlationId, request, cancellationToken)
.ConfigureAwait(false);
var operation = new ReplayOperation
{
ReplayId = replayId,
CorrelationId = correlationId,
Mode = request.Mode,
Status = ReplayStatus.Initiated,
Progress = 0,
EventsProcessed = 0,
TotalEvents = events.Count,
StartedAt = now
};
_operations[replayId] = operation;
_logger.LogInformation(
"Initiated replay {ReplayId} for correlation {CorrelationId} with {EventCount} events",
replayId,
correlationId,
events.Count);
// Start replay in background (in production, this would be queued to a worker)
_ = Task.Run(() => ExecuteReplayAsync(replayId, events, request, cancellationToken), cancellationToken);
return operation;
}
/// <inheritdoc/>
public Task<ReplayOperation?> GetReplayStatusAsync(
string replayId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(replayId);
_operations.TryGetValue(replayId, out var operation);
return Task.FromResult(operation);
}
/// <inheritdoc/>
public Task<bool> CancelReplayAsync(
string replayId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(replayId);
if (!_operations.TryGetValue(replayId, out var operation))
{
return Task.FromResult(false);
}
if (operation.Status is ReplayStatus.Completed or ReplayStatus.Failed or ReplayStatus.Cancelled)
{
return Task.FromResult(false);
}
var cancelled = operation with
{
Status = ReplayStatus.Cancelled,
CompletedAt = _timeProvider.GetUtcNow()
};
_operations[replayId] = cancelled;
_logger.LogInformation("Cancelled replay {ReplayId}", replayId);
return Task.FromResult(true);
}
private async Task<IReadOnlyList<TimelineEvent>> GetEventsForReplayAsync(
string correlationId,
ReplayRequest request,
CancellationToken cancellationToken)
{
if (request.FromHlc.HasValue && request.ToHlc.HasValue)
{
return await _eventStore.GetByHlcRangeAsync(
correlationId,
request.FromHlc.Value,
request.ToHlc.Value,
cancellationToken).ConfigureAwait(false);
}
return await _eventStore.GetByCorrelationIdAsync(
correlationId,
limit: 100000, // Get all events
offset: 0,
cancellationToken).ConfigureAwait(false);
}
private async Task ExecuteReplayAsync(
string replayId,
IReadOnlyList<TimelineEvent> events,
ReplayRequest request,
CancellationToken cancellationToken)
{
var startTime = _timeProvider.GetUtcNow();
try
{
// Update status to in-progress
UpdateOperation(replayId, op => op with { Status = ReplayStatus.InProgress });
// Create a FakeTimeProvider for deterministic replay
var fakeTimeProvider = new FakeTimeProvider();
// Compute original digest from events
var originalDigest = ComputeEventChainDigest(events);
var processedCount = 0;
var replayedPayloads = new List<string>();
foreach (var evt in events)
{
if (cancellationToken.IsCancellationRequested)
{
UpdateOperation(replayId, op => op with
{
Status = ReplayStatus.Cancelled,
CompletedAt = _timeProvider.GetUtcNow()
});
return;
}
// Check if cancelled
if (_operations.TryGetValue(replayId, out var current) && current.Status == ReplayStatus.Cancelled)
{
return;
}
// Simulate replay processing
// In production, this would re-execute the logic that produced each event
fakeTimeProvider.SetUtcNow(evt.TsWall);
// For dry-run mode, we just verify we can process all events
// For verify mode, we would re-execute and compare outputs
replayedPayloads.Add(evt.Payload);
processedCount++;
// Update progress
var progress = (double)processedCount / events.Count;
UpdateOperation(replayId, op => op with
{
Progress = progress,
EventsProcessed = processedCount
});
// Small delay to simulate processing (remove in production)
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
}
// Compute replayed digest
var replayDigest = ComputePayloadChainDigest(replayedPayloads);
var deterministicMatch = originalDigest == replayDigest;
var endTime = _timeProvider.GetUtcNow();
var duration = (endTime - startTime).TotalSeconds;
// Update final status
UpdateOperation(replayId, op => op with
{
Status = ReplayStatus.Completed,
Progress = 1.0,
EventsProcessed = events.Count,
CompletedAt = endTime,
OriginalDigest = originalDigest,
ReplayDigest = replayDigest,
DeterministicMatch = deterministicMatch
});
_metrics.RecordReplay(
request.Mode,
deterministicMatch ? "SUCCESS" : "MISMATCH",
events.Count,
duration);
_logger.LogInformation(
"Completed replay {ReplayId}: {EventCount} events, deterministic={Match}, duration={Duration}s",
replayId,
events.Count,
deterministicMatch,
duration);
}
catch (Exception ex)
{
_logger.LogError(ex, "Replay {ReplayId} failed", replayId);
UpdateOperation(replayId, op => op with
{
Status = ReplayStatus.Failed,
CompletedAt = _timeProvider.GetUtcNow(),
Error = ex.Message
});
_metrics.RecordReplay(request.Mode, "FAILED", events.Count, 0);
}
}
private void UpdateOperation(string replayId, Func<ReplayOperation, ReplayOperation> update)
{
if (_operations.TryGetValue(replayId, out var current))
{
_operations[replayId] = update(current);
}
}
private static string GenerateReplayId()
{
return Guid.NewGuid().ToString("N")[..16];
}
private static string ComputeEventChainDigest(IReadOnlyList<TimelineEvent> events)
{
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var evt in events)
{
hasher.AppendData(evt.PayloadDigest);
}
var hash = hasher.GetHashAndReset();
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string ComputePayloadChainDigest(IReadOnlyList<string> payloads)
{
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var payload in payloads)
{
hasher.AppendData(Encoding.UTF8.GetBytes(payload));
}
var hash = hasher.GetHashAndReset();
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Timeline.Core.Export;
using StellaOps.Timeline.Core.Replay;
using StellaOps.Timeline.Core.Telemetry;
namespace StellaOps.Timeline.Core;
/// <summary>
/// Extension methods for registering timeline services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds timeline query and replay services.
/// </summary>
public static IServiceCollection AddTimelineServices(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Register metrics (singleton for consistent counters)
services.TryAddSingleton<TimelineMetrics>();
// Register query service
services.TryAddScoped<ITimelineQueryService, TimelineQueryService>();
// Register replay orchestrator
services.TryAddScoped<ITimelineReplayOrchestrator, TimelineReplayOrchestrator>();
// Register export bundle builder
services.TryAddScoped<ITimelineBundleBuilder, TimelineBundleBuilder>();
return services;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Timeline.Core</RootNamespace>
<Description>StellaOps Timeline Core - Query and replay services</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,173 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace StellaOps.Timeline.Core.Telemetry;
/// <summary>
/// Metrics instrumentation for the Timeline service.
/// </summary>
public sealed class TimelineMetrics : IDisposable
{
private readonly Meter _meter;
private readonly Counter<long> _queriesCounter;
private readonly Counter<long> _replaysCounter;
private readonly Counter<long> _exportsCounter;
private readonly Histogram<double> _queryDurationHistogram;
private readonly Histogram<double> _replayDurationHistogram;
private readonly Histogram<long> _exportSizeHistogram;
private readonly Counter<long> _cacheHitsCounter;
private readonly Counter<long> _cacheMissesCounter;
/// <summary>
/// Activity source for tracing.
/// </summary>
public static readonly ActivitySource ActivitySource = new("StellaOps.Timeline", "1.0.0");
/// <summary>
/// Initializes a new instance of the <see cref="TimelineMetrics"/> class.
/// </summary>
public TimelineMetrics()
{
_meter = new Meter("StellaOps.Timeline", "1.0.0");
_queriesCounter = _meter.CreateCounter<long>(
"stellaops_timeline_queries_total",
description: "Total number of timeline queries");
_replaysCounter = _meter.CreateCounter<long>(
"stellaops_timeline_replays_total",
description: "Total number of replay operations");
_exportsCounter = _meter.CreateCounter<long>(
"stellaops_timeline_exports_total",
description: "Total number of export operations");
_queryDurationHistogram = _meter.CreateHistogram<double>(
"stellaops_timeline_query_duration_seconds",
unit: "s",
description: "Duration of timeline query operations");
_replayDurationHistogram = _meter.CreateHistogram<double>(
"stellaops_timeline_replay_duration_seconds",
unit: "s",
description: "Duration of replay operations");
_exportSizeHistogram = _meter.CreateHistogram<long>(
"stellaops_timeline_export_size_bytes",
unit: "By",
description: "Size of exported timeline bundles");
_cacheHitsCounter = _meter.CreateCounter<long>(
"stellaops_timeline_cache_hits_total",
description: "Total number of cache hits");
_cacheMissesCounter = _meter.CreateCounter<long>(
"stellaops_timeline_cache_misses_total",
description: "Total number of cache misses");
}
/// <summary>
/// Records a timeline query.
/// </summary>
public void RecordQuery(string queryType, int eventCount, double durationSeconds)
{
_queriesCounter.Add(1,
new KeyValuePair<string, object?>("query_type", queryType));
_queryDurationHistogram.Record(durationSeconds,
new KeyValuePair<string, object?>("query_type", queryType),
new KeyValuePair<string, object?>("event_count_bucket", GetCountBucket(eventCount)));
}
/// <summary>
/// Records a replay operation.
/// </summary>
public void RecordReplay(string mode, string status, int eventCount, double durationSeconds)
{
_replaysCounter.Add(1,
new KeyValuePair<string, object?>("mode", mode),
new KeyValuePair<string, object?>("status", status));
_replayDurationHistogram.Record(durationSeconds,
new KeyValuePair<string, object?>("mode", mode),
new KeyValuePair<string, object?>("event_count_bucket", GetCountBucket(eventCount)));
}
/// <summary>
/// Records an export operation.
/// </summary>
public void RecordExport(string format, bool signed, long sizeBytes, int eventCount)
{
_exportsCounter.Add(1,
new KeyValuePair<string, object?>("format", format),
new KeyValuePair<string, object?>("signed", signed));
_exportSizeHistogram.Record(sizeBytes,
new KeyValuePair<string, object?>("format", format),
new KeyValuePair<string, object?>("event_count_bucket", GetCountBucket(eventCount)));
}
/// <summary>
/// Records a cache hit.
/// </summary>
public void RecordCacheHit(string cacheType)
{
_cacheHitsCounter.Add(1,
new KeyValuePair<string, object?>("cache_type", cacheType));
}
/// <summary>
/// Records a cache miss.
/// </summary>
public void RecordCacheMiss(string cacheType)
{
_cacheMissesCounter.Add(1,
new KeyValuePair<string, object?>("cache_type", cacheType));
}
/// <summary>
/// Starts a query activity for tracing.
/// </summary>
public Activity? StartQueryActivity(string correlationId, string queryType)
{
return ActivitySource.StartActivity(
"timeline.query",
ActivityKind.Server,
parentContext: default,
tags: new[]
{
new KeyValuePair<string, object?>("correlation_id", correlationId),
new KeyValuePair<string, object?>("query_type", queryType)
});
}
/// <summary>
/// Starts a replay activity for tracing.
/// </summary>
public Activity? StartReplayActivity(string correlationId, string mode)
{
return ActivitySource.StartActivity(
"timeline.replay",
ActivityKind.Server,
parentContext: default,
tags: new[]
{
new KeyValuePair<string, object?>("correlation_id", correlationId),
new KeyValuePair<string, object?>("mode", mode)
});
}
private static string GetCountBucket(int count) => count switch
{
<= 10 => "1-10",
<= 100 => "11-100",
<= 1000 => "101-1000",
<= 10000 => "1001-10000",
_ => "10000+"
};
/// <inheritdoc/>
public void Dispose()
{
_meter.Dispose();
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using Microsoft.Extensions.Logging;
using StellaOps.Eventing.Models;
using StellaOps.Eventing.Storage;
using StellaOps.HybridLogicalClock;
namespace StellaOps.Timeline.Core;
/// <summary>
/// Implementation of <see cref="ITimelineQueryService"/>.
/// </summary>
public sealed class TimelineQueryService : ITimelineQueryService
{
private readonly ITimelineEventStore _eventStore;
private readonly ILogger<TimelineQueryService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TimelineQueryService"/> class.
/// </summary>
public TimelineQueryService(
ITimelineEventStore eventStore,
ILogger<TimelineQueryService> logger)
{
_eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<TimelineQueryResult> GetByCorrelationIdAsync(
string correlationId,
TimelineQueryOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
options ??= new TimelineQueryOptions();
IReadOnlyList<TimelineEvent> events;
if (options.FromHlc.HasValue && options.ToHlc.HasValue)
{
events = await _eventStore.GetByHlcRangeAsync(
correlationId,
options.FromHlc.Value,
options.ToHlc.Value,
cancellationToken).ConfigureAwait(false);
}
else
{
events = await _eventStore.GetByCorrelationIdAsync(
correlationId,
options.Limit + 1, // Fetch one extra to check for more
options.Offset,
cancellationToken).ConfigureAwait(false);
}
// Apply additional filters
var filteredEvents = ApplyFilters(events, options);
// Check if there are more results
var hasMore = filteredEvents.Count > options.Limit;
if (hasMore)
{
filteredEvents = filteredEvents.Take(options.Limit).ToList();
}
var totalCount = await _eventStore.CountByCorrelationIdAsync(correlationId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Queried {Count} events for correlation {CorrelationId}",
filteredEvents.Count,
correlationId);
return new TimelineQueryResult
{
Events = filteredEvents,
TotalCount = totalCount,
HasMore = hasMore,
NextCursor = hasMore && filteredEvents.Count > 0
? filteredEvents[^1].THlc.ToSortableString()
: null
};
}
/// <inheritdoc/>
public async Task<CriticalPathResult> GetCriticalPathAsync(
string correlationId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
var events = await _eventStore.GetByCorrelationIdAsync(
correlationId,
limit: 10000, // Get all events for critical path analysis
offset: 0,
cancellationToken).ConfigureAwait(false);
if (events.Count < 2)
{
return new CriticalPathResult
{
CorrelationId = correlationId,
TotalDuration = TimeSpan.Zero,
Stages = Array.Empty<CriticalPathStage>()
};
}
var stages = new List<CriticalPathStage>();
var totalDuration = events[^1].TsWall - events[0].TsWall;
for (int i = 1; i < events.Count; i++)
{
var prev = events[i - 1];
var curr = events[i];
var stageDuration = curr.TsWall - prev.TsWall;
stages.Add(new CriticalPathStage
{
Stage = $"{prev.Kind} -> {curr.Kind}",
Service = curr.Service,
Duration = stageDuration,
Percentage = totalDuration.TotalMilliseconds > 0
? stageDuration.TotalMilliseconds / totalDuration.TotalMilliseconds * 100
: 0,
FromHlc = prev.THlc,
ToHlc = curr.THlc
});
}
// Sort by duration descending (critical path = longest stages first)
stages = stages.OrderByDescending(s => s.Duration).ToList();
return new CriticalPathResult
{
CorrelationId = correlationId,
TotalDuration = totalDuration,
Stages = stages
};
}
/// <inheritdoc/>
public async Task<TimelineQueryResult> GetByServiceAsync(
string service,
HlcTimestamp? fromHlc = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(service);
var events = await _eventStore.GetByServiceAsync(
service,
fromHlc,
limit + 1,
cancellationToken).ConfigureAwait(false);
var hasMore = events.Count > limit;
var resultEvents = hasMore ? events.Take(limit).ToList() : events;
return new TimelineQueryResult
{
Events = resultEvents,
TotalCount = resultEvents.Count,
HasMore = hasMore,
NextCursor = hasMore && resultEvents.Count > 0
? resultEvents[^1].THlc.ToSortableString()
: null
};
}
private static List<TimelineEvent> ApplyFilters(
IReadOnlyList<TimelineEvent> events,
TimelineQueryOptions options)
{
var query = events.AsEnumerable();
if (options.Services is { Count: > 0 })
{
var services = new HashSet<string>(options.Services, StringComparer.OrdinalIgnoreCase);
query = query.Where(e => services.Contains(e.Service));
}
if (options.Kinds is { Count: > 0 })
{
var kinds = new HashSet<string>(options.Kinds, StringComparer.OrdinalIgnoreCase);
query = query.Where(e => kinds.Contains(e.Kind));
}
return query.ToList();
}
}

View File

@@ -0,0 +1,24 @@
# Timeline Core Tests Charter
## Mission
- Verify timeline core query, replay, and export logic.
## Responsibilities
- Cover ordering, filtering, and pagination behavior.
- Exercise replay and export determinism and cancellation.
- Validate HLC range handling and edge cases.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/timeline-indexer/architecture.md
- docs/modules/timeline-indexer/README.md
## Working Agreement
- Use fixed times and IDs in fixtures.
- Avoid network dependencies in tests.
## Testing Strategy
- Unit tests for query, replay, and export logic.
- Determinism tests for ordering and digests.

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Timeline.Core.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,224 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Eventing.Models;
using StellaOps.Eventing.Storage;
using StellaOps.HybridLogicalClock;
using Xunit;
namespace StellaOps.Timeline.Core.Tests;
[Trait("Category", "Unit")]
public sealed class TimelineQueryServiceTests
{
private readonly InMemoryTimelineEventStore _eventStore;
private readonly TimelineQueryService _queryService;
public TimelineQueryServiceTests()
{
_eventStore = new InMemoryTimelineEventStore();
_queryService = new TimelineQueryService(
_eventStore,
NullLogger<TimelineQueryService>.Instance);
}
private static TimelineEvent CreateEvent(
string correlationId,
string kind,
HlcTimestamp hlc,
string service = "TestService")
{
return new TimelineEvent
{
EventId = $"{correlationId}-{kind}-{hlc.LogicalCounter}",
CorrelationId = correlationId,
Kind = kind,
THlc = hlc,
TsWall = DateTimeOffset.UtcNow,
Service = service,
Payload = "{}",
PayloadDigest = new byte[32],
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
SchemaVersion = 1
};
}
[Fact]
public async Task GetByCorrelationIdAsync_ReturnsEventsOrderedByHlc()
{
// Arrange
var hlc1 = new HlcTimestamp(1000, 0, "n1");
var hlc2 = new HlcTimestamp(1000, 1, "n1");
var hlc3 = new HlcTimestamp(2000, 0, "n1");
await _eventStore.AppendAsync(CreateEvent("corr-1", "C", hlc3));
await _eventStore.AppendAsync(CreateEvent("corr-1", "A", hlc1));
await _eventStore.AppendAsync(CreateEvent("corr-1", "B", hlc2));
// Act
var result = await _queryService.GetByCorrelationIdAsync("corr-1");
// Assert
result.Events.Should().HaveCount(3);
result.Events[0].Kind.Should().Be("A");
result.Events[1].Kind.Should().Be("B");
result.Events[2].Kind.Should().Be("C");
}
[Fact]
public async Task GetByCorrelationIdAsync_FiltersByServices()
{
// Arrange
var hlc1 = new HlcTimestamp(1000, 0, "n1");
var hlc2 = new HlcTimestamp(2000, 0, "n1");
var hlc3 = new HlcTimestamp(3000, 0, "n1");
await _eventStore.AppendAsync(CreateEvent("corr-1", "A", hlc1, "Scheduler"));
await _eventStore.AppendAsync(CreateEvent("corr-1", "B", hlc2, "AirGap"));
await _eventStore.AppendAsync(CreateEvent("corr-1", "C", hlc3, "Scheduler"));
// Act
var result = await _queryService.GetByCorrelationIdAsync("corr-1", new TimelineQueryOptions
{
Services = new[] { "Scheduler" }
});
// Assert
result.Events.Should().HaveCount(2);
result.Events.All(e => e.Service == "Scheduler").Should().BeTrue();
}
[Fact]
public async Task GetByCorrelationIdAsync_FiltersByKinds()
{
// Arrange
var hlc1 = new HlcTimestamp(1000, 0, "n1");
var hlc2 = new HlcTimestamp(2000, 0, "n1");
var hlc3 = new HlcTimestamp(3000, 0, "n1");
await _eventStore.AppendAsync(CreateEvent("corr-1", "ENQUEUE", hlc1));
await _eventStore.AppendAsync(CreateEvent("corr-1", "EXECUTE", hlc2));
await _eventStore.AppendAsync(CreateEvent("corr-1", "COMPLETE", hlc3));
// Act
var result = await _queryService.GetByCorrelationIdAsync("corr-1", new TimelineQueryOptions
{
Kinds = new[] { "ENQUEUE", "COMPLETE" }
});
// Assert
result.Events.Should().HaveCount(2);
result.Events.Select(e => e.Kind).Should().BeEquivalentTo(new[] { "ENQUEUE", "COMPLETE" });
}
[Fact]
public async Task GetByCorrelationIdAsync_HasMoreFlag_WhenMoreResults()
{
// Arrange
for (int i = 0; i < 10; i++)
{
await _eventStore.AppendAsync(CreateEvent("corr-1", $"E{i}", new HlcTimestamp(1000 + i, 0, "n1")));
}
// Act
var result = await _queryService.GetByCorrelationIdAsync("corr-1", new TimelineQueryOptions { Limit = 5 });
// Assert
result.Events.Should().HaveCount(5);
result.HasMore.Should().BeTrue();
result.TotalCount.Should().Be(10);
result.NextCursor.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task GetCriticalPathAsync_ReturnsStagesOrderedByDuration()
{
// Arrange
var baseTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
await _eventStore.AppendAsync(new TimelineEvent
{
EventId = "e1",
CorrelationId = "corr-1",
Kind = "ENQUEUE",
THlc = new HlcTimestamp(1000, 0, "n1"),
TsWall = baseTime,
Service = "Scheduler",
Payload = "{}",
PayloadDigest = new byte[32],
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
SchemaVersion = 1
});
await _eventStore.AppendAsync(new TimelineEvent
{
EventId = "e2",
CorrelationId = "corr-1",
Kind = "EXECUTE",
THlc = new HlcTimestamp(2000, 0, "n1"),
TsWall = baseTime.AddSeconds(1), // 1 second
Service = "Scheduler",
Payload = "{}",
PayloadDigest = new byte[32],
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
SchemaVersion = 1
});
await _eventStore.AppendAsync(new TimelineEvent
{
EventId = "e3",
CorrelationId = "corr-1",
Kind = "COMPLETE",
THlc = new HlcTimestamp(3000, 0, "n1"),
TsWall = baseTime.AddSeconds(5), // 4 more seconds
Service = "Scheduler",
Payload = "{}",
PayloadDigest = new byte[32],
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
SchemaVersion = 1
});
// Act
var result = await _queryService.GetCriticalPathAsync("corr-1");
// Assert
result.CorrelationId.Should().Be("corr-1");
result.TotalDuration.Should().Be(TimeSpan.FromSeconds(5));
result.Stages.Should().HaveCount(2);
// Stages should be ordered by duration descending
result.Stages[0].Stage.Should().Be("EXECUTE -> COMPLETE");
result.Stages[0].Duration.Should().Be(TimeSpan.FromSeconds(4));
}
[Fact]
public async Task GetCriticalPathAsync_EmptyForSingleEvent()
{
// Arrange
await _eventStore.AppendAsync(CreateEvent("corr-1", "ENQUEUE", new HlcTimestamp(1000, 0, "n1")));
// Act
var result = await _queryService.GetCriticalPathAsync("corr-1");
// Assert
result.Stages.Should().BeEmpty();
result.TotalDuration.Should().Be(TimeSpan.Zero);
}
[Fact]
public async Task GetByServiceAsync_ReturnsEventsFromService()
{
// Arrange
await _eventStore.AppendAsync(CreateEvent("corr-1", "A", new HlcTimestamp(1000, 0, "n1"), "Scheduler"));
await _eventStore.AppendAsync(CreateEvent("corr-2", "B", new HlcTimestamp(2000, 0, "n1"), "AirGap"));
await _eventStore.AppendAsync(CreateEvent("corr-3", "C", new HlcTimestamp(3000, 0, "n1"), "Scheduler"));
// Act
var result = await _queryService.GetByServiceAsync("Scheduler");
// Assert
result.Events.Should().HaveCount(2);
result.Events.All(e => e.Service == "Scheduler").Should().BeTrue();
}
}

View File

@@ -0,0 +1,24 @@
# Timeline WebService Tests Charter
## Mission
- Verify timeline API endpoints, validation, and auth gating.
## Responsibilities
- Exercise request validation, paging, and response ordering.
- Cover replay and export endpoints and status flows.
- Validate authorization middleware behavior.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/timeline-indexer/architecture.md
- docs/modules/timeline-indexer/README.md
## Working Agreement
- Use fixed times and IDs in fixtures.
- Avoid network dependencies beyond the local test server.
## Testing Strategy
- Integration tests for endpoint behavior and auth.
- Determinism checks for ordered responses.

View File

@@ -0,0 +1,163 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Eventing.Models;
using StellaOps.Eventing.Storage;
using StellaOps.HybridLogicalClock;
using StellaOps.Timeline.Core.Replay;
using StellaOps.Timeline.Core.Telemetry;
using Xunit;
namespace StellaOps.Timeline.WebService.Tests;
[Trait("Category", "Integration")]
public sealed class ReplayOrchestratorIntegrationTests
{
private readonly InMemoryTimelineEventStore _eventStore;
private readonly TimelineReplayOrchestrator _orchestrator;
private readonly FakeTimeProvider _fakeTimeProvider;
public ReplayOrchestratorIntegrationTests()
{
_eventStore = new InMemoryTimelineEventStore();
_fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero));
_orchestrator = new TimelineReplayOrchestrator(
_eventStore,
new TimelineMetrics(),
_fakeTimeProvider,
NullLogger<TimelineReplayOrchestrator>.Instance);
}
[Fact]
public async Task InitiateReplayAsync_CreatesOperation()
{
// Arrange
const string correlationId = "replay-test-001";
await SeedEventsAsync(correlationId, 10);
// Act
var operation = await _orchestrator.InitiateReplayAsync(
correlationId,
new ReplayRequest { Mode = "dry-run" });
// Assert
operation.Should().NotBeNull();
operation.ReplayId.Should().NotBeNullOrEmpty();
operation.CorrelationId.Should().Be(correlationId);
operation.Mode.Should().Be("dry-run");
operation.Status.Should().Be(ReplayStatus.Initiated);
operation.TotalEvents.Should().Be(10);
}
[Fact]
public async Task GetReplayStatusAsync_ReturnsOperation()
{
// Arrange
const string correlationId = "replay-status-001";
await SeedEventsAsync(correlationId, 5);
var operation = await _orchestrator.InitiateReplayAsync(
correlationId,
new ReplayRequest { Mode = "verify" });
// Act
var status = await _orchestrator.GetReplayStatusAsync(operation.ReplayId);
// Assert
status.Should().NotBeNull();
status!.ReplayId.Should().Be(operation.ReplayId);
}
[Fact]
public async Task GetReplayStatusAsync_ReturnsNull_WhenNotFound()
{
// Act
var status = await _orchestrator.GetReplayStatusAsync("nonexistent-replay");
// Assert
status.Should().BeNull();
}
[Fact]
public async Task CancelReplayAsync_CancelsOperation()
{
// Arrange
const string correlationId = "replay-cancel-001";
await SeedEventsAsync(correlationId, 100); // Many events to ensure we can cancel
var operation = await _orchestrator.InitiateReplayAsync(
correlationId,
new ReplayRequest { Mode = "dry-run" });
// Act
var cancelled = await _orchestrator.CancelReplayAsync(operation.ReplayId);
// Assert
cancelled.Should().BeTrue();
var status = await _orchestrator.GetReplayStatusAsync(operation.ReplayId);
status!.Status.Should().Be(ReplayStatus.Cancelled);
}
[Fact]
public async Task CancelReplayAsync_ReturnsFalse_WhenNotFound()
{
// Act
var cancelled = await _orchestrator.CancelReplayAsync("nonexistent-replay");
// Assert
cancelled.Should().BeFalse();
}
[Fact]
public async Task ReplayCompletes_WithDeterministicDigest()
{
// Arrange
const string correlationId = "replay-digest-001";
await SeedEventsAsync(correlationId, 5);
var operation = await _orchestrator.InitiateReplayAsync(
correlationId,
new ReplayRequest { Mode = "verify" });
// Wait for completion
await Task.Delay(500); // Give background task time to complete
// Act
var status = await _orchestrator.GetReplayStatusAsync(operation.ReplayId);
// Assert - should complete or still be running
status.Should().NotBeNull();
// The operation should have started processing
status!.Status.Should().BeOneOf(
ReplayStatus.InProgress,
ReplayStatus.Completed,
ReplayStatus.Initiated);
}
private async Task SeedEventsAsync(string correlationId, int count)
{
var baseTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
for (int i = 0; i < count; i++)
{
var evt = new TimelineEvent
{
EventId = $"{correlationId}-evt-{i:D4}",
CorrelationId = correlationId,
Kind = $"EVENT_{i}",
THlc = new HlcTimestamp(1000 + i, 0, "test-node"),
TsWall = baseTime.AddSeconds(i),
Service = "TestService",
Payload = $"{{\"index\": {i}}}",
PayloadDigest = new byte[32],
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test-digest"),
SchemaVersion = 1
};
await _eventStore.AppendAsync(evt);
}
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Timeline.WebService.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Timeline.WebService\StellaOps.Timeline.WebService.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Testcontainers.PostgreSql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,213 @@
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Eventing.Models;
using StellaOps.Eventing.Storage;
using StellaOps.HybridLogicalClock;
using StellaOps.Timeline.WebService.Endpoints;
using Xunit;
namespace StellaOps.Timeline.WebService.Tests;
[Trait("Category", "Integration")]
public sealed class TimelineApiIntegrationTests : IClassFixture<TimelineWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly TimelineWebApplicationFactory _factory;
public TimelineApiIntegrationTests(TimelineWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task GetTimeline_ReturnsEvents_WhenCorrelationExists()
{
// Arrange
const string correlationId = "test-corr-001";
await SeedEventsAsync(correlationId, 5);
// Act
var response = await _client.GetAsync($"/api/v1/timeline/{correlationId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var timeline = await response.Content.ReadFromJsonAsync<TimelineResponse>();
timeline.Should().NotBeNull();
timeline!.CorrelationId.Should().Be(correlationId);
timeline.Events.Should().HaveCount(5);
}
[Fact]
public async Task GetTimeline_Returns404_WhenCorrelationNotFound()
{
// Act
var response = await _client.GetAsync("/api/v1/timeline/nonexistent-correlation");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetTimeline_ReturnsEventsOrderedByHlc()
{
// Arrange
const string correlationId = "test-corr-ordered";
await SeedEventsAsync(correlationId, 10);
// Act
var response = await _client.GetAsync($"/api/v1/timeline/{correlationId}");
var timeline = await response.Content.ReadFromJsonAsync<TimelineResponse>();
// Assert
timeline.Should().NotBeNull();
timeline!.Events.Should().BeInAscendingOrder(e => e.THlc);
}
[Fact]
public async Task GetTimeline_SupportsPagination()
{
// Arrange
const string correlationId = "test-corr-pagination";
await SeedEventsAsync(correlationId, 20);
// Act - Get first page
var response1 = await _client.GetAsync($"/api/v1/timeline/{correlationId}?limit=10");
var page1 = await response1.Content.ReadFromJsonAsync<TimelineResponse>();
// Assert
page1.Should().NotBeNull();
page1!.Events.Should().HaveCount(10);
page1.HasMore.Should().BeTrue();
page1.TotalCount.Should().Be(20);
}
[Fact]
public async Task GetTimeline_FiltersbyService()
{
// Arrange
const string correlationId = "test-corr-filter-svc";
await SeedEventsWithServicesAsync(correlationId);
// Act
var response = await _client.GetAsync($"/api/v1/timeline/{correlationId}?services=Scheduler");
var timeline = await response.Content.ReadFromJsonAsync<TimelineResponse>();
// Assert
timeline.Should().NotBeNull();
timeline!.Events.Should().AllSatisfy(e => e.Service.Should().Be("Scheduler"));
}
[Fact]
public async Task GetCriticalPath_ReturnsStages()
{
// Arrange
const string correlationId = "test-corr-critical";
await SeedEventsAsync(correlationId, 5);
// Act
var response = await _client.GetAsync($"/api/v1/timeline/{correlationId}/critical-path");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var criticalPath = await response.Content.ReadFromJsonAsync<CriticalPathResponse>();
criticalPath.Should().NotBeNull();
criticalPath!.CorrelationId.Should().Be(correlationId);
criticalPath.Stages.Should().HaveCountGreaterThan(0);
}
[Fact]
public async Task HealthCheck_ReturnsHealthy()
{
// Act
var response = await _client.GetAsync("/health");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
private async Task SeedEventsAsync(string correlationId, int count)
{
using var scope = _factory.Services.CreateScope();
var eventStore = scope.ServiceProvider.GetRequiredService<ITimelineEventStore>();
var baseTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
for (int i = 0; i < count; i++)
{
var evt = new TimelineEvent
{
EventId = $"{correlationId}-evt-{i:D4}",
CorrelationId = correlationId,
Kind = $"EVENT_{i}",
THlc = new HlcTimestamp(1000 + i, 0, "test-node"),
TsWall = baseTime.AddSeconds(i),
Service = "TestService",
Payload = $"{{\"index\": {i}}}",
PayloadDigest = new byte[32],
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test-digest"),
SchemaVersion = 1
};
await eventStore.AppendAsync(evt);
}
}
private async Task SeedEventsWithServicesAsync(string correlationId)
{
using var scope = _factory.Services.CreateScope();
var eventStore = scope.ServiceProvider.GetRequiredService<ITimelineEventStore>();
var services = new[] { "Scheduler", "AirGap", "Scheduler", "Attestor", "Scheduler" };
var baseTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
for (int i = 0; i < services.Length; i++)
{
var evt = new TimelineEvent
{
EventId = $"{correlationId}-evt-{i:D4}",
CorrelationId = correlationId,
Kind = $"EVENT_{i}",
THlc = new HlcTimestamp(1000 + i, 0, "test-node"),
TsWall = baseTime.AddSeconds(i),
Service = services[i],
Payload = $"{{\"index\": {i}}}",
PayloadDigest = new byte[32],
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test-digest"),
SchemaVersion = 1
};
await eventStore.AppendAsync(evt);
}
}
}
/// <summary>
/// Custom WebApplicationFactory for Timeline integration tests.
/// </summary>
public sealed class TimelineWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureServices(services =>
{
// Replace with in-memory store for tests
services.AddSingleton<ITimelineEventStore, InMemoryTimelineEventStore>();
});
}
}
/// <summary>
/// Minimal Program class reference for WebApplicationFactory.
/// </summary>
public partial class Program { }