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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user