using System.Text; using ClawdDotNet.Core.Files; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Files; /// /// #719: The history of the workspaces — every change with its agent, its run and the version /// before; changes made by hand found as external; a version put back only with a reason. /// public sealed class FileHistoryTests : IDisposable { private readonly string _instance = Path.Combine(Path.GetTempPath(), "clawd-tests", "history-" + Guid.NewGuid().ToString("N")[..8]); private readonly string _agents; private readonly string _shared; private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 9, 28, 10, 0, 0, TimeSpan.Zero)); private readonly FileHistorySettings _settings = new(); private readonly FileHistory _history; public FileHistoryTests() { _agents = Path.Combine(_instance, "Agents"); _shared = Path.Combine(_agents, "SharedWorkspace"); Directory.CreateDirectory(_shared); _history = new FileHistory(_agents, new SqliteStorage(Path.Combine(_instance, "state.db")), () => _settings, _clock); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_instance); try { Directory.Delete(_instance, recursive: true); } catch { /* cleanup is secondary */ } } private string File_(string name) => Path.Combine(_shared, name); private async Task Change(string name, string? content, string action, string agent = "a", string run = "r1") { var path = File_(name); var change = await _history.BeforeAsync(path, "shared", default); if (content is null) File.Delete(path); else File.WriteAllText(path, content); _clock.Advance(TimeSpan.FromMinutes(1)); return await _history.RecordAsync(change, action, agent, run, default); } private Task> All() => _history.ListAsync(null, null, null, null, 1000, default); // ═══════════════════ recording ═══════════════════ [Fact] public async Task Every_change_is_journaled_with_agent_run_and_the_version_before() { await Change("plan.md", "v1", FileAction.Replace, "a", "r1"); await Change("plan.md", "v2", FileAction.Replace, "b", "r2"); await Change("plan.md", null, FileAction.Delete, "c", "r3"); var entries = (await All()).Reverse().ToList(); entries.Select(e => (e.Action, e.AgentId, e.RunId)).ShouldBe([ (FileAction.Create, "a", "r1"), (FileAction.Replace, "b", "r2"), (FileAction.Delete, "c", "r3") ]); entries[0].Path.ShouldBe("SharedWorkspace/plan.md"); Encoding.UTF8.GetString(_history.Content(entries[1].HashBefore)!).ShouldBe("v1"); Encoding.UTF8.GetString(_history.Content(entries[2].HashBefore)!).ShouldBe("v2"); entries[2].HashAfter.ShouldBeNull(); } [Fact] public async Task A_write_that_changes_nothing_is_not_a_change() { await Change("plan.md", "same", FileAction.Replace); (await Change("plan.md", "same", FileAction.Replace)).ShouldBeNull(); (await All()).Count.ShouldBe(1); } [Fact] public async Task Each_content_is_stored_once() { await Change("a.md", "shared text", FileAction.Replace); await Change("b.md", "shared text", FileAction.Replace); var objects = Directory.GetFiles(Path.Combine(_agents, FileHistory.FolderName, "objects"), "*", SearchOption.AllDirectories); objects.Length.ShouldBe(1); Path.GetExtension(objects[0]).ShouldBeEmpty("the backup leaves out .bak and .tmp — an object must not look like one"); } [Fact] public async Task A_file_too_large_is_journaled_but_not_kept() { _settings.MaxObjectMegabytes = 1; var entry = await Change("big.bin", new string('x', 1024 * 1024 + 1), FileAction.Replace); entry!.Stored.ShouldBeFalse(); entry.HashAfter.ShouldNotBeNull(); _history.Content(entry.HashAfter).ShouldBeNull(); } [Fact] public async Task Switched_off_it_records_nothing() { _settings.Enabled = false; (await Change("plan.md", "v1", FileAction.Replace)).ShouldBeNull(); (await All()).ShouldBeEmpty(); } // ═══════════════════ restoring ═══════════════════ [Fact] public async Task A_version_is_put_back_with_a_reason_and_the_current_one_stays_in_the_history() { await Change("plan.md", "good", FileAction.Replace); var bad = await Change("plan.md", "broken", FileAction.Replace, "b", "r2"); var restored = await _history.RestoreAsync(bad!, versionBefore: true, "human", "b broke the plan", [_shared], default); File.ReadAllText(File_("plan.md")).ShouldBe("good"); restored.Action.ShouldBe(FileAction.Restore); restored.AgentId.ShouldBe("human"); restored.Note!.ShouldContain("b broke the plan"); Encoding.UTF8.GetString(_history.Content(restored.HashBefore)!).ShouldBe("broken"); } [Fact] public async Task Without_a_reason_nothing_is_restored() { await Change("plan.md", "good", FileAction.Replace); var bad = await Change("plan.md", "broken", FileAction.Replace); await Should.ThrowAsync(() => _history.RestoreAsync(bad!, true, "human", " ", [_shared], default)); File.ReadAllText(File_("plan.md")).ShouldBe("broken"); } [Fact] public async Task A_file_outside_the_named_workspaces_is_not_restored() { await Change("plan.md", "good", FileAction.Replace); var bad = await Change("plan.md", "broken", FileAction.Replace); await Should.ThrowAsync(() => _history.RestoreAsync(bad!, true, "human", "reason", [Path.Combine(_agents, "Agent-other", "Workspace")], default)); } // ═══════════════════ outside the tools ═══════════════════ [Fact] public async Task The_first_look_only_notes_what_is_there() { File.WriteAllText(File_("old.md"), "from before the history"); (await _history.ReconcileAsync([("shared", _shared)], default)).ShouldBe(0); (await All()).ShouldBeEmpty(); } [Fact] public async Task A_change_by_hand_is_recorded_as_external_with_both_versions() { await Change("plan.md", "by the agent", FileAction.Replace); File.WriteAllText(File_("plan.md"), "edited by hand"); File.SetLastWriteTimeUtc(File_("plan.md"), DateTime.UtcNow.AddMinutes(5)); (await _history.ReconcileAsync([("shared", _shared)], default)).ShouldBe(1); var external = (await All())[0]; external.Action.ShouldBe(FileAction.External); external.AgentId.ShouldBeNull(); Encoding.UTF8.GetString(_history.Content(external.HashBefore)!).ShouldBe("by the agent"); Encoding.UTF8.GetString(_history.Content(external.HashAfter)!).ShouldBe("edited by hand"); } [Fact] public async Task A_deletion_by_hand_is_recorded_too() { await Change("plan.md", "v1", FileAction.Replace); File.Delete(File_("plan.md")); (await _history.ReconcileAsync([("shared", _shared)], default)).ShouldBe(1); (await All())[0].HashAfter.ShouldBeNull(); } [Fact] public async Task Nothing_changed_means_nothing_recorded() { await Change("plan.md", "v1", FileAction.Replace); await _history.ReconcileAsync([("shared", _shared)], default); (await _history.ReconcileAsync([("shared", _shared)], default)).ShouldBe(0); (await All()).Count.ShouldBe(1); } [Fact] public async Task The_history_does_not_record_itself() { await Change("plan.md", "v1", FileAction.Replace); await _history.ReconcileAsync([("everything", _agents)], default); await _history.ReconcileAsync([("everything", _agents)], default); (await All()).ShouldAllBe(e => !e.Path.Contains(FileHistory.FolderName)); } // ═══════════════════ retention ═══════════════════ [Fact] public async Task Old_entries_go_but_the_newest_of_each_file_stays() { _settings.RetentionDays = 30; await Change("plan.md", "v1", FileAction.Replace); await Change("plan.md", "v2", FileAction.Replace); await Change("once.md", "only", FileAction.Replace); _clock.Advance(TimeSpan.FromDays(40)); await _history.PurgeAsync(default); var left = await All(); left.Select(e => e.Path).ShouldBe(["SharedWorkspace/once.md", "SharedWorkspace/plan.md"], ignoreOrder: true); } [Fact] public async Task Beyond_the_version_limit_the_oldest_go() { _settings.MaxVersionsPerFile = 2; for (var i = 1; i <= 4; i++) await Change("plan.md", $"v{i}", FileAction.Replace); await _history.PurgeAsync(default); (await All()).Count.ShouldBe(2); } [Fact] public async Task A_version_nobody_points_to_is_removed() { _settings.MaxVersionsPerFile = 1; await Change("plan.md", "v1", FileAction.Replace); await Change("plan.md", "v2", FileAction.Replace); await Change("plan.md", "v3", FileAction.Replace); var v1 = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData("v1"u8)); _clock.Advance(TimeSpan.FromHours(1)); foreach (var file in Directory.GetFiles(Path.Combine(_agents, FileHistory.FolderName, "objects"), "*", SearchOption.AllDirectories)) File.SetLastWriteTimeUtc(file, _clock.GetUtcNow().UtcDateTime.AddHours(-1)); await _history.PurgeAsync(default); _history.Content(v1).ShouldBeNull("v1 is referenced by no remaining entry"); _history.Content((await All())[0].HashAfter).ShouldNotBeNull(); } }