V5 / WORKSPACE

workspace_approve_operation

Scenario-only workspace approve operation. Inputs are bound by XML; application code enforces access policy.

Scenario onlyIntroduced in V4V5

What it does

Read the exact source snapshot below for its parameters, behavior, and registration.

Read the implementation

This page describes the selected release. The source link points to the same exact commit; it cannot silently open a newer implementation.

Lilith/Core/Workspace/WorkspaceTool.cs ↗

Implementation code

Read the implementation from this version's exact source snapshot. Helper links below stay on the same release.

Lilith/Core/Workspace/WorkspaceTool.cs

C#
using System.Text.Json;
using Lilith.Core.Tools;

namespace Lilith.Core.Workspace;

internal sealed class WorkspaceTool(WorkspaceSession session, string operation, IReadOnlyDictionary<string, string> inputs, Action<WorkspaceResult> completed) : ITurnResultTool
{
    public string Name => "workspace_" + operation + "_operation";
    public string Description => "Perform the scenario's " + operation + " operation. Use the declared scenario inputs exactly. Returns a verified storage result. Never invent completion.";
    public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(30);
    public JsonElement Parameters => JsonSerializer.SerializeToElement(new { type = "object", properties = new { }, additionalProperties = false });
    public async Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default) =>
        (await ExecuteResultAsync(arguments, cancellationToken)).Content;
    public async Task<ToolExecutionResult> ExecuteResultAsync(JsonElement arguments, CancellationToken cancellationToken)
    {
        if (arguments.ValueKind != JsonValueKind.Object || arguments.EnumerateObject().Any()) throw new ArgumentException("Workspace tools take no arguments; values are bound to the scenario inputs.");
        string Get(string name) => inputs.GetValueOrDefault(name, "");
        if (!int.TryParse(Get("offset").Length == 0 ? "0" : Get("offset"), out int offset)) throw new ArgumentException("Invalid offset.");
        var request = new WorkspaceRequest(operation, Get("path"), Get("content"), Get("destination"), Get("query"), Get("bin_id"), offset);
        var result = await session.ExecuteAsync(request, cancellationToken);
        completed(result);
        string json = JsonSerializer.Serialize(result);
        return new(json, json);
    }
    internal static readonly string[] Operations = ["save", "mkdir", "move", "bin", "restore", "search", "read", "list", "bin_list", "pending", "approve"];
    internal static string? Operation(string name) => Operations.FirstOrDefault(op => name == "workspace_" + op + "_operation");
}

Lilith/Core/Workspace/WorkspaceSession.cs

Open this helper's source page ↗

C#
using System.Collections.Concurrent;
using System.Text.Json;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Workspace;

/// <summary>Owns workspace serialization, immutable configuration and host approval capabilities.</summary>
public sealed class WorkspaceSession
{
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> Gates = new(StringComparer.OrdinalIgnoreCase);
    private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
    private readonly HashSet<string> _authorized = new(StringComparer.Ordinal);
    public WorkspaceOptions Options { get; }

