using System.Security.Cryptography; using System.Text; using System.Text.Json; using Launcher.Next.Core; namespace Launcher.Next.Infrastructure; public sealed class PilotExecutionService( IPackageManifestValidator validator, IPilotRootPolicy rootPolicy, INextPilotSafetyGate gate, IFileHasher hasher, IStartupScriptBuilder scriptBuilder, IExecutionPermitService permits, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker, IPathResolver? pathResolver = null) : IPilotExecutionService { private sealed record Pointer(string Version); private sealed record ActivationSnapshot( string TransactionId, string PackageId, string PackageVersion, string? CurrentContent, string? CurrentSha256, string? PreviousContent, string? PreviousSha256, string VersionDirectory, string StartupScriptPath, string StagingPath); private sealed record PersistedSession( int SchemaVersion, string SessionId, string TransactionId, string State, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string StartupScriptPath, string LaunchMode, IReadOnlyDictionary ResolvedPaths, IReadOnlyList AcadArguments, string? ActualStartupScriptSha256, string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc, int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc, string? LastErrorCode, string? LastErrorMessage); public async Task PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var resolver = pathResolver ?? new PathResolver(); var paths = resolver.Resolve(new LauncherPathVariables(request.Configuration.PathVariables), [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]); if (!string.Equals(Path.GetFullPath(request.PilotRoot), paths.LocalLauncherRoot, StringComparison.OrdinalIgnoreCase)) return new(null, new(false, ["PILOT_ROOT_PATH_VARIABLE_MISMATCH"])); var root = rootPolicy.Validate(paths.LocalLauncherRoot); var validation = validator.Validate(request.Manifest, 2026, "x64"); var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken); var product = request.Product with { ExecutablePath = paths.AcadExecutable }; var profile = new AutoCadProfileCandidate(string.Empty, false, true, ["Confirmed Plant 3D launch contract has no /p argument."]); var safety = gate.Evaluate(new(request.Configuration, product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false); if (!safety.IsAllowed) return new(null, safety); var automation = request.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe; var content = automation ? "AUTOMATION_SCRIPT_PENDING" : "PRODUCTION_NO_SCRIPT"; var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); var plan = PilotPlanFactory.Create(product, profile, request.Manifest, request.ManifestHash, paths.LocalLauncherRoot, automation ? paths.Require(LauncherPathVariableNames.AutomationScriptPath) : string.Empty, scriptHash, safety, request.Configuration.LaunchMode); var environment = new Dictionary { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() }; if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath)) environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath; if (automation) { environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot"; environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode!; environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = paths.Require(LauncherPathVariableNames.AutomationConfigPath); } return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety); } public async Task ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken) { var plan = request.Plan; var resolver = pathResolver ?? new PathResolver(); var resolvedPaths = resolver.Resolve(new LauncherPathVariables(request.Preparation.Configuration.PathVariables)); var root = rootPolicy.Validate(request.Preparation.PilotRoot); var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64"); var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken); var context = new PilotSafetyContext(request.Preparation.Configuration, request.Preparation.Product, new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning()); var decision = gate.Evaluate(context, true); if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null); if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(plan.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, plan.PilotRoot, plan.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision, request.Preparation.Configuration.LaunchMode).PlanId, StringComparison.Ordinal)) return new(false, null, "PILOT_PLAN_CHANGED", null, null); var sessionId = Guid.NewGuid().ToString("N"); var transactionId = Guid.NewGuid().ToString("N"); var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId); var versionDirectory = plan.VersionDirectory; var staging = Path.Combine(plan.PilotRoot, "staging", transactionId); var scriptPath = plan.StartupScriptPath; var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json"); Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!); var currentPath = Path.Combine(packageRoot, "current.json"); var previousPath = Path.Combine(packageRoot, "previous.json"); var current = await ReadTextOrNull(currentPath, cancellationToken); var previous = await ReadTextOrNull(previousPath, cancellationToken); var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging); await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken); await WriteSession(plan.PilotRoot, new PersistedSession(2, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, request.Preparation.Configuration.LaunchMode.ToString(), resolvedPaths.Values, plan.Process.Arguments, null, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken); try { Directory.CreateDirectory(staging); foreach (var file in request.Preparation.Manifest.Files) { cancellationToken.ThrowIfCancellationRequested(); var source = Path.Combine(request.Preparation.PackageSourceRoot, file.Path); var target = Path.GetFullPath(Path.Combine(staging, file.Path)); if (!target.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE"); Directory.CreateDirectory(Path.GetDirectoryName(target)!); File.Copy(source, target, true); if (!string.Equals(await hasher.Sha256Async(target, cancellationToken), file.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_VALIDATION_FAILED"); } Directory.CreateDirectory(Path.GetDirectoryName(versionDirectory)!); if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, versionDirectory); await WriteTextAtomic(previousPath, current ?? string.Empty, cancellationToken, current is not null); await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true); var automation = request.Preparation.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe; if (automation) { var paths = resolver.Resolve(new LauncherPathVariables(request.Preparation.Configuration.PathVariables), [LauncherPathVariableNames.AutomationScriptPath, LauncherPathVariableNames.FixtureOrthoDwg, LauncherPathVariableNames.FixtureModelDwg, LauncherPathVariableNames.LocalSessionRoot]); scriptPath = paths.Require(LauncherPathVariableNames.AutomationScriptPath); if (!Path.GetFullPath(scriptPath).StartsWith(Path.GetFullPath(paths.Require(LauncherPathVariableNames.LocalSessionRoot)).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_SCRIPT_OUTSIDE_SESSION_ROOT"); Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply)); await File.WriteAllTextAsync(scriptPath, script, cancellationToken); } var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null); var actualScriptHash = automation ? Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(scriptPath, cancellationToken))) : null; await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", StartupScriptPath = automation ? scriptPath : string.Empty, AcadArguments = arguments, ActualStartupScriptSha256 = actualScriptHash, UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken); var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = automation ? scriptPath : string.Empty, Process = plan.Process with { Arguments = arguments, WorkingDirectory = plan.PilotRoot } }, request.Permit, permits); if (!result.Started) { await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken); return new(false, null, result.BlockCode, versionDirectory, scriptPath, sessionId, snapshotPath); } var session = (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "WaitingForRuntimeCheck", ProcessId = result.ProcessId, ProcessStartUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow }; await WriteSession(plan.PilotRoot, session, cancellationToken); return new(true, result.ProcessId, null, versionDirectory, scriptPath, sessionId, snapshotPath); } catch (Exception ex) { await MarkFailed(plan.PilotRoot, ex.Message, cancellationToken); throw; } } private static string ReadFixtureOrtho(string? automationConfigurationPath) { if (string.IsNullOrWhiteSpace(automationConfigurationPath) || !File.Exists(automationConfigurationPath)) throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING"); using var json = JsonDocument.Parse(File.ReadAllText(automationConfigurationPath)); if (!json.RootElement.TryGetProperty("orthoDrawingPath", out var value) || string.IsNullOrWhiteSpace(value.GetString())) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED"); return Path.GetFullPath(value.GetString()!); } public async Task RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken) { try { var session = await ReadSession(request.PilotRoot, cancellationToken); var snapshotPath = request.ActivationSnapshotPath ?? session?.ActivationSnapshotPath; if (string.IsNullOrWhiteSpace(snapshotPath) || !File.Exists(snapshotPath)) return new(false, "ACTIVATION_SNAPSHOT_MISSING"); var snapshot = await JsonSerializer.DeserializeAsync(File.OpenRead(snapshotPath), cancellationToken: cancellationToken); if (snapshot is null) return new(false, "ACTIVATION_SNAPSHOT_INVALID"); if (session?.ProcessId is int pid && probe.IsAcadRunning() && IsProcessRunning(pid)) return new(false, "PROCESS_STILL_RUNNING"); var packageRoot = Path.Combine(request.PilotRoot, "packages", snapshot.PackageId); await Restore(Path.Combine(packageRoot, "current.json"), snapshot.CurrentContent, snapshot.CurrentSha256, cancellationToken); await Restore(Path.Combine(packageRoot, "previous.json"), snapshot.PreviousContent, snapshot.PreviousSha256, cancellationToken); if (File.Exists(snapshot.StartupScriptPath)) File.Delete(snapshot.StartupScriptPath); if (Directory.Exists(snapshot.StagingPath)) Directory.Delete(snapshot.StagingPath, true); if (Directory.Exists(snapshot.VersionDirectory)) Directory.Delete(snapshot.VersionDirectory, true); if (session is not null) await WriteSession(request.PilotRoot, session with { State = "RolledBack", UpdatedAtUtc = DateTimeOffset.UtcNow, LastErrorCode = null, LastErrorMessage = null }, cancellationToken); return new(true, null); } catch { return new(false, "ROLLBACK_FAILED"); } } private async Task ValidateSource(PackageManifest manifest, string root, CancellationToken ct) { foreach (var file in manifest.Files) { var path = Path.Combine(root, file.Path); if (!File.Exists(path) || !string.Equals(await hasher.Sha256Async(path, ct), file.Sha256, StringComparison.OrdinalIgnoreCase)) return false; } return true; } private static bool IsProcessRunning(int pid) { try { return !System.Diagnostics.Process.GetProcessById(pid).HasExited; } catch { return false; } } private static string? Hash(string? text) => text is null ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))); private static async Task ReadTextOrNull(string path, CancellationToken ct) => File.Exists(path) ? await File.ReadAllTextAsync(path, ct) : null; private static async Task WriteTextAtomic(string path, string content, CancellationToken ct, bool exists) { if (!exists) { if (File.Exists(path)) File.Delete(path); return; } Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temp = path + ".tmp"; await File.WriteAllTextAsync(temp, content, ct); File.Move(temp, path, true); } private static async Task WriteJsonAtomic(string path, T value, CancellationToken ct) => await WriteTextAtomic(path, JsonSerializer.Serialize(value), ct, true); private static async Task ReadSession(string root, CancellationToken ct) { var path = Path.Combine(root, "state", "pilot-session.json"); if (!File.Exists(path)) return null; await using var stream = File.OpenRead(path); return await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); } private static Task WriteSession(string root, PersistedSession session, CancellationToken ct) => WriteJsonAtomic(Path.Combine(root, "state", "pilot-session.json"), session, ct); private static async Task MarkFailed(string root, string code, CancellationToken ct) { var s = await ReadSession(root, ct); if (s is not null) await WriteSession(root, s with { State = "Failed", UpdatedAtUtc = DateTimeOffset.UtcNow, LastErrorCode = code, LastErrorMessage = code }, ct); } private static async Task Restore(string path, string? content, string? sha, CancellationToken ct) { if (content is null) { if (File.Exists(path)) File.Delete(path); return; } if (!string.Equals(Hash(content), sha, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SNAPSHOT_HASH_INVALID"); await WriteTextAtomic(path, content, ct, true); if (!string.Equals(Hash(await File.ReadAllTextAsync(path, ct)), sha, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("POINTER_RESTORE_FAILED"); } }