V15 / 313404a015e8
WorkspaceSession.cs
Lilith/Core/Workspace/WorkspaceSession.cs
Unchanged from the preceding versionCS
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);
await Options.RecoverProjectUpdatesAsync();
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),
"exists" => request.CheckExists(Options),
"reject" => request.RejectProposal(Options),
"approve" => await ApproveAsync(request.BinId, ct),
"save" or "mkdir" or "move" or "bin" or "restore" or "project_create" or "project_update" => 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" or "project_create" or "project_update"))
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"))!;
}
}