V16 / 6df58cd3ed86
WorkspaceProjectExtensions.cs
Lilith/Core/Workspace/Extensions/WorkspaceProjectExtensions.cs
Unchanged from the preceding versionCS
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);
}
}