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
+10
View File
@@ -375,3 +375,13 @@ public sealed record AgentCommunicationDto(
string Content,
DateTime Timestamp);
/// <summary>#719: One change in the history. Hashes are not sent — only whether a version exists.</summary>
public sealed record FileHistoryEntryDto(
long Id, string Workspace, string Path, string Action, string? AgentId, string? RunId,
bool HasBefore, bool HasAfter, long? SizeAfter, bool Stored, DateTime OccurredAtUtc, string? Note);
/// <summary>#719: A version's content. <see cref="Text"/> only for text of a showable size.</summary>
public sealed record FileVersionDto(string? Text, bool Binary, bool TooLarge, bool Kept, long Bytes);
/// <summary>#719: Putting a version back — which one, and why. Without a reason it is refused.</summary>
public sealed record FileRestoreInputDto(string? Version, string? Reason);
@@ -33,6 +33,7 @@ internal static class ApiEndpoints
api.MapLogs();
api.MapOrganisation();
api.MapCases();
api.MapFileHistory();
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
app.MapEvents();
@@ -0,0 +1,87 @@
using System.Text;
using ClawdDotNet.App;
using ClawdDotNet.Core.Files;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// #719: The history of the workspaces for the human — who changed what, what was in it
/// before, and putting a version back. Restoring is the human's only: agents have no way to it.
/// </summary>
internal static class FileHistoryEndpoints
{
/// <summary>A version larger than this is offered as "too large to show", not sent.</summary>
public const int MaxShownBytes = 512 * 1024;
public static void MapFileHistory(this RouteGroupBuilder api)
{
var history = api.MapGroup("/file-history");
history.MapGet("/", async (AppHost host, string? workspace, string? path, string? agent, string? run, int? limit, long? before, CancellationToken ct) =>
{
if (host.Engine?.History is not { } files)
return Results.Ok(Array.Empty<FileHistoryEntryDto>());
var list = await files.ListAsync(workspace, path, agent, run, limit ?? 100, ct, before);
return Results.Ok(list.Select(ToDto).ToList());
});
history.MapGet("/workspaces", (AppHost host) =>
FileHistoryKeeper.WorkspacesOf(host.Instance.Agents, Core.Organisation.OrgChart.From(host.Instance))
.Select(w => w.Workspace).ToList());
history.MapGet("/{id:long}/content", async (AppHost host, long id, string? version, CancellationToken ct) =>
{
if (host.Engine?.History is not { } files || await files.FindAsync(id, ct) is not { } entry)
return Results.NotFound(new OutcomeDto(false, $"No history entry {id}."));
var hash = version == "before" ? entry.HashBefore : entry.HashAfter;
if (hash is null)
return Results.Ok(new FileVersionDto(null, false, false, true, 0));
if (files.Content(hash) is not { } bytes)
return Results.Ok(new FileVersionDto(null, false, false, false, entry.SizeAfter ?? 0));
if (bytes.Length > MaxShownBytes)
return Results.Ok(new FileVersionDto(null, false, true, true, bytes.Length));
return Results.Ok(AsText(bytes) is { } text
? new FileVersionDto(text, false, false, true, bytes.Length)
: new FileVersionDto(null, true, false, true, bytes.Length));
});
history.MapPost("/{id:long}/restore", async (AppHost host, long id, FileRestoreInputDto input, CancellationToken ct) =>
{
if (host.Engine?.History is not { } files || await files.FindAsync(id, ct) is not { } entry)
return Results.NotFound(new OutcomeDto(false, $"No history entry {id}."));
try
{
var roots = FileHistoryKeeper.WorkspacesOf(host.Instance.Agents, Core.Organisation.OrgChart.From(host.Instance))
.Select(w => w.Root).ToList();
var restored = await files.RestoreAsync(entry, input.Version == "before", "human", input.Reason ?? "", roots, ct);
return Results.Ok(ToDto(restored));
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Results.BadRequest(new OutcomeDto(false, ex.Message));
}
});
}
/// <summary>The bytes as text if they are UTF-8 without control characters a text file would not have.</summary>
private static string? AsText(byte[] bytes)
{
try
{
var text = new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(bytes);
return text.Any(c => char.IsControl(c) && c is not ('\n' or '\r' or '\t')) ? null : text;
}
catch (DecoderFallbackException)
{
return null;
}
}
private static FileHistoryEntryDto ToDto(FileHistoryEntry e) => new(
e.Id, e.Workspace, e.Path, e.Action, e.AgentId, e.RunId, e.HashBefore is not null, e.HashAfter is not null,
e.SizeAfter, e.Stored, e.OccurredAtUtc, e.Note);
}