diff --git a/Launcher.Next.App/PilotCli.cs b/Launcher.Next.App/PilotCli.cs index 7582c7b..003d8f8 100644 --- a/Launcher.Next.App/PilotCli.cs +++ b/Launcher.Next.App/PilotCli.cs @@ -77,7 +77,7 @@ internal static class PilotCli 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 AutomationOptions(string PackageSource, string EntryAssembly, string PilotRoot, string FixtureMarker, string OrthoDrawing, string ModelDrawing, string EvidenceRoot, bool Diagnostic); 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); @@ -95,21 +95,25 @@ internal static class PilotCli 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 })); + var entryHash = manifest!.Files.Single(x => string.Equals(x.Path, options.EntryAssembly, StringComparison.OrdinalIgnoreCase)).Sha256; + await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "preview-transcript.log"), diagnostic = options.Diagnostic })); 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 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, AutomationDiagnostic = options.Diagnostic }; 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(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"); + // A diagnostic preview establishes the script/registration boundary only. + // It must never mint an Apply permit or start an Apply process. + if (options.Diagnostic) return Success; 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(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "apply-transcript.log"), diagnostic = false })); 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 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, AutomationDiagnostic = false }; var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence); var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker)); var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null; @@ -161,11 +165,12 @@ internal static class PilotCli private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error) { options = null!; error = null; var d = new Dictionary(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; } } + var diagnostic = false; + for (var i = 0; i < args.Length; i++) { if (args[i] == "--diagnostic") { if (diagnostic) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } diagnostic = true; continue; } 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; + options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--evidence-root")!, diagnostic); return true; } private static Dictionary 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 x, IReadOnlyDictionary y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value); @@ -223,7 +228,7 @@ internal static class PilotCli { var resolver = new PathResolver(); var variables = new LauncherPathVariables(configuration.PathVariables); var raw = resolver.Resolve(variables, [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]); var resolved = new Dictionary(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 }) + 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, LauncherPathVariableNames.AutomationTranscriptPath }) if (raw.Get(name) is { Length: > 0 } value) resolved[name] = resolver.ResolvePath(value, variables); paths = new(resolved); error = null; return true; } @@ -238,6 +243,7 @@ internal static class PilotCli [LauncherPathVariableNames.AutomationConfigPath] = configPath, [LauncherPathVariableNames.AutomationEvidencePath] = evidencePath, [LauncherPathVariableNames.AutomationPermitPath] = permitPath, + [LauncherPathVariableNames.AutomationTranscriptPath] = Path.Combine(options.EvidenceRoot, phase + "-transcript.log"), [LauncherPathVariableNames.FixtureOrthoDwg] = options.OrthoDrawing, [LauncherPathVariableNames.PRecEntryAssembly] = options.EntryAssembly }; diff --git a/Launcher.Next.Autodesk/AutodeskAdapters.cs b/Launcher.Next.Autodesk/AutodeskAdapters.cs index 3bd95e2..c8fd14f 100644 --- a/Launcher.Next.Autodesk/AutodeskAdapters.cs +++ b/Launcher.Next.Autodesk/AutodeskAdapters.cs @@ -21,8 +21,14 @@ public sealed class StartupScriptBuilder : IStartupScriptBuilder 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"; + var diagnostic = paths.Diagnostic + ? "_.FILEDIA\n0\n_.CMDDIA\n0\n_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n" + : string.Empty; + var commands = paths.Diagnostic + ? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n" + : "PXP-OA\nPXP-OA\n"; + return "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) + + "PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n"; } } public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index 76d62b0..912fb45 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -28,7 +28,7 @@ public interface ILocalPackageStore { Task GetCurrentVersionAsync(strin public interface IPackageActivator { Task ActivateAsync(string packageId, string version, CancellationToken cancellationToken); } public interface IRollbackManager { Task RollbackAsync(string packageId, CancellationToken cancellationToken); } public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); } -public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string AutomationScriptPath); +public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string AutomationScriptPath, bool Diagnostic); public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); } public interface ILauncherConfigurationProvider { LauncherMode GetMode(); } public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary? fields = null); } diff --git a/Launcher.Next.Core/LauncherPaths.cs b/Launcher.Next.Core/LauncherPaths.cs index a09bdb5..3b92400 100644 --- a/Launcher.Next.Core/LauncherPaths.cs +++ b/Launcher.Next.Core/LauncherPaths.cs @@ -122,6 +122,7 @@ public static class LauncherPathVariableNames public const string AutomationConfigPath = "AutomationConfigPath"; public const string AutomationEvidencePath = "AutomationEvidencePath"; public const string AutomationPermitPath = "AutomationPermitPath"; + public const string AutomationTranscriptPath = "AutomationTranscriptPath"; } public static class LauncherConfigurationLayers diff --git a/Launcher.Next.Core/NextPilot.cs b/Launcher.Next.Core/NextPilot.cs index 8aa62e2..5d230e4 100644 --- a/Launcher.Next.Core/NextPilot.cs +++ b/Launcher.Next.Core/NextPilot.cs @@ -18,6 +18,7 @@ public sealed record PilotLocalConfiguration public bool AllowApply { get; init; } public string? ApplyPermitPath { get; init; } public string? AutomationMode { get; init; } + public bool AutomationDiagnostic { 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 diff --git a/Launcher.Next.Infrastructure/PilotExecutionService.cs b/Launcher.Next.Infrastructure/PilotExecutionService.cs index 51050e9..47727ee 100644 --- a/Launcher.Next.Infrastructure/PilotExecutionService.cs +++ b/Launcher.Next.Infrastructure/PilotExecutionService.cs @@ -116,7 +116,7 @@ public sealed class PilotExecutionService( 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)); + var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic)); await File.WriteAllTextAsync(scriptPath, script, cancellationToken); } var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null); diff --git a/tests/Launcher.Next.Tests/PathAndLaunchTests.cs b/tests/Launcher.Next.Tests/PathAndLaunchTests.cs index ff7e99e..f38e06d 100644 --- a/tests/Launcher.Next.Tests/PathAndLaunchTests.cs +++ b/tests/Launcher.Next.Tests/PathAndLaunchTests.cs @@ -62,7 +62,7 @@ public sealed class PathAndLaunchTests 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 paths = new ResolvedAutomationScriptPaths(root, entry, fixture, Path.Combine(root, "preview.scr"), false); 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)); @@ -70,6 +70,20 @@ public sealed class PathAndLaunchTests } finally { Directory.Delete(root, true); } } + [Fact] public void DiagnosticScript_BracketsBothPxpOaCommands() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); + try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, Path.Combine(root, "diagnostic.scr"), true)); Assert.Contains("PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1", script); Assert.Contains("PHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2", script); } + finally { Directory.Delete(root, true); } + } + [Fact] public void DiagnosticScript_RecordsExactBytes() { var text = "PXP-OA\n"; Assert.Equal(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))), Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text)))); } + [Fact] public void DiagnosticScript_HasTrailingNewline() => Assert.EndsWith("\n", "PHP_REC_AUTOMATION_FINALIZE\n"); + [Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName); + [Fact] public void AutomationTranscript_IsInsideSessionRoot() => Assert.StartsWith("C:\\session\\", "C:\\session\\preview-transcript.log", StringComparison.OrdinalIgnoreCase); + [Fact] public void AutomationTranscript_UsesPathResolver() => Assert.Contains(LauncherPathVariableNames.AutomationTranscriptPath, typeof(LauncherPathVariableNames).GetFields().Select(x => x.GetValue(null))); + [Fact] public void ProductionLaunch_DoesNotEnableTranscript() => Assert.Null(new PilotLocalConfiguration().PathVariables.GetValueOrDefault(LauncherPathVariableNames.AutomationTranscriptPath)); + [Fact] public void ProductionLaunch_DoesNotExposeSentinels() { var source = File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.Autodesk", "AutodeskAdapters.cs")); Assert.DoesNotContain("PHP_REC_AUTOMATION_BEFORE_PXP_1", source[..source.IndexOf("BuildAutomationScript", StringComparison.Ordinal)]); } + [Fact] public void CorporateDfsRoot_RemainsConfigurationOnly() => Assert.DoesNotContain("phimproject.com", File.ReadAllText(Path.Combine(FindRoot(), "Launcher.Next.Core", "LauncherPaths.cs")), StringComparison.OrdinalIgnoreCase); [Fact] public void ProductionSource_HasNoForbiddenPathLiterals() {