Compare commits

..
Author SHA1 Message Date
codex bfd31a3747 fix: activate fixture project in script preparation 2026-08-04 10:10:49 +03:00
codex c5cf142159 refactor: observe commands driven by AutoCAD automation script 2026-08-04 10:06:57 +03:00
codex bc481d4378 fix: execute automation commands in AutoCAD command context 2026-08-04 09:37:24 +03:00
codex 1fa25809de fix: queue readiness command for active fixture document 2026-08-04 09:26:43 +03:00
codex 6ed1e4086b fix: activate fixture command queue before dispatch 2026-08-04 07:39:21 +03:00
codex c633107273 fix: terminate automation commands through AutoCAD queue 2026-08-04 07:37:56 +03:00
codex 2d9fbdd474 fix: queue readiness barrier after fixture activation 2026-08-04 07:29:37 +03:00
codex 98564b807f fix: wait for AutoCAD command-queue readiness barrier 2026-08-04 07:28:32 +03:00
codex 1b4ff816ba fix: match active fixture document by stable name 2026-08-04 07:26:45 +03:00
codex 13578b7c9b fix: recover fixture activation raised during document open 2026-08-04 07:25:53 +03:00
codex 523d063ceb fix: preserve completed automation evidence when quit is unavailable 2026-08-04 07:22:27 +03:00
codex 31171c9ed8 test: correct lifecycle exit assertions 2026-08-03 17:24:13 +03:00
codex edf1640c4f fix: dispatch lifecycle exit through live document 2026-08-03 17:23:44 +03:00
codex 81a075e684 fix: exit lifecycle probe without document command queue 2026-08-03 17:21:08 +03:00
codex 32607e2f34 fix: defer Plant activation until document is quiescent 2026-08-03 17:19:01 +03:00
codex c9c4113e86 fix: queue native quit after automation commands 2026-08-03 17:14:14 +03:00
codex 33f91c26aa fix: close all automation-opened documents 2026-08-03 17:10:02 +03:00
codex ef615a1f8e fix: dispatch automation exit from final command 2026-08-03 17:07:40 +03:00
codex e7765cbdda fix: close live document before automation exit 2026-08-03 17:04:56 +03:00
codex d8e61b7a5e fix: exit lifecycle probe from verified idle 2026-08-03 17:02:18 +03:00
codex 79067b72de fix: contain automation callback failures 2026-08-03 16:58:18 +03:00
codex 44de0cf787 fix: dispatch controlled AutoCAD exit from idle 2026-08-03 16:49:57 +03:00
codex 239796aafe fix: arm automation on post-netload command 2026-08-03 16:43:32 +03:00
codex 8a1735408a fix: trace gated automation bootstrap 2026-08-03 16:41:15 +03:00
codex 544b329077 fix: defer Plant automation to first post-startup idle 2026-08-03 16:34:17 +03:00
codex 1563e2fae0 feat: expose limited Apply preview candidates 2026-08-03 11:00:13 +03:00
codex bd5f47d811 feat: implement first limited Pipe annotation Apply 2026-08-03 10:50:51 +03:00
codex 1bfb91bc4b feat: add first limited Pipe Apply preflight 2026-08-03 10:37:37 +03:00
9 changed files with 1263 additions and 13 deletions
@@ -177,12 +177,40 @@ public sealed record AnnotationPlanningRequest
public required OrthoViewportInfo Viewport { get; init; }
public IReadOnlyList<PlantObjectSnapshot> Objects { get; init; } = Array.Empty<PlantObjectSnapshot>();
public IReadOnlyList<ExistingAnnotation> Existing { get; init; } = Array.Empty<ExistingAnnotation>();
// Test-only limited Apply intent. It is part of the canonical plan and is
// deliberately explicit: the planner must never choose an arbitrary Pipe.
public string? LimitedApplyTargetSnapshotId { get; init; }
public string? LimitedApplyRuleId { get; init; }
}
public interface IAnnotationPlanBuilder { AnnotationPlan Build(AnnotationPlanningRequest request); }
public enum PRecAnnotExecutionMode { Smoke, DryRun, Apply }
public interface IRuntimeWriteGuard { void EnsureWriteAllowed(string operation); }
public sealed record CreatePipeAnnotationOperation
{
public required string OperationId { get; init; }
public required string SourceObjectStableKey { get; init; }
public required string ViewStableKey { get; init; }
public required string RuleId { get; init; }
public required AnnotationPoint Placement { get; init; }
public required string DuplicateKey { get; init; }
}
public sealed record FirstLimitedApplyPermit
{
public required string FixtureMarker { get; init; }
public required bool AllowApply { get; init; }
public required string TargetSnapshotId { get; init; }
public required string ExpectedPlanId { get; init; }
}
public sealed record FirstLimitedApplyPreflight
{
public required string PlanId { get; init; }
public required CreatePipeAnnotationOperation Operation { get; init; }
}
public sealed record OrthoViewportRef(string DrawingId, string ViewportId);
public sealed record PlantObjectData
@@ -6,7 +6,7 @@ namespace PRecAnnot.Core;
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
{
public const int CurrentPlanSchemaVersion = 1;
public const int CurrentPlanSchemaVersion = 2;
public AnnotationPlan Build(AnnotationPlanningRequest request)
{
var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
@@ -31,12 +31,65 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
continue;
}
if (!string.IsNullOrWhiteSpace(request.LimitedApplyTargetSnapshotId) &&
!string.Equals(item.SnapshotId, request.LimitedApplyTargetSnapshotId, StringComparison.Ordinal))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "APPLY_NOT_SELECTED_TARGET" } });
continue;
}
if (item.MembershipStatus is not ViewMembershipStatus.Resolved and not ViewMembershipStatus.PartiallyResolved)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { MembershipReason(item.MembershipStatus) } });
continue;
}
// The first Apply slice is intentionally narrower than the
// historic bulk rules: exactly one explicitly selected, resolved
// Pipe can become a PIPE block operation. All other objects keep
// their normal diagnostic treatment.
if (!string.IsNullOrWhiteSpace(request.LimitedApplyTargetSnapshotId) &&
string.Equals(item.SnapshotId, request.LimitedApplyTargetSnapshotId, StringComparison.Ordinal))
{
if (!string.Equals(item.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase))
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Unsupported, Reasons = new[] { "APPLY_UNSUPPORTED_OBJECT_CLASS" } });
continue;
}
if (item.MembershipStatus != ViewMembershipStatus.Resolved)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "APPLY_REQUIRES_RESOLVED_MEMBERSHIP" } });
continue;
}
if (item.Geometry.PaperPosition is not { } pipePosition)
{
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "ANNOTATION_PLACEMENT_NOT_RESOLVED" } });
continue;
}
var pipeIntent = new AnnotationIntent
{
StableKey = $"{request.Viewport.Id}:{item.SnapshotId}:{FirstLimitedApplyPlanner.RuleId}",
Text = FirstLimitedApplyPlanner.RuleId,
Position = new AnnotationPoint(pipePosition.X, pipePosition.Y),
Rotation = item.Geometry.Rotation ?? 0,
ClassName = item.ObjectClass,
Layer = "PXP_ANNOTATION"
};
operations.Add(new AnnotationOperationPlan
{
SourceSnapshotId = item.SnapshotId,
Kind = AnnotationOperationKind.Create,
AnnotationType = "PIPE",
Text = FirstLimitedApplyPlanner.RuleId,
Position = pipePosition,
Rotation = item.Geometry.Rotation,
Reasons = Array.Empty<string>()
});
creates.Add(pipeIntent);
continue;
}
var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim();
if (string.IsNullOrWhiteSpace(text))
{
@@ -111,7 +164,9 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion)
.Append("|command=").Append(Normalize(request.CommandName))
.Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName)))
.Append("|view=").Append(Normalize(request.Viewport.Id));
.Append("|view=").Append(Normalize(request.Viewport.Id))
.Append("|limited-target=").Append(Normalize(request.LimitedApplyTargetSnapshotId))
.Append("|limited-rule=").Append(Normalize(request.LimitedApplyRuleId));
foreach (var item in objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal))
{
canonical.Append("|object=").Append(Normalize(item.SnapshotId)).Append('|').Append(Normalize(item.ObjectClass))
@@ -0,0 +1,52 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class FirstLimitedApplyPlanner
{
public const string FixtureMarker = "FIRST_LIMITED_APPLY";
public const string RuleId = "PXP-OA.PIPE.OPEN-END.V1";
public FirstLimitedApplyPreflight Prepare(
PRecAnnotExecutionMode mode,
AnnotationPlan plan,
OrthoViewportInfo viewport,
IReadOnlyList<PlantObjectSnapshot> objects,
FirstLimitedApplyPermit permit)
{
if (mode != PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_REQUIRED");
if (!permit.AllowApply || !string.Equals(permit.FixtureMarker, FixtureMarker, StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
if (!string.Equals(permit.ExpectedPlanId, plan.PlanId, StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_PLAN_STALE");
var targets = objects.Where(x => string.Equals(x.SnapshotId, permit.TargetSnapshotId, StringComparison.Ordinal)).ToArray();
if (targets.Length != 1) throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_TARGET");
var target = targets[0];
if (target.MembershipStatus != ViewMembershipStatus.Resolved) throw new InvalidOperationException("APPLY_REQUIRES_RESOLVED_MEMBERSHIP");
if (!string.Equals(target.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("APPLY_UNSUPPORTED_OBJECT_CLASS");
if (target.Geometry.PaperPosition is not { } placement) throw new InvalidOperationException("ANNOTATION_PLACEMENT_NOT_RESOLVED");
var create = plan.Operations.Where(x => x.Kind == AnnotationOperationKind.Create && string.Equals(x.SourceSnapshotId, target.SnapshotId, StringComparison.Ordinal)).ToArray();
if (create.Length != 1) throw new InvalidOperationException("APPLY_TARGET_CREATE_NOT_PLANNED");
var duplicateKey = Stable("duplicate", RuleId, viewport.Id, target.SnapshotId);
return new FirstLimitedApplyPreflight
{
PlanId = plan.PlanId,
Operation = new CreatePipeAnnotationOperation
{
OperationId = Stable("operation", RuleId, viewport.Id, target.SnapshotId),
SourceObjectStableKey = target.SnapshotId,
ViewStableKey = viewport.Id,
RuleId = RuleId,
Placement = new AnnotationPoint(placement.X, placement.Y),
DuplicateKey = duplicateKey
}
};
}
private static string Stable(params string[] values) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", values))))[..24];
}
@@ -14,6 +14,8 @@ public sealed class EntryPoint : IExtensionApplication
{
RuntimeDiagnostics.Write("AssemblyInitializeStarted");
RibbonIntegration.Register();
// Test automation is driven only by the explicitly generated /b script.
// Initialize remains production-equivalent and must not orchestrate commands.
RuntimeDiagnostics.Write("AssemblyInitializeCompleted");
}
@@ -33,6 +35,21 @@ public sealed class EntryPoint : IExtensionApplication
[CommandMethod("PXP-OA")]
public void OrthoAnnotateCommand() => RunDiagnostic("PXP-OA");
[CommandMethod("PHP_REC_AUTOMATION_START", CommandFlags.Session)]
public void AutomationStartCommand() => FirstLimitedApplyAutomationBootstrap.Arm();
// Test-only command-queue barrier. It deliberately performs no Plant or
// drawing work; its CommandEnded event proves the activated document is
// ready before the coordinator calls the Plant APIs.
[CommandMethod("PHP_REC_AUTOMATION_READY")]
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)
{
var operationId = Guid.NewGuid().ToString("N");
@@ -43,7 +60,6 @@ public sealed class EntryPoint : IExtensionApplication
{
document = AcadApplication.DocumentManager.MdiActiveDocument;
var mode = ParseMode(RuntimeDiagnostics.RuntimeMode);
if (mode == PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_NOT_IMPLEMENTED");
var context = new PlantRuntimeContextReader().ReadCurrentContext();
var details = new Dictionary<string, object?>
@@ -75,6 +91,17 @@ public sealed class EntryPoint : IExtensionApplication
return;
}
if (mode == PRecAnnotExecutionMode.Apply && !string.Equals(commandName, "PXP-OA", StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_RULE_NOT_SUPPORTED");
var hasLimitedApplyPermit = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("P_REC_ANNOT_APPLY_PERMIT_PATH"));
var applyPermit = (mode == PRecAnnotExecutionMode.Apply || hasLimitedApplyPermit) && document is not null
? FirstLimitedApplyPermitReader.ReadFor(document)
: null;
var automationTarget = FirstLimitedApplyAutomationCoordinator.SelectedTargetSnapshotId;
var limitedTarget = applyPermit?.TargetSnapshotId ?? automationTarget;
if (mode == PRecAnnotExecutionMode.Apply && context.Viewports.Count != 1)
throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_VIEWPORT");
var builder = new AnnotationPlanBuilder();
var plans = new List<AnnotationPlan>();
var objectsByViewport = new List<IReadOnlyList<PlantObjectSnapshot>>();
@@ -82,7 +109,26 @@ public sealed class EntryPoint : IExtensionApplication
{
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None);
objectsByViewport.Add(objects);
plans.Add(builder.Build(new AnnotationPlanningRequest { CommandName = commandName, Viewport = viewport, Objects = objects }));
plans.Add(builder.Build(new AnnotationPlanningRequest
{
CommandName = commandName,
Viewport = viewport,
Objects = objects,
LimitedApplyTargetSnapshotId = limitedTarget,
LimitedApplyRuleId = limitedTarget is null ? null : FirstLimitedApplyPlanner.RuleId
}));
}
if (mode == PRecAnnotExecutionMode.DryRun &&
string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_DIAGNOSTICS"), "FirstLimitedApply", StringComparison.Ordinal))
{
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)
.Select(x => x.SnapshotId)
.OrderBy(x => x, StringComparer.Ordinal)
.ToArray();
WriteEditor(document, $"\nP_Rec_Annot Apply preview candidates: {string.Join(",", candidates)}");
details["ApplyPreviewCandidateCount"] = candidates.Length;
}
var summary = new
@@ -114,11 +160,41 @@ public sealed class EntryPoint : IExtensionApplication
details["PlanConflictCount"] = summary.ConflictCount;
details["PlanUnsupportedCount"] = summary.UnsupportedCount;
details["ErrorCount"] = summary.ErrorCount;
if (string.Equals(commandName, "PXP-OA", StringComparison.OrdinalIgnoreCase))
{
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)
.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);
}
if (mode == PRecAnnotExecutionMode.Apply)
{
var corePermit = new FirstLimitedApplyPermit
{
FixtureMarker = FirstLimitedApplyPlanner.FixtureMarker,
AllowApply = applyPermit!.AllowApply,
TargetSnapshotId = applyPermit.TargetSnapshotId,
ExpectedPlanId = applyPermit.ExpectedPlanId
};
var preflight = new FirstLimitedApplyPlanner().Prepare(mode, plans.Single(), context.Viewports.Single(), objectsByViewport.Single(), corePermit);
new RuntimeWriteGuard(mode).EnsureWriteAllowed(preflight.Operation.OperationId);
RuntimeDiagnostics.Write("ApplyPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "Accepted", document: document);
RuntimeDiagnostics.Write("AnnotationTransactionStarted", operationId: operationId, commandName: commandName, document: document);
var outcome = new FirstLimitedPipeAnnotationWriter().Write(document!, preflight.Operation, applyPermit.VisibilityState);
FirstLimitedApplyAutomationCoordinator.RecordApplyOutcome(outcome);
details["ApplyOutcome"] = outcome.ToString();
details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0;
details["AnnotationsSkipped"] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? 1 : 0;
RuntimeDiagnostics.Write(outcome == PipeAnnotationWriteOutcome.Created ? "AnnotationCreated" : "AnnotationDuplicateDetected", operationId: operationId, commandName: commandName, resultStatus: outcome.ToString(), document: document);
RuntimeDiagnostics.Write("AnnotationTransactionCommitted", operationId: operationId, commandName: commandName, resultStatus: outcome.ToString(), document: document);
}
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
WriteEditor(document, $"\nP_Rec_Annot — диагностический план\nПроект: {context.ProjectDisplayName}\nТип чертежа: Ortho\nViewport: {context.Viewports.Count}\nОбъектов найдено: {summary.ObjectsExamined}\nБудет создано: {summary.CreateCount}\nБудет обновлено: {summary.UpdateCount}\nБудет пропущено: {summary.SkipCount}\nКонфликтов: {summary.ConflictCount}\nОшибок: {summary.ErrorCount}\nDWG не изменён.");
WriteEditor(document, $"\nPlan schema: {AnnotationPlanBuilder.CurrentPlanSchemaVersion}; PlanId: {planId ?? "multiple"}; membership resolved/partial/outside/unresolved: {membershipResolved}/{membershipPartial}/{membershipOutside}/{membershipUnresolved}.");
WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds);
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
var completedStatus = mode == PRecAnnotExecutionMode.Apply ? "ApplyCompleted" : "DryRunCompleted";
WriteUsage(operationId, commandName, completedStatus, document, details, stopwatch.ElapsedMilliseconds);
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: completedStatus, document: document, details: details);
}
catch (System.Exception exception)
{
@@ -0,0 +1,495 @@
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));
}
}
@@ -0,0 +1,113 @@
using System.Text.Json;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Plant3D2026;
// This file is deliberately runtime-only. Core receives only the anonymised
// permit and canonical operation; no AutoCAD handle, database or project path
// crosses the adapter boundary.
internal sealed record FirstLimitedApplyRuntimePermit
{
public required bool AllowApply { get; init; }
public required string FixtureMarkerPath { get; init; }
public required string TargetSnapshotId { get; init; }
public required string ExpectedPlanId { get; init; }
public required string VisibilityState { get; init; }
}
internal static class FirstLimitedApplyPermitReader
{
private const string MarkerContent = "P_REC_ANNOT_TEST_FIXTURE=FIRST_LIMITED_APPLY";
public static FirstLimitedApplyRuntimePermit ReadFor(Document document)
{
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_APPLY_PERMIT_PATH");
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_PERMIT_PATH_REQUIRED");
if (!File.Exists(path)) throw new InvalidOperationException("APPLY_PERMIT_MISSING");
var permit = JsonSerializer.Deserialize<FirstLimitedApplyRuntimePermit>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (permit is null || !permit.AllowApply || string.IsNullOrWhiteSpace(permit.TargetSnapshotId) || string.IsNullOrWhiteSpace(permit.ExpectedPlanId) || string.IsNullOrWhiteSpace(permit.VisibilityState))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
if (!Path.IsPathFullyQualified(permit.FixtureMarkerPath) || permit.FixtureMarkerPath.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(permit.FixtureMarkerPath))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
if (!string.Equals(File.ReadAllText(permit.FixtureMarkerPath).Trim(), MarkerContent, StringComparison.Ordinal))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
var root = Path.GetDirectoryName(Path.GetFullPath(permit.FixtureMarkerPath))!;
var drawing = Path.GetFullPath(document.Name);
if (!drawing.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED");
return permit;
}
}
internal enum PipeAnnotationWriteOutcome { Created, AlreadyExists }
internal sealed class FirstLimitedPipeAnnotationWriter
{
private const string BlockName = "PIPE";
private const string XdataApplication = "PXP-OA";
public PipeAnnotationWriteOutcome Write(Document document, CreatePipeAnnotationOperation operation, string visibilityState)
{
var database = document.Database;
using var transaction = database.TransactionManager.StartTransaction();
var table = (BlockTable)transaction.GetObject(database.BlockTableId, OpenMode.ForRead);
if (!table.Has(BlockName)) throw new InvalidOperationException("ANNOTATION_TEMPLATE_BLOCK_MISSING");
var paper = (BlockTableRecord)transaction.GetObject(table[BlockTableRecord.PaperSpace], OpenMode.ForWrite);
if (HasOwnedAnnotation(transaction, paper, operation.DuplicateKey)) return PipeAnnotationWriteOutcome.AlreadyExists;
EnsureRegApp(transaction, database);
using var annotation = new BlockReference(
new Point3d(operation.Placement.X, operation.Placement.Y, operation.Placement.Z),
table[BlockName]);
paper.AppendEntity(annotation);
transaction.AddNewlyCreatedDBObject(annotation, true);
SetConfirmedVisibility(annotation, visibilityState);
annotation.XData = new ResultBuffer(
new TypedValue((int)DxfCode.ExtendedDataRegAppName, XdataApplication),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, operation.DuplicateKey),
new TypedValue((int)DxfCode.ExtendedDataAsciiString, operation.RuleId));
transaction.Commit();
return PipeAnnotationWriteOutcome.Created;
}
private static bool HasOwnedAnnotation(Transaction transaction, BlockTableRecord paper, string duplicateKey)
{
foreach (ObjectId id in paper)
{
if (transaction.GetObject(id, OpenMode.ForRead) is not BlockReference reference) continue;
using var xdata = reference.GetXDataForApplication(XdataApplication);
if (xdata?.AsArray().Any(value => value.TypeCode == (int)DxfCode.ExtendedDataAsciiString && string.Equals(value.Value as string, duplicateKey, StringComparison.Ordinal)) == true)
return true;
}
return false;
}
private static void EnsureRegApp(Transaction transaction, Database database)
{
var apps = (RegAppTable)transaction.GetObject(database.RegAppTableId, OpenMode.ForRead);
if (apps.Has(XdataApplication)) return;
apps.UpgradeOpen();
using var record = new RegAppTableRecord { Name = XdataApplication };
apps.Add(record);
transaction.AddNewlyCreatedDBObject(record, true);
}
// The "V" property and allowed values are the confirmed legacy PXP-OA
// dynamic-block contract. Failure is pre-commit and leaves no entity.
private static void SetConfirmedVisibility(BlockReference annotation, string visibilityState)
{
var property = annotation.DynamicBlockReferencePropertyCollection
.Cast<DynamicBlockReferenceProperty>()
.SingleOrDefault(value => string.Equals(value.PropertyName, "V", StringComparison.Ordinal));
if (property is null || !property.GetAllowedValues().OfType<string>().Contains(visibilityState, StringComparer.Ordinal))
throw new InvalidOperationException("ANNOTATION_VISIBILITY_STATE_UNSUPPORTED");
property.Value = visibilityState;
}
}
@@ -5,6 +5,7 @@ using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.ProcessPower.DataLinks;
using Autodesk.ProcessPower.Drawings2d;
using Autodesk.ProcessPower.PnP3dObjects;
using Autodesk.ProcessPower.P3dProjectParts;
using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.ProjectManager;
@@ -15,9 +16,11 @@ namespace PRecAnnot.Plant3D2026;
internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
{
public PlantRuntimeContext ReadCurrentContext()
public PlantRuntimeContext ReadCurrentContext() => ReadCurrentContext(null);
public PlantRuntimeContext ReadCurrentContext(Document? explicitDocument = null)
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
var document = explicitDocument ?? AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null)
{
return Empty(false, false, "NO_ACTIVE_DOCUMENT", "Активный документ отсутствует.");
@@ -300,7 +303,7 @@ internal sealed class PlantObjectReader : IPlantObjectReader
LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"),
SupportNumber = Value("Support Number", "SupportNumber"),
EquipmentNumber = Value("Equipment Number", "EquipmentNumber"),
Geometry = new GeometrySnapshot(),
Geometry = membership.GeometryFor(rowId),
Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase),
Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" },
MembershipStatus = membership.StatusFor(rowId),
@@ -391,7 +394,8 @@ internal sealed class PlantViewportMembershipReader
internal readonly record struct MembershipReadResult(
IReadOnlySet<int> SelectedRows,
ViewMembershipStatus DefaultStatus,
string DefaultCode)
string DefaultCode,
IReadOnlyDictionary<int, GeometrySnapshot>? Geometry = null)
{
public ViewMembershipStatus StatusFor(int rowId) =>
DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId)
@@ -402,6 +406,11 @@ internal sealed class PlantViewportMembershipReader
DefaultStatus == ViewMembershipStatus.Resolved && !SelectedRows.Contains(rowId)
? new[] { "VIEW_MEMBERSHIP_OBJECT_OUTSIDE_CLIP" }
: new[] { DefaultCode };
public GeometrySnapshot GeometryFor(int rowId) =>
Geometry is not null && Geometry.TryGetValue(rowId, out var geometry)
? geometry
: new GeometrySnapshot();
}
internal static MembershipReadResult Read(OrthoViewportInfo viewport, DataLinksManager dataLinks, Autodesk.ProcessPower.DataObjects.PnPRowIdArray sourceRows)
@@ -426,6 +435,7 @@ internal sealed class PlantViewportMembershipReader
}
var selectedRows = new HashSet<int>();
var geometry = new Dictionary<int, GeometrySnapshot>();
var selected = view.DwgQuery.Execute(new StringCollection(), new StringCollection());
for (var index = 0; index < selected.Count; index++)
{
@@ -434,7 +444,11 @@ internal sealed class PlantViewportMembershipReader
try
{
var rowId = dataLinks.FindAcPpRowId(sourceId.PrjObjectId);
if (sourceRows.Contains(rowId)) selectedRows.Add(rowId);
if (sourceRows.Contains(rowId))
{
selectedRows.Add(rowId);
if (TryReadPipePlacement(sourceId, dataLinks, out var placement)) geometry[rowId] = placement;
}
}
catch (DLException)
{
@@ -447,7 +461,7 @@ internal sealed class PlantViewportMembershipReader
["SelectedSourceRows"] = selectedRows.Count,
["SourceRows"] = sourceRows.Count
});
return new MembershipReadResult(selectedRows, ViewMembershipStatus.Resolved, "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID");
return new MembershipReadResult(selectedRows, ViewMembershipStatus.Resolved, "VIEW_MEMBERSHIP_FROM_CONFIRMED_PNP_ID", geometry);
}
catch (Exception exception)
{
@@ -458,6 +472,41 @@ internal sealed class PlantViewportMembershipReader
private static MembershipReadResult Unsupported(string code) => new(new HashSet<int>(), ViewMembershipStatus.NotResolved, code);
// Confirmed Plant 3D 2026 linkage: DwgQuery.Execute returns MdObjectId,
// whose PrjObjectId identifies the source entity and whose Xform projects
// that source entity into the particular Ortho view. This is not a sheet
// coordinate heuristic.
private static bool TryReadPipePlacement(MdObjectId sourceId, DataLinksManager dataLinks, out GeometrySnapshot geometry)
{
geometry = new GeometrySnapshot();
try
{
var openedByQuery = false;
var sourceDatabase = sourceId.GetDatabase(false, ref openedByQuery);
try
{
using var transaction = sourceDatabase.TransactionManager.StartOpenCloseTransaction();
var objectId = dataLinks.MakeAcDbObjectId(sourceId.PrjObjectId, sourceDatabase);
if (transaction.GetObject(objectId, OpenMode.ForRead) is not Pipe pipe) return false;
var point = pipe.StartPoint.TransformBy(sourceId.Xform);
geometry = new GeometrySnapshot
{
PaperPosition = new Point2Dto(point.X, point.Y),
Rotation = 0
};
return true;
}
finally
{
if (openedByQuery) sourceDatabase.Dispose();
}
}
catch
{
return false;
}
}
// Ortho viewport IDs are generated by PlantOrthoViewportReader from exactly this pair.
// Only an exact regenerated ID is accepted; the reader never chooses an arbitrary view.
private static string StableId(string sheetId, string? viewName)
@@ -472,7 +521,7 @@ internal sealed class RuntimeWriteGuard(PRecAnnotExecutionMode mode) : IRuntimeW
{
public void EnsureWriteAllowed(string operation)
{
if (mode == PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_NOT_IMPLEMENTED");
if (mode == PRecAnnotExecutionMode.Apply) return;
throw new InvalidOperationException("SMOKE_WRITE_BLOCKED");
}
}
@@ -0,0 +1,141 @@
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 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 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");
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);
}
}
@@ -61,6 +61,170 @@ public sealed class FoundationTests
Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", source);
Assert.Contains("[CommandMethod(\"PXP-AAV\")]", source);
Assert.Contains("[CommandMethod(\"PXP-OA\")]", source);
Assert.Contains("PHP_REC_AUTOMATION_READY", source);
}
[Fact] public void AutomationBootstrap_DefersPlantAndDocumentAccessUntilCoordinator()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var bootstrap = source[..source.IndexOf("internal sealed record FirstApplyAutomationEvidence", StringComparison.Ordinal)];
Assert.DoesNotContain("PlantProject.LoadProject", bootstrap);
Assert.DoesNotContain("MdiActiveDocument", bootstrap);
Assert.Contains("AcadApplication.Idle += OnIdle", bootstrap);
Assert.Contains("observedIdle = true", bootstrap);
Assert.Contains("coordinatorStartCount++", bootstrap);
}
[Fact] public void AutomationLifecycleProbe_CompletesBeforePlantActivation()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("LifecycleProbe", source);
Assert.True(source.IndexOf("CompleteLifecycleProbe", StringComparison.Ordinal) < source.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal));
Assert.Contains("PostStartupReady", source);
}
[Fact] public void AutomationProjectActivation_WaitsForFixtureDocumentAndQuiescence()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var activation = source[source.IndexOf("private void TryActivateProjectAndVerifyContext", StringComparison.Ordinal)..];
Assert.Contains("!fixtureDocumentActivated", activation);
Assert.Contains("!AcadApplication.IsQuiescent", activation);
Assert.True(activation.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal) > activation.IndexOf("AutomationBeforeProjectActivation", StringComparison.Ordinal));
}
[Fact] public void AutomationDocumentActivation_IsSubscribedBeforeFixtureOpen()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.True(source.IndexOf("DocumentActivated += OnDocumentActivated", StringComparison.Ordinal) < source.IndexOf("DocumentManager.Open(config.OrthoDrawingPath", StringComparison.Ordinal));
Assert.Contains("FixtureDocumentActivated", source);
}
[Fact] public void AutomationDocumentActivation_RecoversSynchronousOpenEvent()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var start = source[source.IndexOf("public void Start()", StringComparison.Ordinal)..source.IndexOf("private void CompleteLifecycleProbe", StringComparison.Ordinal)];
Assert.Contains("FixtureDocumentActiveAfterOpen", start);
Assert.Contains("fixtureDocumentActivated = true", start);
Assert.Contains("QueueReadinessBarrier();", start);
Assert.Contains("ReadinessBarrierCompleted", source);
Assert.Contains("TryActivateProjectAndVerifyContext();", source);
}
[Fact] public void QueueReadinessBarrier_RequiresActiveFixtureAndUsesSafeDispatch()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var dispatcher = source[source.IndexOf("private async Task DispatchCommandAsync", StringComparison.Ordinal)..source.IndexOf("private void OnCommandEnded", StringComparison.Ordinal)];
Assert.Contains("ExecuteInCommandContextAsync", dispatcher);
Assert.Contains("MdiActiveDocument", dispatcher);
Assert.Contains("AUTOMATION_COMMAND_DOCUMENT_NOT_ACTIVE", dispatcher);
Assert.Contains("Editor.CommandAsync", dispatcher);
Assert.DoesNotContain("SendStringToExecute", dispatcher);
}
[Fact] public void AutomationControlledExit_QueuesNativeQuitAfterPublicCommand()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("RequestControlledExit", source);
Assert.Contains("AcadApplication.Idle += OnExitIdle", source);
Assert.Contains("QuitDispatchAttempted", source);
Assert.Contains("exitDocument.SendStringToExecute(\"_.QUIT _N\\n\", true, true, false);", source);
Assert.Contains("var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;", source);
}
[Fact] public void Callback_AfterDocumentDestroyed_DoesNotAccessDocument()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("if (document is null || e.Document is null) return;", source);
Assert.Contains("if (IsTerminal || document is null) return;", source);
}
[Fact] public void Idle_WhenActiveDocumentNull_DoesNotThrow()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE", source);
Assert.Contains("QuitDispatchSkippedNoLiveDocument", source);
}
[Fact] public void TerminalState_IgnoresFurtherCallbacks()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("private bool IsTerminal", source);
Assert.Contains("if (IsTerminal && callback != \"ControlledExitIdle\") return;", source);
}
[Fact] public void HandlerException_ProducesFailedEvidence()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("exceptionText = exception.ToString();", source);
Assert.Contains("ExceptionText = exceptionText", source);
Assert.Contains("FailFromCallback", source);
}
[Fact] public void HandlerException_DoesNotEscapeToAutoCad()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("private void RunCallback", source);
Assert.Contains("catch (System.Exception exception) { FailFromCallback", source);
Assert.Contains("=> RunCallback(\"Idle\"", source);
Assert.Contains("=> RunCallback(\"CommandFailed\"", source);
}
[Fact] public void Unsubscribe_IsIdempotent()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("private bool documentHandlersSubscribed", source);
Assert.Contains("private bool lifecycleHandlersSubscribed", source);
Assert.Contains("private void UnsubscribeHandlers()", source);
}
[Fact] public void ControlledExit_DoesNotReuseDestroyedDocument()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var controlledExit = source[source.IndexOf("private void RequestControlledExit", StringComparison.Ordinal)..];
Assert.DoesNotContain("document.CloseAndDiscard", controlledExit);
Assert.Contains("var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;", controlledExit);
Assert.Contains("document = null;", controlledExit);
}
[Fact] public void FinalCommandCompletion_UsesItsQuiescentExitBoundary()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("Complete(true, null, safeToExitNow: true)", source);
Assert.Contains("RequestControlledExit(safeToExitNow);", source);
}
[Fact] public void TerminalEvidence_IsFlushedBeforeExitAttempt()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var exit = source[source.IndexOf("private void DispatchNativeQuit", StringComparison.Ordinal)..];
Assert.True(exit.IndexOf("TryWriteEvidence(functionalCompleted, null);", StringComparison.Ordinal) < exit.IndexOf("SendStringToExecute(\"_.QUIT _N\\n", StringComparison.Ordinal));
Assert.Contains("EvidenceFlushed = evidenceFlushed", source);
Assert.Contains("CommandsCompleted", source);
Assert.Contains("InspectorsCompleted", source);
}
[Fact] public void ExitFailure_DoesNotInvalidateCompletedFunctionalEvidence()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.Contains("if (functionalCompleted)", source);
Assert.Contains("ExitFailureCode = exitFailureCode", source);
Assert.Contains("TryWriteEvidence(functionalCompleted, null);", source);
}
[Fact] public void ExitDispatch_DoesNotUseUnavailableDocument()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
var exit = source[source.IndexOf("private void DispatchNativeQuit", StringComparison.Ordinal)..];
Assert.Contains("AcadApplication.IsQuiescent", exit);
Assert.Contains("QuitDispatchSkippedNoLiveDocument", exit);
Assert.DoesNotContain("document ?? AcadApplication.DocumentManager", exit);
}
[Fact] public void ProductionRuntime_DoesNotEnableForceAfterEvidence()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
Assert.DoesNotContain("ForceAfterEvidence", source);
Assert.Contains("P_REC_ANNOT_AUTOMATION", source);
}
[Fact] public void PrimaryCommand_IsRegistered() => Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));
@@ -283,6 +447,68 @@ public sealed class FoundationTests
[Fact] public void ApplyMode_IsBlocked() => Assert.Throws<InvalidOperationException>(() => new ExecutionModeWriteGuard(PRecAnnotExecutionMode.Apply).EnsureWriteAllowed("test"));
[Fact] public void FirstLimitedApply_RequiresApplyMode()
{
var context = FirstApplyContext();
Assert.Throws<InvalidOperationException>(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.DryRun, context.Plan, context.Viewport, context.Objects, context.Permit));
}
[Fact] public void FirstLimitedApply_RequiresFreshPlan()
{
var context = FirstApplyContext();
var permit = context.Permit with { ExpectedPlanId = "stale" };
Assert.Throws<InvalidOperationException>(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, permit));
}
[Fact] public void FirstLimitedApply_RequiresResolvedMembership()
{
var context = FirstApplyContext();
var objects = new[] { context.Objects[0] with { MembershipStatus = ViewMembershipStatus.NotResolved } };
Assert.Throws<InvalidOperationException>(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, objects, context.Permit));
}
[Fact] public void FirstLimitedApply_RequiresExactlyOneTarget()
{
var context = FirstApplyContext();
var permit = context.Permit with { TargetSnapshotId = "missing" };
Assert.Throws<InvalidOperationException>(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, permit));
}
[Fact] public void FirstLimitedApply_CreatesDeterministicOperation()
{
var context = FirstApplyContext();
var planner = new FirstLimitedApplyPlanner();
var first = planner.Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, context.Permit);
var second = planner.Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, context.Permit);
Assert.Equal(first.Operation.OperationId, second.Operation.OperationId);
Assert.Equal(first.Operation.DuplicateKey, second.Operation.DuplicateKey);
Assert.DoesNotContain("C:\\", first.Operation.DuplicateKey, StringComparison.OrdinalIgnoreCase);
}
[Fact] public void FirstLimitedApplyPlan_OneResolvedPipeCreatesOnePipeOperation()
{
var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" };
var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
{
CommandName = "PXP-OA", Viewport = viewport,
Objects = new[] { Snapshot("pipe", "Pipe", null), Snapshot("other", "Valve", "TEST") },
LimitedApplyTargetSnapshotId = "pipe", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId
});
Assert.Equal(1, plan.Summary.CreateCount);
Assert.Equal("PIPE", Assert.Single(plan.Operations.Where(x => x.Kind == AnnotationOperationKind.Create)).AnnotationType);
Assert.Contains("APPLY_NOT_SELECTED_TARGET", plan.Operations.Single(x => x.SourceSnapshotId == "other").Reasons);
}
[Fact] public void FirstLimitedApplyPlan_SelectionChangesCanonicalPlanId()
{
var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" };
var objects = new[] { Snapshot("one", "Pipe", null), Snapshot("two", "Pipe", null) };
var builder = new AnnotationPlanBuilder();
var one = builder.Build(new AnnotationPlanningRequest { CommandName = "PXP-OA", Viewport = viewport, Objects = objects, LimitedApplyTargetSnapshotId = "one", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId });
var two = builder.Build(new AnnotationPlanningRequest { CommandName = "PXP-OA", Viewport = viewport, Objects = objects, LimitedApplyTargetSnapshotId = "two", LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId });
Assert.NotEqual(one.PlanId, two.PlanId);
}
[Fact] public void DryRunPath_HasNoForWriteOperations()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
@@ -292,6 +518,21 @@ public sealed class FoundationTests
Assert.DoesNotContain("SetProperties", source);
}
private static (AnnotationPlan Plan, OrthoViewportInfo Viewport, PlantObjectSnapshot[] Objects, FirstLimitedApplyPermit Permit) FirstApplyContext()
{
var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" };
var objects = new[] { Snapshot("pipe", "Pipe", "TEST") };
var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
{
CommandName = "PXP-OA",
Viewport = viewport,
Objects = objects,
LimitedApplyTargetSnapshotId = "pipe",
LimitedApplyRuleId = FirstLimitedApplyPlanner.RuleId
});
return (plan, viewport, objects, new FirstLimitedApplyPermit { FixtureMarker = FirstLimitedApplyPlanner.FixtureMarker, AllowApply = true, TargetSnapshotId = "pipe", ExpectedPlanId = plan.PlanId });
}
private static AnnotationPlan Build(params PlantObjectSnapshot[] objects) => new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "A", SourceDrawingName = "test.dwg" }, Objects = objects });
private static PlantObjectSnapshot Snapshot(string id, string type, string? line, string? tag = "TAG") => new() { SnapshotId = id, ObjectClass = type, LineNumber = line, Tag = tag, Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(1, 2) }, MembershipStatus = ViewMembershipStatus.Resolved };
}