496 lines
28 KiB
C#
496 lines
28 KiB
C#
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;
|
|
RuntimeDiagnostics.Write("AutomationBootstrapSubscribed", resultStatus: "Prepared");
|
|
}
|
|
|
|
// Public test command is intentionally side-effect free with respect to Plant APIs.
|
|
internal static void Arm()
|
|
{
|
|
if (state is BootstrapState.Prepared)
|
|
{
|
|
// The command itself is the post-NETLOAD barrier. The next Idle
|
|
// callback, not this command callback, is allowed to start work.
|
|
observedIdle = true;
|
|
state = BootstrapState.Armed;
|
|
}
|
|
}
|
|
|
|
private static void OnIdle(object? sender, EventArgs args)
|
|
{
|
|
try
|
|
{
|
|
idleCallbackCount++;
|
|
RuntimeDiagnostics.Write("AutomationBootstrapIdle", resultStatus: state.ToString());
|
|
// 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++;
|
|
RuntimeDiagnostics.Write("AutomationCoordinatorStarting", resultStatus: "FirstPostStartupIdle");
|
|
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 bool? Prepared { get; init; }
|
|
public bool? FunctionalEvidenceCompleted { 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 string? ApplyOutcomeRun1 { get; init; }
|
|
public string? ApplyOutcomeRun2 { get; init; }
|
|
public bool? PermitAccepted { get; init; }
|
|
public bool? CommandsCompleted { get; init; }
|
|
public bool? InspectorsCompleted { get; init; }
|
|
public bool? EvidenceFlushed { get; init; }
|
|
public bool? ExitAttemptQueued { get; init; }
|
|
public bool? QuitCommandWillStart { get; init; }
|
|
public bool? QuitCommandEnded { get; init; }
|
|
public string? ExitFailureCode { get; init; }
|
|
public int? IdleCallbackCount { get; init; }
|
|
public int? CoordinatorStartCount { get; init; }
|
|
// Local test evidence only. This is intentionally never copied into Git
|
|
// evidence because exception stacks may contain machine-specific paths.
|
|
public string? ExceptionText { get; init; }
|
|
public string? PreviousState { get; init; }
|
|
public string? CurrentState { get; init; }
|
|
public IReadOnlyList<string> StateTransitions { get; init; } = Array.Empty<string>();
|
|
}
|
|
|
|
internal sealed class FirstLimitedApplyAutomationCoordinator
|
|
{
|
|
private enum CoordinatorState { Created, Starting, Running, Completed, Failed, ExitQueued }
|
|
private sealed record PxpResult(string? PlanId, IReadOnlyList<string> Candidates, string? View, int Create, int Skip, bool? PermitAccepted, string? ApplyOutcome);
|
|
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, null));
|
|
}
|
|
internal static void RecordApplyOutcome(PipeAnnotationWriteOutcome outcome)
|
|
{
|
|
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || pxpResults.Count == 0) return;
|
|
var last = pxpResults[^1];
|
|
pxpResults[^1] = last with { ApplyOutcome = outcome.ToString() };
|
|
}
|
|
private FirstApplyAutomationConfiguration? config;
|
|
private Document? document;
|
|
private readonly List<Document> automationOpenedDocuments = [];
|
|
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"];
|
|
private CoordinatorState state = CoordinatorState.Created;
|
|
private CoordinatorState previousState = CoordinatorState.Created;
|
|
private bool documentHandlersSubscribed;
|
|
private bool lifecycleHandlersSubscribed;
|
|
private bool fixtureDocumentActivated;
|
|
private bool projectActivationStarted;
|
|
private bool exitQueued;
|
|
private bool exitHandlerSubscribed;
|
|
private string? exceptionText;
|
|
private bool functionalCompleted;
|
|
private bool evidenceFlushed;
|
|
private bool exitAttemptQueued;
|
|
private string? exitFailureCode;
|
|
|
|
public void Start()
|
|
{
|
|
try
|
|
{
|
|
Transition(CoordinatorState.Starting, "PostStartupReady");
|
|
config = ReadConfiguration();
|
|
if (string.Equals(config.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
CompleteLifecycleProbe();
|
|
return;
|
|
}
|
|
if (string.IsNullOrWhiteSpace(config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED");
|
|
// A first startup Idle is sufficient to arm automation, but not to
|
|
// call Plant APIs. Establish an AutoCAD document first and wait for
|
|
// its activation plus a quiescent callback before project activation.
|
|
AcadApplication.DocumentManager.DocumentActivated += OnDocumentActivated;
|
|
AcadApplication.Idle += OnIdle;
|
|
lifecycleHandlersSubscribed = true;
|
|
if (!string.IsNullOrWhiteSpace(config.ModelDrawingPath)) automationOpenedDocuments.Add(AcadApplication.DocumentManager.Open(config.ModelDrawingPath, false));
|
|
transitions.Add("DocumentOpenStarted");
|
|
RuntimeDiagnostics.Write("AutomationBeforeDocumentOpen", resultStatus: "PostStartupReady");
|
|
document = AcadApplication.DocumentManager.Open(config.OrthoDrawingPath, false);
|
|
automationOpenedDocuments.Add(document);
|
|
AcadApplication.DocumentManager.MdiActiveDocument = document;
|
|
transitions.Add("DocumentOpened");
|
|
RuntimeDiagnostics.Write("AutomationAfterDocumentOpen", resultStatus: "WaitingForDocumentActivation", document: document);
|
|
document.CommandEnded += OnCommandEnded;
|
|
document.CommandFailed += OnCommandFailed;
|
|
document.CommandCancelled += OnCommandCancelled;
|
|
documentHandlersSubscribed = true;
|
|
Transition(CoordinatorState.Running, "AutomationRunning");
|
|
// DocumentActivated can be raised synchronously by Open before the
|
|
// coordinator receives the returned Document. Confirm the current
|
|
// active document after assignment so that one missed event cannot
|
|
// strand the workflow waiting for another startup Idle.
|
|
// Open was requested for this exact local fixture and it has just
|
|
// been made MDI-active. Queue a no-op public command regardless of
|
|
// wrapper identity/name normalisation; CommandEnded is the actual
|
|
// readiness proof before any Plant API is used.
|
|
fixtureDocumentActivated = true;
|
|
transitions.Add("FixtureDocumentActiveAfterOpen");
|
|
QueueReadinessBarrier();
|
|
}
|
|
catch (System.Exception ex) { FailFromCallback("Start", ex, "AUTOMATION_START_FAILED"); }
|
|
}
|
|
|
|
private void CompleteLifecycleProbe()
|
|
{
|
|
transitions.Add("LifecycleProbeCompleted");
|
|
WriteEvidence(true, null);
|
|
// LifecycleProbe is already running from the first verified Idle callback.
|
|
// Do not wait for a second Idle: a startup-script host is permitted not to
|
|
// raise one, which otherwise leaves acad.exe responsive but stranded.
|
|
RequestControlledExit(idleSafe: true);
|
|
}
|
|
|
|
private void OnIdle(object? sender, EventArgs e) => RunCallback("Idle", TryActivateProjectAndVerifyContext);
|
|
private void OnDocumentActivated(object sender, DocumentCollectionEventArgs e)
|
|
{
|
|
RunCallback("DocumentActivated", () =>
|
|
{
|
|
if (document is null || e.Document is null) return;
|
|
if (!string.Equals(e.Document.Name, document.Name, StringComparison.OrdinalIgnoreCase)) return;
|
|
fixtureDocumentActivated = true;
|
|
transitions.Add("FixtureDocumentActivated");
|
|
TryActivateProjectAndVerifyContext();
|
|
});
|
|
}
|
|
private void TryActivateProjectAndVerifyContext()
|
|
{
|
|
if (IsTerminal || document is null || !fixtureDocumentActivated || projectActivationStarted) return;
|
|
if (!AcadApplication.IsQuiescent) return;
|
|
projectActivationStarted = true;
|
|
AcadApplication.Idle -= OnIdle;
|
|
AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated;
|
|
lifecycleHandlersSubscribed = false;
|
|
try
|
|
{
|
|
transitions.Add("ProjectActivationStarted");
|
|
RuntimeDiagnostics.Write("AutomationBeforeProjectActivation", resultStatus: "DocumentActivatedAndQuiescent", document: document);
|
|
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");
|
|
RuntimeDiagnostics.Write("AutomationAfterProjectActivation", resultStatus: "ProjectAvailable", document: document);
|
|
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) { FailFromCallback("ContextVerification", ex, "AUTOMATION_CONTEXT_FAILED"); }
|
|
}
|
|
|
|
private void Queue(string command) => _ = DispatchCommandAsync(command, false);
|
|
private void QueueReadinessBarrier()
|
|
{
|
|
_ = DispatchCommandAsync("PHP_REC_AUTOMATION_READY", true);
|
|
}
|
|
private async Task DispatchCommandAsync(string command, bool readiness)
|
|
{
|
|
if (IsTerminal || document is null) return;
|
|
var expected = document;
|
|
transitions.Add(readiness ? "ReadinessCommandDispatchRequested" : "CommandDispatchRequested:" + command);
|
|
try
|
|
{
|
|
await AcadApplication.DocumentManager.ExecuteInCommandContextAsync(async _ =>
|
|
{
|
|
transitions.Add("CommandContextEntered:" + command);
|
|
var active = AcadApplication.DocumentManager.MdiActiveDocument;
|
|
if (active is null || !string.Equals(active.Name, expected.Name, StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("AUTOMATION_COMMAND_DOCUMENT_NOT_ACTIVE");
|
|
transitions.Add(readiness ? "ReadinessBarrierQueued" : "CommandQueued:" + command);
|
|
await active.Editor.CommandAsync(command);
|
|
transitions.Add("CommandInvocationReturned:" + command);
|
|
}, null);
|
|
}
|
|
catch (System.Exception exception) { FailFromCallback("CommandDispatcher", exception, "AUTOMATION_COMMAND_CONTEXT_FAILED"); }
|
|
}
|
|
private void OnCommandEnded(object sender, CommandEventArgs e)
|
|
{
|
|
RunCallback("CommandEnded", () =>
|
|
{
|
|
var name = e.GlobalCommandName?.Trim().TrimStart('.', '_').ToUpperInvariant();
|
|
if (name == "PHP_REC_AUTOMATION_READY")
|
|
{
|
|
transitions.Add("ReadinessBarrierCompleted");
|
|
TryActivateProjectAndVerifyContext();
|
|
return;
|
|
}
|
|
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, safeToExitNow: true); }
|
|
else { after2 = Dbmod(); counts2 = Counts(); Complete(true, null, safeToExitNow: true); }
|
|
});
|
|
}
|
|
private void OnCommandFailed(object sender, CommandEventArgs e) => RunCallback("CommandFailed", () => Complete(false, "AUTOMATION_COMMAND_FAILED:" + e.GlobalCommandName));
|
|
private void OnCommandCancelled(object sender, CommandEventArgs e) => RunCallback("CommandCancelled", () => Complete(false, "AUTOMATION_COMMAND_CANCELLED:" + e.GlobalCommandName));
|
|
|
|
private int Dbmod() => Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD"));
|
|
private AutomationEntityCounts Counts()
|
|
{
|
|
if (document is null) throw new InvalidOperationException("AUTOMATION_DOCUMENT_UNAVAILABLE");
|
|
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, bool safeToExitNow = false)
|
|
{
|
|
if (IsTerminal) return;
|
|
functionalCompleted = success;
|
|
Transition(success ? CoordinatorState.Completed : CoordinatorState.Failed, success ? "Completed" : "Failed");
|
|
UnsubscribeHandlers();
|
|
TryWriteEvidence(success, error);
|
|
selectedTargetSnapshotId = null;
|
|
pxpResults.Clear();
|
|
RequestControlledExit(safeToExitNow);
|
|
}
|
|
|
|
// The managed Application.Quit call can leave a startup-script host alive
|
|
// when it is invoked from a CommandEnded callback. Queue AutoCAD's native
|
|
// QUIT command instead; the command processor runs it after the current
|
|
// public PXP-OA command has fully returned and discards every open drawing.
|
|
private void RequestControlledExit(bool idleSafe = false)
|
|
{
|
|
if (exitQueued) return;
|
|
exitQueued = true;
|
|
Transition(CoordinatorState.ExitQueued, "ExitQueued");
|
|
if (idleSafe)
|
|
{
|
|
DispatchNativeQuit();
|
|
return;
|
|
}
|
|
AcadApplication.Idle += OnExitIdle;
|
|
exitHandlerSubscribed = true;
|
|
}
|
|
|
|
private void OnExitIdle(object? sender, EventArgs args)
|
|
{
|
|
RunCallback("ControlledExitIdle", DispatchNativeQuit);
|
|
}
|
|
|
|
private void DispatchNativeQuit()
|
|
{
|
|
try
|
|
{
|
|
if (exitHandlerSubscribed) AcadApplication.Idle -= OnExitIdle;
|
|
exitHandlerSubscribed = false;
|
|
exitAttemptQueued = true;
|
|
if (!AcadApplication.IsQuiescent)
|
|
{
|
|
transitions.Add("QuitDispatchDeferredNotQuiescent");
|
|
TryWriteEvidence(functionalCompleted, null);
|
|
return;
|
|
}
|
|
// LifecycleProbe opens no fixture document, but the real startup
|
|
// document is available at this point (observed as Drawing1.dwg).
|
|
// Use that live document only to queue native QUIT; never retain it
|
|
// past this callback or use it for fixture/Plant work.
|
|
var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;
|
|
if (exitDocument is null)
|
|
{
|
|
exitFailureCode = "AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE";
|
|
transitions.Add("QuitDispatchSkippedNoLiveDocument");
|
|
TryWriteEvidence(functionalCompleted, null);
|
|
return;
|
|
}
|
|
transitions.Add("QuitDispatchAttempted");
|
|
// Persist the exit boundary before handing control back to AutoCAD;
|
|
// process termination can otherwise prevent the final evidence flush.
|
|
TryWriteEvidence(functionalCompleted, null);
|
|
exitDocument.SendStringToExecute("_.QUIT _N\n", true, true, false);
|
|
document = null;
|
|
automationOpenedDocuments.Clear();
|
|
}
|
|
catch (System.Exception exception)
|
|
{
|
|
// This is terminal cleanup: contain the exception and leave process
|
|
// cleanup to Launcher rather than re-entering document APIs.
|
|
// A failed graceful-exit dispatch is not a failure of already
|
|
// completed PXP-OA/UNDO work. Launcher owns bounded forced cleanup.
|
|
exitFailureCode = "AUTOMATION_CONTROLLED_EXIT_FAILED:" + exception.GetType().Name;
|
|
transitions.Add("QuitDispatchFailed");
|
|
TryWriteEvidence(functionalCompleted, null);
|
|
}
|
|
}
|
|
|
|
private bool IsTerminal => state is CoordinatorState.Completed or CoordinatorState.Failed or CoordinatorState.ExitQueued;
|
|
|
|
private void Transition(CoordinatorState next, string transition)
|
|
{
|
|
previousState = state;
|
|
state = next;
|
|
transitions.Add(transition);
|
|
}
|
|
|
|
private void RunCallback(string callback, Action action)
|
|
{
|
|
if (IsTerminal && callback != "ControlledExitIdle") return;
|
|
try { action(); }
|
|
catch (System.Exception exception) { FailFromCallback(callback, exception, "AUTOMATION_CALLBACK_FAILED"); }
|
|
}
|
|
|
|
private void FailFromCallback(string callback, System.Exception exception, string code)
|
|
{
|
|
exceptionText = exception.ToString();
|
|
RuntimeDiagnostics.Write("AutomationCallbackFailed", "ERROR", resultStatus: code + ":" + callback, exception: exception);
|
|
if (!IsTerminal) Complete(false, code + ":" + callback + ":" + exception.GetType().Name);
|
|
else
|
|
{
|
|
UnsubscribeHandlers();
|
|
if (functionalCompleted)
|
|
{
|
|
exitFailureCode = code + ":" + callback + ":" + exception.GetType().Name;
|
|
transitions.Add("ExitCallbackFailed");
|
|
TryWriteEvidence(true, null);
|
|
}
|
|
else TryWriteEvidence(false, code + ":" + callback + ":" + exception.GetType().Name);
|
|
}
|
|
}
|
|
|
|
private void UnsubscribeHandlers()
|
|
{
|
|
if (documentHandlersSubscribed && document is not null)
|
|
{
|
|
try { document.CommandEnded -= OnCommandEnded; document.CommandFailed -= OnCommandFailed; document.CommandCancelled -= OnCommandCancelled; }
|
|
catch (System.Exception exception) { RuntimeDiagnostics.Write("AutomationHandlerUnsubscribeFailed", "ERROR", resultStatus: "AUTOMATION_UNSUBSCRIBE_FAILED", exception: exception); }
|
|
}
|
|
documentHandlersSubscribed = false;
|
|
if (lifecycleHandlersSubscribed)
|
|
{
|
|
try { AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated; AcadApplication.Idle -= OnIdle; }
|
|
catch (System.Exception exception) { RuntimeDiagnostics.Write("AutomationHandlerUnsubscribeFailed", "ERROR", resultStatus: "AUTOMATION_UNSUBSCRIBE_FAILED", exception: exception); }
|
|
}
|
|
lifecycleHandlersSubscribed = false;
|
|
}
|
|
|
|
private bool TryWriteEvidence(bool success, string? error)
|
|
{
|
|
try { evidenceFlushed = true; WriteEvidence(success, error); return true; }
|
|
catch (System.Exception exception) { evidenceFlushed = false; RuntimeDiagnostics.Write("AutomationEvidenceWriteFailed", "ERROR", resultStatus: "AUTOMATION_EVIDENCE_WRITE_FAILED", exception: exception); return false; }
|
|
}
|
|
|
|
private void WriteEvidence(bool success, string? error)
|
|
{
|
|
if (config is null) return;
|
|
var first = pxpResults.ElementAtOrDefault(0); var second = pxpResults.ElementAtOrDefault(1);
|
|
var expectedCommands = string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase) ? 3 : 2;
|
|
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, ApplyOutcomeRun1 = first?.ApplyOutcome, ApplyOutcomeRun2 = second?.ApplyOutcome, PermitAccepted = first?.PermitAccepted, CommandsCompleted = completed.Count >= expectedCommands, InspectorsCompleted = baseline is not null && counts1 is not null && counts2 is not null && (!string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase) || countsUndo is not null), EvidenceFlushed = evidenceFlushed, ExitAttemptQueued = exitAttemptQueued, ExitFailureCode = exitFailureCode, IdleCallbackCount = FirstLimitedApplyAutomationBootstrap.IdleCallbackCount, CoordinatorStartCount = FirstLimitedApplyAutomationBootstrap.CoordinatorStartCount, ExceptionText = exceptionText, PreviousState = previousState.ToString(), CurrentState = state.ToString(), StateTransitions = transitions };
|
|
Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
|
|
File.WriteAllText(config.EvidencePath, JsonSerializer.Serialize(evidence));
|
|
}
|
|
}
|