using System.Text.Json; using System.Reflection; using System.Security.Cryptography; using Autodesk.AutoCAD.ApplicationServices; using Autodesk.AutoCAD.DatabaseServices; using Autodesk.AutoCAD.Runtime; using Autodesk.ProcessPower.PlantInstance; using Autodesk.ProcessPower.ProjectManager; using PRecAnnot.Abstractions; using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application; 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, string ExpectedEntryAssemblySha256, bool Diagnostic); private sealed record Run(int Sequence, string? PlanId, IReadOnlyList Candidates, string? View, int Create, int Skip, bool? PermitAccepted, string? ApplyOutcome, 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; private static Database? database; private static AutomationEntityCounts? baseline; 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 readonly List correlations = []; private static PxpOaDiscoveryEvidence? discovery; // Plant's implicit current-document lookup can lag between script commands. // PREPARE has already proved this immutable context belongs to the live fixture; // retain it for the two immediately following public PXP-OA commands only. private static PlantRuntimeContext? preparedContext; private static string? selectedTargetSnapshotId; private static bool prepared; private static bool terminal; private static string? applyExceptionText; internal static void Prepare() { try { 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; database = active.Database ?? throw new InvalidOperationException("AUTOMATION_FIXTURE_DATABASE_MISSING"); fixture.CommandWillStart += OnWillStart; fixture.CommandEnded += OnEnded; fixture.CommandFailed += OnFailed; fixture.CommandCancelled += OnCancelled; var projectXml = Path.Combine(Path.GetDirectoryName(config.FixtureMarkerPath)!, "Project.xml"); if (!File.Exists(projectXml)) throw new InvalidOperationException("AUTOMATION_PROJECT_XML_MISSING"); var project = PlantProject.LoadProject(projectXml, false, string.Empty, string.Empty); PlantApplication.SetCurrentProject(project, true); var context = new PlantRuntimeContextReader().ReadCurrentContext(fixture); if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1) throw new InvalidOperationException("AUTOMATION_ORTHO_CONTEXT_INVALID"); var candidates = new PlantObjectReader().ReadObjects(context.Viewports.Single(), CancellationToken.None) .Where(x => string.Equals(x.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase) && x.MembershipStatus == ViewMembershipStatus.Resolved && x.Geometry.PaperPosition is not null) .Select(x => x.SnapshotId).OrderBy(x => x, StringComparer.Ordinal).ToArray(); if (candidates.Length == 0) throw new InvalidOperationException("AUTOMATION_NO_ELIGIBLE_CANDIDATE"); preparedContext = context; selectedTargetSnapshotId = candidates[0]; dbmodBaseline = Dbmod(); baseline = Counts(database); prepared = true; transitions.Add("Prepared"); Write(false, null); } 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; var last = runs[^1]; if (!last.WillStart || last.PlanId is not null) return; runs[^1] = last with { PlanId = planId, Candidates = candidates, View = view, Create = create, Skip = skip, PermitAccepted = permitAccepted, SourceRows = sourceRows, MembershipResolved = membershipResolved }; } internal static void RecordApplyOutcome(PipeAnnotationWriteOutcome outcome) { if (!prepared || terminal || runs.Count == 0) return; var last = runs[^1]; runs[^1] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? last with { ApplyOutcome = outcome.ToString(), Create = 0, Skip = 1 } : last with { ApplyOutcome = outcome.ToString() }; } internal static void RecordApplyFailure(System.Exception exception) { if (!prepared || terminal) return; applyExceptionText = exception.ToString(); Write(false, "AUTOMATION_APPLY_COMMAND_FAILED"); } internal static bool TryGetPreparedContext(Document? commandDocument, out PlantRuntimeContext context) { context = null!; if (!prepared || terminal || fixture is null || preparedContext is null || commandDocument is null || config is null) return false; if (!SamePath(commandDocument.Name, config.OrthoDrawingPath) || !SamePath(fixture.Name, config.OrthoDrawingPath)) return false; if (commandDocument.Database is null || database is null || !SamePath(commandDocument.Database.Filename, database.Filename)) return false; context = preparedContext; return true; } internal static string? SelectedTargetSnapshotId => prepared && !terminal ? selectedTargetSnapshotId : null; internal static void FinalizeAutomation() { try { if (!prepared) throw new InvalidOperationException("AUTOMATION_NOT_PREPARED"); if (runs.Count != 2) throw new InvalidOperationException("AUTOMATION_EXPECTS_EXACTLY_TWO_PXP_OA"); if (runs.Any(x => !x.WillStart || !x.Ended || x.Failed || x.Cancelled)) throw new InvalidOperationException("AUTOMATION_PXP_OA_NOT_ENDED"); if (runs.Any(x => string.IsNullOrWhiteSpace(x.PlanId))) throw new InvalidOperationException("PXP_OA_PLAN_RESULT_NOT_CAPTURED"); if (!string.Equals(runs[0].PlanId, runs[1].PlanId, StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_PLAN_IDS_DIFFER"); var apply = string.Equals(config!.Phase, "Apply", StringComparison.OrdinalIgnoreCase); if ((!apply && runs.Any(x => x.Create != 1)) || (apply && (runs[0].Create != 1 || runs[1].Create != 0))) throw new InvalidOperationException("AUTOMATION_CREATE_COUNTS_INVALID"); if (runs[0].Candidates.Count == 0) throw new InvalidOperationException("AUTOMATION_NO_ELIGIBLE_CANDIDATE"); if (apply) { if (runs[0].PermitAccepted != true) throw new InvalidOperationException("AUTOMATION_APPLY_PERMIT_NOT_ACCEPTED"); if (!string.Equals(runs[0].ApplyOutcome, PipeAnnotationWriteOutcome.Created.ToString(), StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_APPLY_CREATE_NOT_CONFIRMED"); if (!string.Equals(runs[1].ApplyOutcome, PipeAnnotationWriteOutcome.AlreadyExists.ToString(), StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_APPLY_DUPLICATE_NOT_CONFIRMED"); var undoCounts = Counts(database!); if (!Equals(undoCounts, baseline)) throw new InvalidOperationException("AUTOMATION_UNDO_DID_NOT_RESTORE_BASELINE"); } else if (Dbmod() != dbmodBaseline || !Equals(Counts(database!), baseline)) throw new InvalidOperationException("AUTOMATION_INSPECTOR_INVARIANCE_FAILED"); terminal = true; transitions.Add(apply ? "ApplyCompleted" : "PreviewCompleted"); Write(true, null); Unsubscribe(); } catch (System.Exception exception) { Fail("PREVIEW_ACCEPTANCE_FAILED", exception); } } private static void OnWillStart(object sender, CommandEventArgs args) { Guard(() => { commandEvents.Add("WillStart:" + args.GlobalCommandName); var correlation = Correlate("WillStart", sender, args.GlobalCommandName, runs.Count < 2); correlations.Add(correlation); if (correlation.RejectionReason is not null) return; runs.Add(new Run(runs.Count + 1, null, [], null, 0, 0, null, null, Dbmod(), null, Counts(database!), null, true, false, false, false, null, null)); transitions.Add("PxpOaWillStart:" + runs.Count); }); } private static void OnEnded(object sender, CommandEventArgs args) => Finish(sender, args, false, false); private static void OnFailed(object sender, CommandEventArgs args) => Finish(sender, args, true, false); private static void OnCancelled(object sender, CommandEventArgs args) => Finish(sender, args, false, true); private static void Finish(object sender, CommandEventArgs args, bool failed, bool cancelled) { Guard(() => { var kind = failed ? "Failed" : cancelled ? "Cancelled" : "Ended"; commandEvents.Add(kind + ":" + args.GlobalCommandName); var correlation = Correlate(kind, sender, args.GlobalCommandName, runs.Count > 0); correlations.Add(correlation); if (correlation.RejectionReason is not null) return; var last = runs[^1]; if (last.Ended || last.Failed || last.Cancelled) return; runs[^1] = last with { Ended = !failed && !cancelled, Failed = failed, Cancelled = cancelled, DbmodAfter = Dbmod(), After = Counts(database!) }; transitions.Add(failed ? "PxpOaFailed" : cancelled ? "PxpOaCancelled" : "PxpOaEnded:" + last.Sequence); Write(false, null); }); } private static AutomationCommandCorrelationEvidence Correlate(string kind, object sender, string rawName, bool sequenceAccepted) { var normalized = rawName.Trim(); var command = string.Equals(normalized, "PXP-OA", StringComparison.OrdinalIgnoreCase); var state = prepared && !terminal && config is not null && fixture is not null && database is not null; var senderDocument = sender as Document; var senderPath = senderDocument?.Name; var expectedPath = config?.OrthoDrawingPath; var activePath = AcadApplication.DocumentManager.MdiActiveDocument?.Name; var fixturePath = senderPath is not null && expectedPath is not null && SamePath(senderPath, expectedPath); var activeFixture = activePath is not null && expectedPath is not null && SamePath(activePath, expectedPath); var document = fixturePath && activeFixture; var databaseMatch = senderDocument?.Database is not null && database is not null && SamePath(senderDocument.Database.Filename, database.Filename); var rejection = !command ? "COMMAND_NAME_MISMATCH" : !state ? "NOT_PREPARED" : !document ? "DOCUMENT_IDENTITY_MISMATCH" : !databaseMatch ? "DATABASE_IDENTITY_MISMATCH" : !sequenceAccepted ? "UNEXPECTED_SEQUENCE" : null; return new(kind, rawName, normalized, command, state, document, databaseMatch, fixturePath, sequenceAccepted, rejection, sender.GetType().FullName, senderPath, expectedPath, activePath, runs.Count, DateTimeOffset.UtcNow); } private static void Guard(Action action) { try { if (!terminal) action(); } catch (System.Exception ex) { Fail("AUTOMATION_EVENT_HANDLER_FAILED", ex); } } private static void Fail(string code, System.Exception exception) { if (terminal) return; terminal = true; transitions.Add("Failed:" + code); try { Write(false, code + ":" + exception); } finally { Unsubscribe(); } } private static void Unsubscribe() { if (fixture is not null) try { fixture.CommandWillStart -= OnWillStart; fixture.CommandEnded -= OnEnded; fixture.CommandFailed -= OnFailed; fixture.CommandCancelled -= OnCancelled; } catch { } preparedContext = null; selectedTargetSnapshotId = null; } private static int? Dbmod() { try { return Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD")); } catch { return null; } } private static AutomationEntityCounts Counts(Database db) { using var tx = db.TransactionManager.StartTransaction(); var space = (BlockTableRecord)tx.GetObject(db.CurrentSpaceId, OpenMode.ForRead); var total = 0; var pipes = 0; var xdata = 0; var owned = 0; foreach (ObjectId id in space) { if (tx.GetObject(id, OpenMode.ForRead) is not Entity entity) continue; total++; if (entity is BlockReference br && string.Equals(br.Name, "PIPE", StringComparison.OrdinalIgnoreCase)) pipes++; if (entity.XData is not null) { foreach (TypedValue value in entity.XData) if (value.Value is string text && string.Equals(text, "PXP-OA", StringComparison.OrdinalIgnoreCase)) { xdata++; owned++; break; } } } return new(total, pipes, xdata, owned); } private static Config ReadConfig() { 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"), 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) { if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_LAUNCHER_MODE"), "NextPilot", StringComparison.Ordinal) || !File.Exists(value.FixtureMarkerPath)) throw new InvalidOperationException("AUTOMATION_GATE_REJECTED"); } private static bool SamePath(string left, string right) => string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); private static void Write(bool completed, string? error) { if (config is null) return; var first = runs.ElementAtOrDefault(0); var second = runs.ElementAtOrDefault(1); var apply = string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase); var afterUndo = apply && terminal ? Counts(database!) : null; var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = completed, Prepared = prepared, FunctionalEvidenceCompleted = completed, ErrorCode = error, DbmodBefore = dbmodBaseline, DbmodAfterCommand1 = first?.DbmodAfter, DbmodAfterCommand2 = second?.DbmodAfter, DbmodAfterUndo = apply && terminal ? Dbmod() : null, Baseline = baseline, AfterCommand1 = first?.After, AfterCommand2 = second?.After, AfterUndo = afterUndo, CompletedCommands = runs.Where(x => x.Ended).Select(x => "PXP-OA").Concat(apply && commandEvents.Any(x => string.Equals(x, "Ended:UNDO", StringComparison.OrdinalIgnoreCase)) ? ["UNDO"] : []).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, ApplyOutcomeRun1 = first?.ApplyOutcome, ApplyOutcomeRun2 = second?.ApplyOutcome, PermitAccepted = first?.PermitAccepted, CommandsCompleted = runs.Count == 2 && runs.All(x => x.Ended) && (!apply || commandEvents.Any(x => string.Equals(x, "Ended:UNDO", StringComparison.OrdinalIgnoreCase))), InspectorsCompleted = baseline is not null && first?.After is not null && second?.After is not null && (!apply || afterUndo is not null), EvidenceFlushed = true, ExceptionText = applyExceptionText, CurrentState = terminal ? (completed ? "Completed" : "Failed") : "Prepared", StateTransitions = transitions, PxpOaDiscovery = discovery, Sentinels = sentinels, CommandEvents = commandEvents, CommandCorrelations = correlations }; 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"); } }