using System.Text.Json; using System.Text.Json.Nodes; using StellaOps.AirGap.Policy; using StellaOps.TaskRunner.Core.Execution; using StellaOps.TaskRunner.Core.Planning; using StellaOps.TaskRunner.Core.TaskPacks; namespace StellaOps.TaskRunner.Infrastructure.Execution; public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRunJobScheduler { private readonly string queuePath; private readonly string archivePath; private readonly TaskPackManifestLoader manifestLoader = new(); private readonly TaskPackPlanner planner; private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web); public FilesystemPackRunDispatcher(string queuePath, string archivePath, IEgressPolicy? egressPolicy = null) { this.queuePath = queuePath ?? throw new ArgumentNullException(nameof(queuePath)); this.archivePath = archivePath ?? throw new ArgumentNullException(nameof(archivePath)); planner = new TaskPackPlanner(egressPolicy); Directory.CreateDirectory(queuePath); Directory.CreateDirectory(archivePath); } public string QueuePath => queuePath; public async Task TryDequeueAsync(CancellationToken cancellationToken) { var files = Directory.GetFiles(queuePath, "*.json", SearchOption.TopDirectoryOnly) .OrderBy(path => path, StringComparer.Ordinal) .ToArray(); foreach (var file in files) { cancellationToken.ThrowIfCancellationRequested(); try { var jobJson = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false); var job = JsonSerializer.Deserialize(jobJson, serializerOptions); if (job is null) { continue; } TaskPackPlan? plan = job.Plan; if (plan is null) { if (string.IsNullOrWhiteSpace(job.ManifestPath)) { continue; } var manifestPath = ResolvePath(queuePath, job.ManifestPath); var inputsPath = job.InputsPath is null ? null : ResolvePath(queuePath, job.InputsPath); var manifest = manifestLoader.Load(manifestPath); var inputs = await LoadInputsAsync(inputsPath, cancellationToken).ConfigureAwait(false); var planResult = planner.Plan(manifest, inputs); if (!planResult.Success || planResult.Plan is null) { throw new InvalidOperationException($"Failed to plan pack for run {job.RunId}: {string.Join(';', planResult.Errors.Select(e => e.Message))}"); } plan = planResult.Plan; } var archiveFile = Path.Combine(archivePath, Path.GetFileName(file)); File.Move(file, archiveFile, overwrite: true); var requestedAt = job.RequestedAt ?? DateTimeOffset.UtcNow; var runId = string.IsNullOrWhiteSpace(job.RunId) ? Guid.NewGuid().ToString("n") : job.RunId; return new PackRunExecutionContext(runId, plan, requestedAt); } catch (Exception ex) { var failedPath = file + ".failed"; File.Move(file, failedPath, overwrite: true); Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}"); } } return null; } public async Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); var envelope = new JobEnvelope( context.RunId, ManifestPath: null, InputsPath: null, context.RequestedAt, context.Plan); Directory.CreateDirectory(queuePath); var safeRunId = string.IsNullOrWhiteSpace(context.RunId) ? Guid.NewGuid().ToString("n") : SanitizeFileName(context.RunId); var fileName = $"{safeRunId}-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}.json"; var path = Path.Combine(queuePath, fileName); var json = JsonSerializer.Serialize(envelope, serializerOptions); await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false); } private static string ResolvePath(string root, string relative) => Path.IsPathRooted(relative) ? relative : Path.Combine(root, relative); private static async Task> LoadInputsAsync(string? path, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) { return new Dictionary(StringComparer.Ordinal); } var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); var node = JsonNode.Parse(json) as JsonObject; if (node is null) { return new Dictionary(StringComparer.Ordinal); } return node.ToDictionary( pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); } private sealed record JobEnvelope( string? RunId, string? ManifestPath, string? InputsPath, DateTimeOffset? RequestedAt, TaskPackPlan? Plan); private static string SanitizeFileName(string value) { var safe = value.Trim(); foreach (var invalid in Path.GetInvalidFileNameChars()) { safe = safe.Replace(invalid, '_'); } return safe; } }