fix: defer Plant automation to first post-startup idle

This commit is contained in:
codex
2026-08-03 16:34:17 +03:00
parent 1563e2fae0
commit 544b329077
4 changed files with 313 additions and 4 deletions
@@ -0,0 +1,264 @@
using System.Text.Json;
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 coordinator. It is not registered in Ribbon and is inert unless
// the explicit NextPilot automation environment and local marker are present.
internal sealed record FirstApplyAutomationConfiguration
{
public required string Phase { get; init; }
public string? OrthoDrawingPath { get; init; }
public string? ModelDrawingPath { get; init; }
public required string EvidencePath { get; init; }
public required string FixtureMarkerPath { get; init; }
}
internal sealed record AutomationEntityCounts(int Total, int PipeBlocks, int PxpOaXdata, int Owned);
internal static class FirstLimitedApplyAutomationBootstrap
{
private enum BootstrapState { Created, Prepared, Armed, Starting, Completed }
private static BootstrapState state;
private static bool observedIdle;
private static int idleCallbackCount;
private static int coordinatorStartCount;
internal static int IdleCallbackCount => idleCallbackCount;
internal static int CoordinatorStartCount => coordinatorStartCount;
// Called from Initialize: BCL gates and one lightweight subscription only.
internal static void Prepare()
{
if (state != BootstrapState.Created) return;
state = BootstrapState.Prepared;
AcadApplication.Idle += OnIdle;
}
// Public test command is intentionally side-effect free with respect to Plant APIs.
internal static void Arm()
{
if (state is BootstrapState.Prepared) state = BootstrapState.Armed;
}
private static void OnIdle(object? sender, EventArgs args)
{
try
{
idleCallbackCount++;
// The script engine can consume the custom command after NETLOAD. The
// first idle is therefore the explicit post-NETLOAD arming point.
if (state == BootstrapState.Prepared) { observedIdle = true; state = BootstrapState.Armed; }
if (!observedIdle || state != BootstrapState.Armed) return;
state = BootstrapState.Starting;
AcadApplication.Idle -= OnIdle;
coordinatorStartCount++;
new FirstLimitedApplyAutomationCoordinator().Start();
}
catch (System.Exception exception)
{
state = BootstrapState.Completed;
AcadApplication.Idle -= OnIdle;
FirstLimitedApplyAutomationCoordinator.WriteBootstrapFailure(exception);
}
}
}
internal sealed record FirstApplyAutomationEvidence
{
public int SchemaVersion { get; init; } = 1;
public required string Phase { get; init; }
public bool Completed { get; init; }
public string? ErrorCode { get; init; }
public int? DbmodBefore { get; init; }
public int? DbmodAfterCommand1 { get; init; }
public int? DbmodAfterCommand2 { get; init; }
public int? DbmodAfterUndo { get; init; }
public AutomationEntityCounts? Baseline { get; init; }
public AutomationEntityCounts? AfterCommand1 { get; init; }
public AutomationEntityCounts? AfterCommand2 { get; init; }
public AutomationEntityCounts? AfterUndo { get; init; }
public IReadOnlyList<string> CompletedCommands { get; init; } = Array.Empty<string>();
public string? PlanIdRun1 { get; init; }
public string? PlanIdRun2 { get; init; }
public bool? PlanIdsEqual { get; init; }
public int? EligibleCandidateCount { get; init; }
public string? SelectedCandidateHash { get; init; }
public string? ViewHash { get; init; }
public int? CreateRun1 { get; init; }
public int? CreateRun2 { get; init; }
public int? SkipRun2 { get; init; }
public bool? PermitAccepted { get; init; }
public int? IdleCallbackCount { get; init; }
public int? CoordinatorStartCount { get; init; }
public IReadOnlyList<string> StateTransitions { get; init; } = Array.Empty<string>();
}
internal sealed class FirstLimitedApplyAutomationCoordinator
{
private sealed record PxpResult(string? PlanId, IReadOnlyList<string> Candidates, string? View, int Create, int Skip, bool? PermitAccepted);
private static string? selectedTargetSnapshotId;
private static readonly List<PxpResult> pxpResults = [];
internal static string? SelectedTargetSnapshotId => selectedTargetSnapshotId;
internal static void WriteBootstrapFailure(System.Exception exception) =>
RuntimeDiagnostics.Write("AutomationBootstrapFailed", "ERROR", resultStatus: "AUTOMATION_BOOTSTRAP_FAILED", exception: exception);
internal static void RecordPxpOa(string? planId, IReadOnlyList<string> candidates, string? view, int create, int skip, bool? permitAccepted)
{
if (string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal))
pxpResults.Add(new(planId, candidates, view, create, skip, permitAccepted));
}
private FirstApplyAutomationConfiguration config = null!;
private Document document = null!;
private readonly List<string> completed = [];
private int step;
private int? before, after1, after2, afterUndo;
private AutomationEntityCounts? baseline, counts1, counts2, countsUndo;
private readonly List<string> transitions = ["Created"];
public void Start()
{
try
{
config = ReadConfiguration();
transitions.Add("PostStartupReady");
if (string.Equals(config.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase))
{
CompleteLifecycleProbe();
return;
}
if (string.IsNullOrWhiteSpace(config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED");
transitions.Add("ProjectActivationStarted");
var projectXml = Path.Combine(Path.GetDirectoryName(config.FixtureMarkerPath)!, "Project.xml");
var project = PlantProject.LoadProject(projectXml, false, string.Empty, string.Empty);
PlantApplication.SetCurrentProject(project, true);
transitions.Add("ProjectActivated");
if (!string.IsNullOrWhiteSpace(config.ModelDrawingPath)) AcadApplication.DocumentManager.Open(config.ModelDrawingPath, false);
transitions.Add("DocumentOpenStarted");
document = AcadApplication.DocumentManager.Open(config.OrthoDrawingPath, false);
AcadApplication.DocumentManager.MdiActiveDocument = document;
transitions.Add("DocumentOpened");
document.CommandEnded += OnCommandEnded;
document.CommandFailed += OnCommandFailed;
document.CommandCancelled += OnCommandCancelled;
AcadApplication.DocumentManager.DocumentActivated += OnDocumentActivated;
AcadApplication.Idle += OnIdle;
}
catch (System.Exception ex) { Complete(false, "AUTOMATION_START_FAILED:" + ex.GetType().Name); }
}
private void CompleteLifecycleProbe()
{
transitions.Add("LifecycleProbeCompleted");
WriteEvidence(true, null);
transitions.Add("ExitRequested");
AcadApplication.Quit();
}
private void OnIdle(object? sender, EventArgs e)
{
BeginContextVerification();
}
private void OnDocumentActivated(object sender, DocumentCollectionEventArgs e)
{
if (string.Equals(e.Document.Name, document.Name, StringComparison.OrdinalIgnoreCase)) BeginContextVerification();
}
private void BeginContextVerification()
{
AcadApplication.Idle -= OnIdle;
AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated;
try
{
transitions.Add("ContextVerificationStarted");
var context = new PlantRuntimeContextReader().ReadCurrentContext(document);
if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1) { Complete(false, $"AUTOMATION_ORTHO_CONTEXT_INVALID:P={context.HasPlantProject};O={context.IsOrthoDrawing};V={context.Viewports.Count}"); return; }
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) { Complete(false, "AUTOMATION_NO_ELIGIBLE_CANDIDATE"); return; }
selectedTargetSnapshotId = candidates[0];
transitions.Add("ContextVerified");
before = Dbmod(); baseline = Counts();
Queue("PXP-OA");
}
catch (System.Exception ex) { Complete(false, "AUTOMATION_CONTEXT_FAILED:" + ex.GetType().Name); }
}
private void Queue(string command)
{
transitions.Add("CommandQueued:" + command);
document.SendStringToExecute(command + " ", true, false, false);
}
private void OnCommandEnded(object sender, CommandEventArgs e)
{
var name = e.GlobalCommandName?.Trim().TrimStart('.', '_').ToUpperInvariant();
if (name is not ("PXP-OA" or "UNDO" or "U")) return;
completed.Add(name);
transitions.Add("CommandCompleted:" + name);
if (step++ == 0) { after1 = Dbmod(); counts1 = Counts(); Queue("PXP-OA"); return; }
if (step == 2 && string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase)) { after2 = Dbmod(); counts2 = Counts(); Queue("_.UNDO _1"); return; }
if (string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase)) { afterUndo = Dbmod(); countsUndo = Counts(); Complete(true, null); }
else { after2 = Dbmod(); counts2 = Counts(); Complete(true, null); }
}
private void OnCommandFailed(object sender, CommandEventArgs e) => Complete(false, "AUTOMATION_COMMAND_FAILED:" + e.GlobalCommandName);
private void OnCommandCancelled(object sender, CommandEventArgs e) => Complete(false, "AUTOMATION_COMMAND_CANCELLED:" + e.GlobalCommandName);
private int Dbmod() => Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD"));
private AutomationEntityCounts Counts()
{
using var tx = document.Database.TransactionManager.StartOpenCloseTransaction();
var table = (BlockTable)tx.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
var paper = (BlockTableRecord)tx.GetObject(table[BlockTableRecord.PaperSpace], OpenMode.ForRead);
var total = 0; var pipes = 0; var xdata = 0; var owned = 0;
foreach (ObjectId id in paper)
{
total++;
if (tx.GetObject(id, OpenMode.ForRead) is BlockReference block && string.Equals(block.Name, "PIPE", StringComparison.OrdinalIgnoreCase)) pipes++;
if (tx.GetObject(id, OpenMode.ForRead) is Entity entity)
{
using var data = entity.GetXDataForApplication("PXP-OA");
if (data is not null) { xdata++; if (data.AsArray().Any(x => x.TypeCode == 1000)) owned++; }
}
}
return new(total, pipes, xdata, owned);
}
private FirstApplyAutomationConfiguration ReadConfiguration()
{
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_DISABLED");
if (!string.Equals(RuntimeDiagnostics.RuntimeMode, "DryRun", StringComparison.OrdinalIgnoreCase) && !string.Equals(RuntimeDiagnostics.RuntimeMode, "Apply", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_MODE_BLOCKED");
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION_CONFIG");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_CONFIGURATION_REQUIRED");
var loaded = JsonSerializer.Deserialize<FirstApplyAutomationConfiguration>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("AUTOMATION_CONFIGURATION_INVALID");
if (!File.Exists(loaded.FixtureMarkerPath) || !string.Equals(File.ReadAllText(loaded.FixtureMarkerPath).Trim(), "P_REC_ANNOT_TEST_FIXTURE=FIRST_LIMITED_APPLY", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_FIXTURE_MARKER_INVALID");
if (!string.Equals(loaded.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase) &&
(string.IsNullOrWhiteSpace(loaded.OrthoDrawingPath) || !Path.GetFullPath(loaded.OrthoDrawingPath).StartsWith(Path.GetDirectoryName(Path.GetFullPath(loaded.FixtureMarkerPath))! + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException("AUTOMATION_FIXTURE_OUTSIDE_MARKER_ROOT");
return loaded;
}
private void Complete(bool success, string? error)
{
try
{
if (document is not null) { document.CommandEnded -= OnCommandEnded; document.CommandFailed -= OnCommandFailed; document.CommandCancelled -= OnCommandCancelled; }
AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated;
WriteEvidence(success, error);
if (document is not null) document.CloseAndDiscard();
AcadApplication.Quit();
}
catch { }
finally { selectedTargetSnapshotId = null; pxpResults.Clear(); }
}
private void WriteEvidence(bool success, string? error)
{
if (config is null) return;
var first = pxpResults.ElementAtOrDefault(0); var second = pxpResults.ElementAtOrDefault(1);
var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = success, ErrorCode = error, DbmodBefore = before, DbmodAfterCommand1 = after1, DbmodAfterCommand2 = after2, DbmodAfterUndo = afterUndo, Baseline = baseline, AfterCommand1 = counts1, AfterCommand2 = counts2, AfterUndo = countsUndo, CompletedCommands = completed, 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 = selectedTargetSnapshotId, ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, PermitAccepted = first?.PermitAccepted, IdleCallbackCount = FirstLimitedApplyAutomationBootstrap.IdleCallbackCount, CoordinatorStartCount = FirstLimitedApplyAutomationBootstrap.CoordinatorStartCount, StateTransitions = transitions };
Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
File.WriteAllText(config.EvidencePath, JsonSerializer.Serialize(evidence));
}
}