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);
}