refactor: resolve launcher paths and isolate batch automation
This commit is contained in:
@@ -28,14 +28,15 @@ internal static class PilotCli
|
|||||||
if (command == "status") return await StatusAsync(root);
|
if (command == "status") return await StatusAsync(root);
|
||||||
if (command == "rollback") return await RollbackAsync(root);
|
if (command == "rollback") return await RollbackAsync(root);
|
||||||
if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!);
|
if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!);
|
||||||
if (!string.Equals(config.PilotRoot, root, StringComparison.OrdinalIgnoreCase)) return Fail(Blocked, "PILOT_ROOT_CONFIGURATION_MISMATCH");
|
if (!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!);
|
if (!TryBuildManifest(options.PackageSource!, options.EntryAssembly!, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
|
||||||
Directory.CreateDirectory(Path.Combine(root, "catalog"));
|
Directory.CreateDirectory(Path.Combine(root, "catalog"));
|
||||||
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
|
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
|
||||||
if (!File.Exists(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe")) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND");
|
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);
|
if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root);
|
||||||
var service = CreateService();
|
var service = CreateService(paths);
|
||||||
var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []);
|
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 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);
|
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));
|
if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations));
|
||||||
@@ -86,6 +87,7 @@ internal static class PilotCli
|
|||||||
{
|
{
|
||||||
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
|
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 (!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 (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 (!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");
|
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
|
||||||
@@ -95,7 +97,7 @@ internal static class PilotCli
|
|||||||
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.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(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker }));
|
||||||
await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}");
|
await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}");
|
||||||
var previewConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath };
|
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);
|
var previewSession = await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview", previewEvidence);
|
||||||
if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED");
|
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 });
|
var preview = JsonSerializer.Deserialize<PreviewEvidence>(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
@@ -107,7 +109,7 @@ internal static class PilotCli
|
|||||||
var applyEvidence = Path.Combine(options.EvidenceRoot, "apply-result.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(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker }));
|
||||||
await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}");
|
await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}");
|
||||||
var applyConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath };
|
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 applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence);
|
||||||
var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker));
|
var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker));
|
||||||
var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null;
|
var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null;
|
||||||
@@ -118,7 +120,8 @@ internal static class PilotCli
|
|||||||
|
|
||||||
private static async Task<AutomationSessionResult> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase, string evidencePath)
|
private static async Task<AutomationSessionResult> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase, string evidencePath)
|
||||||
{
|
{
|
||||||
var service = CreateService(); var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []);
|
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 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 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 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);
|
||||||
@@ -166,7 +169,7 @@ internal static class PilotCli
|
|||||||
}
|
}
|
||||||
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 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 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() => new(new PackageManifestValidator(), new PilotRootPolicy(@"C:\AutoCAD User Support"), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker());
|
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 async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }
|
||||||
private static bool HasCompletedEvidence(string path)
|
private static bool HasCompletedEvidence(string path)
|
||||||
{
|
{
|
||||||
@@ -175,7 +178,8 @@ internal static class PilotCli
|
|||||||
}
|
}
|
||||||
private static async Task<int> RollbackAsync(string root)
|
private static async Task<int> RollbackAsync(string root)
|
||||||
{
|
{
|
||||||
var result = await CreateService().RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
|
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");
|
return result.Completed ? Success : Fail(ExplicitRollbackFailed, result.ErrorCode ?? "ROLLBACK_FAILED");
|
||||||
}
|
}
|
||||||
private static async Task<int> StatusAsync(string root)
|
private static async Task<int> StatusAsync(string root)
|
||||||
@@ -207,12 +211,38 @@ internal static class PilotCli
|
|||||||
private static string SessionPath(string root) => Path.Combine(root, "state", "pilot-session.json");
|
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)
|
private static bool TryReadConfiguration(string root, out PilotLocalConfiguration config, out string? error)
|
||||||
{
|
{
|
||||||
config = PilotLocalConfiguration.SafeDefault; error = null; var path = Path.Combine(root, "config", "pilot.local.json");
|
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(path)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
|
if (!File.Exists(machine)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
|
||||||
var parsed = PilotConfigurationParser.Parse(File.ReadAllText(path)); config = parsed.Configuration;
|
var parsed = LauncherConfigurationLayers.LoadFromFiles(defaults, machine, session); config = parsed.Configuration;
|
||||||
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
|
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
|
||||||
return true;
|
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)
|
private static bool TryBuildManifest(string source, string entry, out PackageManifest? manifest, out string? hash, out string? error)
|
||||||
{
|
{
|
||||||
manifest = null; hash = null; error = null;
|
manifest = null; hash = null; error = null;
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ 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 $"_.NETLOAD\n\"{full.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(string root, string entry, string fixtureOrthoDwg)
|
public string BuildAutomationScript(ResolvedAutomationScriptPaths paths)
|
||||||
{
|
{
|
||||||
var fixture = Path.GetFullPath(fixtureOrthoDwg);
|
var fixture = Path.GetFullPath(paths.FixtureOrthoDwg);
|
||||||
if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND");
|
if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND");
|
||||||
var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
|
var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
|
||||||
return "_.OPEN\n" + quote(fixture) + "\n" + BuildNetloadScript(root, entry) +
|
return "_.OPEN\n" + quote(fixture) + "\n" + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
|
||||||
"PHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
|
"PHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,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); string BuildAutomationScript(string validatedPackageRoot, string entryAssembly, string fixtureOrthoDwg); }
|
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; } }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public sealed record PilotLocalConfiguration
|
|||||||
public string? ApplyPermitPath { get; init; }
|
public string? ApplyPermitPath { get; init; }
|
||||||
public string? AutomationMode { get; init; }
|
public string? AutomationMode { get; init; }
|
||||||
public string? AutomationConfigurationPath { 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
|
// Test-only: permits a pilot process to be terminated after its plugin has
|
||||||
// persisted completed automation evidence. It never represents graceful exit.
|
// persisted completed automation evidence. It never represents graceful exit.
|
||||||
public bool ForceAfterEvidence { get; init; }
|
public bool ForceAfterEvidence { get; init; }
|
||||||
@@ -27,8 +28,12 @@ public sealed record PilotLocalConfiguration
|
|||||||
public string? SelectedProductId { get; init; }
|
public string? SelectedProductId { get; init; }
|
||||||
public string? SelectedProfileName { get; init; }
|
public string? SelectedProfileName { get; init; }
|
||||||
public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
|
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 static PilotLocalConfiguration SafeDefault { get; } = new();
|
||||||
}
|
}
|
||||||
|
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
|
||||||
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
||||||
{ public bool IsValid => Errors.Count == 0; }
|
{ public bool IsValid => Errors.Count == 0; }
|
||||||
public static class PilotConfigurationParser
|
public static class PilotConfigurationParser
|
||||||
@@ -67,14 +72,42 @@ public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafety
|
|||||||
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
||||||
{
|
{
|
||||||
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
|
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"); if (c.Configuration.AutomationMode is not null && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("AUTOMATION_CONFIGURATION_INVALID"); if (c.Configuration.ForceAfterEvidence && c.Configuration.AutomationMode is null) 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) { } else v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v); }
|
{
|
||||||
|
var v = new List<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 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 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 class PilotPlanFactory
|
||||||
{
|
{
|
||||||
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision)
|
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 List<string> { "/product", "PLNT3D", "/language", "ru-RU" }; if (!string.IsNullOrWhiteSpace(profile.Name)) { args.Add("/p"); args.Add(profile.Name); } args.Add("/b"); args.Add(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, profile.Name, entry, scriptHash); 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); }
|
{ 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 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 interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); }
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public sealed class PilotExecutionService(
|
|||||||
IStartupScriptBuilder scriptBuilder,
|
IStartupScriptBuilder scriptBuilder,
|
||||||
IExecutionPermitService permits,
|
IExecutionPermitService permits,
|
||||||
IAutoCadProcessProbe probe,
|
IAutoCadProcessProbe probe,
|
||||||
IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
|
IOperatingSystemProcessInvoker invoker,
|
||||||
|
IPathResolver? pathResolver = null) : IPilotExecutionService
|
||||||
{
|
{
|
||||||
private sealed record Pointer(string Version);
|
private sealed record Pointer(string Version);
|
||||||
private sealed record ActivationSnapshot(
|
private sealed record ActivationSnapshot(
|
||||||
@@ -25,6 +26,7 @@ public sealed class PilotExecutionService(
|
|||||||
int SchemaVersion, string SessionId, string TransactionId, string State,
|
int SchemaVersion, string SessionId, string TransactionId, string State,
|
||||||
string PackageId, string PackageVersion, string ManifestSha256,
|
string PackageId, string PackageVersion, string ManifestSha256,
|
||||||
string PilotRoot, string VersionDirectory, string StartupScriptPath,
|
string PilotRoot, string VersionDirectory, string StartupScriptPath,
|
||||||
|
string LaunchMode, IReadOnlyDictionary<string, string?> ResolvedPaths, IReadOnlyList<string> AcadArguments, string? ActualStartupScriptSha256,
|
||||||
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
|
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
|
||||||
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
|
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
|
||||||
string? LastErrorCode, string? LastErrorMessage);
|
string? LastErrorCode, string? LastErrorMessage);
|
||||||
@@ -32,28 +34,30 @@ public sealed class PilotExecutionService(
|
|||||||
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
|
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var root = rootPolicy.Validate(request.PilotRoot);
|
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 validation = validator.Validate(request.Manifest, 2026, "x64");
|
||||||
var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken);
|
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 profile = new AutoCadProfileCandidate(string.Empty, false, true, ["Confirmed Plant 3D launch contract has no /p argument."]);
|
||||||
var safety = gate.Evaluate(new(request.Configuration, request.Product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false);
|
var safety = gate.Evaluate(new(request.Configuration, product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false);
|
||||||
if (!safety.IsAllowed) return new(null, safety);
|
if (!safety.IsAllowed) return new(null, safety);
|
||||||
|
|
||||||
var futureEntry = Path.Combine(request.PilotRoot, "packages", request.Manifest.PackageId, "versions", request.Manifest.Version, request.Manifest.EntryAssembly);
|
var automation = request.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
|
||||||
var fixture = request.Configuration.AutomationMode is null ? null : ReadFixtureOrtho(request.Configuration.AutomationConfigurationPath);
|
var content = automation ? "AUTOMATION_SCRIPT_PENDING" : "PRODUCTION_NO_SCRIPT";
|
||||||
var content = request.Configuration.AutomationMode is null
|
|
||||||
? $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n"
|
|
||||||
: $"_.OPEN\n\"{fixture!.Replace("\"", "\\\"")}\"\n_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\nPHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
|
|
||||||
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
||||||
var plan = PilotPlanFactory.Create(request.Product, profile, request.Manifest, request.ManifestHash, request.PilotRoot, request.StartupScriptPath, scriptHash, safety);
|
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() };
|
var environment = new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() };
|
||||||
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
|
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
|
||||||
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
|
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
|
||||||
if (request.Configuration.AutomationMode is not null)
|
if (automation)
|
||||||
{
|
{
|
||||||
environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot";
|
environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot";
|
||||||
environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode;
|
environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode!;
|
||||||
environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = request.Configuration.AutomationConfigurationPath!;
|
environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = paths.Require(LauncherPathVariableNames.AutomationConfigPath);
|
||||||
}
|
}
|
||||||
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety);
|
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety);
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,8 @@ public sealed class PilotExecutionService(
|
|||||||
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
|
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var plan = request.Plan;
|
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 root = rootPolicy.Validate(request.Preparation.PilotRoot);
|
||||||
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
|
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
|
||||||
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
|
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
|
||||||
@@ -68,7 +74,7 @@ public sealed class PilotExecutionService(
|
|||||||
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
|
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
|
||||||
var decision = gate.Evaluate(context, true);
|
var decision = gate.Evaluate(context, true);
|
||||||
if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null);
|
if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null);
|
||||||
if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(request.Preparation.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, request.Preparation.PilotRoot, request.Preparation.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision).PlanId, StringComparison.Ordinal))
|
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);
|
return new(false, null, "PILOT_PLAN_CHANGED", null, null);
|
||||||
|
|
||||||
var sessionId = Guid.NewGuid().ToString("N");
|
var sessionId = Guid.NewGuid().ToString("N");
|
||||||
@@ -76,7 +82,7 @@ public sealed class PilotExecutionService(
|
|||||||
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
|
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
|
||||||
var versionDirectory = plan.VersionDirectory;
|
var versionDirectory = plan.VersionDirectory;
|
||||||
var staging = Path.Combine(plan.PilotRoot, "staging", transactionId);
|
var staging = Path.Combine(plan.PilotRoot, "staging", transactionId);
|
||||||
var scriptPath = Path.Combine(plan.PilotRoot, "startup", sessionId + ".scr");
|
var scriptPath = plan.StartupScriptPath;
|
||||||
var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json");
|
var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json");
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
|
||||||
var currentPath = Path.Combine(packageRoot, "current.json");
|
var currentPath = Path.Combine(packageRoot, "current.json");
|
||||||
@@ -85,7 +91,7 @@ public sealed class PilotExecutionService(
|
|||||||
var previous = await ReadTextOrNull(previousPath, 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);
|
var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging);
|
||||||
await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken);
|
await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken);
|
||||||
await WriteSession(plan.PilotRoot, new PersistedSession(1, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken);
|
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
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(staging);
|
Directory.CreateDirectory(staging);
|
||||||
@@ -103,13 +109,20 @@ public sealed class PilotExecutionService(
|
|||||||
if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, 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(previousPath, current ?? string.Empty, cancellationToken, current is not null);
|
||||||
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
|
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)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
|
||||||
var script = request.Preparation.Configuration.AutomationMode is null
|
var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), scriptPath));
|
||||||
? scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly)
|
|
||||||
: scriptBuilder.BuildAutomationScript(versionDirectory, request.Preparation.Manifest.EntryAssembly, ReadFixtureOrtho(request.Preparation.Configuration.AutomationConfigurationPath));
|
|
||||||
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
|
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
|
||||||
await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken);
|
}
|
||||||
var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = scriptPath, Process = plan.Process with { Arguments = ["/product", "PLNT3D", "/language", "ru-RU", "/b", scriptPath], WorkingDirectory = plan.PilotRoot } }, request.Permit, permits);
|
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)
|
if (!result.Started)
|
||||||
{
|
{
|
||||||
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
|
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
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 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 `C:\AutoCAD User Support\LauncherNextPilot`. It does not modify the legacy launcher, AutoCAD profiles, registry, trusted paths, or the legacy Applications directory.
|
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.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# Pilot local configuration
|
# Pilot local configuration
|
||||||
|
|
||||||
Local file: `C:\AutoCAD User Support\LauncherNextPilot\config\pilot.local.json`. It is never auto-created or tracked. Unknown/corrupt configuration blocks pilot mode.
|
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
|
```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}
|
{"schemaVersion":1,"launcherMode":"NextPilot","pilotEnabled":true,"allowProcessStart":false,"requireExplicitExecute":true,"pluginExecutionMode":"Smoke","pilotRoot":"C:\\AutoCAD User Support\\LauncherNextPilot","selectedProductId":null,"selectedProfileName":null,"executionPermitLifetimeSeconds":300}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public sealed class ControlledExecutionTests
|
|||||||
[Fact] public void ForceAfterEvidence_RequiresTestAutomation()
|
[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); }
|
{ 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()
|
[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" }; 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); }
|
{ 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()
|
[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)); }
|
{ 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()
|
[Fact] public void GuardedLauncher_RealExecutionReleaseDisabled_BlocksStart()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user