using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Launcher.Next.Autodesk; using Launcher.Next.Core; using Launcher.Next.Infrastructure; namespace Launcher.Next.App; internal static class PilotCli { private const int Success = 0, Blocked = 2, DiscoveryFailed = 3, AcadRunning = 4, SessionConflict = 5, ProcessFailed = 6, RollbackAfterExitFailed = 7, ExplicitRollbackFailed = 8, Unexpected = 10; public static async Task RunAsync(string[] args) { try { 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!); var root = options.PilotRoot!; if (command == "status") return await StatusAsync(root); if (command == "rollback") return await RollbackAsync(root); if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!); if (!TryResolvePaths(config, out var paths, out error)) return Fail(Blocked, error!); if (!string.Equals(paths.LocalLauncherRoot, root, StringComparison.OrdinalIgnoreCase) || !string.Equals(config.PilotRoot, root, StringComparison.OrdinalIgnoreCase)) return Fail(Blocked, "PILOT_ROOT_CONFIGURATION_MISMATCH"); if (!TryBuildManifest(options.PackageSource!, options.EntryAssembly!, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!); Directory.CreateDirectory(Path.Combine(root, "catalog")); await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest)); if (!File.Exists(paths.AcadExecutable)) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND"); if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root); var service = CreateService(paths); var product = new ProductInstallation(paths.AcadExecutable, "2026", "x64", []); var request = new PilotPreparationRequest(config, product, manifest!, manifestHash!, options.PackageSource!, root, Path.Combine(root, "startup", "prepare-only.scr")); var prepared = await service.PrepareAsync(request, CancellationToken.None); if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations)); PrintPlan(prepared.Plan); if (command == "prepare") return Success; var sessionScript = Path.Combine(root, "startup", Guid.NewGuid().ToString("N") + ".scr"); request = request with { StartupScriptPath = sessionScript }; prepared = await service.PrepareAsync(request, CancellationToken.None); if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations)); var permits = PermitService; var permit = permits.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds); var execution = await service.ExecuteAsync(new(request, prepared.Plan, permit), CancellationToken.None); if (!execution.Started) return Fail(execution.BlockCode == "ACAD_ALREADY_RUNNING" ? AcadRunning : ProcessFailed, execution.BlockCode ?? "PROCESS_START_FAILED"); Console.WriteLine($"Plant 3D запущен. PID: {execution.ProcessId}. P_Rec_Annot загружается через startup script."); if (!options.WaitForExit) return Success; try { await WaitForExitAsync(execution.ProcessId!.Value); Console.WriteLine("Plant 3D завершён штатно."); } catch (Exception ex) { Console.Error.WriteLine($"PROCESS_WAIT_FAILED: {ex.Message}"); return ProcessFailed; } finally { if (options.RollbackAfterExit) { var rollback = await service.RollbackAsync(new(root, manifest!.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None); if (!rollback.Completed) Console.Error.WriteLine(rollback.ErrorCode ?? "ROLLBACK_FAILED"); } } return await IsRolledBack(root) ? Success : RollbackAfterExitFailed; } catch (Exception ex) { return Fail(Unexpected, ex.Message); } } 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); private sealed record ApplyPermit(bool AllowApply, string FixtureMarkerPath, string TargetSnapshotId, string ExpectedPlanId, string VisibilityState, string AnnotationTemplateDwgPath); private sealed record AutomationSessionResult(bool Started, bool GracefulExitVerified, bool ForcedCleanupUsed, bool RollbackVerified, bool EvidenceCompleted); 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); 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; await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "preview-transcript.log"), diagnostic = options.Diagnostic })); await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}"); var previewConfig = WithAutomationPaths(baseConfig, options, previewConfigPath, previewEvidence, null, "preview") with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotPreview, AutomationDiagnostic = options.Diagnostic }; var previewSession = await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview", previewEvidence); 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, 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; var permitPath = Path.Combine(options.EvidenceRoot, "apply-permit.json"); await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "М25 DN15-32", options.AnnotationTemplateDwg))); var applyConfigPath = Path.Combine(options.EvidenceRoot, "automation-apply.config.json"); var applyEvidence = Path.Combine(options.EvidenceRoot, "apply-result.json"); await File.WriteAllTextAsync(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "apply-transcript.log"), diagnostic = false })); 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, 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; return applySession.Started && applySession.RollbackVerified && applySession.EvidenceCompleted && applyVerified && hashesEqual ? Success : Fail(ProcessFailed, !hashesEqual ? "AUTOMATION_CONTENT_CHANGED" : "AUTOMATION_APPLY_EVIDENCE_INVALID"); } private static async Task RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase, string evidencePath) { if (!TryResolvePaths(config, out var paths, out _)) return new(false, false, false, false, false); var service = CreateService(paths); var product = new ProductInstallation(paths.AcadExecutable, "2026", "x64", []); var request = new PilotPreparationRequest(config, product, manifest, manifestHash, options.PackageSource, options.PilotRoot, Path.Combine(options.PilotRoot, "startup", phase + ".scr")); var prepared = await service.PrepareAsync(request, CancellationToken.None); if (prepared.Plan is null) { Console.Error.WriteLine("AUTOMATION_PREPARE_BLOCKED:" + string.Join(',', prepared.SafetyDecision.Violations)); return new(false, false, false, false, false); } var execution = await service.ExecuteAsync(new(request, prepared.Plan, PermitService.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds)), CancellationToken.None); if (!execution.Started || execution.ProcessId is null) { Console.Error.WriteLine("AUTOMATION_EXECUTION_BLOCKED:" + (execution.BlockCode ?? "PROCESS_START_FAILED")); return new(false, false, false, false, false); } var forcedCleanup = false; var gracefulExit = false; try { var exit = WaitForExitAsync(execution.ProcessId.Value); if (!config.ForceAfterEvidence) { await exit; gracefulExit = true; } else { // The graceful-exit timeout is deliberately measured only after // terminal evidence is durable. Killing an active AutoCAD // command before that boundary would turn ForceAfterEvidence // into an unsafe general process killer. var evidenceTimeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120))); while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath)) if (await Task.WhenAny(exit, evidenceTimeout) == evidenceTimeout) break; if (exit.IsCompleted) gracefulExit = true; else { var timeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120))); if (HasCompletedEvidence(evidencePath) && await Task.WhenAny(exit, timeout) == exit) gracefulExit = true; else { using var process = Process.GetProcessById(execution.ProcessId.Value); if (!process.HasExited) { process.Kill(entireProcessTree: true); forcedCleanup = true; } await exit; } } } } finally { await service.RollbackAsync(new(options.PilotRoot, manifest.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None); } var evidenceCompleted = HasCompletedEvidence(evidencePath); return new(true, gracefulExit, forcedCleanup, await IsRolledBack(options.PilotRoot), evidenceCompleted); } private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error) { options = null!; error = null; var d = new Dictionary(StringComparer.OrdinalIgnoreCase); var diagnostic = false; for (var i = 0; i < args.Length; i++) { if (args[i] == "--diagnostic") { if (diagnostic) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } diagnostic = true; continue; } if (i + 1 >= args.Length || !args[i].StartsWith("--") || !d.TryAdd(args[i], args[++i])) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } } string? V(string x) => d.GetValueOrDefault(x); if (d.Keys.Except(new[] { "--package-source", "--entry-assembly", "--pilot-root", "--fixture-marker", "--ortho-dwg", "--model-dwg", "--annotation-template-dwg", "--evidence-root" }).Any() || 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") }.Any(string.IsNullOrWhiteSpace)) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } var paths = new[] { V("--package-source")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--annotation-template-dwg")!, V("--evidence-root")! }; if (paths.Any(x => !Path.IsPathFullyQualified(x) || x.StartsWith("\\\\"))) { error = "AUTOMATION_PATH_INVALID"; return false; } 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 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 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(); } private static bool HasCompletedEvidence(string path) { try { using var json = JsonDocument.Parse(File.ReadAllText(path)); return json.RootElement.TryGetProperty("Completed", out var completed) && completed.ValueKind == JsonValueKind.True; } catch { return false; } } private static async Task RollbackAsync(string root) { if (!TryReadConfiguration(root, out var config, out _) || !TryResolvePaths(config, out var paths, out _)) return Fail(ExplicitRollbackFailed, "PATH_CONFIGURATION_INVALID"); var result = await CreateService(paths).RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None); return result.Completed ? Success : Fail(ExplicitRollbackFailed, result.ErrorCode ?? "ROLLBACK_FAILED"); } private static async Task StatusAsync(string root) { var session = SessionPath(root); if (!File.Exists(session)) { Console.WriteLine("State: Clean"); return Success; } using var document = JsonDocument.Parse(await File.ReadAllTextAsync(session)); var state = document.RootElement.GetProperty("State").GetString() ?? "Unknown"; Console.WriteLine($"State: {state}"); return state is "RolledBack" ? Success : SessionConflict; } private static async Task HandleExistingSession(string root) { using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root))); var state = document.RootElement.GetProperty("State").GetString(); return state == "RolledBack" ? Success : Fail(SessionConflict, "PILOT_SESSION_ALREADY_ACTIVE"); } private static async Task HasActiveSession(string root) { using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root))); return document.RootElement.GetProperty("State").GetString() != "RolledBack"; } private static async Task IsRolledBack(string root) { if (!File.Exists(SessionPath(root))) return false; using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root))); return document.RootElement.GetProperty("State").GetString() == "RolledBack"; } private static string SessionPath(string root) => Path.Combine(root, "state", "pilot-session.json"); private static bool TryReadConfiguration(string root, out PilotLocalConfiguration config, out string? error) { config = PilotLocalConfiguration.SafeDefault; error = null; var directory = Path.Combine(root, "config"); var defaults = Path.Combine(directory, "launcher.defaults.json"); var machine = Path.Combine(directory, "launcher.machine.json"); var session = Path.Combine(directory, "launcher.session.json"); if (!File.Exists(machine)) { error = "PILOT_CONFIGURATION_MISSING"; return false; } var parsed = LauncherConfigurationLayers.LoadFromFiles(defaults, machine, session); config = parsed.Configuration; if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; } return true; } private static bool TryResolvePaths(PilotLocalConfiguration configuration, out ResolvedLauncherPaths paths, out string? error) { try { var resolver = new PathResolver(); var variables = new LauncherPathVariables(configuration.PathVariables); var raw = resolver.Resolve(variables, [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]); var resolved = new Dictionary(raw.Values, StringComparer.OrdinalIgnoreCase); foreach (var name in new[] { LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.LocalPackageCache, LauncherPathVariableNames.LocalActiveRoot, LauncherPathVariableNames.LocalSessionRoot, LauncherPathVariableNames.LocalEvidenceRoot, LauncherPathVariableNames.LocalLogsRoot, LauncherPathVariableNames.LocalRollbackRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.PRecEntryAssembly, LauncherPathVariableNames.FixtureOrthoDwg, LauncherPathVariableNames.FixtureModelDwg, LauncherPathVariableNames.AutomationScriptPath, LauncherPathVariableNames.AutomationConfigPath, LauncherPathVariableNames.AutomationEvidencePath, LauncherPathVariableNames.AutomationPermitPath, LauncherPathVariableNames.AutomationTranscriptPath }) if (raw.Get(name) is { Length: > 0 } value) resolved[name] = resolver.ResolvePath(value, variables); paths = new(resolved); error = null; return true; } catch (Exception ex) { paths = null!; error = ex.Message; return false; } } private static PilotLocalConfiguration WithAutomationPaths(PilotLocalConfiguration config, AutomationOptions options, string configPath, string evidencePath, string? permitPath, string phase) { var variables = new Dictionary(config.PathVariables, StringComparer.OrdinalIgnoreCase) { [LauncherPathVariableNames.LocalSessionRoot] = options.EvidenceRoot, [LauncherPathVariableNames.AutomationScriptPath] = Path.Combine(options.EvidenceRoot, phase + ".scr"), [LauncherPathVariableNames.AutomationConfigPath] = configPath, [LauncherPathVariableNames.AutomationEvidencePath] = evidencePath, [LauncherPathVariableNames.AutomationPermitPath] = permitPath, [LauncherPathVariableNames.AutomationTranscriptPath] = Path.Combine(options.EvidenceRoot, phase + "-transcript.log"), [LauncherPathVariableNames.FixtureOrthoDwg] = options.OrthoDrawing, [LauncherPathVariableNames.FixtureModelDwg] = options.ModelDrawing, [LauncherPathVariableNames.PRecEntryAssembly] = Path.Combine(options.PackageSource, options.EntryAssembly) }; return config with { PathVariables = variables }; } private static bool TryBuildManifest(string source, string entry, out PackageManifest? manifest, out string? hash, out string? error) { manifest = null; hash = null; error = null; if (!Directory.Exists(source) || !File.Exists(Path.Combine(source, entry))) { error = "PACKAGE_SOURCE_INVALID"; return false; } var list = new List(); foreach (var path in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) { var relative = Path.GetRelativePath(source, path); var name = Path.GetFileName(relative); if (relative.StartsWith("..") || IsForbidden(name)) { error = "PACKAGE_FILE_FORBIDDEN"; return false; } using var stream = File.OpenRead(path); var bytes = SHA256.HashData(stream); list.Add(new(relative, new FileInfo(path).Length, Convert.ToHexString(bytes))); } var assemblyVersion = "0.1.0-pilot"; try { assemblyVersion = System.Reflection.AssemblyName.GetAssemblyName(Path.Combine(source, entry)).Version?.ToString() ?? assemblyVersion; } catch { } // A pilot version directory is immutable for the duration of its activation. // Bind its name to the validated package content so a rebuilt local bundle // cannot silently reuse a stale directory with the same assembly version. var contentIdentity = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).Select(x => $"{x.Path}:{x.Sha256}")))))[..12]; var version = assemblyVersion + "-" + contentIdentity; manifest = new PackageManifest(1, "P_Rec_Annot", version, "Plant3D", 2026, "x64", entry, list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).ToArray(), new("netload", "PHP_REC_ANNOT"), "0.1.0", ["R25.1"], "net8.0-windows", null, null, "pilot", false, true); var json = JsonSerializer.Serialize(manifest); hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))); return true; } private static bool IsForbidden(string name) => name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || name.Contains("test", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Ac", StringComparison.OrdinalIgnoreCase) || name.StartsWith("PnP", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Autodesk.ProcessPower", StringComparison.OrdinalIgnoreCase); private static void PrintPlan(PilotLaunchPlan plan) => Console.WriteLine($"Plan={plan.PlanId}; Package={plan.PackageId}/{plan.PackageVersion}; NETLOAD=1; Args={string.Join(' ', plan.Process.Arguments)}; /p=false"); private static int Fail(int code, string error) { Console.Error.WriteLine(error); return code; } private sealed record Options(string? PackageSource, string? EntryAssembly, string? PilotRoot, bool WaitForExit, bool RollbackAfterExit); private static bool TryParse(string command, string[] args, out Options options, out string? error) { options = new(null, null, null, false, false); error = null; var values = new Dictionary(StringComparer.OrdinalIgnoreCase); var wait = false; var rollback = false; for (var i = 0; i < args.Length; i++) { var token = args[i]; if (token is "--wait-for-exit" or "--rollback-after-exit") { if (command != "run" || (token == "--wait-for-exit" ? wait : rollback)) { error = "PILOT_ARGUMENT_INVALID"; return false; } if (token == "--wait-for-exit") wait = true; else rollback = true; continue; } if (token is not ("--package-source" or "--entry-assembly" or "--pilot-root") || i + 1 >= args.Length || args[i + 1].StartsWith("--") || !values.TryAdd(token, args[++i])) { error = "PILOT_ARGUMENT_INVALID"; return false; } } if (rollback && !wait) { error = "ROLLBACK_AFTER_EXIT_REQUIRES_WAIT"; return false; } if (!values.TryGetValue("--pilot-root", out var root) || !Path.IsPathFullyQualified(root) || root.StartsWith("\\\\")) { error = "PILOT_ROOT_INVALID"; return false; } if (command is "prepare" or "run") { if (!values.TryGetValue("--package-source", out var source) || source is null || !Path.IsPathFullyQualified(source) || source.StartsWith("\\\\") || Path.GetFullPath(source).StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { error = "PACKAGE_SOURCE_INVALID"; return false; } if (!values.TryGetValue("--entry-assembly", out var entry) || entry is null || !string.Equals(Path.GetFileName(entry), entry, StringComparison.Ordinal) || entry.Contains("..", StringComparison.Ordinal)) { error = "ENTRY_ASSEMBLY_INVALID"; return false; } } else if (values.Keys.Any(x => x != "--pilot-root")) { error = "PILOT_ARGUMENT_INVALID"; return false; } options = new(values.GetValueOrDefault("--package-source"), values.GetValueOrDefault("--entry-assembly"), root, wait, rollback); return true; } private sealed class Probe : IAutoCadProcessProbe { public bool IsAcadRunning() => Process.GetProcessesByName("acad").Any(); } }