using System.Text.Json; namespace Agent.Windows.Recovery; public enum JournalEntryState { Prepared, Applied, Cleaned } public sealed class JournalEntry { public Guid Id { get; set; } = Guid.NewGuid(); public string Kind { get; set; } = string.Empty; public string Target { get; set; } = string.Empty; public JournalEntryState State { get; set; } public Dictionary Data { get; set; } = new(StringComparer.Ordinal); } public sealed class JournalDocument { public int SchemaVersion { get; set; } = 1; public Guid TaskId { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public List Entries { get; set; } = []; } public sealed class TaskResourceJournal { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private readonly object _sync = new(); private readonly JournalDocument _document; private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; } public string Path { get; } public Guid TaskId => _document.TaskId; public IReadOnlyList Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } } public static TaskResourceJournal Create(string journalDirectory, Guid taskId) { Directory.CreateDirectory(journalDirectory); string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json"); if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'."); var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId }); journal.Persist(); return journal; } public static TaskResourceJournal Open(string path) { JournalDocument document = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? throw new InvalidDataException($"Journal '{path}' is empty."); if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid."); return new(path, document); } public Guid Prepare(string kind, string target, IReadOnlyDictionary data) { lock (_sync) { var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) }; _document.Entries.Add(entry); Persist(); return entry.Id; } } public void MarkApplied(Guid entryId, IReadOnlyDictionary? additionalData = null) { lock (_sync) { JournalEntry entry = Get(entryId); if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied."); if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value; entry.State = JournalEntryState.Applied; Persist(); } } public void MarkCleaned(Guid entryId) { lock (_sync) { JournalEntry entry = Get(entryId); if (entry.State == JournalEntryState.Cleaned) return; entry.State = JournalEntryState.Cleaned; Persist(); } } public void CompleteAndDelete() { lock (_sync) { if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources."); File.Delete(Path); } } private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found."); private void Persist() { string temporary = Path + ".tmp"; using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) { JsonSerializer.Serialize(stream, _document, JsonOptions); stream.Flush(flushToDisk: true); } File.Move(temporary, Path, overwrite: true); } private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) }; }