What it does
Read the exact source snapshot below for its parameters, behavior, and registration.
Watch this version
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),
"exists" => request.CheckExists(Options),
"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/WorkspaceReadExtensions.cs
Open this helper's source page ↗
C#
using System.Text;
using System.Text.Json;
namespace Lilith.Core.Workspace.Extensions;
// Operation helpers are called under the session's gate and exclusive workspace lease.
internal static class WorkspaceReadExtensions
{
internal static WorkspaceResult CheckExists(this WorkspaceRequest request, WorkspaceOptions options)
{
string path = options.ResolvePath(request.Path);
return new("completed", "Workspace existence checked.", Data: new { path = request.Path, exists = File.Exists(path) || Directory.Exists(path) });
}
internal static WorkspaceResult ListPending(this WorkspaceRequest request, WorkspaceOptions options)
{
string pending = Path.Combine(options.GetMetadataDirectory(), "pending");
var proposals = Directory.Exists(pending) ? Directory.EnumerateFiles(pending, "*.json").Order().Skip(request.Offset).Take(20)
.Select(p => JsonSerializer.Deserialize<WorkspaceProposal>(File.ReadAllText(p))!).ToArray() : [];
return new("completed", "Pending proposals (use host approval commands).", Data: proposals.Select(p => new { p.Id, p.Request.Operation, p.Request.Path, p.Request.Destination, p.CreatedAt }));
}
internal static WorkspaceResult ListBin(this WorkspaceRequest request, WorkspaceOptions options)
{
string bin = Path.Combine(options.GetMetadataDirectory(), "bin");
return new("completed", "Workspace bin entries.", Data: Directory.Exists(bin) ? Directory.EnumerateDirectories(bin).Where(p => File.Exists(Path.Combine(p, "item")) || Directory.Exists(Path.Combine(p, "item"))).Order().Skip(request.Offset).Take(20)
.Select(p => new { id = Path.GetFileName(p), original_path = options.GetOriginalBinPath(Path.GetFileName(p)) }).ToArray() : []);
}
internal static async Task<WorkspaceResult> ReadAsync(this WorkspaceRequest request, WorkspaceOptions options, CancellationToken ct)
{
string path = options.ResolvePath(request.Path);
if (!path.IsTextFile()) throw new IOException("Text reading supports Markdown, TXT, JSON, CSV, XML and HTML. PDF/Office extraction is not implemented.");
if (new FileInfo(path).Length > 1000000) throw new IOException("File exceeds the text-read limit.");
string text = await File.ReadAllTextAsync(path, new UTF8Encoding(false, true), ct);
int start = Math.Min(request.Offset, text.Length), length = Math.Min(4000, text.Length - start);
return new("completed", "Read workspace text.", Data: new { path = request.Path, text = text.Substring(start, length), next_offset = start + length < text.Length ? start + length : (int?)null });
}
internal static WorkspaceResult ListEntries(this WorkspaceRequest request, WorkspaceOptions options)
{
string directory = request.Path.Length == 0 ? options.RootDirectory : options.ResolvePath(request.Path);
var items = Directory.EnumerateFileSystemEntries(directory).Where(p => !Path.GetFileName(p).StartsWith('.')).Order().Skip(request.Offset).Take(21).ToArray();
foreach (string item in items) item.EnsureNoLinks();
return new("completed", "Workspace folder entries.", Data: new { matches = items.Take(20).Select(p => new { path = Path.GetRelativePath(options.RootDirectory, p).Replace('\\', '/'), folder = Directory.Exists(p), excerpt = (string?)null }), next_offset = items.Length > 20 ? request.Offset + 20 : (int?)null });
}
internal static async Task<WorkspaceResult> SearchAsync(this WorkspaceRequest request, WorkspaceOptions options, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Query) || request.Query.Length > 200) throw new ArgumentException("Supply a query up to 200 characters.");
string scope = request.Path.Length == 0 ? options.RootDirectory : options.ResolvePath(request.Path);
var matches = new List<object>(); int found = 0;
foreach (string item in scope.EnumerateEntries(2000))
{
ct.ThrowIfCancellationRequested();
string relative = Path.GetRelativePath(options.RootDirectory, item).Replace('\\', '/');
string? excerpt = null;
if (File.Exists(item) && new FileInfo(item).Length <= 1000000 && item.IsTextFile())
{
string text = await File.ReadAllTextAsync(item, new UTF8Encoding(false, true), ct);
int index = text.IndexOf(request.Query, StringComparison.OrdinalIgnoreCase);
if (index >= 0) excerpt = text.Substring(Math.Max(0, index - 40), Math.Min(200, text.Length - Math.Max(0, index - 40)));
}
if (!relative.Contains(request.Query, StringComparison.OrdinalIgnoreCase) && excerpt is null) continue;
if (found++ < request.Offset) continue;
matches.Add(new { path = relative, folder = Directory.Exists(item), excerpt });
if (matches.Count == 20) break;
}
return new("completed", "Workspace search results; use read for selected excerpts.", Data: new { matches, next_offset = matches.Count == 20 ? request.Offset + 20 : (int?)null });
}
}
Related source in this version
- Lilith/Core/Workspace/Extensions/WorkspaceFileSystemExtensions.cs
- Lilith/Core/Workspace/Extensions/WorkspaceMutationExtensions.cs
- Lilith/Core/Workspace/Extensions/WorkspaceReadExtensions.cs
- Lilith/Core/Workspace/Extensions/WorkspaceRequestExtensions.cs
- Lilith/Core/Workspace/Extensions/WorkspaceResultExtensions.cs
- Lilith/Core/Workspace/WorkspaceModels.cs
- Lilith/Core/Workspace/WorkspaceSession.cs
- Lilith/Core/Workspace/WorkspaceTool.cs
Scenarios using this tool
- Workspace bin_list — bundled