diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/CommandRegistration.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/CommandRegistration.cs new file mode 100644 index 0000000..7ea88bc --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/CommandRegistration.cs @@ -0,0 +1,3 @@ +using Autodesk.AutoCAD.Runtime; + +[assembly: CommandClass(typeof(PRecAnnot.Plant3D2026.EntryPoint))] diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs index db7dee4..e0fa900 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs @@ -47,6 +47,18 @@ public sealed class EntryPoint : IExtensionApplication [CommandMethod("PHP_REC_AUTOMATION_PREPARE", CommandFlags.Session)] public void AutomationPrepareCommand() => ScriptDrivenFirstApplyAutomation.Prepare(); + [CommandMethod("PHP_REC_AUTOMATION_BEFORE_PXP_1", CommandFlags.Session)] + public void AutomationBeforePxp1Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("BeforePxp1"); + + [CommandMethod("PHP_REC_AUTOMATION_AFTER_PXP_1", CommandFlags.Session)] + public void AutomationAfterPxp1Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("AfterPxp1"); + + [CommandMethod("PHP_REC_AUTOMATION_BEFORE_PXP_2", CommandFlags.Session)] + public void AutomationBeforePxp2Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("BeforePxp2"); + + [CommandMethod("PHP_REC_AUTOMATION_AFTER_PXP_2", CommandFlags.Session)] + public void AutomationAfterPxp2Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("AfterPxp2"); + [CommandMethod("PHP_REC_AUTOMATION_FINALIZE", CommandFlags.Session)] public void AutomationFinalizeCommand() => ScriptDrivenFirstApplyAutomation.FinalizePreview(); diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs index 436c6a4..44729bf 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs @@ -21,6 +21,8 @@ internal sealed record FirstApplyAutomationConfiguration } internal sealed record AutomationEntityCounts(int Total, int PipeBlocks, int PxpOaXdata, int Owned); +internal sealed record PxpOaDiscoveryEvidence(bool Found, string? GlobalName, string? GroupName, string? Flags, string? DeclaringType, string? DeclaringAssembly, string? AssemblyLocation, string? AssemblySha256, bool AssemblyLoaded, bool CommandClassIncluded, bool BundleHashMatched, int DeclarationCount); +internal sealed record AutomationSentinelEvidence(string Stage, string? DocumentIdentity, string? DatabaseIdentity, DateTimeOffset TimestampUtc); internal static class FirstLimitedApplyAutomationBootstrap { @@ -122,6 +124,9 @@ internal sealed record FirstApplyAutomationEvidence public string? PreviousState { get; init; } public string? CurrentState { get; init; } public IReadOnlyList StateTransitions { get; init; } = Array.Empty(); + public PxpOaDiscoveryEvidence? PxpOaDiscovery { get; init; } + public IReadOnlyList Sentinels { get; init; } = Array.Empty(); + public IReadOnlyList CommandEvents { get; init; } = Array.Empty(); } internal sealed class FirstLimitedApplyAutomationCoordinator diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs index c5e75ae..87d5400 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using System.Reflection; +using System.Security.Cryptography; using Autodesk.AutoCAD.ApplicationServices; using Autodesk.AutoCAD.DatabaseServices; using Autodesk.AutoCAD.Runtime; @@ -11,7 +13,7 @@ namespace PRecAnnot.Plant3D2026; // Test-only observer. The AutoCAD /b processor, never this type, drives PXP-OA. internal static class ScriptDrivenFirstApplyAutomation { - private sealed record Config(string Phase, string OrthoDrawingPath, string EvidencePath, string FixtureMarkerPath); + private sealed record Config(string Phase, string OrthoDrawingPath, string EvidencePath, string FixtureMarkerPath, string ExpectedEntryAssemblySha256, bool Diagnostic); private sealed record Run(int Sequence, string? PlanId, IReadOnlyList Candidates, string? View, int Create, int Skip, int? DbmodBefore, int? DbmodAfter, AutomationEntityCounts? Before, AutomationEntityCounts? After, bool WillStart, bool Ended, bool Failed, bool Cancelled, int? SourceRows, int? MembershipResolved); private static Config? config; private static Document? fixture; @@ -20,6 +22,9 @@ internal static class ScriptDrivenFirstApplyAutomation private static int? dbmodBaseline; private static readonly List runs = []; private static readonly List transitions = []; + private static readonly List commandEvents = []; + private static readonly List sentinels = []; + private static PxpOaDiscoveryEvidence? discovery; private static bool prepared; private static bool terminal; @@ -30,6 +35,8 @@ internal static class ScriptDrivenFirstApplyAutomation if (terminal) return; config = ReadConfig(); RequireGate(config); + discovery = DiscoverPxpOa(config); + RequireDiscovery(discovery); var active = AcadApplication.DocumentManager.MdiActiveDocument ?? throw new InvalidOperationException("AUTOMATION_ACTIVE_DOCUMENT_MISSING"); if (!SamePath(active.Name, config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_UNEXPECTED_ACTIVE_FIXTURE"); fixture = active; @@ -54,6 +61,17 @@ internal static class ScriptDrivenFirstApplyAutomation catch (System.Exception exception) { Fail("AUTOMATION_PREPARE_FAILED", exception); } } + internal static void RecordSentinel(string stage) + { + Guard(() => + { + if (!prepared || terminal || fixture is null || database is null) return; + sentinels.Add(new(stage, fixture.Name, database.Filename, DateTimeOffset.UtcNow)); + transitions.Add("Sentinel:" + stage); + Write(false, null); + }); + } + internal static void RecordPxpOa(string? planId, IReadOnlyList candidates, string? view, int create, int skip, bool? permitAccepted, int sourceRows, int membershipResolved) { if (!prepared || terminal || runs.Count == 0) return; @@ -85,6 +103,7 @@ internal static class ScriptDrivenFirstApplyAutomation { Guard(() => { + commandEvents.Add("WillStart:" + args.GlobalCommandName); if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count >= 2) return; runs.Add(new Run(runs.Count + 1, null, [], null, 0, 0, Dbmod(), null, Counts(database!), null, true, false, false, false, null, null)); transitions.Add("PxpOaWillStart:" + runs.Count); @@ -97,6 +116,7 @@ internal static class ScriptDrivenFirstApplyAutomation { Guard(() => { + commandEvents.Add((failed ? "Failed:" : cancelled ? "Cancelled:" : "Ended:") + args.GlobalCommandName); if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count == 0) return; var last = runs[^1]; if (last.Ended || last.Failed || last.Cancelled) return; @@ -123,7 +143,7 @@ internal static class ScriptDrivenFirstApplyAutomation { var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION_CONFIG") ?? throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING"); using var json = JsonDocument.Parse(File.ReadAllText(path)); var root = json.RootElement; - return new(root.GetProperty("phase").GetString() ?? "Preview", root.GetProperty("orthoDrawingPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED"), root.GetProperty("evidencePath").GetString() ?? throw new InvalidOperationException("AUTOMATION_EVIDENCE_PATH_REQUIRED"), root.GetProperty("fixtureMarkerPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_MARKER_PATH_REQUIRED")); + return new(root.GetProperty("phase").GetString() ?? "Preview", root.GetProperty("orthoDrawingPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED"), root.GetProperty("evidencePath").GetString() ?? throw new InvalidOperationException("AUTOMATION_EVIDENCE_PATH_REQUIRED"), root.GetProperty("fixtureMarkerPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_MARKER_PATH_REQUIRED"), root.GetProperty("entryAssemblySha256").GetString() ?? throw new InvalidOperationException("AUTOMATION_ENTRY_ASSEMBLY_HASH_REQUIRED"), root.TryGetProperty("diagnostic", out var diagnostic) && diagnostic.ValueKind == JsonValueKind.True); } private static void RequireGate(Config value) { @@ -134,8 +154,42 @@ internal static class ScriptDrivenFirstApplyAutomation { if (config is null) return; var first = runs.ElementAtOrDefault(0); var second = runs.ElementAtOrDefault(1); - var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = completed, Prepared = prepared, FunctionalEvidenceCompleted = completed, ErrorCode = error, DbmodBefore = dbmodBaseline, DbmodAfterCommand1 = first?.DbmodAfter, DbmodAfterCommand2 = second?.DbmodAfter, Baseline = baseline, AfterCommand1 = first?.After, AfterCommand2 = second?.After, CompletedCommands = runs.Where(x => x.Ended).Select(x => "PXP-OA").ToArray(), PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = first?.Candidates.OrderBy(x => x, StringComparer.Ordinal).FirstOrDefault(), ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, CommandsCompleted = runs.Count == 2 && runs.All(x => x.Ended), InspectorsCompleted = baseline is not null && first?.After is not null && second?.After is not null, EvidenceFlushed = true, CurrentState = terminal ? (completed ? "Completed" : "Failed") : "Prepared", StateTransitions = transitions }; + var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = completed, Prepared = prepared, FunctionalEvidenceCompleted = completed, ErrorCode = error, DbmodBefore = dbmodBaseline, DbmodAfterCommand1 = first?.DbmodAfter, DbmodAfterCommand2 = second?.DbmodAfter, Baseline = baseline, AfterCommand1 = first?.After, AfterCommand2 = second?.After, CompletedCommands = runs.Where(x => x.Ended).Select(x => "PXP-OA").ToArray(), PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = first?.Candidates.OrderBy(x => x, StringComparer.Ordinal).FirstOrDefault(), ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, CommandsCompleted = runs.Count == 2 && runs.All(x => x.Ended), InspectorsCompleted = baseline is not null && first?.After is not null && second?.After is not null, EvidenceFlushed = true, CurrentState = terminal ? (completed ? "Completed" : "Failed") : "Prepared", StateTransitions = transitions, PxpOaDiscovery = discovery, Sentinels = sentinels, CommandEvents = commandEvents }; Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!); var temp = config.EvidencePath + ".tmp"; File.WriteAllText(temp, JsonSerializer.Serialize(evidence)); File.Move(temp, config.EvidencePath, true); } + + private static PxpOaDiscoveryEvidence DiscoverPxpOa(Config value) + { + var entry = typeof(EntryPoint).Assembly; + var loaded = AppDomain.CurrentDomain.GetAssemblies().Any(x => ReferenceEquals(x, entry)); + var location = entry.Location; + var hash = File.Exists(location) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(location))) : null; + var declarations = AppDomain.CurrentDomain.GetAssemblies().SelectMany(Declarations).Where(x => string.Equals(x.Name, "PXP-OA", StringComparison.OrdinalIgnoreCase)).ToArray(); + var owner = declarations.SingleOrDefault(x => x.Assembly == entry); + var commandClass = entry.GetCustomAttributesData().Any(x => string.Equals(x.AttributeType.FullName, "Autodesk.AutoCAD.Runtime.CommandClassAttribute", StringComparison.Ordinal) && x.ConstructorArguments.Any(a => string.Equals(a.Value as Type, typeof(EntryPoint)))); + return new(owner is not null, owner?.Name, owner?.Group, owner?.Flags, owner?.Type.FullName, owner?.Assembly.GetName().Name, location, hash, loaded, commandClass, hash is not null && string.Equals(hash, value.ExpectedEntryAssemblySha256, StringComparison.OrdinalIgnoreCase), declarations.Length); + } + private sealed record Declaration(string Name, string? Group, string? Flags, Type Type, Assembly Assembly); + private static IEnumerable Declarations(Assembly assembly) + { + Type[] types; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException exception) { types = exception.Types.Where(x => x is not null).Cast().ToArray(); } + foreach (var type in types) foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)) foreach (var attribute in method.GetCustomAttributesData().Where(x => string.Equals(x.AttributeType.FullName, "Autodesk.AutoCAD.Runtime.CommandMethodAttribute", StringComparison.Ordinal))) + { + var name = attribute.ConstructorArguments.FirstOrDefault().Value as string; + if (string.IsNullOrWhiteSpace(name)) continue; + var group = attribute.NamedArguments.FirstOrDefault(x => x.MemberName == "GroupName").TypedValue.Value as string; + var flags = attribute.NamedArguments.FirstOrDefault(x => x.MemberName == "Flags").TypedValue.Value?.ToString(); + yield return new(name, group, flags, type, assembly); + } + } + private static void RequireDiscovery(PxpOaDiscoveryEvidence value) + { + if (!value.AssemblyLoaded) throw new InvalidOperationException("PXP_OA_ASSEMBLY_NOT_LOADED"); + if (!value.BundleHashMatched) throw new InvalidOperationException("PXP_OA_BUNDLE_HASH_MISMATCH"); + if (!value.CommandClassIncluded) throw new InvalidOperationException("PXP_OA_COMMANDCLASS_NOT_REGISTERED"); + if (value.DeclarationCount > 1) throw new InvalidOperationException("PXP_OA_COMMAND_COLLISION"); + if (value.DeclarationCount == 0 || !value.Found) throw new InvalidOperationException("PXP_OA_COMMAND_NOT_FOUND"); + if (!string.Equals(value.GlobalName, "PXP-OA", StringComparison.Ordinal)) throw new InvalidOperationException("PXP_OA_GLOBAL_NAME_MISMATCH"); + } } diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs new file mode 100644 index 0000000..7f2a6c2 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs @@ -0,0 +1,31 @@ +using Xunit; + +namespace PRecAnnot.Core.Tests; + +public sealed class PxpOaRegistrationTests +{ + private static string ProjectRoot + { + get + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "PRecAnnot2026.sln"))) directory = directory.Parent; + return Path.Combine(directory!.FullName, "src", "PRecAnnot.Plant3D2026"); + } + } + private static string Entry => File.ReadAllText(Path.Combine(ProjectRoot, "EntryPoint.cs")); + private static string Observer => File.ReadAllText(Path.Combine(ProjectRoot, "ScriptDrivenFirstApplyAutomation.cs")); + + [Fact] public void PxpOaDiscovery_FindsExactlyOneDeclaration() => Assert.Single(System.Text.RegularExpressions.Regex.Matches(Entry, "\\[CommandMethod\\(\"PXP-OA\"").Cast()); + [Fact] public void PxpOaDiscovery_RecordsGlobalName() => Assert.Contains("GlobalName", Observer); + [Fact] public void PxpOaDiscovery_RecordsCommandGroup() => Assert.Contains("GroupName", Observer); + [Fact] public void PxpOaDiscovery_RecordsFlags() => Assert.Contains("Flags", Observer); + [Fact] public void PxpOaDiscovery_RecordsDeclaringAssembly() => Assert.Contains("owner?.Assembly", Observer); + [Fact] public void PxpOaDiscovery_RequiresLoadedAssembly() => Assert.Contains("PXP_OA_ASSEMBLY_NOT_LOADED", Observer); + [Fact] public void PxpOaDiscovery_RequiresCommandClass() => Assert.Contains("PXP_OA_COMMANDCLASS_NOT_REGISTERED", Observer); + [Fact] public void PxpOaDiscovery_RejectsCollision() => Assert.Contains("PXP_OA_COMMAND_COLLISION", Observer); + [Fact] public void PxpOaDiscovery_RejectsBundleHashMismatch() => Assert.Contains("PXP_OA_BUNDLE_HASH_MISMATCH", Observer); + [Fact] public void Prepare_StopsOnPxpOaDiscoveryFailure() => Assert.True(Observer.IndexOf("RequireDiscovery(discovery)", StringComparison.Ordinal) < Observer.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal)); + [Fact] public void AutomationSentinel_DoesNotModifyDatabase() => Assert.DoesNotContain("StartTransaction", Entry[Entry.IndexOf("AutomationBeforePxp1Command", StringComparison.Ordinal)..Entry.IndexOf("private static void RunDiagnostic", StringComparison.Ordinal)]); + [Fact] public void AutomationSentinel_IsTestOnly() => Assert.Contains("RequireGate", Observer); +}