Author SHA1 Message Date
codex e13f44ac3c refactor: resolve launcher paths and isolate batch automation 2026-08-04 11:48:30 +03:00
codex ea6f3cdcdf Merge remote-tracking branch 'origin/test/launcher-next-first-apply-automation-20260803' into integration/script-automation-20260804 2026-08-04 11:24:44 +03:00
codex 115f44fcb9 feat: drive Preview automation through AutoCAD script 2026-08-04 10:07:03 +03:00
codex 8a2ca2daa6 fix: wait for completed evidence before forced cleanup 2026-08-04 07:36:05 +03:00
codex e2725c7f4c fix: clear stale automation evidence before pilot session 2026-08-04 07:23:38 +03:00
codex b88e09afd4 feat: allow forced cleanup after completed automation evidence 2026-08-04 07:22:35 +03:00
codex 9209937b85 fix: isolate rebuilt pilot package versions 2026-08-03 16:38:31 +03:00
codex 399cdf503e feat: pass limited Apply permit to pilot process 2026-08-03 10:52:12 +03:00
codex 72cd0cc337 feat: require local permit for first Apply pilot 2026-08-03 10:48:03 +03:00
codex dc9aeff1b5 feat: gate first limited Apply pilot 2026-08-03 10:44:51 +03:00
codex e97b10cf2e docs: record local Plant 3D Ortho DryRun 2026-08-03 08:09:36 +03:00
codex 02e5dbc61f test: allow controlled P_Rec_Annot DryRun pilot 2026-07-31 15:57:14 +03:00
codex afd3924214 docs: record pilot runner exit-code limitation 2026-07-31 15:51:52 +03:00
codex 0314dfc997 docs: document Launcher Next mechanics and real P_Rec_Annot pilot 2026-07-31 15:51:16 +03:00
codex 3b6e43fae1 fix: allow new pilot run after completed rollback 2026-07-31 15:44:44 +03:00
codex 5f3a75f160 fix: release persisted pilot session before state transition 2026-07-31 15:44:11 +03:00
codex fef3db996a fix: parse string pilot configuration enums 2026-07-31 15:42:31 +03:00
codex 39a6ed1f4a feat: persist real pilot manifest catalog 2026-07-31 15:41:51 +03:00
codex f7202a4a3d feat: complete pilot CLI session and rollback lifecycle 2026-07-31 15:41:34 +03:00
codex e50dd7843f feat: complete NextPilot execution service 2026-07-31 15:27:01 +03:00
codex 90108d1c06 feat: add controlled NextPilot execution pipeline 2026-07-31 15:19:11 +03:00
30 changed files with 1135 additions and 6 deletions
+1 -1
View File
@@ -1 +1 @@
<Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="MainWindow.xaml" /> <Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" />
+6 -1
View File
@@ -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(); }
}
+1 -1
View File
@@ -1 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /></ItemGroup></Project> <Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /><ProjectReference Include="..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" /><ProjectReference Include="..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" /></ItemGroup></Project>
+299
View File
@@ -0,0 +1,299 @@
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<int> RunAsync(string[] args)
{
try
{
if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback" or "automate-first-apply")) return Blocked;
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 AutomationOptions(string PackageSource, string EntryAssembly, string PilotRoot, string FixtureMarker, string OrthoDrawing, string ModelDrawing, string EvidenceRoot);
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);
private sealed record AutomationSessionResult(bool Started, bool GracefulExitVerified, bool ForcedCleanupUsed, bool RollbackVerified, bool EvidenceCompleted);
private static async Task<int> AutomateFirstApplyAsync(string[] args)
{
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
if (!TryReadConfiguration(options.PilotRoot, out var baseConfig, out error)) return Fail(Blocked, error!);
if (!TryResolvePaths(baseConfig, out var basePaths, out error)) return Fail(Blocked, error!);
if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute || !baseConfig.ForceAfterEvidence) return Fail(Blocked, "AUTOMATION_SAFETY_GATE_REJECTED");
if (!TryBuildManifest(options.PackageSource, options.EntryAssembly, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
Directory.CreateDirectory(options.EvidenceRoot);
var before = ContentHashes(options.FixtureMarker);
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json");
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker }));
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 };
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<PreviewEvidence>(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (preview is null || !preview.Completed || preview.CommandsCompleted != true || preview.InspectorsCompleted != true || preview.EvidenceFlushed != true || preview.PlanIdRun1 is null || preview.PlanIdRun1 != preview.PlanIdRun2 || preview.PlanIdsEqual != true || preview.EligibleCandidateCount is not > 0 || string.IsNullOrWhiteSpace(preview.SelectedCandidateHash)) return Fail(Blocked, "AUTOMATION_PREVIEW_EVIDENCE_INVALID");
if (!HashesEqual(before, ContentHashes(options.FixtureMarker))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED");
var permitPath = Path.Combine(options.EvidenceRoot, "apply-permit.json");
await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "Visible")));
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, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker }));
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 };
var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence);
var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker));
var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(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<AutomationSessionResult> 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) 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) 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.
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath))
await Task.Delay(TimeSpan.FromSeconds(1));
if (exit.IsCompleted) gracefulExit = true;
else
{
var timeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
if (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<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++) { 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", "--evidence-root" }).Any() || new[] { V("--package-source"), V("--entry-assembly"), V("--pilot-root"), V("--fixture-marker"), V("--ortho-dwg"), V("--model-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("--evidence-root")! }; if (paths.Any(x => !Path.IsPathFullyQualified(x) || x.StartsWith("\\\\"))) { error = "AUTOMATION_PATH_INVALID"; return false; }
options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--evidence-root")!); return true;
}
private static Dictionary<string,string> ContentHashes(string marker) { var root = Path.GetDirectoryName(marker)!; return Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(x => x.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".dcf", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Equals("Project.xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToDictionary(x => Path.GetRelativePath(root,x), x => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))), StringComparer.OrdinalIgnoreCase); }
private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> 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<int> 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<int> 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<int> 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<bool> HasActiveSession(string root)
{
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root)));
return document.RootElement.GetProperty("State").GetString() != "RolledBack";
}
private static async Task<bool> 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<string, string?>(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.AutomationScriptPath, LauncherPathVariableNames.AutomationConfigPath, LauncherPathVariableNames.AutomationEvidencePath, LauncherPathVariableNames.AutomationPermitPath })
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<string, string?>(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.FixtureOrthoDwg] = options.OrthoDrawing,
[LauncherPathVariableNames.PRecEntryAssembly] = 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<PackageFile>();
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<string, string>(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(); }
}
+9 -1
View File
@@ -15,7 +15,15 @@ public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDi
public sealed class StartupScriptBuilder : IStartupScriptBuilder public sealed class StartupScriptBuilder : IStartupScriptBuilder
{ {
public string BuildNetloadScript(string root, string entry) public string BuildNetloadScript(string root, string entry)
{ var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"(command \"._netload\" \"{full.Replace("\\", "\\\\").Replace("\"", "\\\"")}\")\n"; } { var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"_.NETLOAD\n\"{full.Replace("\"", "\\\"")}\"\n"; }
public string BuildAutomationScript(ResolvedAutomationScriptPaths paths)
{
var fixture = Path.GetFullPath(paths.FixtureOrthoDwg);
if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND");
var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
return "_.OPEN\n" + quote(fixture) + "\n" + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
"PHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
}
} }
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
{ {
+4 -2
View File
@@ -1,6 +1,7 @@
namespace Launcher.Next.Core; namespace Launcher.Next.Core;
public enum LauncherMode { Legacy, NextPilot, NextPreferred } public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred }
public enum PluginExecutionMode { Smoke, DryRun, Apply }
public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles); public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles);
public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false); public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false);
public sealed record StartupDefinition(string Type, string Command); public sealed record StartupDefinition(string Type, string Command);
@@ -27,7 +28,8 @@ public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(strin
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); } public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); } public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); } public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); } public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string AutomationScriptPath);
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); } public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); } public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
public interface IClock { DateTimeOffset UtcNow { get; } } public interface IClock { DateTimeOffset UtcNow { get; } }
+158
View File
@@ -0,0 +1,158 @@
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Launcher.Next.Core;
public sealed record LauncherPathVariables(IReadOnlyDictionary<string, string?> Values)
{
public string? this[string name] => Values.TryGetValue(name, out var value) ? value : null;
}
public sealed record ResolvedLauncherPaths(IReadOnlyDictionary<string, string?> Values)
{
public string Require(string name) => Values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
? value : throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
public string? Get(string name) => Values.TryGetValue(name, out var value) ? value : null;
public string LocalLauncherRoot => Require(nameof(LocalLauncherRoot));
public string AcadExecutable => Require(nameof(AcadExecutable));
public string? AutomationScriptPath => Get(nameof(AutomationScriptPath));
public string? FixtureOrthoDwg => Get(nameof(FixtureOrthoDwg));
}
public interface IPathResolver
{
ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null);
string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null);
}
public sealed class PathResolver(IReadOnlyDictionary<string, string?>? environment = null) : IPathResolver
{
private static readonly Regex Token = new(@"\$\{(?<name>[A-Za-z][A-Za-z0-9_]*)\}", RegexOptions.Compiled);
private readonly IReadOnlyDictionary<string, string?> environment = environment ?? Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>().ToDictionary(x => (string)x.Key, x => x.Value?.ToString(), StringComparer.OrdinalIgnoreCase);
public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null)
{
var resolved = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var name in variables.Values.Keys) resolved[name] = ResolveVariable(name, variables, resolved, []);
foreach (var name in required ?? [])
if (!resolved.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
return new(resolved);
}
public string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null)
{
var resolved = Expand(value, variables, new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase), []);
if (string.IsNullOrWhiteSpace(resolved) || !Path.IsPathFullyQualified(resolved))
throw new InvalidOperationException("PATH_ABSOLUTE_REQUIRED");
var full = Path.GetFullPath(resolved);
if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase))
{
var corporate = Resolve(variables, ["CorporateDfsRoot"]).Require("CorporateDfsRoot");
full = Path.Combine(corporate, full[3..]);
}
if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DRIVE_A_UNRESOLVED");
if (!string.IsNullOrWhiteSpace(constrainedRoot))
{
var root = Path.GetFullPath(ResolvePath(constrainedRoot, variables));
if (!full.Equals(root, StringComparison.OrdinalIgnoreCase) && !full.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("PATH_OUTSIDE_CONSTRAINED_ROOT");
}
return full;
}
private string? ResolveVariable(string name, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack)
{
if (resolved.TryGetValue(name, out var cached)) return cached;
if (!stack.Add(name)) throw new InvalidOperationException($"PATH_VARIABLE_CYCLE:{name}");
try
{
var source = variables[name] ?? (environment.TryGetValue(name, out var fromEnvironment) ? fromEnvironment : null);
if (source is null) throw new InvalidOperationException($"PATH_VARIABLE_UNKNOWN:{name}");
var expanded = Token.Replace(source, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
expanded = expanded.Replace('/', Path.DirectorySeparatorChar);
resolved[name] = expanded;
return expanded;
}
finally { stack.Remove(name); }
}
private string Expand(string value, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack) =>
Token.Replace(value, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
}
public static class LauncherPathVariableNames
{
public const string CorporateDfsRoot = "CorporateDfsRoot";
public const string CorporateLauncherRoot = "CorporateLauncherRoot";
public const string CorporateCatalogRoot = "CorporateCatalogRoot";
public const string CorporatePackagesRoot = "CorporatePackagesRoot";
public const string CorporateProfilesRoot = "CorporateProfilesRoot";
public const string CorporatePalettesRoot = "CorporatePalettesRoot";
public const string CorporateFontsRoot = "CorporateFontsRoot";
public const string CorporateTemplatesRoot = "CorporateTemplatesRoot";
public const string CorporateScriptsRoot = "CorporateScriptsRoot";
public const string ProjectName = "ProjectName";
public const string DisciplineName = "DisciplineName";
public const string ProjectRoot = "ProjectRoot";
public const string DisciplineRoot = "DisciplineRoot";
public const string PlantProjectFile = "PlantProjectFile";
public const string ProjectRosterPath = "ProjectRosterPath";
public const string ProjectApplicationsRoot = "ProjectApplicationsRoot";
public const string ProjectPaletteOverlayRoot = "ProjectPaletteOverlayRoot";
public const string LocalLauncherRoot = "LocalLauncherRoot";
public const string LocalPackageCache = "LocalPackageCache";
public const string LocalActiveRoot = "LocalActiveRoot";
public const string LocalSessionRoot = "LocalSessionRoot";
public const string LocalEvidenceRoot = "LocalEvidenceRoot";
public const string LocalLogsRoot = "LocalLogsRoot";
public const string LocalRollbackRoot = "LocalRollbackRoot";
public const string AcadExecutable = "AcadExecutable";
public const string PRecEntryAssembly = "PRecEntryAssembly";
public const string FixtureOrthoDwg = "FixtureOrthoDwg";
public const string AutomationScriptPath = "AutomationScriptPath";
public const string AutomationConfigPath = "AutomationConfigPath";
public const string AutomationEvidencePath = "AutomationEvidencePath";
public const string AutomationPermitPath = "AutomationPermitPath";
}
public static class LauncherConfigurationLayers
{
private static readonly HashSet<string> Protected = [LauncherPathVariableNames.CorporateDfsRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.LocalLauncherRoot];
public static PilotConfigurationLoadResult Load(string? corporateDefaultsJson, string? machineJson, string? sessionJson)
{
try
{
var merged = Parse(corporateDefaultsJson);
Merge(merged, Parse(machineJson), false);
Merge(merged, Parse(sessionJson), false);
return PilotConfigurationParser.Parse(merged.ToJsonString());
}
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
}
public static PilotConfigurationLoadResult LoadFromFiles(string corporateDefaultsPath, string machinePath, string sessionPath)
{
var defaults = File.Exists(corporateDefaultsPath) ? File.ReadAllText(corporateDefaultsPath) : null;
var machine = File.Exists(machinePath) ? File.ReadAllText(machinePath) : null;
var session = File.Exists(sessionPath) ? File.ReadAllText(sessionPath) : null;
return Load(defaults, machine, session);
}
private static JsonObject Parse(string? json) => string.IsNullOrWhiteSpace(json) ? [] : JsonNode.Parse(json)?.AsObject() ?? [];
private static void Merge(JsonObject target, JsonObject overlay, bool roster)
{
foreach (var entry in overlay)
{
if (entry.Key.Equals("PathVariables", StringComparison.OrdinalIgnoreCase) && entry.Value is JsonObject paths)
{
var targetPaths = target["PathVariables"] as JsonObject ?? new JsonObject(); target["PathVariables"] = targetPaths;
foreach (var path in paths) if (!roster || !Protected.Contains(path.Key)) targetPaths[path.Key] = path.Value?.DeepClone();
}
else target[entry.Key] = entry.Value?.DeepClone();
}
}
}
+115
View File
@@ -0,0 +1,115 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Launcher.Next.Core;
public sealed record PilotLocalConfiguration
{
public int SchemaVersion { get; init; }
public LauncherMode LauncherMode { get; init; } = LauncherMode.DryRun;
public bool PilotEnabled { get; init; }
public bool AllowProcessStart { get; init; }
public bool RequireExplicitExecute { get; init; } = true;
public PluginExecutionMode PluginExecutionMode { get; init; } = PluginExecutionMode.Smoke;
// Apply is never a normal pilot default. It is accepted only by this
// dedicated test branch and still requires the plugin-side fixture permit.
public bool AllowApply { get; init; }
public string? ApplyPermitPath { get; init; }
public string? AutomationMode { get; init; }
public string? AutomationConfigurationPath { get; init; }
public string? FixtureMarkerPath { get; init; }
// Test-only: permits a pilot process to be terminated after its plugin has
// persisted completed automation evidence. It never represents graceful exit.
public bool ForceAfterEvidence { get; init; }
public int GracefulExitTimeoutSeconds { get; init; } = 30;
public string? PilotRoot { get; init; }
public string? SelectedProductId { get; init; }
public string? SelectedProfileName { get; init; }
public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
public AcadLaunchMode LaunchMode { get; init; } = AcadLaunchMode.Production;
public bool ProductionDeploy { get; init; }
public IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
public static PilotLocalConfiguration SafeDefault { get; } = new();
}
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
{ public bool IsValid => Errors.Count == 0; }
public static class PilotConfigurationParser
{
public static PilotConfigurationLoadResult Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new(PilotLocalConfiguration.SafeDefault, []);
try { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; options.Converters.Add(new JsonStringEnumConverter()); var config = JsonSerializer.Deserialize<PilotLocalConfiguration>(json, options);
if (config is null || config.SchemaVersion != 1) return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]);
return new(config, []); }
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
}
}
public sealed record PilotRootValidationResult(bool IsValid, string? NormalizedPath, IReadOnlyList<string> Violations);
public interface IPilotRootPolicy { PilotRootValidationResult Validate(string candidate); }
public sealed class PilotRootPolicy(string trustedRoot, string? testRoot = null) : IPilotRootPolicy
{
public PilotRootValidationResult Validate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate) || !Path.IsPathFullyQualified(candidate) || candidate.StartsWith("\\\\", StringComparison.Ordinal) || candidate.Contains(':', StringComparison.Ordinal) && candidate.IndexOf(':') != 1) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
var full = Path.GetFullPath(candidate); var permitted = new[] { trustedRoot, testRoot }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => Path.GetFullPath(x!));
if (!permitted.Any(x => full.StartsWith(x + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) || full.Contains("Program Files", StringComparison.OrdinalIgnoreCase) || full.Contains("Applications", StringComparison.OrdinalIgnoreCase)) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
return new(true, full, []);
}
}
public sealed record AutoCadProfileCandidate(string Name, bool IsCurrent, bool IsPlantSpecific, IReadOnlyList<string> Evidence);
public interface IAutoCadProfileDiscovery { IReadOnlyList<AutoCadProfileCandidate> DiscoverProfiles(ProductInstallation product); }
public static class ProfileSelector
{
public static AutoCadProfileCandidate? Select(IReadOnlyList<AutoCadProfileCandidate> candidates, string? explicitName, out string? violation)
{ var eligible = candidates.Where(x => x.IsPlantSpecific && x.Evidence.Count > 0).ToArray(); var selected = explicitName is null ? eligible : eligible.Where(x => string.Equals(x.Name, explicitName, StringComparison.Ordinal)).ToArray(); violation = selected.Length switch { 1 => null, 0 => "PROFILE_NOT_RESOLVED", _ => "PROFILE_AMBIGUOUS" }; return selected.Length == 1 ? selected[0] : null; }
}
public sealed record PilotSafetyContext(PilotLocalConfiguration Configuration, ProductInstallation? Product, AutoCadProfileCandidate? Profile, PilotRootValidationResult Root, ValidationResult Manifest, bool HashesValid, bool AutodeskDllPresent, bool AcadRunning);
public sealed record PilotSafetyDecision(bool IsAllowed, IReadOnlyList<string> Violations);
public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafetyContext context, bool forExecution); }
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
{
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
{
var v = new List<string>();
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 is not (PluginExecutionMode.Smoke or PluginExecutionMode.DryRun or PluginExecutionMode.Apply)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED");
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && !c.Configuration.AllowApply) v.Add("APPLY_NOT_EXPLICITLY_ALLOWED");
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED");
var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID");
if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH");
if (c.Configuration.ForceAfterEvidence && !automation) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED");
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<string> Arguments, string WorkingDirectory, IReadOnlyDictionary<string,string> 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);
public sealed class AcadArgumentBuilder
{
public IReadOnlyList<string> Build(AcadLaunchMode mode, string? automationScriptPath)
{
var arguments = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" };
if (mode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe)
{
if (string.IsNullOrWhiteSpace(automationScriptPath) || !Path.IsPathFullyQualified(automationScriptPath)) throw new InvalidOperationException("AUTOMATION_SCRIPT_PATH_REQUIRED");
arguments.Add("/b"); arguments.Add(automationScriptPath);
}
else if (!string.IsNullOrWhiteSpace(automationScriptPath)) throw new InvalidOperationException("PRODUCTION_BATCH_SCRIPT_FORBIDDEN");
return arguments;
}
}
public static class PilotPlanFactory
{
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision, AcadLaunchMode mode = AcadLaunchMode.Production)
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new AcadArgumentBuilder().Build(mode, mode == AcadLaunchMode.Production ? null : scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, entry, scriptHash, mode); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
}
public sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id);
public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); }
public sealed class ExecutionPermitService : IExecutionPermitService
{ private readonly HashSet<Guid> used = []; public ExecutionPermit Issue(PilotLaunchPlan p, int seconds) => new(p.PlanId, p.ManifestSha256 + ":" + p.StartupScriptSha256, DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(seconds, 1, 600)), Guid.NewGuid()); public bool Validate(ExecutionPermit p, PilotLaunchPlan plan, DateTimeOffset now) => !used.Contains(p.Id) && p.ExpiresAtUtc >= now && p.PlanId == plan.PlanId && p.Fingerprint == plan.ManifestSha256 + ":" + plan.StartupScriptSha256 && used.Add(p.Id); }
@@ -0,0 +1,13 @@
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, 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
{
Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken);
Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken);
Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken);
}
@@ -0,0 +1,14 @@
using System.Diagnostics;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed record ProcessInvocationResult(bool Started, int? ProcessId, string? BlockCode);
public interface IOperatingSystemProcessInvoker { ProcessInvocationResult Start(ProcessLaunchSpecification specification); }
public interface IAutoCadProcessProbe { bool IsAcadRunning(); }
public sealed class OperatingSystemProcessInvoker : IOperatingSystemProcessInvoker
{ public ProcessInvocationResult Start(ProcessLaunchSpecification s) { var info = new ProcessStartInfo { FileName=s.ExecutablePath, WorkingDirectory=s.WorkingDirectory, UseShellExecute=false }; foreach(var a in s.Arguments) info.ArgumentList.Add(a); foreach(var e in s.EnvironmentVariables) info.Environment[e.Key]=e.Value; var p=Process.Start(info); return new(p is not null,p?.Id,null); } }
public sealed class GuardedAutodeskProcessLauncher(INextPilotSafetyGate gate, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker)
{
public static readonly bool RealExecutionReleaseEnabled = true;
public ProcessInvocationResult Start(PilotSafetyContext context, PilotLaunchPlan plan, ExecutionPermit? permit, IExecutionPermitService permits)
{ var checkedContext = context with { AcadRunning = probe.IsAcadRunning() }; var decision = gate.Evaluate(checkedContext, true); if (!decision.IsAllowed) return new(false, null, decision.Violations.First()); if (permit is null || !permits.Validate(permit, plan, DateTimeOffset.UtcNow)) return new(false,null,"EXECUTION_PERMIT_MISSING"); if (!RealExecutionReleaseEnabled) return new(false,null,"REAL_EXECUTION_RELEASE_NOT_ENABLED"); return invoker.Start(plan.Process); }
}
@@ -0,0 +1,194 @@
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<string, string?> ResolvedPaths, IReadOnlyList<string> AcadArguments, string? ActualStartupScriptSha256,
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
string? LastErrorCode, string? LastErrorMessage);
public async Task<PilotPreparationResult> 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<string, string> { ["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<PilotExecutionResult> 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.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), scriptPath));
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<PilotRollbackResult> 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<ActivationSnapshot>(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<bool> 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<string?> 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<T>(string path, T value, CancellationToken ct) => await WriteTextAtomic(path, JsonSerializer.Serialize(value), ct, true);
private static async Task<PersistedSession?> 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<PersistedSession>(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"); }
}
+16
View File
@@ -0,0 +1,16 @@
# AutoCAD startup
Plant 3D 2026 is started with the confirmed contract:
```text
acad.exe /product PLNT3D /language ru-RU /b <absolute-local-script>
```
The generated script has one command:
```text
_.NETLOAD
"<absolute local versioned PRecAnnot.Plant3D2026.dll>"
```
No `/p`, legacy LISP, `OrthoLoader`, UNC path, trusted-path mutation, or secondary dependency NETLOAD is used.
+5
View File
@@ -0,0 +1,5 @@
# Package activation
Before changing package pointers, Launcher Next writes an activation snapshot containing exact previous `current.json` and `previous.json` contents and their SHA-256 values. Files are copied to a unique staging directory and rehashed. Only then is staging moved to `packages/<package>/versions/<version>` and the package-local pointer updated atomically.
Rollback restores the exact pointers, deletes the pilot version and startup script, and removes incomplete staging. Autodesk assemblies, PDBs, executables, test assemblies and legacy files are rejected from the pilot package.
+25
View File
@@ -0,0 +1,25 @@
# Launcher path resolution
`PathResolver` is the only resolver for launcher path variables. Configuration
is layered in this order: `launcher.session.json`, `launcher.machine.json`,
then `launcher.defaults.json`; built-in defaults are non-path safety defaults.
Corporate and local locations are variables, not source-code paths. The
supported variable names are: `CorporateDfsRoot`, `CorporateLauncherRoot`,
`CorporateCatalogRoot`, `CorporatePackagesRoot`, `CorporateProfilesRoot`,
`CorporatePalettesRoot`, `CorporateFontsRoot`, `CorporateTemplatesRoot`,
`CorporateScriptsRoot`, `ProjectName`, `DisciplineName`, `ProjectRoot`,
`DisciplineRoot`, `PlantProjectFile`, `ProjectRosterPath`,
`ProjectApplicationsRoot`, `ProjectPaletteOverlayRoot`, `LocalLauncherRoot`,
`LocalPackageCache`, `LocalActiveRoot`, `LocalSessionRoot`,
`LocalEvidenceRoot`, `LocalLogsRoot`, `LocalRollbackRoot`, `AcadExecutable`,
`PRecEntryAssembly`, `FixtureOrthoDwg`, `AutomationScriptPath`,
`AutomationConfigPath`, `AutomationEvidencePath`, and `AutomationPermitPath`.
Undefined corporate locations remain `null` / `NOT VERIFIED`; the resolver does
not invent a DFS hierarchy. The legacy `A:` drive is accepted only as input and
is canonicalized through configured `CorporateDfsRoot`; it is never emitted.
`Production` launch arguments are `/product PLNT3D /language ru-RU` only.
`NextPilotPreview`, `NextPilotApply`, and `LifecycleProbe` require a resolved
script beneath the local session root and add exactly one `/b` argument.
+7
View File
@@ -0,0 +1,7 @@
# Pilot state machine
`Prepared → Activating → ProcessStarting → WaitingForRuntimeCheck → RolledBack`
Failures are persisted as `Failed`. A new run is blocked by an active session; a completed `RolledBack` session permits a new run. `pilot rollback` restores state from its persisted activation snapshot and is repeatable.
The current session schema persists the terminal state. It does not yet persist a transition-history array; evidence must not infer intermediate transitions after the fact.
+5
View File
@@ -0,0 +1,5 @@
# Rollback mechanics
Rollback is performed only after the pilot-owned AutoCAD PID has exited. It restores pointer contents from the persisted snapshot, removes the generated SCR, cleans staging, deletes the activated pilot version, and writes session state `RolledBack`.
If an application process exits unexpectedly, `pilot rollback` can be run in a new Launcher Next process because it reads the persisted session and activation snapshot.
+7
View File
@@ -0,0 +1,7 @@
# Launcher Next runtime mechanics
The controlled pilot flow is: CLI `pilot run` → local `pilot.local.json` → manifest generation and SHA-256 validation → `PrepareAsync` → one-time in-memory permit → `ExecuteAsync` → staging → version activation → startup SCR → guarded `Process.Start` → wait for the specific AutoCAD PID → `RollbackAsync`.
The pilot uses only `${LocalLauncherRoot}` resolved from local configuration. It does not modify the legacy launcher, AutoCAD profiles, registry, trusted paths, or the legacy Applications directory.
The runtime command is `acad.exe /product PLNT3D /language ru-RU /b <startup-script>`; no profile argument is supplied. The SCR invokes exactly one `NETLOAD`, for the local versioned entry assembly only.
@@ -0,0 +1 @@
{"nextPilotModeImplemented":true,"pilotConfigurationImplemented":true,"pilotRootPolicyImplemented":true,"productDiscoveryImplemented":true,"profileDiscoveryImplemented":true,"safetyGateImplemented":true,"prepareImplemented":true,"executionPermitImplemented":true,"isolatedStoreImplemented":true,"atomicActivationImplemented":true,"rollbackImplemented":true,"startupScriptLifecycleImplemented":true,"realProcessAdapterCompiled":true,"realExecutionReleaseEnabled":false,"actualProcessStarted":false,"actualPilotRootCreated":false,"actualPackageActivated":false,"productionDeploy":false}
@@ -0,0 +1,31 @@
{
"date": "2026-08-03",
"launcherMode": "NextPilot",
"pluginExecutionMode": "DryRun",
"startupNetloadCount": 1,
"profileArgumentPresent": false,
"legacyBootstrapUsed": false,
"processStarted": true,
"assemblyLoadedFromVersionedPackage": true,
"orthoDetection": "Confirmed",
"viewportCount": 1,
"objectsExamined": 0,
"phpRecAnnotRuns": 2,
"pxpAavRuns": 2,
"pxpOaRuns": 1,
"commandErrors": 0,
"dbmodBefore": 0,
"dbmodAfter": 0,
"plansDeterministic": null,
"sourcePlantObjectsRead": false,
"dataLinksSheetToSourceMapping": "blocked",
"rollbackCompleted": true,
"startupScriptRemoved": true,
"activatedVersionRemoved": true,
"fixtureDwgUnchanged": null,
"fixtureDcfUnchanged": false,
"fixtureChangedFiles": ["Piping.dcf"],
"ribbonVerified": null,
"unhandledExceptions": 0,
"productionDeploymentPerformed": false
}
+29
View File
@@ -0,0 +1,29 @@
{
"launcherMode": "NextPilot",
"product": "Autodesk AutoCAD Plant 3D 2026",
"packageId": "P_Rec_Annot",
"packageVersion": "1.0.0.0",
"manifestValidated": true,
"hashesValidated": true,
"startupNetloadCount": 1,
"processStarted": true,
"processId": 9960,
"launcherRunnerExitCode": null,
"assemblyLoadedFromVersionedPackage": true,
"commands": { "PHP_REC_ANNOT": true, "PXP-AAV": true, "PXP-OA": true },
"drawingType": "NotOrtho",
"dbmodBefore": 0,
"dbmodAfter": null,
"ribbon": null,
"unhandledExceptions": 0,
"pluginDefects": ["PLANT_PROJECT_DISPLAY_NAME_EMPTY"],
"automaticRollback": true,
"sessionState": "RolledBack",
"startupScriptRemoved": true,
"activatedVersionRemoved": true,
"currentPointerRestored": true,
"previousPointerRestored": true,
"legacyFilesModified": null,
"productionDeployment": false,
"notes": ["The background process exit code was not captured by the launcher host.", "Legacy local sentinel files were absent; hashes were not available.", "Session records terminal RolledBack state; historic intermediate states were not persisted."]
}
@@ -0,0 +1,3 @@
# Controlled NextPilot execution
`NextPilot` is opt-in. Missing or invalid local configuration defaults to DryRun, with process start disabled and plugin mode Smoke. Prepare builds a deterministic plan only; Execute requires all safety conditions and a one-time, short-lived permit. `RealExecutionReleaseEnabled` is compile-time false in this foundation, so the process adapter always returns `REAL_EXECUTION_RELEASE_NOT_ENABLED` before calling the OS invoker.
+3
View File
@@ -0,0 +1,3 @@
# Manual runtime checkpoint
Before any real execution: review the LaunchPlan, explicitly confirm product/profile/root/package, enable real execution only in a separately reviewed commit, rebuild/test, create a dedicated runtime branch, then Prepare, issue permit, Execute, manually validate AutoCAD UI, and rollback.
+27
View File
@@ -0,0 +1,27 @@
# P_Rec_Annot Ortho DryRun
Date: 2026-08-03
Launcher Next executed the isolated `NextPilot` flow in `DryRun` mode. The
generated AutoCAD startup script contained one `NETLOAD` for the versioned
local entry assembly; no legacy bootstrap was used and no `/p` argument was
passed.
The actual Ortho drawing was `TestPipeOrtho.dwg`. `PHP_REC_ANNOT` ran twice,
`PXP-AAV` ran twice, and `PXP-OA` ran once. Each completed with Ortho detected,
one viewport, zero examined objects, zero planned operations, and no command
error. `DBMOD` was `0` both before and after the commands.
The Ortho sheet's `SelectAcPpRowIds` call raised `DLException: NotFound` in the
preceding attempt. The read adapter now treats that condition as an empty,
read-only result so it cannot fail a DryRun command. It does not yet map an
Ortho sheet back to source-model DataLinks rows; therefore successful source
Pipe discovery and a non-empty AnnotationPlan remain unverified.
After Plant 3D closed, Launcher Next reported `RolledBack`; the startup script,
version directory, current/previous pointers, and staging contents were absent.
The fixture SHA comparison found one changed project file: `Piping.dcf`.
Its last-write time predates the first DryRun command, so the observation cannot
be attributed to a P_Rec_Annot write. Nevertheless, project/DCF invariance is
not established by this run.
+11
View File
@@ -0,0 +1,11 @@
# P_Rec_Annot real Smoke pilot
Date: 2026-07-31. Launcher Next started Plant 3D 2026 as PID 9960 from an isolated local pilot package. The entry assembly was loaded from the versioned package directory through one generated NETLOAD.
The manual runtime checkpoint confirmed `PHP_REC_ANNOT`, `PXP-AAV`, and `PXP-OA` executed in Smoke mode; drawing type was `NotOrtho`; initial DBMOD was `0`; no unhandled exceptions were observed. The final DBMOD and Ribbon observations were not supplied, so they are intentionally not asserted.
After normal Plant shutdown, automatic rollback completed. No `acad.exe` remained. Startup scripts, staging contents, package pointers and activated version were absent; the activation snapshot was retained. The checked local legacy Application files were absent, so no before/after hashes were available.
The background runner exit code was not captured by its `Start-Process` host. The persisted terminal state and the post-run `pilot status` command both reported `RolledBack`.
Known plugin defect: `PLANT_PROJECT_DISPLAY_NAME_EMPTY`. Plant context was found but displayed an empty project name. Impact was diagnostic/UI-only for this Smoke pilot; write protection and command execution were unaffected. Follow-up: inspect `PlantApplication.CurrentProject` metadata and distinguish absent display name from false-positive project detection.
+11
View File
@@ -0,0 +1,11 @@
# Pilot local configuration
Configuration files are `${LocalLauncherRoot}\config\launcher.defaults.json`,
`launcher.machine.json`, and `launcher.session.json`. They are never
auto-created or tracked. Unknown/corrupt configuration blocks pilot mode;
session values take precedence over machine values, which take precedence over
corporate defaults.
```json
{"schemaVersion":1,"launcherMode":"NextPilot","pilotEnabled":true,"allowProcessStart":false,"requireExplicitExecute":true,"pluginExecutionMode":"Smoke","pilotRoot":"C:\\AutoCAD User Support\\LauncherNextPilot","selectedProductId":null,"selectedProfileName":null,"executionPermitLifetimeSeconds":300}
```
+3
View File
@@ -0,0 +1,3 @@
# Prepare, Execute and rollback
Prepare performs no package/store/script/process writes. Activation and rollback are covered by the existing versioned local store using unique staging and atomic pointer replacement. Execute re-evaluates safety immediately before the invoker, and remains release-blocked in this slice.
+3
View File
@@ -0,0 +1,3 @@
# Read-only profile discovery
The adapter returns candidates with evidence. An explicit exact selection is accepted; otherwise exactly one confirmed Plant-specific candidate is required. Zero candidates blocks with `PROFILE_NOT_RESOLVED`; multiple candidates block with `PROFILE_AMBIGUOUS`. No profile is created or modified.
@@ -0,0 +1,30 @@
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class ControlledExecutionTests
{
[Fact] public void ApplyMode_RequiresExplicitAllowApply()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.Contains("APPLY_NOT_EXPLICITLY_ALLOWED", d.Violations); }
[Fact] public void ApplyMode_WithExplicitAllowApply_IsAllowedByLauncherGate()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, AllowApply = true, ApplyPermitPath = "C:\\test\\permit.json", RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.True(d.IsAllowed); }
[Fact] public void ApplyMode_RequiresLocalPermitPath()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, AllowApply = true, RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.Contains("APPLY_PERMIT_PATH_REQUIRED", d.Violations); }
[Fact] public void MissingPilotConfiguration_DefaultsToDryRun() => Assert.Equal(LauncherMode.DryRun, PilotConfigurationParser.Parse(null).Configuration.LauncherMode);
[Fact] public void InvalidPilotConfiguration_BlocksPilot() => Assert.False(PilotConfigurationParser.Parse("{").IsValid);
[Fact] public void PilotRoot_Unc_IsRejected() => Assert.False(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("\\\\server\\share").IsValid);
[Fact] public void PilotRoot_AllowedCorporateChild_IsAccepted() => Assert.True(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("C:\\AutoCAD User Support\\LauncherNextPilot").IsValid);
[Fact] public void SafetyGate_ProcessStartDisabled_BlocksExecute()
{ var c=new PilotLocalConfiguration { SchemaVersion=1, LauncherMode=LauncherMode.NextPilot, PilotEnabled=true, PluginExecutionMode=PluginExecutionMode.Smoke }; var decision=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.Contains("PROCESS_START_DISABLED",decision.Violations); }
[Fact] public void ForceAfterEvidence_RequiresTestAutomation()
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true }; var d=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.Contains("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED",d.Violations); }
[Fact] public void ForceAfterEvidence_WithLocalAutomationConfiguration_IsAllowed()
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true,AutomationMode="FirstLimitedApplyRuntimeTest",AutomationConfigurationPath="C:\\test\\automation.json",FixtureMarkerPath="C:\\test\\fixture.marker",LaunchMode=AcadLaunchMode.NextPilotPreview }; var d=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.True(d.IsAllowed); }
[Fact] public void Permit_IsOneTime()
{ var p=Plan(); var service=new ExecutionPermitService(); var permit=service.Issue(p,60); Assert.True(service.Validate(permit,p,DateTimeOffset.UtcNow)); Assert.False(service.Validate(permit,p,DateTimeOffset.UtcNow)); }
[Fact] public void GuardedLauncher_RealExecutionReleaseDisabled_BlocksStart()
{ var p=Plan(); var config=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=false,PluginExecutionMode=PluginExecutionMode.Smoke }; var context=new PilotSafetyContext(config,p.Product,p.Profile,new(true,p.PilotRoot,[]),ValidationResult.Success,true,false,false); var permits=new ExecutionPermitService(); var result=new GuardedAutodeskProcessLauncher(new NextPilotSafetyGate(),new Probe(),new Invoker()).Start(context,p,permits.Issue(p,60),permits); Assert.Equal("PROCESS_START_DISABLED",result.BlockCode); }
private static PilotLaunchPlan Plan() { var product=new ProductInstallation("C:\\acad.exe","2026","x64",[]); var profile=new AutoCadProfileCandidate("Pilot",false,true,["registry"]); var manifest=new PackageManifest(1,"P","1","Plant3D",2026,"x64","P.dll",[new("P.dll",1,new string('A',64))],new("netload","P")); return PilotPlanFactory.Create(product,profile,manifest,"M","C:\\AutoCAD User Support\\LauncherNextPilot","C:\\script.scr","S",new(true,[])); }
private sealed class Probe : IAutoCadProcessProbe { public bool IsAcadRunning()=>false; }
private sealed class Invoker : IOperatingSystemProcessInvoker { public ProcessInvocationResult Start(ProcessLaunchSpecification _) => throw new Xunit.Sdk.XunitException("must not start"); }
}
@@ -36,4 +36,12 @@ public sealed class FoundationTests
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); } try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); }
finally { Directory.Delete(root, true); } finally { Directory.Delete(root, true); }
} }
[Fact] public void PilotCli_UsesContentAddressedVersionForImmutableActivation()
{
var root = new DirectoryInfo(AppContext.BaseDirectory);
while (root is not null && !File.Exists(Path.Combine(root.FullName, "Launcher.Next.sln"))) root = root.Parent;
var source = File.ReadAllText(Path.Combine(root!.FullName, "Launcher.Next.App", "PilotCli.cs"));
Assert.Contains("contentIdentity", source);
Assert.Contains("assemblyVersion + \"-\" + contentIdentity", source);
}
} }
@@ -0,0 +1,96 @@
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class PathAndLaunchTests
{
private static readonly IReadOnlyDictionary<string, string?> Paths = new Dictionary<string, string?>
{
[LauncherPathVariableNames.CorporateDfsRoot] = "\\\\phimproject.com\\a",
[LauncherPathVariableNames.ProjectName] = "Проект",
[LauncherPathVariableNames.DisciplineName] = "Plant 3D",
[LauncherPathVariableNames.ProjectRoot] = "${CorporateDfsRoot}\\${ProjectName}",
[LauncherPathVariableNames.DisciplineRoot] = "${ProjectRoot}/${DisciplineName}",
[LauncherPathVariableNames.LocalLauncherRoot] = "C:\\Launcher Pilot",
[LauncherPathVariableNames.LocalSessionRoot] = "${LocalLauncherRoot}\\sessions",
[LauncherPathVariableNames.AcadExecutable] = "C:\\Plant 3D 2026\\acad.exe",
[LauncherPathVariableNames.FixtureOrthoDwg] = "C:\\Fixture Space\\Тест.dwg",
[LauncherPathVariableNames.AutomationScriptPath] = "${LocalSessionRoot}\\preview.scr",
[LauncherPathVariableNames.AutomationConfigPath] = "${LocalSessionRoot}\\preview.json"
};
private static PathResolver Resolver(IReadOnlyDictionary<string, string?>? env = null) => new(env ?? new Dictionary<string, string?>());
[Fact] public void PathResolver_ResolvesSimpleVariable() => Assert.Equal("C:\\Launcher Pilot", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.LocalLauncherRoot));
[Fact] public void PathResolver_ResolvesNestedVariables() => Assert.Equal("\\\\phimproject.com\\a\\Проект\\Plant 3D", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));
[Fact] public void PathResolver_ResolvesEnvironmentVariable() => Assert.Equal("C:\\From Environment", Resolver(new Dictionary<string, string?> { ["ROOT"] = "C:\\From Environment" }).Resolve(new(new Dictionary<string, string?> { ["Value"] = "${ROOT}" })).Require("Value"));
[Fact] public void PathResolver_RejectsUnknownVariable() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["Value"] = "${Missing}" })));
[Fact] public void PathResolver_RejectsEmptyRequiredVariable() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["Value"] = "" }), ["Value"]));
[Fact] public void PathResolver_RejectsCycle() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["A"] = "${B}", ["B"] = "${A}" })));
[Fact] public void PathResolver_PreservesUnicode() => Assert.Contains("Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot));
[Fact] public void PathResolver_NormalizesSeparators() => Assert.DoesNotContain('/', Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));
[Fact] public void PathResolver_NormalizesLegacyDriveAToCorporateDfs() => Assert.Equal("\\\\phimproject.com\\a\\Project\\Plant", Resolver().ResolvePath("A:\\Project\\Plant", new(Paths)));
[Fact] public void PathResolver_DoesNotEmitDriveA() => Assert.False(Resolver().ResolvePath("A:\\Project", new(Paths)).StartsWith("A:\\", StringComparison.OrdinalIgnoreCase));
[Fact] public void PathResolver_RejectsTraversalOutsideRoot() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("${LocalSessionRoot}\\..\\escape", new(Paths), "${LocalSessionRoot}"));
[Fact] public void PathResolver_RequiresAbsolutePath() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("relative\\file", new(Paths)));
[Fact] public void ProjectRoot_UsesProjectAndDisciplineVariables() => Assert.Equal("\\\\phimproject.com\\a\\Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot));
[Fact] public void CorporateDfsRoot_ComesFromConfiguration() => Assert.Equal("\\\\phimproject.com\\a", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.CorporateDfsRoot));
[Fact] public void ConfigurationLayers_SessionOverridesMachineOverridesDefaults()
{
var result = LauncherConfigurationLayers.Load("{\"SchemaVersion\":1,\"PathVariables\":{\"LocalLauncherRoot\":\"C:\\\\defaults\",\"AcadExecutable\":\"C:\\\\acad.exe\"}}", "{\"SchemaVersion\":1,\"PathVariables\":{\"LocalLauncherRoot\":\"C:\\\\machine\"}}", "{\"SchemaVersion\":1,\"PilotEnabled\":true,\"PathVariables\":{\"LocalSessionRoot\":\"C:\\\\session\"}}");
Assert.True(result.IsValid); Assert.Equal("C:\\machine", result.Configuration.PathVariables[LauncherPathVariableNames.LocalLauncherRoot]); Assert.True(result.Configuration.PilotEnabled);
}
[Fact] public void ProductionExecute_DoesNotAddBatchScript() => Assert.DoesNotContain("/b", new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null));
[Fact] public void ProductionExecute_RejectsAutomationScriptPath() => Assert.Throws<InvalidOperationException>(() => new AcadArgumentBuilder().Build(AcadLaunchMode.Production, "C:\\session\\a.scr"));
[Fact] public void ProductionExecute_DoesNotCreateScript() => Assert.DoesNotContain(new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null), x => x.EndsWith(".scr", StringComparison.OrdinalIgnoreCase));
[Fact] public void ProductionExecute_DoesNotSetAutomationEnvironment() => Assert.DoesNotContain("P_REC_ANNOT_AUTOMATION", new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }.Keys);
[Fact] public void PreviewExecute_RequiresBatchScript() => Assert.Throws<InvalidOperationException>(() => new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, null));
[Fact] public void PreviewExecute_AddsExactlyOneBatchArgument() => Assert.Single(new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"), x => x == "/b");
[Fact] public void PreviewExecute_UsesResolvedScriptPath() => Assert.Contains(Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.AutomationScriptPath), new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.AutomationScriptPath)));
[Fact] public void PreviewExecute_RejectsPathOutsideSessionRoot() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("C:\\elsewhere\\preview.scr", new(Paths), "${LocalSessionRoot}"));
[Fact] public void PreviewExecute_RequiresFixtureMarker() => Assert.Contains("AUTOMATION_CONFIGURATION_INVALID", new NextPilotSafetyGate().Evaluate(Context(new PilotLocalConfiguration { AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = "C:\\session\\automation.json", LaunchMode = AcadLaunchMode.NextPilotPreview }, false), false).Violations);
[Fact] public void PreviewExecute_RequiresProductionDisabled() => Assert.Contains("AUTOMATION_CONFIGURATION_INVALID", new NextPilotSafetyGate().Evaluate(Context(new PilotLocalConfiguration { AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = "C:\\session\\automation.json", FixtureMarkerPath = "C:\\session\\fixture.marker", LaunchMode = AcadLaunchMode.NextPilotPreview, ProductionDeploy = true }, false), false).Violations);
[Fact] public void AcadArguments_ProductionContainProductAndLanguage() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null); Assert.Equal(["/product", "PLNT3D", "/language", "ru-RU"], a); }
[Fact] public void AcadArguments_AutomationContainProductLanguageAndBatch() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"); Assert.Equal(["/product", "PLNT3D", "/language", "ru-RU", "/b", "C:\\session\\preview.scr"], a); }
[Fact] public void AcadArguments_DoNotContainProfileOrWorkspaceYet() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"); Assert.DoesNotContain("/p", a); Assert.DoesNotContain("/w", a); }
[Fact] public void PreviewScript_IsDeterministicAndContainsRequiredCommandOrder()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root);
var entry = "PRecAnnot.dll"; var fixture = Path.Combine(root, "Fixture Орто.dwg"); File.WriteAllText(Path.Combine(root, entry), string.Empty); File.WriteAllText(fixture, string.Empty);
try
{
var paths = new ResolvedAutomationScriptPaths(root, entry, fixture, Path.Combine(root, "preview.scr"));
var first = new StartupScriptBuilder().BuildAutomationScript(paths); var second = new StartupScriptBuilder().BuildAutomationScript(paths);
Assert.Equal(first, second); Assert.EndsWith("\n", first); Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(first, "PXP-OA").Count);
Assert.True(first.IndexOf("PHP_REC_AUTOMATION_PREPARE", StringComparison.Ordinal) < first.IndexOf("PXP-OA", StringComparison.Ordinal));
Assert.True(first.LastIndexOf("PXP-OA", StringComparison.Ordinal) < first.IndexOf("PHP_REC_AUTOMATION_FINALIZE", StringComparison.Ordinal));
}
finally { Directory.Delete(root, true); }
}
[Fact] public void ProductionSource_HasNoForbiddenPathLiterals()
{
var root = FindRoot();
var forbidden = new[] { "A:\\", "\\\\phimproject.com\\a", "C:\\AutoCAD User Support", "C:\\CAD User Support", "C:\\dev\\" };
var production = new[] { "Launcher.Next.App", "Launcher.Next.Core", "Launcher.Next.Infrastructure", "Launcher.Next.Autodesk" }
.SelectMany(folder => Directory.GetFiles(Path.Combine(root, folder), "*.cs", SearchOption.AllDirectories));
foreach (var file in production)
{
foreach (var text in forbidden)
{
if (Path.GetFileName(file) == "LauncherPaths.cs" && text == "A:\\") continue; // explicit legacy-input alias parser exception
Assert.DoesNotContain(text, File.ReadAllText(file), StringComparison.OrdinalIgnoreCase);
}
}
}
private static PilotSafetyContext Context(PilotLocalConfiguration configuration, bool execution) => new(configuration with { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, RequireExplicitExecute = true, AllowProcessStart = execution }, new("C:\\acad.exe", "2026", "x64", []), new("", false, true, ["test"]), new(true, "C:\\Pilot", []), ValidationResult.Success, true, false, false);
private static string FindRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Launcher.Next.sln"))) directory = directory.Parent;
return directory!.FullName;
}
}