← All source files

V16 / 6df58cd3ed86

WorkspaceProjectExtensions.cs

Lilith/Core/Workspace/Extensions/WorkspaceProjectExtensions.cs

Unchanged from the preceding version
CS
using Lilith.Core.Projects;

namespace Lilith.Core.Workspace.Extensions;

internal static class WorkspaceProjectExtensions
{
    // Called under the workspace gate/lease. A prior explicit approval already authorized these transactions.
    internal static async Task RecoverProjectUpdatesAsync(this WorkspaceOptions options)
    {
        string journals = Path.Combine(options.GetMetadataDirectory(), "journal"); journals.EnsureNoLinks();
        if (!Directory.Exists(journals)) return;
        foreach (string file in Directory.EnumerateFiles(journals, "*.json"))
        {
            file.EnsureNoLinks(); string id = Path.GetFileNameWithoutExtension(file);
            if (!Guid.TryParseExact(id, "N", out _)) continue;
            string receipt = options.GetMetadataPath("receipts", id) + ".json";
            if (File.Exists(receipt)) continue;
            string backup = options.GetMetadataPath("project-backups", id);
            if (!Directory.Exists(backup)) continue;
            _ = backup.ComputeSignature();
            using var journal = System.Text.Json.JsonDocument.Parse(await File.ReadAllTextAsync(file));
            if (!journal.RootElement.TryGetProperty("request", out var raw)) continue;
            var request = ProjectJson.Parse<WorkspaceRequest>(raw.GetRawText());
            if (request.Operation != "project_update") continue;
            string target = options.ResolvePath(request.Path);
            if (!File.Exists(target) && !Directory.Exists(target))
            {
                // Process interruption between directory renames: restore the complete original.
                Directory.Move(backup, target);
                string staging = options.GetMetadataPath("project-staging", id);
                if (Directory.Exists(staging))
                {
                    string abandoned = options.GetMetadataPath("project-abandoned", id); Directory.CreateDirectory(Path.GetDirectoryName(abandoned)!);
                    Directory.Move(staging, abandoned);
                }
                continue;
            }
            if (!Directory.Exists(target)) continue;
            var update = ProjectJson.Parse<ProjectUpdateBundle>(request.Content);
            _ = (request with { Content = ProjectJson.Serialize(update.Bundle) }).ReadProjectBundle(options);
            bool matches = update.Bundle.Files.All(source => File.Exists(options.ResolvePath(request.Path + "/" + source.Path)) && File.ReadAllText(options.ResolvePath(request.Path + "/" + source.Path)) == source.Content);
            bool removed = update.OriginalManagedPaths.Where(path => !update.Bundle.Files.Any(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase))).All(path => !File.Exists(options.ResolvePath(request.Path + "/" + path)));
            if (!matches || !removed) continue; // Preserve both copies for manual review; never clobber later edits.
            var recovered = new WorkspaceResult("completed", "Recovered a completed project update; previous revision retained.", Data: new { operation_id = id, path = request.Path, recovered = true });
            await receipt.WriteAtomicallyAsync(System.Text.Json.JsonSerializer.Serialize(recovered), CancellationToken.None);
            string pending = options.GetMetadataPath("pending", id) + ".json"; if (File.Exists(pending)) File.Delete(pending);
        }
    }
    internal static ProjectUpdateBundle ReadProjectUpdate(this WorkspaceRequest request, WorkspaceOptions options)
    {
        if (request.Content.Length > 500000) throw new InvalidDataException("Oversized project update.");
        var update = ProjectJson.Parse<ProjectUpdateBundle>(request.Content);
        if (update.OriginalManagedPaths is null || update.OriginalManagedPaths.Length > 16 || update.OriginalManagedPaths.Distinct(StringComparer.OrdinalIgnoreCase).Count() != update.OriginalManagedPaths.Length) throw new InvalidDataException("Invalid managed baseline.");
        _ = (request with { Content = ProjectJson.Serialize(update.Bundle) }).ReadProjectBundle(options);
        foreach (string path in update.OriginalManagedPaths)
        {
            string full = options.ResolvePath(request.Path + "/" + path);
            if (!full.StartsWith(options.ResolvePath(request.Path) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new IOException("Invalid managed baseline path.");
        }
        foreach (var file in update.Bundle.Files)
            if (!update.OriginalManagedPaths.Contains(file.Path, StringComparer.OrdinalIgnoreCase) && (File.Exists(options.ResolvePath(request.Path + "/" + file.Path)) || Directory.Exists(options.ResolvePath(request.Path + "/" + file.Path)))) throw new IOException("A new managed path would overwrite an unmanaged file.");
        return update;
    }
    internal static async Task UpdateProjectAsync(this WorkspaceRequest request, WorkspaceOptions options, string target, string operationId)
    {
        var update = request.ReadProjectUpdate(options);
        string temporary = options.GetMetadataPath("project-staging", operationId), backup = options.GetMetadataPath("project-backups", operationId);
        if (Directory.Exists(temporary) || Directory.Exists(backup)) throw new IOException("Retained project transaction needs review.");
        Directory.CreateDirectory(temporary);
        // Preserve all unmanaged files, including assets, rather than silently deleting them.
        foreach (string entry in target.EnumerateEntries(10000, includeHidden: true))
        {
            string destination = Path.Combine(temporary, Path.GetRelativePath(target, entry));
            if (Directory.Exists(entry)) Directory.CreateDirectory(destination);
            else { Directory.CreateDirectory(Path.GetDirectoryName(destination)!); File.Copy(entry, destination); }
        }
        foreach (string path in update.OriginalManagedPaths)
        {
            string old = Path.Combine(temporary, path); if (File.Exists(old)) File.Delete(old);
        }
        foreach (var file in update.Bundle.Files)
        {
            string destination = Path.Combine(temporary, file.Path); Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
            await File.WriteAllTextAsync(destination, file.Content, CancellationToken.None);
        }
        Directory.CreateDirectory(Path.GetDirectoryName(backup)!);
        Directory.Move(target, backup);
        try { Directory.Move(temporary, target); }
        catch { if (!Directory.Exists(target)) Directory.Move(backup, target); throw; }
        // The previous complete project is retained; journal/receipt identify the backup.
    }
    internal static ProjectBundle ReadProjectBundle(this WorkspaceRequest request, WorkspaceOptions options)
    {
        if (request.Content.Length > 500000) throw new InvalidDataException("Project proposal exceeds 500000 characters.");
        var bundle = ProjectJson.Parse<ProjectBundle>(request.Content);
        if (bundle.Files is null || bundle.Files.Length is < 3 or > 16 || bundle.Files.Sum(f => f.Content?.Length ?? 0) > 400000 ||
            bundle.Files.Select(f => f.Path).Distinct(StringComparer.OrdinalIgnoreCase).Count() != bundle.Files.Length)
            throw new InvalidDataException("Invalid project publication bundle.");
        string target = options.ResolvePath(request.Path);
        foreach (var file in bundle.Files)
        {
            if (file.Content is null) throw new InvalidDataException("Missing project file content.");
            string path = options.ResolvePath(request.Path + "/" + file.Path);
            if (!path.StartsWith(target + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
                Path.GetExtension(file.Path).ToLowerInvariant() is not (".cs" or ".csproj" or ".html" or ".css" or ".js" or ".txt" or ".json"))
                throw new InvalidDataException("Invalid project publication path.");
        }
        if (!bundle.Files.Any(f => f.Path == "lilith-project.json")) throw new InvalidDataException("Missing project manifest.");
        return bundle;
    }

    internal static async Task PublishProjectAsync(this WorkspaceRequest request, WorkspaceOptions options, string target, string operationId)
    {
        var bundle = request.ReadProjectBundle(options);
        string temporary = options.GetMetadataPath("project-staging", operationId);
        if (Directory.Exists(temporary)) throw new IOException("A previous incomplete publication is retained for review.");
        Directory.CreateDirectory(temporary);
        foreach (var file in bundle.Files)
        {
            string path = Path.Combine(temporary, file.Path.Replace('/', Path.DirectorySeparatorChar));
            Directory.CreateDirectory(Path.GetDirectoryName(path)!);
            await File.WriteAllTextAsync(path, file.Content, CancellationToken.None);
        }
        // Same workspace volume: publication is a single directory rename and never overwrites a project.
        Directory.Move(temporary, target);
    }
}