FileRW: Loeschen ueber den Dateiverlauf umkehrbar, ohne Freigabe; Ordner und Unaufhebbares bleiben beim Menschen (#195)
Der Dateiverlauf (#719) haelt die Vorfassung jeder geloeschten Datei - ein eigener Papierkorb waere doppelt. delete einer Datei, die der Verlauf aufhebt, laeuft ohne Freigabe; Ordner nur mit recursive:true (delete_folder, Freigabe); zu grosse Dateien oder Verlauf aus (delete_unkept, Freigabe); die Wurzel nie. Neue Aktionen deleted und restore mit voller Pfad-/Endungspruefung. IAgentTool.StagingAktion bekommt eine Ueberladung mit Kontext. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2a225129e9
commit
5de60c3b21
@@ -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='unit': your unit's workspace; other named workspaces you are a member of " +
|
||||
"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' " +
|
||||
"(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" +
|
||||
@@ -49,7 +52,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
"properties": {
|
||||
"action": {
|
||||
"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."
|
||||
},
|
||||
"workspace": {
|
||||
@@ -87,6 +90,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
"type": "integer",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"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),
|
||||
"list" => await HandleListAsync(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),
|
||||
"stock_add" => await HandleStockAddAsync(input, context, ct),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}")
|
||||
@@ -245,6 +258,43 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
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)
|
||||
=> 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,
|
||||
"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
|
||||
};
|
||||
}
|
||||
@@ -1055,6 +1105,13 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
{
|
||||
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)
|
||||
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);
|
||||
File.Delete(path);
|
||||
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))
|
||||
{
|
||||
@@ -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)
|
||||
{
|
||||
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context);
|
||||
|
||||
Reference in New Issue
Block a user