release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -0,0 +1,290 @@
// -----------------------------------------------------------------------------
// ChangeTraceBundleBuilder.cs
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
// Description: Builds change trace evidence bundles for export.
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.ProofChain.ChangeTrace;
using StellaOps.Attestor.ProofChain.Signing;
using StellaOps.Scanner.ChangeTrace.CycloneDx;
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
namespace StellaOps.ExportCenter.Core.ChangeTrace;
/// <summary>
/// Builds change trace evidence bundles for export.
/// </summary>
public sealed class ChangeTraceBundleBuilder : IChangeTraceBundleBuilder
{
private const string ManifestVersion = "change-trace-bundle/v1";
private const string RawTraceFileName = "change-trace.json";
private const string AttestationFileName = "attestation.dsse.json";
private const string CycloneDxFileName = "evidence.cdx.json";
private const string ManifestFileName = "manifest.json";
private const string ChecksumsFileName = "checksums.sha256";
private const string VerifyScriptFileName = "verify.sh";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly IChangeTraceAttestationService? _attestationService;
private readonly IChangeTraceEvidenceExtension _evidenceExtension;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Create a new change trace bundle builder.
/// </summary>
/// <param name="attestationService">Optional attestation service for DSSE envelopes.</param>
/// <param name="evidenceExtension">CycloneDX evidence extension service.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
public ChangeTraceBundleBuilder(
IChangeTraceAttestationService? attestationService,
IChangeTraceEvidenceExtension evidenceExtension,
TimeProvider timeProvider)
{
_attestationService = attestationService;
_evidenceExtension = evidenceExtension ?? throw new ArgumentNullException(nameof(evidenceExtension));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Create a new change trace bundle builder with default dependencies.
/// </summary>
public ChangeTraceBundleBuilder()
: this(null, new ChangeTraceEvidenceExtension(), TimeProvider.System)
{
}
/// <inheritdoc />
public async Task<ChangeTraceBundle> BuildAsync(
ChangeTraceModel trace,
ChangeTraceBundleOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(trace);
options ??= ChangeTraceBundleOptions.Default;
var bundleId = GenerateBundleId(trace);
var createdAt = _timeProvider.GetUtcNow();
var files = new Dictionary<string, byte[]>(StringComparer.Ordinal);
var entries = new List<ChangeTraceBundleEntry>();
// 1. Raw trace JSON
if (options.IncludeRawTrace)
{
var traceJson = JsonSerializer.Serialize(trace, SerializerOptions);
var traceBytes = Encoding.UTF8.GetBytes(traceJson);
files[RawTraceFileName] = traceBytes;
entries.Add(CreateEntry("change-traces", RawTraceFileName, traceBytes, "application/json"));
}
// 2. DSSE Attestation
if (options.IncludeAttestation && _attestationService is not null)
{
var attestationOptions = new ChangeTraceAttestationOptions
{
TenantId = options.TenantId,
MaxDeltas = options.MaxDeltas
};
var envelope = await _attestationService.GenerateAttestationAsync(
trace, attestationOptions, ct).ConfigureAwait(false);
var envelopeJson = JsonSerializer.Serialize(envelope, SerializerOptions);
var envelopeBytes = Encoding.UTF8.GetBytes(envelopeJson);
files[AttestationFileName] = envelopeBytes;
entries.Add(CreateEntry("attestations", AttestationFileName, envelopeBytes, "application/vnd.in-toto+json"));
}
// 3. CycloneDX Evidence
if (options.IncludeCycloneDxEvidence)
{
var evidenceOptions = new ChangeTraceEvidenceOptions
{
MaxDeltas = options.MaxDeltas,
IncludeProofSteps = true,
IncludeSymbolDeltas = true
};
using var cycloneDxDoc = _evidenceExtension.ExportAsStandalone(trace, evidenceOptions);
var cdxJson = JsonSerializer.Serialize(cycloneDxDoc, SerializerOptions);
var cdxBytes = Encoding.UTF8.GetBytes(cdxJson);
files[CycloneDxFileName] = cdxBytes;
entries.Add(CreateEntry("evidence", CycloneDxFileName, cdxBytes, "application/vnd.cyclonedx+json"));
}
// 4. Verification script
if (options.IncludeVerifyScript)
{
var verifyScript = BuildVerificationScript(bundleId);
var scriptBytes = Encoding.UTF8.GetBytes(verifyScript);
files[VerifyScriptFileName] = scriptBytes;
entries.Add(CreateEntry("scripts", VerifyScriptFileName, scriptBytes, "application/x-sh"));
}
// 5. Checksums file
var checksums = BuildChecksums(entries);
var checksumsBytes = Encoding.UTF8.GetBytes(checksums);
files[ChecksumsFileName] = checksumsBytes;
entries.Add(CreateEntry("metadata", ChecksumsFileName, checksumsBytes, "text/plain"));
// 6. Manifest
var manifestHash = ComputeManifestHash(entries);
var manifest = new ChangeTraceBundleManifest
{
Version = ManifestVersion,
BundleId = bundleId,
CreatedAt = createdAt,
Entries = entries,
ManifestHash = manifestHash,
SubjectDigest = trace.Subject.Digest,
TenantId = options.TenantId
};
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
var manifestBytes = Encoding.UTF8.GetBytes(manifestJson);
files[ManifestFileName] = manifestBytes;
return new ChangeTraceBundle
{
BundleId = bundleId,
CreatedAt = createdAt,
Manifest = manifest,
Files = files
};
}
/// <summary>
/// Generate a deterministic bundle ID from the trace.
/// </summary>
private static string GenerateBundleId(ChangeTraceModel trace)
{
var input = $"{trace.Subject.Digest}:{trace.Basis.ScanId}:{trace.Commitment?.Sha256 ?? "none"}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"ctb-{Convert.ToHexStringLower(hash)[..16]}";
}
/// <summary>
/// Create a bundle entry from content.
/// </summary>
private static ChangeTraceBundleEntry CreateEntry(
string category,
string path,
byte[] content,
string contentType)
{
var hash = SHA256.HashData(content);
return new ChangeTraceBundleEntry
{
Category = category,
Path = path,
Sha256 = Convert.ToHexStringLower(hash),
SizeBytes = content.Length,
ContentType = contentType
};
}
/// <summary>
/// Build checksums file content.
/// </summary>
private static string BuildChecksums(List<ChangeTraceBundleEntry> entries)
{
var builder = new StringBuilder();
builder.AppendLine("# Change trace bundle checksums (SHA-256)");
builder.AppendLine("# Generated by StellaOps ChangeTraceBundleBuilder");
builder.AppendLine();
foreach (var entry in entries.OrderBy(e => e.Path, StringComparer.Ordinal))
{
builder.Append(entry.Sha256);
builder.Append(" ");
builder.AppendLine(entry.Path);
}
return builder.ToString();
}
/// <summary>
/// Compute manifest hash from all entries.
/// </summary>
private static string ComputeManifestHash(List<ChangeTraceBundleEntry> entries)
{
var builder = new StringBuilder();
foreach (var entry in entries.OrderBy(e => e.Path, StringComparer.Ordinal))
{
builder.Append(entry.Sha256);
builder.Append(':');
builder.Append(entry.Path);
builder.Append('\n');
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexStringLower(hash);
}
/// <summary>
/// Build verification script for the bundle.
/// </summary>
private static string BuildVerificationScript(string bundleId)
{
var builder = new StringBuilder();
builder.AppendLine("#!/usr/bin/env sh");
builder.AppendLine("# Change Trace Bundle Verification Script");
builder.AppendLine("# No network access required");
builder.AppendLine();
builder.AppendLine("set -eu");
builder.AppendLine();
builder.Append("BUNDLE_ID=\"").Append(bundleId).AppendLine("\"");
builder.AppendLine();
builder.AppendLine("echo \"Verifying change trace bundle: $BUNDLE_ID\"");
builder.AppendLine("echo \"\"");
builder.AppendLine();
builder.AppendLine("# Step 1: Verify checksums");
builder.AppendLine("echo \"Verifying file checksums...\"");
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
builder.AppendLine(" sha256sum --check checksums.sha256 --ignore-missing");
builder.AppendLine("elif command -v shasum >/dev/null 2>&1; then");
builder.AppendLine(" shasum -a 256 --check checksums.sha256 --ignore-missing");
builder.AppendLine("else");
builder.AppendLine(" echo \"Error: sha256sum or shasum required\" >&2");
builder.AppendLine(" exit 1");
builder.AppendLine("fi");
builder.AppendLine("echo \"Checksums verified.\"");
builder.AppendLine("echo \"\"");
builder.AppendLine();
builder.AppendLine("# Step 2: Verify DSSE attestation (if present)");
builder.AppendLine("if [ -f \"attestation.dsse.json\" ]; then");
builder.AppendLine(" if command -v stella >/dev/null 2>&1; then");
builder.AppendLine(" echo \"Verifying DSSE attestation with stella CLI...\"");
builder.AppendLine(" stella attest verify --envelope attestation.dsse.json");
builder.AppendLine(" else");
builder.AppendLine(" echo \"Note: stella CLI not found. Manual DSSE verification recommended.\"");
builder.AppendLine(" fi");
builder.AppendLine("fi");
builder.AppendLine("echo \"\"");
builder.AppendLine();
builder.AppendLine("# Step 3: Display summary");
builder.AppendLine("echo \"Bundle verification complete.\"");
builder.AppendLine("if [ -f \"change-trace.json\" ]; then");
builder.AppendLine(" echo \"\"");
builder.AppendLine(" echo \"Change trace summary:\"");
builder.AppendLine(" if command -v jq >/dev/null 2>&1; then");
builder.AppendLine(" jq '.summary' change-trace.json");
builder.AppendLine(" else");
builder.AppendLine(" cat change-trace.json | grep -A5 '\"summary\"'");
builder.AppendLine(" fi");
builder.AppendLine("fi");
return builder.ToString();
}
}

View File

@@ -0,0 +1,166 @@
// -----------------------------------------------------------------------------
// IChangeTraceBundleBuilder.cs
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
// Description: Interface for building change trace evidence bundles.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.ChangeTrace.Models;
namespace StellaOps.ExportCenter.Core.ChangeTrace;
/// <summary>
/// Builds change trace evidence bundles for export.
/// </summary>
public interface IChangeTraceBundleBuilder
{
/// <summary>
/// Build a complete change trace evidence bundle.
/// </summary>
/// <param name="trace">The change trace to bundle.</param>
/// <param name="options">Optional bundle options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The built change trace bundle.</returns>
Task<ChangeTraceBundle> BuildAsync(
Scanner.ChangeTrace.Models.ChangeTrace trace,
ChangeTraceBundleOptions? options = null,
CancellationToken ct = default);
}
/// <summary>
/// Options for change trace bundle building.
/// </summary>
public sealed record ChangeTraceBundleOptions
{
/// <summary>
/// Default bundle options.
/// </summary>
public static readonly ChangeTraceBundleOptions Default = new();
/// <summary>
/// Include DSSE attestation envelope.
/// </summary>
public bool IncludeAttestation { get; init; } = true;
/// <summary>
/// Include CycloneDX evidence file (standalone mode).
/// </summary>
public bool IncludeCycloneDxEvidence { get; init; } = true;
/// <summary>
/// Include raw trace JSON.
/// </summary>
public bool IncludeRawTrace { get; init; } = true;
/// <summary>
/// Include verification script.
/// </summary>
public bool IncludeVerifyScript { get; init; } = true;
/// <summary>
/// Tenant ID for multi-tenant isolation.
/// </summary>
public string TenantId { get; init; } = "default";
/// <summary>
/// Maximum deltas to include in exports.
/// </summary>
public int MaxDeltas { get; init; } = 1000;
}
/// <summary>
/// A bundled change trace export package.
/// </summary>
public sealed record ChangeTraceBundle
{
/// <summary>
/// Unique identifier for this bundle.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// When the bundle was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Bundle manifest with file entries and checksums.
/// </summary>
public required ChangeTraceBundleManifest Manifest { get; init; }
/// <summary>
/// Bundle files keyed by path.
/// </summary>
public required IReadOnlyDictionary<string, byte[]> Files { get; init; }
}
/// <summary>
/// Manifest describing the contents of a change trace bundle.
/// </summary>
public sealed record ChangeTraceBundleManifest
{
/// <summary>
/// Manifest format version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Bundle identifier.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// When the bundle was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Entries in this bundle.
/// </summary>
public required IReadOnlyList<ChangeTraceBundleEntry> Entries { get; init; }
/// <summary>
/// Root hash of all entries for integrity verification.
/// </summary>
public required string ManifestHash { get; init; }
/// <summary>
/// Subject artifact digest.
/// </summary>
public string? SubjectDigest { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public string? TenantId { get; init; }
}
/// <summary>
/// A single entry in a change trace bundle.
/// </summary>
public sealed record ChangeTraceBundleEntry
{
/// <summary>
/// Category of the entry (change-traces, attestations, evidence).
/// </summary>
public required string Category { get; init; }
/// <summary>
/// Path within the bundle.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// SHA-256 hash of the entry content.
/// </summary>
public required string Sha256 { get; init; }
/// <summary>
/// Size in bytes.
/// </summary>
public required long SizeBytes { get; init; }
/// <summary>
/// Content type (MIME type).
/// </summary>
public string? ContentType { get; init; }
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.ExportCenter.Core.Domain;
namespace StellaOps.ExportCenter.Core.Distribution;
@@ -10,16 +11,19 @@ public sealed class DistributionLifecycleService : IDistributionLifecycleService
{
private readonly IDistributionRepository _repository;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ILogger<DistributionLifecycleService> _logger;
public DistributionLifecycleService(
IDistributionRepository repository,
TimeProvider timeProvider,
ILogger<DistributionLifecycleService> logger)
ILogger<DistributionLifecycleService> logger,
IGuidProvider? guidProvider = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
/// <inheritdoc />
@@ -58,7 +62,7 @@ public sealed class DistributionLifecycleService : IDistributionLifecycleService
var distribution = new ExportDistribution
{
DistributionId = Guid.NewGuid(),
DistributionId = _guidProvider.NewGuid(),
RunId = request.RunId,
TenantId = request.TenantId,
Kind = request.Kind,

View File

@@ -3,6 +3,7 @@ using System.Net;
using System.Net.Sockets;
using Cronos;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.ExportCenter.Core.Domain;
namespace StellaOps.ExportCenter.Core.Scheduling;
@@ -14,6 +15,7 @@ public sealed class ExportSchedulerService : IExportSchedulerService
{
private readonly IExportScheduleStore _scheduleStore;
private readonly ILogger<ExportSchedulerService> _logger;
private readonly IGuidProvider _guidProvider;
private readonly ConcurrentDictionary<string, CronExpression> _cronCache = new();
// Pause profiles after this many consecutive failures
@@ -21,10 +23,12 @@ public sealed class ExportSchedulerService : IExportSchedulerService
public ExportSchedulerService(
IExportScheduleStore scheduleStore,
ILogger<ExportSchedulerService> logger)
ILogger<ExportSchedulerService> logger,
IGuidProvider? guidProvider = null)
{
_scheduleStore = scheduleStore ?? throw new ArgumentNullException(nameof(scheduleStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
/// <inheritdoc />
@@ -89,7 +93,7 @@ public sealed class ExportSchedulerService : IExportSchedulerService
}
// Create new run
var runId = Guid.NewGuid();
var runId = _guidProvider.NewGuid();
await _scheduleStore.RecordTriggerAsync(
request.ProfileId,
runId,

View File

@@ -34,10 +34,14 @@ public sealed class EvidencePackSigningService : IEvidencePackSigningService
};
private readonly ILogger<EvidencePackSigningService> _logger;
private readonly TimeProvider _timeProvider;
public EvidencePackSigningService(ILogger<EvidencePackSigningService> logger)
public EvidencePackSigningService(
ILogger<EvidencePackSigningService> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -92,7 +96,7 @@ public sealed class EvidencePackSigningService : IEvidencePackSigningService
var envelopeBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(envelopeJson));
var envelopeDigest = ComputeDigest(envelopeJson);
var signedAt = DateTimeOffset.UtcNow;
var signedAt = _timeProvider.GetUtcNow();
// Build signature record
var signature = new EvidencePackSignature
@@ -318,11 +322,11 @@ public sealed class EvidencePackSigningService : IEvidencePackSigningService
};
}
private static Task<long?> UploadToRekorAsync(DsseEnvelope envelope, CancellationToken ct)
private Task<long?> UploadToRekorAsync(DsseEnvelope envelope, CancellationToken ct)
{
// In real implementation, would upload to Rekor transparency log
// For now, return placeholder index
return Task.FromResult<long?>(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
return Task.FromResult<long?>(_timeProvider.GetUtcNow().ToUnixTimeMilliseconds());
}
private static string ComputeMerkleRoot(ImmutableArray<ManifestEntry> entries)

View File

@@ -21,5 +21,8 @@
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Provcache\StellaOps.Provcache.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.ChangeTrace\StellaOps.Scanner.ChangeTrace.csproj" />
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism;
using StellaOps.ExportCenter.Core.Domain;
using StellaOps.ExportCenter.Core.Planner;
using StellaOps.ExportCenter.WebService.Telemetry;
@@ -202,6 +203,8 @@ public static class ExportApiEndpoints
ClaimsPrincipal user,
IExportProfileRepository profileRepo,
IExportAuditService auditService,
TimeProvider timeProvider,
IGuidProvider guidProvider,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(user);
@@ -212,10 +215,10 @@ public static class ExportApiEndpoints
if (!await profileRepo.IsNameUniqueAsync(tenantId, request.Name, cancellationToken: cancellationToken))
return TypedResults.Conflict($"Profile name '{request.Name}' already exists");
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var profile = new ExportProfile
{
ProfileId = Guid.NewGuid(),
ProfileId = guidProvider.NewGuid(),
TenantId = tenantId,
Name = request.Name,
Description = request.Description,
@@ -255,6 +258,7 @@ public static class ExportApiEndpoints
ClaimsPrincipal user,
IExportProfileRepository profileRepo,
IExportAuditService auditService,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(user);
@@ -291,7 +295,7 @@ public static class ExportApiEndpoints
? JsonSerializer.Serialize(request.Signing)
: existing.SigningJson,
Schedule = request.Schedule ?? existing.Schedule,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = timeProvider.GetUtcNow()
};
await profileRepo.UpdateAsync(updated, cancellationToken);
@@ -347,6 +351,8 @@ public static class ExportApiEndpoints
IExportRunRepository runRepo,
IExportAuditService auditService,
IOptions<ExportConcurrencyOptions> concurrencyOptions,
TimeProvider timeProvider,
IGuidProvider guidProvider,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(user);
@@ -396,18 +402,18 @@ public static class ExportApiEndpoints
return TypedResults.StatusCode(429);
}
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var shouldQueue = activeRunsCount >= options.MaxConcurrentRunsPerTenant ||
profileActiveRuns >= options.MaxConcurrentRunsPerProfile;
var run = new ExportRun
{
RunId = Guid.NewGuid(),
RunId = guidProvider.NewGuid(),
ProfileId = profileId,
TenantId = tenantId,
Status = shouldQueue ? ExportRunStatus.Queued : ExportRunStatus.Running,
Trigger = ExportRunTrigger.Api,
CorrelationId = request.CorrelationId ?? Guid.NewGuid().ToString(),
CorrelationId = request.CorrelationId ?? guidProvider.NewGuid().ToString(),
InitiatedBy = GetUserId(user),
TotalItems = 0,
ProcessedItems = 0,
@@ -632,6 +638,7 @@ public static class ExportApiEndpoints
ClaimsPrincipal user,
HttpContext httpContext,
IExportRunRepository runRepo,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(user);
@@ -661,7 +668,7 @@ public static class ExportApiEndpoints
{
EventType = "connected",
RunId = runId,
Timestamp = DateTimeOffset.UtcNow,
Timestamp = timeProvider.GetUtcNow(),
Data = MapToRunResponse(run, null)
}, cancellationToken);
@@ -685,7 +692,7 @@ public static class ExportApiEndpoints
{
EventType = ExportRunSseEventTypes.RunProgress,
RunId = runId,
Timestamp = DateTimeOffset.UtcNow,
Timestamp = timeProvider.GetUtcNow(),
Data = new ExportRunProgress
{
TotalItems = run.TotalItems,
@@ -714,7 +721,7 @@ public static class ExportApiEndpoints
{
EventType = eventType,
RunId = runId,
Timestamp = DateTimeOffset.UtcNow,
Timestamp = timeProvider.GetUtcNow(),
Data = MapToRunResponse(run, null)
}, cancellationToken);
@@ -729,7 +736,7 @@ public static class ExportApiEndpoints
{
EventType = "disconnected",
RunId = runId,
Timestamp = DateTimeOffset.UtcNow,
Timestamp = timeProvider.GetUtcNow(),
Data = MapToRunResponse(run, null)
}, cancellationToken);
}