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