From e2c360e9f23fce2fc46bb42b57975b467bd3596b Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 4 Aug 2026 16:12:01 +0300 Subject: [PATCH] feat: add launcher-only preflight gate --- Launcher.Next.App/PilotCli.cs | 89 +++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/Launcher.Next.App/PilotCli.cs b/Launcher.Next.App/PilotCli.cs index 5e8e72d..81eba09 100644 --- a/Launcher.Next.App/PilotCli.cs +++ b/Launcher.Next.App/PilotCli.cs @@ -20,7 +20,8 @@ internal static class PilotCli { try { - if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback" or "automate-first-apply")) return Blocked; + if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback" or "preflight" or "automate-first-apply")) return Blocked; + if (args[0] == "preflight") return await PreflightAsync(args.Skip(1).ToArray()); if (args[0] == "automate-first-apply") return await AutomateFirstApplyAsync(args.Skip(1).ToArray()); var command = args[0]; if (!TryParse(command, args.Skip(1).ToArray(), out var options, out var error)) return Fail(Blocked, error!); @@ -77,6 +78,78 @@ internal static class PilotCli private static readonly IExecutionPermitService PermitService = new ExecutionPermitService(); + private sealed record PreflightFile(string Role, string CanonicalPath, string AllowedRoot, string SourcePathVariable, bool Required, long Size, DateTimeOffset LastWriteUtc, string ReparseStatus, bool HashRequired, string? Sha256 = null); + private sealed record PreflightManifest(string SessionId, DateTimeOffset CreatedUtc, IReadOnlyList Files); + + private static async Task PreflightAsync(string[] args) + { + if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!); + var root = options.PilotRoot; + var journal = await AutomationJournal.AllocateAsync(root, "NextPilotPreview", CancellationToken.None); + try + { + if (!TryReadConfiguration(root, out var config, out error) || config.LauncherMode != LauncherMode.NextPilot || !config.PilotEnabled) + return await journal.TerminalAsync("PRELAUNCH_CONFIGURATION_INVALID", "Failed", null, CancellationToken.None); + if (Process.GetProcessesByName("acad").Any()) return await journal.TerminalAsync("ACAD_ALREADY_RUNNING", "Failed", null, CancellationToken.None); + var fixtureRoot = Path.GetFullPath(Path.GetDirectoryName(options.FixtureMarker)!); + var files = new[] + { + ("FixtureOrthoDwg", options.OrthoDrawing, "FixtureOrthoDwg"), + ("FixtureModelDwg", options.ModelDrawing, "FixtureModelDwg"), + ("PipingDcf", Path.Combine(fixtureRoot, "Piping.dcf"), "PipingDcf"), + ("ProjectXml", Path.Combine(fixtureRoot, "Project.xml"), "ProjectXml") + }; + var manifestFiles = new List(); + foreach (var (role, path, variable) in files) + { + var canonical = Path.GetFullPath(path); + if (!canonical.StartsWith(fixtureRoot.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return await journal.TerminalAsync("MANIFEST_OUTSIDE_ALLOWED_ROOT", "Failed", canonical, CancellationToken.None); + var info = new FileInfo(canonical); + if (info.Attributes.HasFlag(FileAttributes.Directory)) return await journal.TerminalAsync("MANIFEST_DIRECTORY_REJECTED", "Failed", canonical, CancellationToken.None); + if (info.Attributes.HasFlag(FileAttributes.ReparsePoint)) return await journal.TerminalAsync("MANIFEST_REPARSE_REJECTED", "Failed", canonical, CancellationToken.None); + if (!info.Exists && role is "FixtureOrthoDwg" or "FixtureModelDwg" or "PipingDcf" or "ProjectXml") return await journal.TerminalAsync("MANIFEST_REQUIRED_FILE_MISSING", "Failed", canonical, CancellationToken.None); + manifestFiles.Add(new(role, canonical, fixtureRoot, variable, true, info.Length, info.LastWriteTimeUtc, "None", true)); + } + if (manifestFiles.Count == 0 || manifestFiles.Select(x => x.CanonicalPath).Distinct(StringComparer.OrdinalIgnoreCase).Count() != manifestFiles.Count) + return await journal.TerminalAsync("MANIFEST_INVALID", "Failed", null, CancellationToken.None); + var manifest = new PreflightManifest(journal.SessionId, DateTimeOffset.UtcNow, manifestFiles); + var sessionDir = Path.Combine(root, "state", "automation-sessions", journal.SessionId); + await WriteAtomicAsync(Path.Combine(sessionDir, "manifest.json"), manifest, CancellationToken.None); + await journal.RecordAsync("ManifestPersisted", "Completed", null, CancellationToken.None); + using var cancellation = new CancellationTokenSource(); + var totalBytes = manifestFiles.Sum(x => x.Size); var completedBytes = 0L; var completed = 0; + for (var fileIndex = 0; fileIndex < manifestFiles.Count; fileIndex++) + { + var file = manifestFiles[fileIndex]; + await journal.RecordProgressAsync("HashFileStarted", "Started", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, false, CancellationToken.None); + try + { + await using var stream = File.OpenRead(file.CanonicalPath); + var hash = Convert.ToHexString(await HashWithWatchdogAsync(stream, cancellation.Token)); + manifestFiles[fileIndex] = file with { Sha256 = hash }; + completed++; completedBytes += file.Size; + await journal.RecordProgressAsync("HashFileCompleted", "Completed", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, false, CancellationToken.None); + } + catch (TimeoutException ex) { cancellation.Cancel(); await journal.RecordFailureAsync("HashFileTimedOut", file.Role, file.CanonicalPath, ex, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_PROGRESS_TIMEOUT", "Failed", file.CanonicalPath, CancellationToken.None); } + catch (OperationCanceledException) { await journal.RecordProgressAsync("HashFileCancelled", "Cancelled", file.Role, file.CanonicalPath, completed, manifestFiles.Count, completedBytes, totalBytes, true, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_CANCELLED", "Cancelled", file.CanonicalPath, CancellationToken.None); } + catch (Exception ex) { await journal.RecordFailureAsync("HashFileFailed", file.Role, file.CanonicalPath, ex, CancellationToken.None); return await journal.TerminalAsync("HASH_FILE_FAILED", "Failed", file.CanonicalPath, CancellationToken.None); } + } + await WriteAtomicAsync(Path.Combine(sessionDir, "manifest.json"), manifest with { Files = manifestFiles }, CancellationToken.None); + await journal.RecordAsync("GateBeforeAutoCADLaunch", "Completed", null, CancellationToken.None); + return await journal.TerminalAsync("PRELAUNCH_GATE_PASSED_AUTOCAD_NOT_STARTED", "Succeeded", null, CancellationToken.None); + } + catch (Exception ex) { await journal.RecordFailureAsync("PreflightFailed", null, null, ex, CancellationToken.None); return await journal.TerminalAsync("PRELAUNCH_PREFLIGHT_FAILED", "Failed", null, CancellationToken.None); } + } + private static async Task HashWithWatchdogAsync(Stream stream, CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var hashing = SHA256.HashDataAsync(stream, linked.Token).AsTask(); + var watchdog = Task.Delay(TimeSpan.FromSeconds(30)); + if (await Task.WhenAny(hashing, watchdog) == watchdog) { linked.Cancel(); throw new TimeoutException("PRELAUNCH_PROGRESS_TIMEOUT"); } + return await hashing; + } + private sealed record AutomationOptions(string PackageSource, string EntryAssembly, string PilotRoot, string FixtureMarker, string OrthoDrawing, string ModelDrawing, string AnnotationTemplateDwg, string EvidenceRoot, bool Diagnostic); private sealed record PreviewEvidence(bool Completed, bool? CommandsCompleted, bool? InspectorsCompleted, bool? EvidenceFlushed, string? PlanIdRun1, string? PlanIdRun2, bool? PlanIdsEqual, int? EligibleCandidateCount, string? SelectedCandidateHash); private sealed record ApplyEvidence(bool Completed, bool? CommandsCompleted, bool? InspectorsCompleted, bool? EvidenceFlushed, bool? PermitAccepted, string? ApplyOutcomeRun1, string? ApplyOutcomeRun2, object? Baseline, object? AfterCommand1, object? AfterCommand2, object? AfterUndo); @@ -201,11 +274,17 @@ internal static class PilotCli } private sealed class AutomationJournal { - private readonly string path; private readonly DateTimeOffset started=DateTimeOffset.UtcNow; private int sequence; - private AutomationJournal(string path) { this.path=path; } - public static async Task AllocateAsync(string root,string mode,CancellationToken ct) { var id=Guid.NewGuid().ToString("N"); var dir=Path.Combine(root,"state","automation-sessions",id); Directory.CreateDirectory(dir); var j=new AutomationJournal(Path.Combine(dir,"session-events.jsonl")); await j.RecordAsync("SessionAllocated","Completed",null,ct); var pointer=Path.Combine(root,"state","automation-current.json"); var temp=pointer+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(new { sessionId=id,launchMode=mode,state="SessionAllocated",terminal=false,currentStage="InitialEvidenceFlushed",createdUtc=DateTimeOffset.UtcNow }),ct); File.Move(temp,pointer,true); await j.RecordAsync("InitialEvidenceFlushed","Completed",null,ct); return j; } - public Task RecordAsync(string stage,string status,string? currentPath,CancellationToken ct) => File.AppendAllTextAsync(path,JsonSerializer.Serialize(new { sequence=Interlocked.Increment(ref sequence),timestampUtc=DateTimeOffset.UtcNow,elapsedMs=(long)(DateTimeOffset.UtcNow-started).TotalMilliseconds,stage,status,canonicalPath=currentPath,managedThreadId=Environment.CurrentManagedThreadId })+Environment.NewLine,ct); + private readonly string path; private readonly string root; private readonly DateTimeOffset started=DateTimeOffset.UtcNow; private int sequence; + public string SessionId { get; } + private AutomationJournal(string root, string sessionId) { this.root=root; SessionId=sessionId; path=Path.Combine(root,"state","automation-sessions",sessionId,"session-events.jsonl"); } + public static async Task AllocateAsync(string root,string mode,CancellationToken ct) { var id=Guid.NewGuid().ToString("N"); var dir=Path.Combine(root,"state","automation-sessions",id); Directory.CreateDirectory(dir); var j=new AutomationJournal(root,id); await j.RecordAsync("SessionAllocated","Completed",null,ct); var pointer=Path.Combine(root,"state","automation-current.json"); await WriteAtomicAsync(pointer,new { sessionId=id,launchMode=mode,state="SessionAllocated",terminal=false,currentStage="InitialEvidenceFlushed",createdUtc=DateTimeOffset.UtcNow },ct); await WriteAtomicAsync(Path.Combine(root,"state","pilot-session.json"),new { sessionId=id,state="InitialEvidenceFlushed",launchMode=mode,foundationUiCreated=false },ct); await j.RecordAsync("InitialEvidenceFlushed","Completed",null,ct); return j; } + public Task RecordAsync(string stage,string status,string? currentPath,CancellationToken ct) => RecordProgressAsync(stage,status,null,currentPath,0,0,0,0,false,ct); + public Task RecordProgressAsync(string stage,string status,string? role,string? currentPath,int processed,int total,long processedBytes,long totalBytes,bool cancellationRequested,CancellationToken ct) => File.AppendAllTextAsync(path,JsonSerializer.Serialize(new { sequence=Interlocked.Increment(ref sequence),timestampUtc=DateTimeOffset.UtcNow,elapsedMs=(long)(DateTimeOffset.UtcNow-started).TotalMilliseconds,sessionId=SessionId,launcherPid=Environment.ProcessId,currentStage=stage,currentOperation=status,currentRole=role,currentPath,processedFiles=processed,totalFiles=total,processedBytes,totalBytes,lastProgressUtc=DateTimeOffset.UtcNow,cancellationRequested,foundationUiCreated=false,managedThreadId=Environment.CurrentManagedThreadId })+Environment.NewLine,ct); + public Task RecordFailureAsync(string stage,string? role,string? currentPath,Exception ex,CancellationToken ct) => File.AppendAllTextAsync(path,JsonSerializer.Serialize(new { sequence=Interlocked.Increment(ref sequence),timestampUtc=DateTimeOffset.UtcNow,sessionId=SessionId,stage,status="Failed",role,path=currentPath,exceptionType=ex.GetType().FullName,exception=ex.ToString(),cancellationRequested=true })+Environment.NewLine,ct); + public async Task TerminalAsync(string code,string state,string? currentPath,CancellationToken ct) { await RecordProgressAsync("TerminalEvidence",state,null,currentPath,0,0,0,0,state!="Succeeded",ct); await WriteAtomicAsync(Path.Combine(root,"state","pilot-session.json"),new { sessionId=SessionId,state,terminal=true,terminalCode=code,foundationUiCreated=false,autocadStarted=false,launcherPid=Environment.ProcessId,completedUtc=DateTimeOffset.UtcNow },ct); Console.WriteLine($"PreflightSession={SessionId}; Terminal={state}; Code={code}; AutoCADStarted=false; FoundationUICreated=false"); return state == "Succeeded" ? Success : ProcessFailed; } } + private static async Task WriteAtomicAsync(string path, T value, CancellationToken ct) + { Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temp=path+"."+Guid.NewGuid().ToString("N")+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(value),ct); File.Move(temp,path,true); } private static bool HashesEqual(IReadOnlyDictionary x, IReadOnlyDictionary y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value); private static PilotExecutionService CreateService(ResolvedLauncherPaths paths) => new(new PackageManifestValidator(), new PilotRootPolicy(paths.LocalLauncherRoot), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker(), new PathResolver()); private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }