diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs index 5e70872..c686c8b 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/EntryPoint.cs @@ -60,7 +60,7 @@ public sealed class EntryPoint : IExtensionApplication public void AutomationAfterPxp2Command() => ScriptDrivenFirstApplyAutomation.RecordSentinel("AfterPxp2"); [CommandMethod("PHP_REC_AUTOMATION_FINALIZE", CommandFlags.Session)] - public void AutomationFinalizeCommand() => ScriptDrivenFirstApplyAutomation.FinalizePreview(); + public void AutomationFinalizeCommand() => ScriptDrivenFirstApplyAutomation.FinalizeAutomation(); private static void RunDiagnostic(string commandName) { @@ -201,6 +201,7 @@ public sealed class EntryPoint : IExtensionApplication 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); + ScriptDrivenFirstApplyAutomation.RecordApplyOutcome(outcome); FirstLimitedApplyAutomationCoordinator.RecordApplyOutcome(outcome); details["ApplyOutcome"] = outcome.ToString(); details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0; diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs index b617ce0..7e67b73 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs @@ -15,7 +15,7 @@ namespace PRecAnnot.Plant3D2026; internal static class ScriptDrivenFirstApplyAutomation { private sealed record Config(string Phase, string OrthoDrawingPath, string EvidencePath, string FixtureMarkerPath, string ExpectedEntryAssemblySha256, bool Diagnostic); - private sealed record Run(int Sequence, string? PlanId, IReadOnlyList 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 sealed record Run(int Sequence, string? PlanId, IReadOnlyList Candidates, string? View, int Create, int Skip, bool? PermitAccepted, string? ApplyOutcome, 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; @@ -90,7 +90,14 @@ internal static class ScriptDrivenFirstApplyAutomation 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 }; + runs[^1] = last with { PlanId = planId, Candidates = candidates, View = view, Create = create, Skip = skip, PermitAccepted = permitAccepted, SourceRows = sourceRows, MembershipResolved = membershipResolved }; + } + + internal static void RecordApplyOutcome(PipeAnnotationWriteOutcome outcome) + { + if (!prepared || terminal || runs.Count == 0) return; + var last = runs[^1]; + runs[^1] = last with { ApplyOutcome = outcome.ToString() }; } internal static bool TryGetPreparedContext(Document? commandDocument, out PlantRuntimeContext context) @@ -105,19 +112,29 @@ internal static class ScriptDrivenFirstApplyAutomation internal static string? SelectedTargetSnapshotId => prepared && !terminal ? selectedTargetSnapshotId : null; - internal static void FinalizePreview() + internal static void FinalizeAutomation() { 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 => string.IsNullOrWhiteSpace(x.PlanId))) throw new InvalidOperationException("PXP_OA_PLAN_RESULT_NOT_CAPTURED"); + if (!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"); + var apply = string.Equals(config!.Phase, "Apply", StringComparison.OrdinalIgnoreCase); + if (apply) + { + if (runs[0].PermitAccepted != true) throw new InvalidOperationException("AUTOMATION_APPLY_PERMIT_NOT_ACCEPTED"); + if (!string.Equals(runs[0].ApplyOutcome, PipeAnnotationWriteOutcome.Created.ToString(), StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_APPLY_CREATE_NOT_CONFIRMED"); + if (!string.Equals(runs[1].ApplyOutcome, PipeAnnotationWriteOutcome.AlreadyExists.ToString(), StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_APPLY_DUPLICATE_NOT_CONFIRMED"); + var undoCounts = Counts(database!); + if (!Equals(undoCounts, baseline)) throw new InvalidOperationException("AUTOMATION_UNDO_DID_NOT_RESTORE_BASELINE"); + } + else if (Dbmod() != dbmodBaseline || !Equals(Counts(database!), baseline)) throw new InvalidOperationException("AUTOMATION_INSPECTOR_INVARIANCE_FAILED"); terminal = true; - transitions.Add("PreviewCompleted"); + transitions.Add(apply ? "ApplyCompleted" : "PreviewCompleted"); Write(true, null); Unsubscribe(); } @@ -132,7 +149,7 @@ internal static class ScriptDrivenFirstApplyAutomation 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)); + runs.Add(new Run(runs.Count + 1, null, [], null, 0, 0, null, null, Dbmod(), null, Counts(database!), null, true, false, false, false, null, null)); transitions.Add("PxpOaWillStart:" + runs.Count); }); } @@ -203,7 +220,9 @@ 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, CommandCorrelations = correlations }; + var apply = string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase); + var afterUndo = apply && terminal ? Counts(database!) : null; + var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = completed, Prepared = prepared, FunctionalEvidenceCompleted = completed, ErrorCode = error, DbmodBefore = dbmodBaseline, DbmodAfterCommand1 = first?.DbmodAfter, DbmodAfterCommand2 = second?.DbmodAfter, DbmodAfterUndo = apply && terminal ? Dbmod() : null, Baseline = baseline, AfterCommand1 = first?.After, AfterCommand2 = second?.After, AfterUndo = afterUndo, CompletedCommands = runs.Where(x => x.Ended).Select(x => "PXP-OA").Concat(apply && commandEvents.Any(x => string.Equals(x, "Ended:UNDO", StringComparison.OrdinalIgnoreCase)) ? ["UNDO"] : []).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, ApplyOutcomeRun1 = first?.ApplyOutcome, ApplyOutcomeRun2 = second?.ApplyOutcome, PermitAccepted = first?.PermitAccepted, CommandsCompleted = runs.Count == 2 && runs.All(x => x.Ended) && (!apply || commandEvents.Any(x => string.Equals(x, "Ended:UNDO", StringComparison.OrdinalIgnoreCase))), InspectorsCompleted = baseline is not null && first?.After is not null && second?.After is not null && (!apply || afterUndo 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); } diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs index 82f101d..d70413a 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs @@ -36,6 +36,8 @@ public sealed class PxpOaRegistrationTests [Fact] public void ScriptDrivenPxpOa_BindsPlantContextToCommandDocument() => Assert.Contains("ReadCurrentContext(document)", Entry); [Fact] public void ScriptDrivenPxpOa_UsesPreparedFixtureContextBeforeImplicitLookup() => Assert.Contains("TryGetPreparedContext(document, out var scriptContext)", Entry); [Fact] public void ScriptDrivenPrepare_SelectsCanonicalTargetAndRetainsReadOnlyContext() { Assert.Contains("preparedContext = context", Observer); Assert.Contains("selectedTargetSnapshotId = candidates[0]", Observer); } + [Fact] public void ScriptDrivenApply_RecordsOutcomeAgainstTheAcceptedRun() { Assert.Contains("internal static void RecordApplyOutcome", Observer); Assert.Contains("ApplyOutcome = outcome.ToString()", Observer); } + [Fact] public void ScriptDrivenApply_RequiresActualUndoBaselineRestoration() { Assert.Contains("AUTOMATION_UNDO_DID_NOT_RESTORE_BASELINE", Observer); Assert.Contains("Ended:UNDO", Observer); } [Fact] public void PlacementReadFailure_IsDiagnosedInsteadOfBeingSilentlyDiscarded() { var source = File.ReadAllText(Path.Combine(ProjectRoot, "PlantReadPath.cs")); Assert.Contains("OrthoPipePlacementReadFailed", source); Assert.Contains("ORTHO_PIPE_PLACEMENT_UNAVAILABLE", source); } [Fact] public void PlacementReader_OpensSourceDatabaseWhenItIsNotActive() { var source = File.ReadAllText(Path.Combine(ProjectRoot, "PlantReadPath.cs")); Assert.Contains("sourceId.GetDatabase(true, ref openedByQuery)", source); } }