diff --git a/Launcher.Next.App/PilotCli.cs b/Launcher.Next.App/PilotCli.cs index 93fd223..5e8e72d 100644 --- a/Launcher.Next.App/PilotCli.cs +++ b/Launcher.Next.App/PilotCli.cs @@ -86,13 +86,18 @@ internal static class PilotCli private static async Task AutomateFirstApplyAsync(string[] args) { if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!); + var journal = await AutomationJournal.AllocateAsync(options.PilotRoot, "NextPilotPreview", CancellationToken.None); + await journal.RecordAsync("ArgumentsParsed", "Completed", null, CancellationToken.None); if (!TryReadConfiguration(options.PilotRoot, out var baseConfig, out error)) return Fail(Blocked, error!); + await journal.RecordAsync("ConfigurationLoaded", "Completed", null, CancellationToken.None); if (!TryResolvePaths(baseConfig, out var basePaths, out error)) return Fail(Blocked, error!); if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute || !baseConfig.ForceAfterEvidence) return Fail(Blocked, "AUTOMATION_SAFETY_GATE_REJECTED"); if (!TryBuildManifest(options.PackageSource, options.EntryAssembly, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!); if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING"); Directory.CreateDirectory(options.EvidenceRoot); - var before = ContentHashes(options.FixtureMarker); + await journal.RecordAsync("HashPreparationStarted", "Started", null, CancellationToken.None); + var before = await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None); + await journal.RecordAsync("HashPreparationCompleted", "Completed", null, CancellationToken.None); var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json"); var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json"); var entryHash = manifest!.Files.Single(x => string.Equals(x.Path, options.EntryAssembly, StringComparison.OrdinalIgnoreCase)).Sha256; @@ -103,7 +108,7 @@ internal static class PilotCli if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED"); var preview = JsonSerializer.Deserialize(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (preview is null || !preview.Completed || preview.CommandsCompleted != true || preview.InspectorsCompleted != true || preview.EvidenceFlushed != true || preview.PlanIdRun1 is null || preview.PlanIdRun1 != preview.PlanIdRun2 || preview.PlanIdsEqual != true || preview.EligibleCandidateCount is not > 0 || string.IsNullOrWhiteSpace(preview.SelectedCandidateHash)) return Fail(Blocked, "AUTOMATION_PREVIEW_EVIDENCE_INVALID"); - if (!HashesEqual(before, ContentHashes(options.FixtureMarker))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED"); + if (!HashesEqual(before, await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED"); // A diagnostic preview establishes the script/registration boundary only. // It must never mint an Apply permit or start an Apply process. if (options.Diagnostic) return Success; @@ -115,7 +120,7 @@ internal static class PilotCli await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}"); var applyConfig = WithAutomationPaths(baseConfig, options, applyConfigPath, applyEvidence, permitPath, "apply") with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotApply, AutomationDiagnostic = false }; var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence); - var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker)); + var hashesEqual = HashesEqual(before, await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None)); var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null; if (File.Exists(permitPath)) File.Delete(permitPath); var applyVerified = apply is { Completed: true, CommandsCompleted: true, InspectorsCompleted: true, EvidenceFlushed: true, PermitAccepted: true, ApplyOutcomeRun1: "Created", ApplyOutcomeRun2: "AlreadyExists" } && apply.Baseline is not null && apply.AfterCommand1 is not null && apply.AfterCommand2 is not null && apply.AfterUndo is not null; @@ -183,7 +188,24 @@ internal static class PilotCli if (!File.Exists(V("--annotation-template-dwg"))) { error = "ANNOTATION_TEMPLATE_ASSET_MISSING"; return false; } options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--annotation-template-dwg")!, V("--evidence-root")!, diagnostic); return true; } - private static Dictionary ContentHashes(string marker) { var root = Path.GetDirectoryName(marker)!; return Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(x => x.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".dcf", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Equals("Project.xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToDictionary(x => Path.GetRelativePath(root,x), x => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))), StringComparer.OrdinalIgnoreCase); } + private static async Task> ContentHashesAsync(string marker, AutomationJournal journal, CancellationToken cancellationToken) + { + var root = Path.GetDirectoryName(marker)!; + var files = new[] { Path.Combine(root, "Piping.dcf"), Path.Combine(root, "Project.xml") } + .Concat(Directory.EnumerateFiles(Path.Combine(root, "Orthos", "DWGs"), "*.dwg", SearchOption.TopDirectoryOnly)) + .Concat(Directory.EnumerateFiles(Path.Combine(root, "Plant 3D Models"), "*.dwg", SearchOption.TopDirectoryOnly)) + .Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var path in files) { if (!File.Exists(path)) throw new InvalidOperationException("HASH_FILE_NOT_FOUND"); await journal.RecordAsync("HashFileStarted", "Started", path, cancellationToken); await using var stream = File.OpenRead(path); result[Path.GetRelativePath(root,path)] = Convert.ToHexString(await SHA256.HashDataAsync(stream,cancellationToken)); await journal.RecordAsync("HashFileCompleted", "Completed", path, cancellationToken); } + return result; + } + 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 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(); }