Versionierung der Arbeitsbereiche: Verlauf je Datei mit Agent, Lauf und Vorfassung (#719)
Eigene inhaltsadressierte Ablage (Agents/.history/objects, SHA-256) statt Git, Journal FileHistory in state.db. Jeder Schreibweg - FileRW write/append/copy/ delete/stock_add, FTP- und RocketChat-Downloads - legt die alte Fassung ab und verbucht die Aenderung mit RunId. Externe Aenderungen findet ein Abgleich beim Start und stuendlich. Aufbewahrung nach Tagen und Fassungen, verwaiste Objekte werden entfernt. WebUI: Dateiverlauf mit Filter, Diff und Wiederherstellen nur durch den Menschen mit Grund. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
621a83de43
commit
e9c39c3c96
@@ -0,0 +1,253 @@
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Files;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Files;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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<FileHistoryEntry?> 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<IReadOnlyList<FileHistoryEntry>> 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<ArgumentException>(() => _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<InvalidOperationException>(() =>
|
||||
_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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Files;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.FileRW;
|
||||
|
||||
/// <summary>
|
||||
/// #719: FileRW is the main way into the workspaces — every change it makes lands in the
|
||||
/// history, with the agent and the run of the call.
|
||||
/// </summary>
|
||||
public sealed class FileHistoryRecordingTests : IDisposable
|
||||
{
|
||||
private readonly string _instance = Path.Combine(Path.GetTempPath(), "clawd-tests", "fh-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
private readonly string _agents;
|
||||
private readonly FileHistory _history;
|
||||
private readonly FileRWTool _tool = new();
|
||||
|
||||
public FileHistoryRecordingTests()
|
||||
{
|
||||
_agents = Path.Combine(_instance, "Agents");
|
||||
Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace"));
|
||||
_history = new FileHistory(_agents, new SqliteStorage(Path.Combine(_instance, "state.db")));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_instance);
|
||||
try { Directory.Delete(_instance, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
private Task<ToolResult> Run(string agentId, object input)
|
||||
{
|
||||
var personal = Path.Combine(_agents, "Agent-" + agentId, "Workspace");
|
||||
Directory.CreateDirectory(personal);
|
||||
var context = new AgentToolContext(agentId, "inst",
|
||||
new Dictionary<string, object?> { ["sharedAccessLevel"] = "Admin" },
|
||||
new NullStateStore(), NullLogger.Instance, CancellationToken.None,
|
||||
WorkspacePath: personal, SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"))
|
||||
{
|
||||
History = _history,
|
||||
RunId = "run-" + agentId
|
||||
};
|
||||
return _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task<List<FileHistoryEntry>> Journal() => (await _history.ListAsync(null, null, null, null, 100, default)).Reverse().ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task Write_append_and_delete_are_recorded_with_agent_and_run()
|
||||
{
|
||||
await Run("a", new { action = "write", workspace = "shared", path = "notes/plan.md", content = "v1" });
|
||||
await Run("b", new { action = "append", workspace = "shared", path = "notes/plan.md", content = "\n+b" });
|
||||
await Run("c", new { action = "delete", workspace = "shared", path = "notes/plan.md" });
|
||||
|
||||
(await Journal()).Select(e => (e.Action, e.AgentId, e.RunId, e.Path, e.Workspace)).ShouldBe([
|
||||
(FileAction.Create, "a", "run-a", "SharedWorkspace/notes/plan.md", "shared"),
|
||||
(FileAction.Append, "b", "run-b", "SharedWorkspace/notes/plan.md", "shared"),
|
||||
(FileAction.Delete, "c", "run-c", "SharedWorkspace/notes/plan.md", "shared")
|
||||
]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task What_a_delete_removed_can_be_read_back()
|
||||
{
|
||||
await Run("a", new { action = "write", workspace = "shared", path = "plan.md", content = "precious" });
|
||||
await Run("b", new { action = "delete", workspace = "shared", path = "plan.md" });
|
||||
|
||||
var delete = (await Journal()).Last();
|
||||
Encoding.UTF8.GetString(_history.Content(delete.HashBefore)!).ShouldBe("precious");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_folder_records_each_file()
|
||||
{
|
||||
await Run("a", new { action = "write", workspace = "shared", path = "old/1.md", content = "one" });
|
||||
await Run("a", new { action = "write", workspace = "shared", path = "old/2.md", content = "two" });
|
||||
|
||||
await Run("a", new { action = "delete", workspace = "shared", path = "old" });
|
||||
|
||||
(await Journal()).Count(e => e.Action == FileAction.Delete).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_copy_is_recorded_at_its_destination()
|
||||
{
|
||||
await Run("a", new { action = "write", workspace = "shared", path = "src.md", content = "x" });
|
||||
await Run("a", new { action = "copy", workspace = "shared", path = "src.md", destinationPath = "dst.md" });
|
||||
|
||||
(await Journal()).Last().ShouldSatisfyAllConditions(
|
||||
e => e.Action.ShouldBe(FileAction.Copy),
|
||||
e => e.Path.ShouldBe("SharedWorkspace/dst.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_personal_workspace_is_recorded_under_its_owner()
|
||||
{
|
||||
await Run("a", new { action = "write", path = "mine.md", content = "x" });
|
||||
|
||||
var entry = (await Journal()).Single();
|
||||
entry.Workspace.ShouldBe("personal:a");
|
||||
entry.Path.ShouldBe("Agent-a/Workspace/mine.md");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stock_data_point_records_the_file_and_the_index()
|
||||
{
|
||||
await Run("a", new { action = "stock_add", ticker = "ACME", category = "news", source = "test", title = "A title" });
|
||||
|
||||
var journal = await Journal();
|
||||
journal.ShouldContain(e => e.Path.EndsWith("/_index.json") && e.Action == FileAction.Create);
|
||||
journal.ShouldContain(e => e.Path.Contains("/stocks/ACME/") && !e.Path.EndsWith("_index.json"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user