using System.Text.Json; using ControlPlane.Core.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace ControlPlane.Core.Services; /// /// Persists build and release history to JSON files in the ClientAssets folder. /// Thread-safe — all writes go through a single lock per file. /// public class BuildHistoryService { private readonly string _buildsPath; private readonly string _releasesPath; private readonly ILogger _logger; private static readonly SemaphoreSlim _buildLock = new(1, 1); private static readonly SemaphoreSlim _releaseLock = new(1, 1); private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; public BuildHistoryService(IConfiguration config, ILogger logger) { var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"] ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets")); Directory.CreateDirectory(folder); _buildsPath = Path.Combine(folder, "builds.json"); _releasesPath = Path.Combine(folder, "releases.json"); _logger = logger; } // ── Builds ────────────────────────────────────────────────────────────── public async Task CreateBuildAsync(BuildKind kind, string target) { var record = new BuildRecord { Kind = kind, Target = target }; await SaveBuildAsync(record); return record; } public async Task CompleteBuildAsync(BuildRecord record, BuildStatus status, string? digest = null) { record.Status = status; record.FinishedAt = DateTimeOffset.UtcNow; record.DurationMs = (int)(record.FinishedAt.Value - record.StartedAt).TotalMilliseconds; record.ImageDigest = digest; await SaveBuildAsync(record); } public async Task AppendBuildLogAsync(BuildRecord record, string line) { record.Log.Add(line); // Flush to disk every 20 lines to avoid excessive I/O but keep reasonable freshness if (record.Log.Count % 20 == 0) await SaveBuildAsync(record); } public async Task> GetBuildsAsync() { await _buildLock.WaitAsync(); try { return LoadJson(_buildsPath); } finally { _buildLock.Release(); } } private async Task SaveBuildAsync(BuildRecord record) { await _buildLock.WaitAsync(); try { var all = LoadJson(_buildsPath); var idx = all.FindIndex(b => b.Id == record.Id); if (idx >= 0) all[idx] = record; else all.Insert(0, record); // Keep last 100 builds if (all.Count > 100) all = all[..100]; await File.WriteAllTextAsync(_buildsPath, JsonSerializer.Serialize(all, JsonOpts)); } finally { _buildLock.Release(); } } // ── Releases ──────────────────────────────────────────────────────────── public async Task CreateReleaseAsync(string environment, string imageName) { var record = new ReleaseRecord { Environment = environment, ImageName = imageName }; await SaveReleaseAsync(record); return record; } public async Task UpdateReleaseAsync(ReleaseRecord record) { record.FinishedAt = DateTimeOffset.UtcNow; await SaveReleaseAsync(record); } public async Task> GetReleasesAsync() { await _releaseLock.WaitAsync(); try { return LoadJson(_releasesPath); } finally { _releaseLock.Release(); } } private async Task SaveReleaseAsync(ReleaseRecord record) { await _releaseLock.WaitAsync(); try { var all = LoadJson(_releasesPath); var idx = all.FindIndex(r => r.Id == record.Id); if (idx >= 0) all[idx] = record; else all.Insert(0, record); if (all.Count > 50) all = all[..50]; await File.WriteAllTextAsync(_releasesPath, JsonSerializer.Serialize(all, JsonOpts)); } finally { _releaseLock.Release(); } } // ── Helpers ───────────────────────────────────────────────────────────── private static List LoadJson(string path) { if (!File.Exists(path)) return []; try { var json = File.ReadAllText(path); return JsonSerializer.Deserialize>(json, JsonOpts) ?? []; } catch { return []; } } }