refactor: observe commands driven by AutoCAD automation script

This commit is contained in:
codex
2026-08-04 10:06:57 +03:00
parent bc481d4378
commit c5cf142159
3 changed files with 146 additions and 14 deletions
@@ -10,24 +10,12 @@ namespace PRecAnnot.Plant3D2026;
public sealed class EntryPoint : IExtensionApplication public sealed class EntryPoint : IExtensionApplication
{ {
private static bool automationQueued;
public void Initialize() public void Initialize()
{ {
RuntimeDiagnostics.Write("AssemblyInitializeStarted"); RuntimeDiagnostics.Write("AssemblyInitializeStarted");
RibbonIntegration.Register(); RibbonIntegration.Register();
if (string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) && !automationQueued) // Test automation is driven only by the explicitly generated /b script.
{ // Initialize remains production-equivalent and must not orchestrate commands.
try
{
automationQueued = true;
FirstLimitedApplyAutomationBootstrap.Prepare();
RuntimeDiagnostics.Write("AutomationBootstrapPrepared", resultStatus: "Gated");
}
catch (System.Exception exception)
{
RuntimeDiagnostics.Write("AutomationBootstrapPreparationFailed", "ERROR", resultStatus: "AUTOMATION_BOOTSTRAP_FAILED", exception: exception);
}
}
RuntimeDiagnostics.Write("AssemblyInitializeCompleted"); RuntimeDiagnostics.Write("AssemblyInitializeCompleted");
} }
@@ -56,6 +44,12 @@ public sealed class EntryPoint : IExtensionApplication
[CommandMethod("PHP_REC_AUTOMATION_READY")] [CommandMethod("PHP_REC_AUTOMATION_READY")]
public void AutomationReadyCommand() { } public void AutomationReadyCommand() { }
[CommandMethod("PHP_REC_AUTOMATION_PREPARE", CommandFlags.Session)]
public void AutomationPrepareCommand() => ScriptDrivenFirstApplyAutomation.Prepare();
[CommandMethod("PHP_REC_AUTOMATION_FINALIZE", CommandFlags.Session)]
public void AutomationFinalizeCommand() => ScriptDrivenFirstApplyAutomation.FinalizePreview();
private static void RunDiagnostic(string commandName) private static void RunDiagnostic(string commandName)
{ {
var operationId = Guid.NewGuid().ToString("N"); var operationId = Guid.NewGuid().ToString("N");
@@ -171,6 +165,7 @@ public sealed class EntryPoint : IExtensionApplication
var candidates = objectsByViewport.SelectMany(x => x) var candidates = objectsByViewport.SelectMany(x => x)
.Where(x => string.Equals(x.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase) && x.MembershipStatus == ViewMembershipStatus.Resolved && x.Geometry.PaperPosition is not null) .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(); .Select(x => x.SnapshotId).OrderBy(x => x, StringComparer.Ordinal).ToArray();
ScriptDrivenFirstApplyAutomation.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null, summary.ObjectsExamined, membershipResolved);
FirstLimitedApplyAutomationCoordinator.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null); FirstLimitedApplyAutomationCoordinator.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null);
} }
if (mode == PRecAnnotExecutionMode.Apply) if (mode == PRecAnnotExecutionMode.Apply)
@@ -83,6 +83,8 @@ internal sealed record FirstApplyAutomationEvidence
public int SchemaVersion { get; init; } = 1; public int SchemaVersion { get; init; } = 1;
public required string Phase { get; init; } public required string Phase { get; init; }
public bool Completed { get; init; } public bool Completed { get; init; }
public bool? Prepared { get; init; }
public bool? FunctionalEvidenceCompleted { get; init; }
public string? ErrorCode { get; init; } public string? ErrorCode { get; init; }
public int? DbmodBefore { get; init; } public int? DbmodBefore { get; init; }
public int? DbmodAfterCommand1 { get; init; } public int? DbmodAfterCommand1 { get; init; }
@@ -0,0 +1,135 @@
using System.Text.Json;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Runtime;
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);
private sealed record Run(int Sequence, string? PlanId, IReadOnlyList<string> 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;
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 bool prepared;
private static bool terminal;
internal static void Prepare()
{
try
{
if (terminal) return;
config = ReadConfig();
RequireGate(config);
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 context = new PlantRuntimeContextReader().ReadCurrentContext(fixture);
if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1)
throw new InvalidOperationException("AUTOMATION_ORTHO_CONTEXT_INVALID");
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 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, SourceRows = sourceRows, MembershipResolved = membershipResolved };
}
internal static void FinalizePreview()
{
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)) || !string.Equals(runs[0].PlanId, runs[1].PlanId, StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_PLAN_IDS_DIFFER");
if (runs.Any(x => x.Create != 1)) throw new InvalidOperationException("AUTOMATION_PREVIEW_CREATE_NOT_ONE");
if (runs[0].Candidates.Count == 0) throw new InvalidOperationException("AUTOMATION_NO_ELIGIBLE_CANDIDATE");
if (Dbmod() != dbmodBaseline || !Equals(Counts(database!), baseline)) throw new InvalidOperationException("AUTOMATION_INSPECTOR_INVARIANCE_FAILED");
terminal = true;
transitions.Add("PreviewCompleted");
Write(true, null);
Unsubscribe();
}
catch (System.Exception exception) { Fail("PREVIEW_ACCEPTANCE_FAILED", exception); }
}
private static void OnWillStart(object sender, CommandEventArgs args)
{
Guard(() =>
{
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);
});
}
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(() =>
{
if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count == 0) 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 bool IsFixture(object sender) => !terminal && fixture is not null && ReferenceEquals(sender, fixture) && database is not null && ReferenceEquals(fixture.Database, database);
private static bool IsPxp(string name) => string.Equals(name.TrimStart('.', '_'), "PXP-OA", StringComparison.OrdinalIgnoreCase);
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 null) return; try { fixture.CommandWillStart -= OnWillStart; fixture.CommandEnded -= OnEnded; fixture.CommandFailed -= OnFailed; fixture.CommandCancelled -= OnCancelled; } catch { } }
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"));
}
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 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 };
Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
var temp = config.EvidencePath + ".tmp"; File.WriteAllText(temp, JsonSerializer.Serialize(evidence)); File.Move(temp, config.EvidencePath, true);
}
}