Merge feat/195-reversible-delete: Loeschen ueber den Dateiverlauf umkehrbar (#195)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
# Umsetzungsplan: FileRW-Papierkorb + Cleanup-Job
|
# Umsetzungsplan: FileRW-Papierkorb + Cleanup-Job
|
||||||
|
|
||||||
|
> **Überholt am 2026-10-02 (#195 umgesetzt, anders als hier geplant).** Seit #719 hebt der
|
||||||
|
> Dateiverlauf die Vorfassung jeder gelöschten Datei auf (90 Tage, bis 20 MB je Datei). Ein
|
||||||
|
> eigener `.trash`-Ordner wäre doppelt gewesen. Umgesetzt ist deshalb: `delete` einer Datei,
|
||||||
|
> die der Verlauf aufheben kann, läuft ohne Freigabe; ein Ordner nur mit `recursive: true` und
|
||||||
|
> mit Freigabe (`FileRW.delete_folder`); eine Datei, die der Verlauf nicht aufheben kann (zu
|
||||||
|
> groß, Verlauf aus), mit Freigabe (`FileRW.delete_unkept`); die Wurzel eines Arbeitsbereichs
|
||||||
|
> nie. Agenten sehen mit `deleted`, was sie zurückholen können, und holen es mit `restore`
|
||||||
|
> zurück — mit derselben Pfad- und Endungsprüfung wie ein Schreiben. Der Rest dieses Dokuments
|
||||||
|
> ist die alte Planung.
|
||||||
|
|
||||||
> **Stand 2026-08-23: beschlossen, noch nicht gebaut.** Eingeordnet als Punkt 3 der
|
> **Stand 2026-08-23: beschlossen, noch nicht gebaut.** Eingeordnet als Punkt 3 der
|
||||||
> [Roadmap](../Roadmap.md) — die Reihenfolge gilt dort, dieses Dokument ist der Bauplan.
|
> [Roadmap](../Roadmap.md) — die Reihenfolge gilt dort, dieses Dokument ist der Bauplan.
|
||||||
|
|
||||||
|
|||||||
@@ -1655,7 +1655,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
if (_stagingGate is not null)
|
if (_stagingGate is not null)
|
||||||
{
|
{
|
||||||
// #641: Die Rechte stehen in der Werkzeugkonfiguration des Agenten.
|
// #641: Die Rechte stehen in der Werkzeugkonfiguration des Agenten.
|
||||||
var operation = tool.StagingAktion(input) ?? Staging.StagingGate.ExtractAction(arguments);
|
var operation = tool.StagingAktion(input, context) ?? Staging.StagingGate.ExtractAction(arguments);
|
||||||
var intercept = await _stagingGate.InterceptAsync(
|
var intercept = await _stagingGate.InterceptAsync(
|
||||||
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
|
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
|
||||||
operation,
|
operation,
|
||||||
|
|||||||
@@ -107,6 +107,24 @@ public sealed class FileHistory
|
|||||||
public bool Covers(string fullPath)
|
public bool Covers(string fullPath)
|
||||||
=> !IsInside(Path.GetFullPath(fullPath), Path.Combine(_agentsDirectory, FolderName));
|
=> !IsInside(Path.GetFullPath(fullPath), Path.Combine(_agentsDirectory, FolderName));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #195: Whether a delete of this file can be undone — the history is on, covers the file
|
||||||
|
/// and keeps content of its size. Only then is deleting it no irreversible act.
|
||||||
|
/// </summary>
|
||||||
|
public bool CanKeep(string fullPath)
|
||||||
|
{
|
||||||
|
fullPath = Path.GetFullPath(fullPath);
|
||||||
|
return Enabled && Covers(fullPath) && File.Exists(fullPath)
|
||||||
|
&& new FileInfo(fullPath).Length <= (long)_settings().MaxObjectMegabytes * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#195: Whether the content of a version is still kept.</summary>
|
||||||
|
public bool CanKeepContent(string? hash)
|
||||||
|
=> !string.IsNullOrWhiteSpace(hash) && hash.Length == 64 && hash.All(Uri.IsHexDigit) && File.Exists(ObjectPath(hash.ToLowerInvariant()));
|
||||||
|
|
||||||
|
/// <summary>#195: How long a deleted file's content stays restorable.</summary>
|
||||||
|
public int RetentionDays => Math.Max(1, _settings().RetentionDays);
|
||||||
|
|
||||||
// ─── Recording ───
|
// ─── Recording ───
|
||||||
|
|
||||||
/// <summary>Keeps the version the file has now, before a tool changes it.</summary>
|
/// <summary>Keeps the version the file has now, before a tool changes it.</summary>
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ public sealed class StagingPolicy
|
|||||||
["Database.truncate"] = StagingDecision.Approve,
|
["Database.truncate"] = StagingDecision.Approve,
|
||||||
|
|
||||||
["FileRW.delete"] = StagingDecision.Approve,
|
["FileRW.delete"] = StagingDecision.Approve,
|
||||||
|
// #195: FileRW names these itself — a folder at once, or a file the history cannot keep.
|
||||||
|
["FileRW.delete_folder"] = StagingDecision.Approve,
|
||||||
|
["FileRW.delete_unkept"] = StagingDecision.Approve,
|
||||||
["FTP.upload"] = StagingDecision.Approve,
|
["FTP.upload"] = StagingDecision.Approve,
|
||||||
["FTP.delete"] = StagingDecision.Approve,
|
["FTP.delete"] = StagingDecision.Approve,
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ public interface IAgentTool
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
string? StagingAktion(JsonElement input) => null;
|
string? StagingAktion(JsonElement input) => null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #195: The staging key with the call's context — for a key that depends on what the call
|
||||||
|
/// meets (FileRW: a file the history keeps, a folder, a file too large to keep). Default:
|
||||||
|
/// <see cref="StagingAktion(JsonElement)"/>.
|
||||||
|
/// </summary>
|
||||||
|
string? StagingAktion(JsonElement input, AgentToolContext context) => StagingAktion(input);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Die Operationen, die sich in der Werkzeugkonfiguration eines Agenten erlauben oder
|
/// Die Operationen, die sich in der Werkzeugkonfiguration eines Agenten erlauben oder
|
||||||
/// verbieten lassen (#641) - dieselben Schluessel, die <see cref="StagingAktion"/> bzw. das
|
/// verbieten lassen (#641) - dieselben Schluessel, die <see cref="StagingAktion"/> bzw. das
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
"- workspace='shared': the instance's SHARED folder, if you have access to it.\n" +
|
"- workspace='shared': the instance's SHARED folder, if you have access to it.\n" +
|
||||||
"- workspace='unit': your unit's workspace; other named workspaces you are a member of " +
|
"- workspace='unit': your unit's workspace; other named workspaces you are a member of " +
|
||||||
"are listed in the Organisation section of your instructions.\n" +
|
"are listed in the Organisation section of your instructions.\n" +
|
||||||
"Actions: read, read_many, write, append, list, delete, copy, stock_add.\n" +
|
"Actions: read, read_many, write, append, list, delete, deleted, restore, copy, stock_add.\n" +
|
||||||
|
"'delete' of a file can be undone: the file history keeps its last version, 'deleted' lists " +
|
||||||
|
"what was deleted in a workspace and 'restore' (with 'entry') puts a file back. A folder is only " +
|
||||||
|
"deleted with 'recursive': true, and that is put to a human first — as is a file too large to keep.\n" +
|
||||||
"'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " +
|
"'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " +
|
||||||
"(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " +
|
"(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " +
|
||||||
"many files individually: each individual call costs a step.\n" +
|
"many files individually: each individual call costs a step.\n" +
|
||||||
@@ -49,7 +52,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
"properties": {
|
"properties": {
|
||||||
"action": {
|
"action": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["read", "read_many", "write", "append", "list", "delete", "copy", "stock_add"],
|
"enum": ["read", "read_many", "write", "append", "list", "delete", "deleted", "restore", "copy", "stock_add"],
|
||||||
"description": "The action to perform. 'read_many' reads multiple files at once. 'stock_add' adds a structured data point to the stock knowledge base."
|
"description": "The action to perform. 'read_many' reads multiple files at once. 'stock_add' adds a structured data point to the stock knowledge base."
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
@@ -87,6 +90,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Only for list: maximum number of entries. Default 50, maximum 500. The response header always mentions total count."
|
"description": "Only for list: maximum number of entries. Default 50, maximum 500. The response header always mentions total count."
|
||||||
},
|
},
|
||||||
|
"recursive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Only for delete: true deletes a folder with everything in it. Put to a human first."
|
||||||
|
},
|
||||||
|
"entry": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Only for restore: the entry number that 'deleted' lists."
|
||||||
|
},
|
||||||
"destinationWorkspace": {
|
"destinationWorkspace": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Destination workspace for copy, named like 'workspace'. Default is the same workspace as 'workspace'."
|
"description": "Destination workspace for copy, named like 'workspace'. Default is the same workspace as 'workspace'."
|
||||||
@@ -166,6 +177,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
"append" => await HandleAppendAsync(input, rootPath, area, context, ct),
|
"append" => await HandleAppendAsync(input, rootPath, area, context, ct),
|
||||||
"list" => await HandleListAsync(input, rootPath, area, context, ct),
|
"list" => await HandleListAsync(input, rootPath, area, context, ct),
|
||||||
"delete" => await HandleDeleteAsync(input, rootPath, area, context, ct),
|
"delete" => await HandleDeleteAsync(input, rootPath, area, context, ct),
|
||||||
|
"deleted" => await HandleDeletedAsync(rootPath, area, context, ct),
|
||||||
|
"restore" => await HandleRestoreAsync(input, rootPath, area, context, ct),
|
||||||
"copy" => await HandleCopyAsync(input, rootPath, area, context, ct),
|
"copy" => await HandleCopyAsync(input, rootPath, area, context, ct),
|
||||||
"stock_add" => await HandleStockAddAsync(input, context, ct),
|
"stock_add" => await HandleStockAddAsync(input, context, ct),
|
||||||
_ => ToolResult.Fail($"Unknown action: {action}")
|
_ => ToolResult.Fail($"Unknown action: {action}")
|
||||||
@@ -245,6 +258,43 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#195: The keys a delete can be filed under, next to the actions — so a tool configuration can allow or forbid them.</summary>
|
||||||
|
public IReadOnlyList<string> Operationen
|
||||||
|
=> [.. Core.Tools.WerkzeugOperationen.AusSchema(InputSchema), "delete_folder", "delete_unkept"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #195: A delete is filed by what it meets. A file the history keeps can be put back, so
|
||||||
|
/// deleting it is no irreversible act; a folder at once, or a file the history cannot keep
|
||||||
|
/// (too large, history off), is — those stay with a human.
|
||||||
|
/// </summary>
|
||||||
|
public string? StagingAktion(JsonElement input, AgentToolContext context)
|
||||||
|
{
|
||||||
|
if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty("action", out var a) || a.GetString() != "delete")
|
||||||
|
return null;
|
||||||
|
if (input.TryGetProperty("recursive", out var r) && r.ValueKind == JsonValueKind.True)
|
||||||
|
return "delete_folder";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (WorkspaceFromPath(ref input) is not null)
|
||||||
|
return null;
|
||||||
|
var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
|
||||||
|
if (ResolveArea(requested, context) is not { Root: { Length: > 0 } root } area)
|
||||||
|
return null;
|
||||||
|
var path = WorkspacePath.Resolve(Path.GetFullPath(root), RelativerPfad(input), area.Name);
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
return "delete_folder";
|
||||||
|
return context.History is { } history && history.CanKeep(path) ? "delete" : "delete_unkept";
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
return null; // the execution reports what is wrong with the path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#195: With the history keeping it, deleting a file needs nobody's approval.</summary>
|
||||||
|
public Core.Staging.StagingDecision? Vorgabe(string operation, AgentToolContext context)
|
||||||
|
=> operation == "delete" && context.History is not null ? Core.Staging.StagingDecision.Auto : null;
|
||||||
|
|
||||||
private static string Denied(string message)
|
private static string Denied(string message)
|
||||||
=> message.StartsWith("Access denied", StringComparison.Ordinal) ? message : $"Access denied: {message}";
|
=> message.StartsWith("Access denied", StringComparison.Ordinal) ? message : $"Access denied: {message}";
|
||||||
|
|
||||||
@@ -368,7 +418,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
{
|
{
|
||||||
"read" or "read_many" or "list" or "copy" => area.Access >= WorkspaceAccess.Read,
|
"read" or "read_many" or "list" or "copy" => area.Access >= WorkspaceAccess.Read,
|
||||||
"write" or "append" or "stock_add" => area.Access >= WorkspaceAccess.Write,
|
"write" or "append" or "stock_add" => area.Access >= WorkspaceAccess.Write,
|
||||||
"delete" => area.Access >= WorkspaceAccess.Manage,
|
"delete" or "deleted" or "restore" => area.Access >= WorkspaceAccess.Manage,
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1055,6 +1105,13 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
{
|
{
|
||||||
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
||||||
|
|
||||||
|
// #195: A workspace itself is never deleted — one call with "." would empty it.
|
||||||
|
if (string.Equals(Path.TrimEndingDirectorySeparator(path), Path.TrimEndingDirectorySeparator(rootPath), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return ToolResult.Fail($"The {area.Name} workspace itself cannot be deleted — name a file or folder in it.");
|
||||||
|
var recursive = input.TryGetProperty("recursive", out var rec) && rec.ValueKind == JsonValueKind.True;
|
||||||
|
if (Directory.Exists(path) && !recursive && Directory.EnumerateFileSystemEntries(path).Any())
|
||||||
|
return ToolResult.Fail("That is a folder with content. Delete its files one by one, or the whole folder with 'recursive': true — which is put to a human first.");
|
||||||
|
|
||||||
// Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin)
|
// Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin)
|
||||||
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
|
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
|
||||||
{
|
{
|
||||||
@@ -1073,7 +1130,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
var change = await BeforeChangeAsync(path, area, context, ct);
|
var change = await BeforeChangeAsync(path, area, context, ct);
|
||||||
File.Delete(path);
|
File.Delete(path);
|
||||||
await AfterChangeAsync(change, FileAction.Delete, context, ct);
|
await AfterChangeAsync(change, FileAction.Delete, context, ct);
|
||||||
return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
var undo = change?.HashBefore is not null && context.History is { } kept && kept.CanKeepContent(change.HashBefore)
|
||||||
|
? $" It can be restored for {kept.RetentionDays} days: action 'deleted' lists it, 'restore' puts it back."
|
||||||
|
: "";
|
||||||
|
return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}.{undo}");
|
||||||
}
|
}
|
||||||
else if (Directory.Exists(path))
|
else if (Directory.Exists(path))
|
||||||
{
|
{
|
||||||
@@ -1095,6 +1155,66 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#195: What was deleted in this workspace and can still be put back — newest first.</summary>
|
||||||
|
private static async Task<ToolResult> HandleDeletedAsync(string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (context.History is not { } history)
|
||||||
|
return ToolResult.Fail("This instance keeps no file history — deleted files cannot be put back.");
|
||||||
|
|
||||||
|
var entries = (await history.ListAsync(HistoryWorkspace(area, context), null, null, null, 500, ct))
|
||||||
|
.Where(e => e.Action == FileAction.Delete && history.CanKeepContent(e.HashBefore))
|
||||||
|
.Where(e => !File.Exists(history.FullPathOf(e.Path)))
|
||||||
|
.Take(30)
|
||||||
|
.ToList();
|
||||||
|
if (entries.Count == 0)
|
||||||
|
return ToolResult.Ok($"[{area.Name} Workspace] Nothing deleted that could be put back.");
|
||||||
|
|
||||||
|
var lines = entries.Select(e =>
|
||||||
|
$"- entry {e.Id}: {Path.GetRelativePath(rootPath, history.FullPathOf(e.Path)).Replace('\\', '/')} — deleted {e.OccurredAtUtc:yyyy-MM-dd HH:mm} UTC by {e.AgentId ?? "?"}");
|
||||||
|
return ToolResult.Ok($"[{area.Name} Workspace] Deleted and restorable ({entries.Count}, newest first):\n{string.Join("\n", lines)}\n" +
|
||||||
|
"Put one back with action 'restore' and its 'entry'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #195: Puts a deleted file back where it was. The journal's path is an input, not a truth —
|
||||||
|
/// the target is checked like any write: inside this workspace, an allowed extension, not a
|
||||||
|
/// blocked place, and nothing there now.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<ToolResult> HandleRestoreAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (context.History is not { } history)
|
||||||
|
return ToolResult.Fail("This instance keeps no file history — deleted files cannot be put back.");
|
||||||
|
if (!input.TryGetProperty("entry", out var e) || !e.TryGetInt64(out var id))
|
||||||
|
return ToolResult.Fail("'entry' is required for restore — action 'deleted' lists the entries.");
|
||||||
|
if (await history.FindAsync(id, ct) is not { } entry || entry.Action != FileAction.Delete
|
||||||
|
|| !string.Equals(entry.Workspace, HistoryWorkspace(area, context), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return ToolResult.Fail($"Entry {id} is no deletion in the {area.Name} workspace — action 'deleted' lists those.");
|
||||||
|
|
||||||
|
var relative = Path.GetRelativePath(rootPath, history.FullPathOf(entry.Path)).Replace('\\', '/');
|
||||||
|
using var probe = JsonDocument.Parse(JsonSerializer.Serialize(new { action = "write", path = relative }));
|
||||||
|
var target = GetAndValidatePath(probe.RootElement, rootPath, area, true, context);
|
||||||
|
|
||||||
|
var fileLock = GetFileLock(target);
|
||||||
|
await fileLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(target) || Directory.Exists(target))
|
||||||
|
return ToolResult.Fail($"'{relative}' exists again — nothing is overwritten by a restore.");
|
||||||
|
var reason = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String && c.GetString() is { Length: > 0 } why
|
||||||
|
? why : "restored by the agent";
|
||||||
|
await history.RestoreAsync(entry, versionBefore: true, by: context.AgentId, reason: reason, [rootPath], ct);
|
||||||
|
return ToolResult.Ok($"[{area.Name} Workspace] Restored: {area.Name}:/{relative}");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return ToolResult.Fail(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
fileLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, Area sourceArea, AgentToolContext context, CancellationToken ct)
|
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, Area sourceArea, AgentToolContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context);
|
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ public sealed class FileHistoryRecordingTests : IDisposable
|
|||||||
await Run("a", new { action = "write", workspace = "shared", path = "old/1.md", content = "one" });
|
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 = "write", workspace = "shared", path = "old/2.md", content = "two" });
|
||||||
|
|
||||||
await Run("a", new { action = "delete", workspace = "shared", path = "old" });
|
await Run("a", new { action = "delete", workspace = "shared", path = "old", recursive = true });
|
||||||
|
|
||||||
(await Journal()).Count(e => e.Action == FileAction.Delete).ShouldBe(2);
|
(await Journal()).Count(e => e.Action == FileAction.Delete).ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClawdDotNet.Core.Files;
|
||||||
|
using ClawdDotNet.Core.Staging;
|
||||||
|
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>
|
||||||
|
/// #195: The file history keeps what a delete removes, so deleting a file is no irreversible
|
||||||
|
/// act any more and needs no approval. A folder at once, or a file the history cannot keep,
|
||||||
|
/// still goes to a human; the workspace itself is never deleted; and an agent can see and undo
|
||||||
|
/// what was deleted. Test instance 2026-10-01: eleven cleanup deletes waited for the human.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReversibleDeleteTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _instance = Path.Combine(Path.GetTempPath(), "clawd-tests", "rd-" + Guid.NewGuid().ToString("N")[..8]);
|
||||||
|
private readonly string _agents;
|
||||||
|
private readonly string _shared;
|
||||||
|
private readonly FileHistorySettings _settings = new();
|
||||||
|
private readonly FileHistory _history;
|
||||||
|
private readonly FileRWTool _tool = new();
|
||||||
|
|
||||||
|
public ReversibleDeleteTests()
|
||||||
|
{
|
||||||
|
_agents = Path.Combine(_instance, "Agents");
|
||||||
|
_shared = Path.Combine(_agents, "SharedWorkspace");
|
||||||
|
Directory.CreateDirectory(_shared);
|
||||||
|
_history = new FileHistory(_agents, new SqliteStorage(Path.Combine(_instance, "state.db")), () => _settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
SqliteStorage.ReleasePooledConnections(_instance);
|
||||||
|
try { Directory.Delete(_instance, recursive: true); }
|
||||||
|
catch { /* cleanup is secondary */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolContext Context(string agentId = "keeper", bool history = true)
|
||||||
|
{
|
||||||
|
var personal = Path.Combine(_agents, "Agent-" + agentId, "Workspace");
|
||||||
|
Directory.CreateDirectory(personal);
|
||||||
|
return new AgentToolContext(agentId, "inst",
|
||||||
|
new Dictionary<string, object?> { ["sharedAccessLevel"] = "Admin", ["sharedAllowedExtensions"] = JsonSerializer.SerializeToElement(new[] { ".txt", ".md" }) },
|
||||||
|
new NullStateStore(), NullLogger.Instance, CancellationToken.None,
|
||||||
|
WorkspacePath: personal, SharedWorkspacePath: _shared)
|
||||||
|
{
|
||||||
|
History = history ? _history : null,
|
||||||
|
RunId = "run-" + agentId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<ToolResult> Run(object input, AgentToolContext? context = null)
|
||||||
|
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context ?? Context(), CancellationToken.None);
|
||||||
|
|
||||||
|
private string? KeyOf(object input, AgentToolContext? context = null)
|
||||||
|
=> _tool.StagingAktion(JsonSerializer.SerializeToElement(input), context ?? Context());
|
||||||
|
|
||||||
|
private void Put(string relative, string content = "x")
|
||||||
|
{
|
||||||
|
var full = Path.Combine(_shared, relative);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||||
|
File.WriteAllText(full, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_kept_file_is_deleted_without_approval_and_put_back_by_the_agent()
|
||||||
|
{
|
||||||
|
Put("wetter/stunden/berlin_00.txt", "13.4 °C");
|
||||||
|
var delete = new { action = "delete", workspace = "shared", path = "wetter/stunden/berlin_00.txt" };
|
||||||
|
|
||||||
|
KeyOf(delete).ShouldBe("delete");
|
||||||
|
_tool.Vorgabe("delete", Context()).ShouldBe(StagingDecision.Auto);
|
||||||
|
|
||||||
|
var deleted = await Run(delete);
|
||||||
|
deleted.Success.ShouldBeTrue(deleted.ErrorMessage);
|
||||||
|
deleted.Content.ShouldContain("can be restored for 90 days");
|
||||||
|
|
||||||
|
var list = await Run(new { action = "deleted", workspace = "shared" });
|
||||||
|
list.Content.ShouldContain("wetter/stunden/berlin_00.txt");
|
||||||
|
var entry = (await _history.ListAsync("shared", null, null, null, 10, default)).First(e => e.Action == FileAction.Delete).Id;
|
||||||
|
|
||||||
|
var restored = await Run(new { action = "restore", workspace = "shared", entry });
|
||||||
|
restored.Success.ShouldBeTrue(restored.ErrorMessage);
|
||||||
|
File.ReadAllText(Path.Combine(_shared, "wetter/stunden/berlin_00.txt")).ShouldBe("13.4 °C");
|
||||||
|
|
||||||
|
(await Run(new { action = "restore", workspace = "shared", entry })).ErrorMessage.ShouldContain("exists again");
|
||||||
|
(await Run(new { action = "deleted", workspace = "shared" })).Content.ShouldContain("Nothing deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_folder_needs_recursive_and_a_human_and_the_workspace_is_never_deleted()
|
||||||
|
{
|
||||||
|
Put("archiv/a.txt");
|
||||||
|
Put("archiv/b.txt");
|
||||||
|
|
||||||
|
KeyOf(new { action = "delete", workspace = "shared", path = "archiv" }).ShouldBe("delete_folder");
|
||||||
|
KeyOf(new { action = "delete", workspace = "shared", path = "archiv", recursive = true }).ShouldBe("delete_folder");
|
||||||
|
new StagingPolicy().Decide("FileRW", "delete_folder").ShouldBe(StagingDecision.Approve);
|
||||||
|
|
||||||
|
(await Run(new { action = "delete", workspace = "shared", path = "archiv" })).ErrorMessage.ShouldContain("recursive");
|
||||||
|
(await Run(new { action = "delete", workspace = "shared", path = "." })).ErrorMessage.ShouldContain("workspace itself cannot be deleted");
|
||||||
|
Directory.Exists(Path.Combine(_shared, "archiv")).ShouldBeTrue();
|
||||||
|
|
||||||
|
(await Run(new { action = "delete", workspace = "shared", path = "archiv", recursive = true })).Success.ShouldBeTrue();
|
||||||
|
Directory.Exists(Path.Combine(_shared, "archiv")).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_file_the_history_cannot_keep_stays_with_a_human()
|
||||||
|
{
|
||||||
|
Put("big.txt", new string('x', 2048));
|
||||||
|
|
||||||
|
KeyOf(new { action = "delete", workspace = "shared", path = "big.txt" }, Context(history: false)).ShouldBe("delete_unkept");
|
||||||
|
_tool.Vorgabe("delete", Context(history: false)).ShouldBeNull("without a history, the standard rule asks a human");
|
||||||
|
|
||||||
|
_settings.MaxObjectMegabytes = 0;
|
||||||
|
KeyOf(new { action = "delete", workspace = "shared", path = "big.txt" }).ShouldBe("delete_unkept");
|
||||||
|
new StagingPolicy().Decide("FileRW", "delete_unkept").ShouldBe(StagingDecision.Approve);
|
||||||
|
new StagingPolicy().Decide("FileRW", "delete").ShouldBe(StagingDecision.Approve, "without the history's word, delete stays with a human");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_deletion_from_another_workspace_or_with_a_forbidden_extension_is_not_restored()
|
||||||
|
{
|
||||||
|
Put("note.txt", "shared note");
|
||||||
|
await Run(new { action = "delete", workspace = "shared", path = "note.txt" });
|
||||||
|
var entry = (await _history.ListAsync("shared", null, null, null, 10, default)).First(e => e.Action == FileAction.Delete).Id;
|
||||||
|
|
||||||
|
(await Run(new { action = "restore", workspace = "personal", entry })).ErrorMessage.ShouldContain("no deletion in the personal workspace");
|
||||||
|
(await Run(new { action = "restore", workspace = "shared" })).ErrorMessage.ShouldContain("'entry' is required");
|
||||||
|
|
||||||
|
// A file whose extension the workspace no longer allows does not come back through restore.
|
||||||
|
Put("tool.exe", "MZ");
|
||||||
|
var exe = await _history.BeforeAsync(Path.Combine(_shared, "tool.exe"), "shared", default);
|
||||||
|
File.Delete(Path.Combine(_shared, "tool.exe"));
|
||||||
|
var recorded = await _history.RecordAsync(exe, FileAction.Delete, "someone", null, default);
|
||||||
|
(await Run(new { action = "restore", workspace = "shared", entry = recorded!.Id })).Success.ShouldBeFalse();
|
||||||
|
File.Exists(Path.Combine(_shared, "tool.exe")).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user