fix: correlate script-driven PXP-OA command events

This commit is contained in:
codex
2026-08-04 14:09:14 +03:00
parent c270a182ce
commit 8a9f95a605
3 changed files with 33 additions and 6 deletions
@@ -23,6 +23,7 @@ internal sealed record FirstApplyAutomationConfiguration
internal sealed record AutomationEntityCounts(int Total, int PipeBlocks, int PxpOaXdata, int Owned);
internal sealed record PxpOaDiscoveryEvidence(bool Found, string? GlobalName, string? GroupName, string? Flags, string? DeclaringType, string? DeclaringAssembly, string? AssemblyLocation, string? AssemblySha256, bool AssemblyLoaded, bool CommandClassIncluded, bool BundleHashMatched, int DeclarationCount);
internal sealed record AutomationSentinelEvidence(string Stage, string? DocumentIdentity, string? DatabaseIdentity, DateTimeOffset TimestampUtc);
internal sealed record AutomationCommandCorrelationEvidence(string EventKind, string RawCommandName, string NormalizedCommandName, bool CommandNameMatched, bool PreparedStateMatched, bool DocumentMatched, bool DatabaseMatched, bool FixturePathMatched, bool SequenceAccepted, string? RejectionReason, string? SenderType, string? SenderDocumentPath, string? ExpectedDocumentPath, string? ActiveDocumentPath, int RunCount, DateTimeOffset TimestampUtc);
internal static class FirstLimitedApplyAutomationBootstrap
{
@@ -127,6 +128,7 @@ internal sealed record FirstApplyAutomationEvidence
public PxpOaDiscoveryEvidence? PxpOaDiscovery { get; init; }
public IReadOnlyList<AutomationSentinelEvidence> Sentinels { get; init; } = Array.Empty<AutomationSentinelEvidence>();
public IReadOnlyList<string> CommandEvents { get; init; } = Array.Empty<string>();
public IReadOnlyList<AutomationCommandCorrelationEvidence> CommandCorrelations { get; init; } = Array.Empty<AutomationCommandCorrelationEvidence>();
}
internal sealed class FirstLimitedApplyAutomationCoordinator
@@ -24,6 +24,7 @@ internal static class ScriptDrivenFirstApplyAutomation
private static readonly List<string> transitions = [];
private static readonly List<string> commandEvents = [];
private static readonly List<AutomationSentinelEvidence> sentinels = [];
private static readonly List<AutomationCommandCorrelationEvidence> correlations = [];
private static PxpOaDiscoveryEvidence? discovery;
private static bool prepared;
private static bool terminal;
@@ -104,7 +105,9 @@ internal static class ScriptDrivenFirstApplyAutomation
Guard(() =>
{
commandEvents.Add("WillStart:" + args.GlobalCommandName);
if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count >= 2) return;
var correlation = Correlate("WillStart", sender, args.GlobalCommandName, runs.Count < 2);
correlations.Add(correlation);
if (correlation.RejectionReason is not null) return;
runs.Add(new Run(runs.Count + 1, null, [], null, 0, 0, Dbmod(), null, Counts(database!), null, true, false, false, false, null, null));
transitions.Add("PxpOaWillStart:" + runs.Count);
});
@@ -116,8 +119,11 @@ internal static class ScriptDrivenFirstApplyAutomation
{
Guard(() =>
{
commandEvents.Add((failed ? "Failed:" : cancelled ? "Cancelled:" : "Ended:") + args.GlobalCommandName);
if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count == 0) return;
var kind = failed ? "Failed" : cancelled ? "Cancelled" : "Ended";
commandEvents.Add(kind + ":" + args.GlobalCommandName);
var correlation = Correlate(kind, sender, args.GlobalCommandName, runs.Count > 0);
correlations.Add(correlation);
if (correlation.RejectionReason is not null) return;
var last = runs[^1];
if (last.Ended || last.Failed || last.Cancelled) return;
runs[^1] = last with { Ended = !failed && !cancelled, Failed = failed, Cancelled = cancelled, DbmodAfter = Dbmod(), After = Counts(database!) };
@@ -125,8 +131,22 @@ internal static class ScriptDrivenFirstApplyAutomation
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 AutomationCommandCorrelationEvidence Correlate(string kind, object sender, string rawName, bool sequenceAccepted)
{
var normalized = rawName.Trim();
var command = string.Equals(normalized, "PXP-OA", StringComparison.OrdinalIgnoreCase);
var state = prepared && !terminal && config is not null && fixture is not null && database is not null;
var senderDocument = sender as Document;
var senderPath = senderDocument?.Name;
var expectedPath = config?.OrthoDrawingPath;
var activePath = AcadApplication.DocumentManager.MdiActiveDocument?.Name;
var fixturePath = senderPath is not null && expectedPath is not null && SamePath(senderPath, expectedPath);
var activeFixture = activePath is not null && expectedPath is not null && SamePath(activePath, expectedPath);
var document = fixturePath && activeFixture;
var databaseMatch = senderDocument?.Database is not null && database is not null && SamePath(senderDocument.Database.Filename, database.Filename);
var rejection = !command ? "COMMAND_NAME_MISMATCH" : !state ? "NOT_PREPARED" : !document ? "DOCUMENT_IDENTITY_MISMATCH" : !databaseMatch ? "DATABASE_IDENTITY_MISMATCH" : !sequenceAccepted ? "UNEXPECTED_SEQUENCE" : null;
return new(kind, rawName, normalized, command, state, document, databaseMatch, fixturePath, sequenceAccepted, rejection, sender.GetType().FullName, senderPath, expectedPath, activePath, runs.Count, DateTimeOffset.UtcNow);
}
private static void Guard(Action action) { try { if (!terminal) action(); } catch (System.Exception ex) { Fail("AUTOMATION_EVENT_HANDLER_FAILED", ex); } }
private static void Fail(string code, System.Exception exception) { if (terminal) return; terminal = true; transitions.Add("Failed:" + code); try { Write(false, code + ":" + exception); } finally { Unsubscribe(); } }
private static void Unsubscribe() { if (fixture is null) return; try { fixture.CommandWillStart -= OnWillStart; fixture.CommandEnded -= OnEnded; fixture.CommandFailed -= OnFailed; fixture.CommandCancelled -= OnCancelled; } catch { } }
@@ -154,7 +174,7 @@ internal static class ScriptDrivenFirstApplyAutomation
{
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, PxpOaDiscovery = discovery, Sentinels = sentinels, CommandEvents = commandEvents };
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, PxpOaDiscovery = discovery, Sentinels = sentinels, CommandEvents = commandEvents, CommandCorrelations = correlations };
Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
var temp = config.EvidencePath + ".tmp"; File.WriteAllText(temp, JsonSerializer.Serialize(evidence)); File.Move(temp, config.EvidencePath, true);
}
@@ -28,4 +28,9 @@ public sealed class PxpOaRegistrationTests
[Fact] public void Prepare_StopsOnPxpOaDiscoveryFailure() => Assert.True(Observer.IndexOf("RequireDiscovery(discovery)", StringComparison.Ordinal) < Observer.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal));
[Fact] public void AutomationSentinel_DoesNotModifyDatabase() => Assert.DoesNotContain("StartTransaction", Entry[Entry.IndexOf("AutomationBeforePxp1Command", StringComparison.Ordinal)..Entry.IndexOf("private static void RunDiagnostic", StringComparison.Ordinal)]);
[Fact] public void AutomationSentinel_IsTestOnly() => Assert.Contains("RequireGate", Observer);
[Fact] public void Observer_RecordsRawEventBeforeCorrelation() => Assert.True(Observer.IndexOf("commandEvents.Add", Observer.IndexOf("OnWillStart", StringComparison.Ordinal), StringComparison.Ordinal) < Observer.IndexOf("Correlate(\"WillStart\"", StringComparison.Ordinal));
[Fact] public void Observer_RecordsEveryRejectionReason() => Assert.Contains("DOCUMENT_IDENTITY_MISMATCH", Observer);
[Fact] public void Observer_DoesNotUseManagedDocumentReferenceAsAcceptance() => Assert.DoesNotContain("ReferenceEquals(sender, fixture)", Observer);
[Fact] public void Observer_RecordsExactlyTwoSequentialRuns() => Assert.Contains("runs.Count < 2", Observer);
[Fact] public void Finalize_ReportsMissingPlanSeparatelyFromMissingRuns() => Assert.True(Observer.IndexOf("AUTOMATION_EXPECTS_EXACTLY_TWO_PXP_OA", StringComparison.Ordinal) < Observer.IndexOf("AUTOMATION_PLAN_IDS_DIFFER", StringComparison.Ordinal));
}