using System.Text.Json; using StellaOps.TaskRunner.Core.Execution; using StellaOps.TaskRunner.Core.Planning; namespace StellaOps.TaskRunner.Infrastructure.Execution; /// /// File-system backed implementation of intended for development and air-gapped smoke tests. /// public sealed class FilePackRunStateStore : IPackRunStateStore { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private readonly string rootPath; private readonly SemaphoreSlim mutex = new(1, 1); public FilePackRunStateStore(string rootPath) { ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); this.rootPath = Path.GetFullPath(rootPath); Directory.CreateDirectory(this.rootPath); } public async Task GetAsync(string runId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(runId); var path = GetPath(runId); if (!File.Exists(path)) { return null; } await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return document?.ToDomain(); } public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(state); var path = GetPath(state.RunId); var document = StateDocument.FromDomain(state); Directory.CreateDirectory(rootPath); await mutex.WaitAsync(cancellationToken).ConfigureAwait(false); try { await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None); await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken) .ConfigureAwait(false); } finally { mutex.Release(); } } public async Task> ListAsync(CancellationToken cancellationToken) { if (!Directory.Exists(rootPath)) { return Array.Empty(); } var states = new List(); var files = Directory.EnumerateFiles(rootPath, "*.json", SearchOption.TopDirectoryOnly) .OrderBy(file => file, StringComparer.Ordinal); foreach (var file in files) { cancellationToken.ThrowIfCancellationRequested(); await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read); var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); if (document is not null) { states.Add(document.ToDomain()); } } return states; } private string GetPath(string runId) { var safeName = SanitizeFileName(runId); return Path.Combine(rootPath, $"{safeName}.json"); } private static string SanitizeFileName(string value) { var result = value.Trim(); foreach (var invalid in Path.GetInvalidFileNameChars()) { result = result.Replace(invalid, '_'); } return result; } private sealed record StateDocument( string RunId, string PlanHash, TaskPackPlan Plan, TaskPackPlanFailurePolicy FailurePolicy, DateTimeOffset RequestedAt, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, IReadOnlyList Steps, string? TenantId) { public static StateDocument FromDomain(PackRunState state) { var steps = state.Steps.Values .OrderBy(step => step.StepId, StringComparer.Ordinal) .Select(step => new StepDocument( step.StepId, step.Kind, step.Enabled, step.ContinueOnError, step.MaxParallel, step.ApprovalId, step.GateMessage, step.Status, step.Attempts, step.LastTransitionAt, step.NextAttemptAt, step.StatusReason)) .ToList(); return new StateDocument( state.RunId, state.PlanHash, state.Plan, state.FailurePolicy, state.RequestedAt, state.CreatedAt, state.UpdatedAt, steps, state.TenantId); } public PackRunState ToDomain() { var steps = Steps.ToDictionary( step => step.StepId, step => new PackRunStepStateRecord( step.StepId, step.Kind, step.Enabled, step.ContinueOnError, step.MaxParallel, step.ApprovalId, step.GateMessage, step.Status, step.Attempts, step.LastTransitionAt, step.NextAttemptAt, step.StatusReason), StringComparer.Ordinal); return new PackRunState( RunId, PlanHash, Plan, FailurePolicy, RequestedAt, CreatedAt, UpdatedAt, steps, TenantId); } } private sealed record StepDocument( string StepId, PackRunStepKind Kind, bool Enabled, bool ContinueOnError, int? MaxParallel, string? ApprovalId, string? GateMessage, PackRunStepExecutionStatus Status, int Attempts, DateTimeOffset? LastTransitionAt, DateTimeOffset? NextAttemptAt, string? StatusReason); }