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"); }
}