From f7202a4a3d78a772c1a6563e83f0cb61c41a6b57 Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 31 Jul 2026 15:41:34 +0300 Subject: [PATCH] feat: complete pilot CLI session and rollback lifecycle --- Launcher.Next.App/App.xaml | 2 +- Launcher.Next.App/App.xaml.cs | 7 +- Launcher.Next.App/Launcher.Next.App.csproj | 2 +- Launcher.Next.App/PilotCli.cs | 160 ++++++++++++++++++ Launcher.Next.Core/NextPilot.cs | 2 +- Launcher.Next.Core/PilotExecutionContracts.cs | 4 +- .../PilotExecutionService.cs | 158 ++++++++++++++++- 7 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 Launcher.Next.App/PilotCli.cs diff --git a/Launcher.Next.App/App.xaml b/Launcher.Next.App/App.xaml index 23e67d3..48fd616 100644 --- a/Launcher.Next.App/App.xaml +++ b/Launcher.Next.App/App.xaml @@ -1 +1 @@ - + diff --git a/Launcher.Next.App/App.xaml.cs b/Launcher.Next.App/App.xaml.cs index 8e77aec..6225945 100644 --- a/Launcher.Next.App/App.xaml.cs +++ b/Launcher.Next.App/App.xaml.cs @@ -1 +1,6 @@ -namespace Launcher.Next.App; public partial class App : System.Windows.Application { } +namespace Launcher.Next.App; +public partial class App : System.Windows.Application +{ + protected override async void OnStartup(System.Windows.StartupEventArgs e) + { base.OnStartup(e); if (e.Args.FirstOrDefault()?.Equals("pilot", StringComparison.OrdinalIgnoreCase) == true) { Shutdown(await PilotCli.RunAsync(e.Args.Skip(1).ToArray())); return; } new MainWindow().Show(); } +} diff --git a/Launcher.Next.App/Launcher.Next.App.csproj b/Launcher.Next.App/Launcher.Next.App.csproj index 837d159..a17b4b9 100644 --- a/Launcher.Next.App/Launcher.Next.App.csproj +++ b/Launcher.Next.App/Launcher.Next.App.csproj @@ -1 +1 @@ -WinExenet8.0-windowstruetrue +WinExenet8.0-windowstruetrue diff --git a/Launcher.Next.App/PilotCli.cs b/Launcher.Next.App/PilotCli.cs new file mode 100644 index 0000000..d0cb636 --- /dev/null +++ b/Launcher.Next.App/PilotCli.cs @@ -0,0 +1,160 @@ +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")) return Blocked; + 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 (!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!); + if (!File.Exists(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe")) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND"); + if (command == "run" && File.Exists(SessionPath(root))) return await HandleExistingSession(root); + var service = CreateService(); + var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "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 static PilotExecutionService CreateService() => new(new PackageManifestValidator(), new PilotRootPolicy(@"C:\AutoCAD User Support"), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker()); + private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); } + private static async Task RollbackAsync(string root) + { + var result = await CreateService().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 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 path = Path.Combine(root, "config", "pilot.local.json"); + if (!File.Exists(path)) { error = "PILOT_CONFIGURATION_MISSING"; return false; } + var parsed = PilotConfigurationParser.Parse(File.ReadAllText(path)); config = parsed.Configuration; + if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; } + return true; + } + 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 version = "0.1.0-pilot-ea3a9e1"; + try { version = System.Reflection.AssemblyName.GetAssemblyName(Path.Combine(source, entry)).Version?.ToString() ?? version; } catch { } + 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(); } +} diff --git a/Launcher.Next.Core/NextPilot.cs b/Launcher.Next.Core/NextPilot.cs index 9fd3aef..c61a7de 100644 --- a/Launcher.Next.Core/NextPilot.cs +++ b/Launcher.Next.Core/NextPilot.cs @@ -57,7 +57,7 @@ public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafety public sealed class NextPilotSafetyGate : INextPilotSafetyGate { public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution) - { var v = new List(); if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE"); if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED"); if (c.Configuration.PluginExecutionMode != PluginExecutionMode.Smoke) v.Add("NON_SMOKE_PLUGIN_MODE"); if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026"); if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (!c.HashesValid) v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v); } + { var v = new List(); if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE"); if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED"); if (!c.Configuration.RequireExplicitExecute) v.Add("EXPLICIT_EXECUTE_REQUIRED"); if (c.Configuration.PluginExecutionMode != PluginExecutionMode.Smoke) v.Add("NON_SMOKE_PLUGIN_MODE"); if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026"); if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (!c.HashesValid) v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v); } } public sealed record ProcessLaunchSpecification(string ExecutablePath, IReadOnlyList Arguments, string WorkingDirectory, IReadOnlyDictionary EnvironmentVariables); public sealed record PilotLaunchPlan(string PlanId, ProductInstallation Product, AutoCadProfileCandidate Profile, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string EntryAssemblyPath, string StartupScriptPath, string StartupScriptSha256, ProcessLaunchSpecification Process, PilotSafetyDecision SafetyDecision); diff --git a/Launcher.Next.Core/PilotExecutionContracts.cs b/Launcher.Next.Core/PilotExecutionContracts.cs index c82de33..ffbe04c 100644 --- a/Launcher.Next.Core/PilotExecutionContracts.cs +++ b/Launcher.Next.Core/PilotExecutionContracts.cs @@ -2,8 +2,8 @@ namespace Launcher.Next.Core; public sealed record PilotPreparationRequest(PilotLocalConfiguration Configuration, ProductInstallation Product, PackageManifest Manifest, string ManifestHash, string PackageSourceRoot, string PilotRoot, string StartupScriptPath); public sealed record PilotPreparationResult(PilotLaunchPlan? Plan, PilotSafetyDecision SafetyDecision); public sealed record PilotExecutionRequest(PilotPreparationRequest Preparation, PilotLaunchPlan Plan, ExecutionPermit Permit); -public sealed record PilotExecutionResult(bool Started, int? ProcessId, string? BlockCode, string? VersionDirectory, string? StartupScriptPath); -public sealed record PilotRollbackRequest(string PilotRoot, string PackageId, string? ActivatedVersion, string StartupScriptPath, string? PreviousCurrentContent); +public sealed record PilotExecutionResult(bool Started, int? ProcessId, string? BlockCode, string? VersionDirectory, string? StartupScriptPath, string? SessionId = null, string? ActivationSnapshotPath = null); +public sealed record PilotRollbackRequest(string PilotRoot, string PackageId, string? ActivatedVersion, string StartupScriptPath, string? PreviousCurrentContent, string? SessionId = null, string? ActivationSnapshotPath = null); public sealed record PilotRollbackResult(bool Completed, string? ErrorCode); public interface IPilotExecutionService { diff --git a/Launcher.Next.Infrastructure/PilotExecutionService.cs b/Launcher.Next.Infrastructure/PilotExecutionService.cs index 21d83f2..65f8adf 100644 --- a/Launcher.Next.Infrastructure/PilotExecutionService.cs +++ b/Launcher.Next.Infrastructure/PilotExecutionService.cs @@ -1,13 +1,157 @@ 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) : IPilotExecutionService + +public sealed class PilotExecutionService( + IPackageManifestValidator validator, + IPilotRootPolicy rootPolicy, + INextPilotSafetyGate gate, + IFileHasher hasher, + IStartupScriptBuilder scriptBuilder, + IExecutionPermitService permits, + IAutoCadProcessProbe probe, + IOperatingSystemProcessInvoker invoker) : IPilotExecutionService { - public Task PrepareAsync(PilotPreparationRequest r, CancellationToken ct) - { ct.ThrowIfCancellationRequested(); var root=rootPolicy.Validate(r.PilotRoot); var validation=validator.Validate(r.Manifest,2026,"x64"); var hashes=ValidateSource(r.Manifest,r.PackageSourceRoot,ct).GetAwaiter().GetResult(); var profile=new AutoCadProfileCandidate("",false,true,["legacy contract: no profile argument"]); var safety=gate.Evaluate(new(r.Configuration,r.Product,profile,root,validation,hashes,false,probe.IsAcadRunning()),false); if(!safety.IsAllowed)return Task.FromResult(new PilotPreparationResult(null,safety)); var futureEntry=Path.Combine(r.PilotRoot,"packages",r.Manifest.PackageId,"versions",r.Manifest.Version,r.Manifest.EntryAssembly); var content="._NETLOAD\n\""+futureEntry.Replace("\\","\\\\")+"\"\n"; var scriptHash=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); var plan=PilotPlanFactory.Create(r.Product,profile,r.Manifest,r.ManifestHash,r.PilotRoot,r.StartupScriptPath,scriptHash,safety); return Task.FromResult(new PilotPreparationResult(plan,safety)); } - public async Task ExecuteAsync(PilotExecutionRequest r, CancellationToken ct) - { var plan=r.Plan; var root=rootPolicy.Validate(r.Preparation.PilotRoot); var valid=validator.Validate(r.Preparation.Manifest,2026,"x64"); var hashes=await ValidateSource(r.Preparation.Manifest,r.Preparation.PackageSourceRoot,ct); var context=new PilotSafetyContext(r.Preparation.Configuration,r.Preparation.Product,new("",false,true,["legacy contract"]),root,valid,hashes,false,probe.IsAcadRunning()); var safety=gate.Evaluate(context,true); if(!safety.IsAllowed)return new(false,null,safety.Violations.First(),null,null); if(!permits.Validate(r.Permit,plan,DateTimeOffset.UtcNow))return new(false,null,"EXECUTION_PERMIT_MISSING",null,null); var version=Path.Combine(plan.PilotRoot,"packages",plan.PackageId,"versions",plan.PackageVersion); var staging=Path.Combine(plan.PilotRoot,"staging",Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); try { foreach(var f in r.Preparation.Manifest.Files){ct.ThrowIfCancellationRequested();var src=Path.Combine(r.Preparation.PackageSourceRoot,f.Path);var dst=Path.Combine(staging,f.Path);Directory.CreateDirectory(Path.GetDirectoryName(dst)!);File.Copy(src,dst,true);if(!string.Equals(await hasher.Sha256Async(dst,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("HASH_VALIDATION_FAILED");} Directory.CreateDirectory(Path.GetDirectoryName(version)!);if(!Directory.Exists(version))Directory.Move(staging,version);else Directory.Delete(staging,true); Directory.CreateDirectory(Path.GetDirectoryName(plan.StartupScriptPath)!);await File.WriteAllTextAsync(plan.StartupScriptPath,scriptBuilder.BuildNetloadScript(version,r.Preparation.Manifest.EntryAssembly),ct); var guarded=new GuardedAutodeskProcessLauncher(gate,probe,invoker);var result=guarded.Start(context,plan,r.Permit,permits);return new(result.Started,result.ProcessId,result.BlockCode,version,plan.StartupScriptPath);}catch{if(Directory.Exists(staging))Directory.Delete(staging,true);throw;} } - public Task RollbackAsync(PilotRollbackRequest r,CancellationToken ct){try{if(File.Exists(r.StartupScriptPath))File.Delete(r.StartupScriptPath);if(r.ActivatedVersion is not null){var d=Path.Combine(r.PilotRoot,"packages",r.PackageId,"versions",r.ActivatedVersion);if(Directory.Exists(d))Directory.Delete(d,true);}return Task.FromResult(new PilotRollbackResult(true,null));}catch{return Task.FromResult(new PilotRollbackResult(false,"ROLLBACK_FAILED"));}} - private async Task ValidateSource(PackageManifest m,string root,CancellationToken ct){foreach(var f in m.Files){var p=Path.Combine(root,f.Path);if(!File.Exists(p)||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;} + 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 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 root = rootPolicy.Validate(request.PilotRoot); + var validation = validator.Validate(request.Manifest, 2026, "x64"); + var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken); + var profile = new AutoCadProfileCandidate(string.Empty, false, true, ["Confirmed Plant 3D launch contract has no /p argument."]); + var safety = gate.Evaluate(new(request.Configuration, request.Product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false); + if (!safety.IsAllowed) return new(null, safety); + + var futureEntry = Path.Combine(request.PilotRoot, "packages", request.Manifest.PackageId, "versions", request.Manifest.Version, request.Manifest.EntryAssembly); + var content = $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n"; + var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); + return new(PilotPlanFactory.Create(request.Product, profile, request.Manifest, request.ManifestHash, request.PilotRoot, request.StartupScriptPath, scriptHash, safety), safety); + } + + public async Task ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken) + { + var plan = request.Plan; + 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(request.Preparation.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, request.Preparation.PilotRoot, request.Preparation.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision).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 = Path.Combine(plan.PilotRoot, "startup", sessionId + ".scr"); + 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(1, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, 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); + Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); + await File.WriteAllTextAsync(scriptPath, scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly), cancellationToken); + await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken); + var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = scriptPath, Process = plan.Process with { Arguments = ["/product", "PLNT3D", "/language", "ru-RU", "/b", scriptPath], 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; + } + } + + 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"); return File.Exists(path) ? await JsonSerializer.DeserializeAsync(File.OpenRead(path), cancellationToken: ct) : null; } + 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"); } }