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:
Richard
2026-09-28 11:12:13 +02:00
co-authored by Claude Opus 5.5
parent 621a83de43
commit e9c39c3c96
21 changed files with 1663 additions and 2 deletions
@@ -311,6 +311,45 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|| relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase);
}
// ─── History (#719) ───
/// <summary>The workspace as the history names it — the personal one with its owner, since every agent has one.</summary>
private static string HistoryWorkspace(Area area, AgentToolContext context)
=> area.IsPersonal ? $"{WorkspaceLayout.Personal}:{context.AgentId}" : area.Name;
/// <summary>
/// Keeps the version before the change. Best effort: a history that cannot be written
/// must not stop the work — it is logged, and the change happens without its entry.
/// </summary>
private static async Task<FileChange?> BeforeChangeAsync(string path, Area area, AgentToolContext context, CancellationToken ct)
{
if (context.History is not { } history)
return null;
try
{
return await history.BeforeAsync(path, HistoryWorkspace(area, context), ct);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException)
{
context.Logger.LogWarning(ex, "File history: the version before could not be kept for {Path}", path);
return null;
}
}
private static async Task AfterChangeAsync(FileChange? change, string action, AgentToolContext context, CancellationToken ct)
{
if (change is null || context.History is not { } history)
return;
try
{
await history.RecordAsync(change, action, context.AgentId, context.RunId, ct);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException)
{
context.Logger.LogWarning(ex, "File history: the change could not be recorded for {Path}", change.FullPath);
}
}
// ─── Lost updates (#720) ───
/// <summary>Only workspaces more than one agent can write to — never the personal one.</summary>
@@ -712,6 +751,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var dir = Path.GetDirectoryName(path);
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
var change = await BeforeChangeAsync(path, area, context, ct);
// Atomar schreiben (#553): erst in eine Nebendatei, dann ersetzen. Ein Absturz
// mitten im Vorgang hinterlässt damit entweder die alte oder die neue Fassung,
@@ -720,6 +760,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// Agenten aber nicht.
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
NoteReplace(path, area, context);
await AfterChangeAsync(change, FileAction.Replace, context, ct);
return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
@@ -744,6 +785,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// #720: appending loses nothing and is not checked — but noted, so whoever read
// the file before does not replace it without the appended part.
var before = Guarded(area, context) ? FileLedger.HashOf(path) : null;
var change = await BeforeChangeAsync(path, area, context, ct);
// #575: Neu entstehende Dateien gehoeren nur dem Besitzer (0600).
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
@@ -752,6 +794,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (Guarded(area, context))
context.Files!.NoteAppend(context.AgentId, context.RunId, path, before, FileLedger.HashOf(path));
await AfterChangeAsync(change, FileAction.Append, context, ct);
return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
@@ -895,12 +938,20 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
{
if (File.Exists(path))
{
var change = await BeforeChangeAsync(path, area, context, ct);
File.Delete(path);
await AfterChangeAsync(change, FileAction.Delete, context, ct);
return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
else if (Directory.Exists(path))
{
// #719: every file in it is a deletion of its own in the history.
var changes = new List<FileChange?>();
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
changes.Add(await BeforeChangeAsync(file, area, context, ct));
Directory.Delete(path, true);
foreach (var change in changes)
await AfterChangeAsync(change, FileAction.Delete, context, ct);
return ToolResult.Ok($"[{area.Name} Workspace] Directory deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
@@ -968,9 +1019,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var destDir = Path.GetDirectoryName(destPath);
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
var change = await BeforeChangeAsync(destPath, destArea, context, ct);
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct);
NoteReplace(destPath, destArea, context);
await AfterChangeAsync(change, FileAction.Copy, context, ct);
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
@@ -988,8 +1041,28 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
else if (Directory.Exists(sourcePath))
{
// #720/#719: each file the copy would replace is checked and recorded like a single copy.
var targets = Directory.EnumerateFiles(sourcePath, "*", SearchOption.AllDirectories)
.Select(f => Path.Combine(destPath, Path.GetRelativePath(sourcePath, f)))
.ToList();
foreach (var target in targets)
{
var shown = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, target).Replace('\\', '/')}";
if (await RefuseLostUpdateAsync(target, shown, destArea, context, ct) is { } conflict)
return conflict;
}
var changes = new List<FileChange?>();
foreach (var target in targets)
changes.Add(await BeforeChangeAsync(target, destArea, context, ct));
await Task.Run(() => CopyDirectory(sourcePath, destPath), ct);
foreach (var (target, change) in targets.Zip(changes))
{
NoteReplace(target, destArea, context);
await AfterChangeAsync(change, FileAction.Copy, context, ct);
}
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
return ToolResult.Ok($"Directory copied: {srcLabel} → {dstLabel}");
@@ -1077,7 +1150,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// Datenpunkt speichern
var json = JsonSerializer.Serialize(entry, StockJsonOpts);
var stockArea = new Area(WorkspaceLayout.Shared, sharedRoot, WorkspaceAccess.Write, []);
var dataChange = await BeforeChangeAsync(filePath, stockArea, context, ct);
await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(filePath, json, new UTF8Encoding(false), ct);
await AfterChangeAsync(dataChange, FileAction.Create, context, ct);
// _index.json aktualisieren (thread-safe via per-file SemaphoreSlim, append-only)
var indexPath = Path.Combine(stockDir, "_index.json");
@@ -1120,10 +1196,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
index.Add(indexEntry);
var indexChange = await BeforeChangeAsync(indexPath, stockArea, context, ct);
// Atomar: Der Index ist die einzige Übersicht über die Datenpunkte —
// eine halb geschriebene Datei wäre nicht wiederherstellbar.
await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(
indexPath, JsonSerializer.Serialize(index, StockJsonOpts), ct: ct);
await AfterChangeAsync(indexChange, FileAction.Append, context, ct);
// Den Stand hier festhalten, solange die Sperre noch gilt (B9). Vorher
// wurde die Datei nach dem Freigeben ein zweites Mal gelesen und geparst: