Files
autodesk/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs
T

273 lines
21 KiB
C#

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<string> 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<Run> runs = [];
private static readonly List<string> transitions = [];
private static readonly List<string> commandEvents = [];
private static readonly List<AutomationSentinelEvidence> sentinels = [];
private static readonly List<AutomationCommandCorrelationEvidence> 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<string> 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<Declaration> Declarations(Assembly assembly)
{
Type[] types; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException exception) { types = exception.Types.Where(x => x is not null).Cast<Type>().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");
}
}