Der Dateiverlauf (#719) haelt die Vorfassung jeder geloeschten Datei - ein eigener Papierkorb waere doppelt. delete einer Datei, die der Verlauf aufhebt, laeuft ohne Freigabe; Ordner nur mit recursive:true (delete_folder, Freigabe); zu grosse Dateien oder Verlauf aus (delete_unkept, Freigabe); die Wurzel nie. Neue Aktionen deleted und restore mit voller Pfad-/Endungspruefung. IAgentTool.StagingAktion bekommt eine Ueberladung mit Kontext. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
5.0 KiB
C#
120 lines
5.0 KiB
C#
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", recursive = true });
|
|
|
|
(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"));
|
|
}
|
|
}
|