    public WorkspaceSession(WorkspaceOptions options)
    {
        if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown workspace mode.");
        Options = options with { RootDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(options.RootDirectory)) };
    }

    public IDisposable Authorize(string proposalId)
    {
        Options.GetMetadataPath("pending", proposalId);
        lock (_authorized)
            if (!_authorized.Add(proposalId)) throw new InvalidOperationException("Approval already in use.");
        return new Authorization(this, proposalId);
    }

    private sealed class Authorization(WorkspaceSession owner, string id) : IDisposable
    {
        public void Dispose() { lock (owner._authorized) owner._authorized.Remove(id); }
    }

    public async Task<WorkspaceResult> ExecuteAsync(WorkspaceRequest request, CancellationToken ct = default)
    {
        var gate = Gates.GetOrAdd(Options.RootDirectory, _ => new SemaphoreSlim(1, 1));
        await gate.WaitAsync(ct);
        try
        {
            Options.RootDirectory.EnsureNoLinks();
            string metadata = Options.GetMetadataDirectory();
            metadata.EnsureNoLinks();
            Directory.CreateDirectory(metadata);
            using var lease = new FileStream(Path.Combine(metadata, "lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
            ct.ThrowIfCancellationRequested();
            if (request.Offset < 0 || request.Offset > 1000000) throw new ArgumentException("Invalid offset.");

            return request.Operation switch
            {
                "pending" => request.ListPending(Options),
                "bin_list" => request.ListBin(Options),
                "read" => await request.ReadAsync(Options, ct),
                "list" => request.ListEntries(Options),
                "search" => await request.SearchAsync(Options, ct),
                "reject" => request.RejectProposal(Options),
                "approve" => await ApproveAsync(request.BinId, ct),
                "save" or "mkdir" or "move" or "bin" or "restore" => await ExecuteMutationAsync(request, null, ct),
                _ => throw new ArgumentException("Unknown workspace operation.")
            };
        }
        finally { gate.Release(); }
    }

    private async Task<WorkspaceResult> ApproveAsync(string proposalId, CancellationToken ct)
    {
        lock (_authorized)
            if (!_authorized.Remove(proposalId)) throw new InvalidOperationException("Explicit host approval is required.");

        string receipt = Options.GetMetadataPath("receipts", proposalId) + ".json";
        if (File.Exists(receipt)) return JsonSerializer.Deserialize<WorkspaceResult>(File.ReadAllText(receipt))!;

        string file = Options.GetMetadataPath("pending", proposalId) + ".json";
        var proposal = JsonSerializer.Deserialize<WorkspaceProposal>(await File.ReadAllTextAsync(file, ct))!;
        if (proposal.Request.Snapshot(Options) != proposal.Before)
            throw new IOException("The target changed. Reject this proposal and prepare a new one.");

        return await ExecuteMutationAsync(proposal.Request, proposalId, ct);
    }

    private async Task<WorkspaceResult> ExecuteMutationAsync(WorkspaceRequest request, string? proposalId, CancellationToken ct)
    {
        if (request.Operation is not ("save" or "mkdir" or "move" or "bin" or "restore"))
            throw new ArgumentException("Unknown workspace operation.");
        request.ValidateMutation(Options);
        return (Options.Mode, proposalId) switch
        {
            (WorkspaceAccessMode.Ask, null) => await PrepareProposalAsync(request, ct),
            _ => await request.CommitMutationAsync(Options, proposalId)
        };
    }

    private async Task<WorkspaceResult> PrepareProposalAsync(WorkspaceRequest request, CancellationToken ct)
    {
        string id = Guid.NewGuid().ToString("N");
        var proposal = new WorkspaceProposal(id, request, request.Snapshot(Options), DateTimeOffset.UtcNow);
        await (Options.GetMetadataPath("pending", id) + ".json").WriteAtomicallyAsync(JsonSerializer.Serialize(proposal, Json), ct);
        return new("needs_approval",
            $"Approve {request.Operation}: {request.Path}{request.Destination}. Review with /workspace review {id}; then /approve {id} or /reject {id}.",
            id, new { request.Operation, request.Path, request.Destination, content_preview = request.Content[..Math.Min(400, request.Content.Length)] });
    }

    public WorkspaceProposal Review(string id)
    {
        Options.GetMetadataDirectory().EnsureNoLinks();
        return JsonSerializer.Deserialize<WorkspaceProposal>(File.ReadAllText(Options.GetMetadataPath("pending", id) + ".json"))!;
    }
}

Lilith/Core/Workspace/Extensions/WorkspaceMutationExtensions.cs

Open this helper's source page ↗

C#
using System.Text.Json;

namespace Lilith.Core.Workspace.Extensions;

internal static class WorkspaceMutationExtensions
{
    private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };

    // Called only while the session holds its process gate and exclusive workspace lease.
    internal static async Task<WorkspaceResult> CommitMutationAsync(this WorkspaceRequest request, WorkspaceOptions options, string? proposalId)
    {
        // After approval validation, complete the short filesystem commit even if cancellation arrives.
        string operationId = proposalId ?? Guid.NewGuid().ToString("N");
        string receiptPath = options.GetMetadataPath("receipts", operationId) + ".json";
        if (File.Exists(receiptPath)) return JsonSerializer.Deserialize<WorkspaceResult>(File.ReadAllText(receiptPath))!;
        await (options.GetMetadataPath("journal", operationId) + ".json").WriteAtomicallyAsync(JsonSerializer.Serialize(new { request, Before = request.Snapshot(options), Status = "prepared", CreatedAt = DateTimeOffset.UtcNow }, Json), CancellationToken.None);
        string? binId = null;
        string target = request.Operation == "restore" ? options.ResolvePath(options.GetOriginalBinPath(request.BinId)) : options.ResolvePath(request.Path);
        switch (request.Operation)
        {
            case "save":
                await target.WriteAtomicallyAsync(request.Content, CancellationToken.None);
                break;
            case "mkdir":
                Directory.CreateDirectory(target);
                break;
            case "move":
                target.MoveTo(options.ResolvePath(request.Destination));
                break;
            case "bin":
                binId = operationId;
                string folder = options.GetMetadataPath("bin", binId);
                Directory.CreateDirectory(folder);
                await Path.Combine(folder, "original.json").WriteAtomicallyAsync(JsonSerializer.Serialize(request), CancellationToken.None);
                await Path.Combine(folder, "deleted.json").WriteAtomicallyAsync(JsonSerializer.Serialize(new { OriginalPath = request.Path, DeletedAtUtc = DateTimeOffset.UtcNow }), CancellationToken.None);
                target.MoveTo(Path.Combine(folder, "item"));
                break;
            case "restore":
                Path.Combine(options.GetMetadataPath("bin", request.BinId), "item").MoveTo(target);
                break;
            default:
                throw new ArgumentException("Unknown workspace operation.");
        }
        var result = new WorkspaceResult("completed", $"Workspace {request.Operation} completed.", Data: new { operation_id = operationId, path = Path.GetRelativePath(options.RootDirectory, target).Replace('\\', '/'), destination = request.Destination, bin_id = binId });
        await receiptPath.WriteAtomicallyAsync(JsonSerializer.Serialize(result, Json), CancellationToken.None);
        if (proposalId is not null) File.Delete(options.GetMetadataPath("pending", proposalId) + ".json");
        return result;
    }

    internal static WorkspaceResult RejectProposal(this WorkspaceRequest request, WorkspaceOptions options)
    {
        string path = options.GetMetadataPath("pending", request.BinId) + ".json";
        if (!File.Exists(path)) throw new IOException("Proposal not found.");
        File.Delete(path);
        return new("completed", "Proposal rejected. No workspace data changed.");
    }
}

Related source in this version

Scenarios using this tool