From 3151a7a318695f18cdaa2a2cc9f95f33053d9c5b Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 02:51:52 -0500 Subject: [PATCH 1/3] Add isolated mutation batch previews --- CHANGELOG.md | 14 + .../DocxSessionPreviewBatchTests.cs | 674 ++++++++++++++++++ Docxodus.Tests/McpServerDispatcherTests.cs | 69 ++ Docxodus/DocxSession.cs | 448 +++++++++++- Docxodus/Internal/DocxSessionJson.cs | 30 + Docxodus/Internal/DocxSessionOps.cs | 36 +- Docxodus/Internal/SessionRegistry.cs | 24 + docs/architecture/docx_agent_server.md | 50 +- npm/src/session.ts | 224 +++++- npm/src/types.ts | 33 + npm/tests/atomic-batch.spec.ts | 122 ++++ python/src/docx_scalpel/__init__.py | 2 + python/src/docx_scalpel/session.py | 29 + python/src/docx_scalpel/types.py | 58 +- python/tests/test_atomic_batches.py | 55 ++ tools/mcp-server/Dispatcher.cs | 157 ++-- tools/mcp-server/README.md | 12 +- tools/mcp-server/ToolCatalog.cs | 8 +- tools/python-host/Dispatcher.cs | 67 +- wasm/DocxodusWasm/DocxSessionBridge.cs | 14 + 20 files changed, 1967 insertions(+), 159 deletions(-) create mode 100644 Docxodus.Tests/DocxSessionPreviewBatchTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 49933556..c3628564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,20 @@ All notable changes to this project will be documented in this file. `navigateToPageCitation` expose materialization and preview navigation. The MCP inline preview remains explicitly continuous pending #434. See [`docs/architecture/page_map.md`](docs/architecture/page_map.md). +- **Intrinsically isolated mutation preview** (issue #446). `.NET` `PreviewBatch`, + Ops/JSON, WASM/npm, stdio/Python, and MCP now run the identical atomic or explicit + `best_effort` batch path on a complete shadow package instead of applying to the live + session and undoing. The clone carries every OPC part/relationship/media/custom-XML + payload plus version, mutable configuration, diff baseline, and id generators, while + caches and undo/redo history remain independent; failure, interruption, disposal, and + abandonment therefore cannot touch live bytes or history. Rich apply/preview receipts + include predicted versions, per-step created/removed/modified anchors and patches, + revision/comment/annotation deltas, warnings, a canonical package-content SHA-256, and + optional scoped/full shadow-only HTML. Deterministic previews and applies have exact + receipt/hash equivalence. Operations that generate anchors/OOXML ids or timestamps are + explicitly semantic-equivalence-only (same outcomes and structure/content/relationship + effects modulo generated metadata) and emit warnings. This supersedes the undo-depth, + redo-destruction, and crash window described in #468. - **Atomic multi-step mutation batches** (issue #445). `DocxSession.ExecuteBatch` and the reusable nested-safe `BeginTransaction` primitive checkpoint the complete OPC package, relationship topology, anchor/revision generators, mutable session diff --git a/Docxodus.Tests/DocxSessionPreviewBatchTests.cs b/Docxodus.Tests/DocxSessionPreviewBatchTests.cs new file mode 100644 index 00000000..06044a81 --- /dev/null +++ b/Docxodus.Tests/DocxSessionPreviewBatchTests.cs @@ -0,0 +1,674 @@ +#nullable enable + +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using DocumentFormat.OpenXml.Packaging; +using Docxodus.Internal; +using Xunit; + +namespace Docxodus.Tests; + +/// Complete-package isolated preview regression coverage (issue #446). +public class DocxSessionPreviewBatchTests +{ + [Fact] + public void DS461_PreviewSuccessFailureThrowAndBestEffort_NeverTouchLiveState() + { + using var session = OpenRich(new DocxSessionSettings + { + PersistAnchorIds = true, + UndoDepth = 1, + TrackedChanges = TrackedChangeMode.RenderInline, + RevisionAuthor = "Preview Author", + }); + var anchors = BodyParagraphs(session); + + // Seed a redo cursor and a tight history ring: the former apply-and-undo implementation + // destroyed redo here and could underflow after more preview steps than UndoDepth. + Assert.True(session.ReplaceText(anchors[0], "Redo target.").Success); + Assert.True(session.Undo()); + _ = session.Project(); + _ = session.AnchorIndex(); + var before = Fingerprint.Capture(session); + + var success = session.PreviewBatch(new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(anchors[0], "Predicted tracked replacement.")), + new MutationBatchStep("docx_create", "set_header_text", + s => s.SetHeaderText(anchors[0], HeaderFooterKind.Default, "Predicted header.")), + new MutationBatchStep("docx_comment", "add", + s => s.AddComment(anchors[1], null, "Alice", "Predicted comment.", + date: new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc))), + new MutationBatchStep("docx_annotate", "add", + s => s.AddAnnotation(anchors[1], new CharSpan(0, 3), new DocumentAnnotation + { + Id = "preview-ann", + LabelId = "RISK", + Label = "Risk", + Color = "#FFCC00", + Created = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc), + })), + }, options: new MutationBatchPreviewOptions + { + HtmlMode = MutationPreviewHtmlMode.Full, + }); + + Assert.True(success.Preview); + Assert.True(success.Success, + success.Failure is null + ? "preview failed without a failure envelope" + : $"{success.Failure.Index}:{success.Failure.Action}:{success.Failure.Error.Code}:{success.Failure.Error.Message}"); + Assert.Equal(before.Version, success.BaseVersion); + Assert.Equal(before.Version + 1, success.ResultVersion); + Assert.NotEmpty(success.PackageHash); + Assert.Equal(4, success.Steps.Count); + Assert.NotEmpty(success.RevisionChanges.Added); + Assert.Single(success.CommentChanges.Added); + Assert.Single(success.AnnotationChanges.Added); + Assert.Contains(success.Warnings, + warning => warning.Contains("Comment date attributes", StringComparison.Ordinal)); + Assert.Contains("Predicted tracked replacement.", success.Html); + before.AssertUnchanged(session); + + var failed = session.PreviewBatch(new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(anchors[0], "Rolled back in the shadow.")), + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText("p:body:missing", "failure")), + }); + Assert.False(failed.Success); + Assert.True(failed.RolledBack); + Assert.Empty(failed.RevisionChanges.Added); + before.AssertUnchanged(session); + + var thrown = session.PreviewBatch(new MutationBatchStep[] + { + new("docx_edit", "replace_text", + s => s.ReplaceText(anchors[0], "Thrown away in shadow.")), + new("docx_edit", "throw", + (Func)(_ => throw new InvalidOperationException("preview fault"))), + }); + Assert.False(thrown.Success); + Assert.Equal(EditErrorCode.InternalError, thrown.Failure?.Error.Code); + before.AssertUnchanged(session); + + var partial = session.PreviewBatch(new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(anchors[0], "Retained only in best-effort shadow.")), + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText("p:body:missing", "failure")), + new MutationBatchStep("docx_create", "set_footer_text", + s => s.SetFooterText(anchors[1], HeaderFooterKind.Default, "Shadow footer.")), + }, MutationBatchMode.BestEffort); + Assert.False(partial.Success); + Assert.False(partial.RolledBack); + Assert.Equal(before.Version + 2, partial.ResultVersion); + Assert.Contains(partial.Warnings, value => value.Contains("Best-effort", StringComparison.Ordinal)); + before.AssertUnchanged(session); + + // The original redo remains usable after every preview, including batches longer than + // UndoDepth. This explicitly supersedes the undo-too-many failure mode from #468. + Assert.False(session.Undo()); + Assert.True(session.Redo()); + Assert.Contains("Redo target.", session.Project().Markdown); + } + + [Fact] + public void DS462_DisposedOrAbandonedShadow_IsIntrinsicallySafe() + { + using var live = OpenRich(); + var anchor = BodyParagraphs(live)[0]; + var before = Fingerprint.Capture(live); + + var shadow = live.CreateShadowSession(); + var liveSettings = PrivateField(live, "_settings"); + var shadowSettings = PrivateField(shadow, "_settings"); + Assert.NotSame(liveSettings, shadowSettings); + Assert.NotSame(liveSettings.ProjectionSettings, shadowSettings.ProjectionSettings); + shadowSettings.ProjectionSettings.HeadingLevelOffset++; + Assert.True(shadow.ReplaceText(anchor, "Only the abandoned clone changes.").Success); + Assert.Contains("Only the abandoned clone changes.", shadow.Project().Markdown); + before.AssertUnchanged(live); // live is safe even while the shadow is still in flight + shadow.Dispose(); + before.AssertUnchanged(live); + + // Timeout-style abandonment: work can fault/dispose independently because no rollback of + // live state is ever needed. + var task = Task.Run(() => + { + using var timedOutShadow = live.CreateShadowSession(); + Assert.True(timedOutShadow.SetHeaderText( + anchor, HeaderFooterKind.Default, "Timed-out shadow.").Success); + throw new TimeoutException("simulated caller abandonment"); + }); + Assert.IsType(Record.Exception(() => task.GetAwaiter().GetResult())); + before.AssertUnchanged(live); + } + + [Fact] + public void DS463_DeterministicPreviewAndApply_HaveIdenticalReceiptsAndPackageHash() + { + using var session = OpenRich(); + var anchors = BodyParagraphs(session); + Assert.True(session.ReplaceText(anchors[0], "Existing live change from initial baseline.").Success); + var expectedDiff = session.GetDiff(); + var expectedTransactionState = PrivateField(session, "_nextTransactionId"); + string? previewDiff = null; + long previewPreflightTransaction = -1; + long previewMutationTransaction = -1; + var previewSteps = new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => + { + previewMutationTransaction = PrivateField(s, "_nextTransactionId"); + return s.ReplaceText(anchors[0], "Deterministic replacement."); + }, + s => + { + previewDiff = s.GetDiff(); + previewPreflightTransaction = PrivateField(s, "_nextTransactionId"); + return null; + }), + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(anchors[1], "Deterministic second replacement.")), + }; + + var preview = session.PreviewBatch(previewSteps); + Assert.Equal(expectedDiff, previewDiff); + Assert.Equal(expectedTransactionState + 1, previewPreflightTransaction); + Assert.Equal(expectedTransactionState + 1, previewMutationTransaction); + Assert.Equal(1, session.Version); + + string? applyDiff = null; + long applyPreflightTransaction = -1; + long applyMutationTransaction = -1; + var applySteps = new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => + { + applyMutationTransaction = PrivateField(s, "_nextTransactionId"); + return s.ReplaceText(anchors[0], "Deterministic replacement."); + }, + s => + { + applyDiff = s.GetDiff(); + applyPreflightTransaction = PrivateField(s, "_nextTransactionId"); + return null; + }), + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(anchors[1], "Deterministic second replacement.")), + }; + var applied = session.ExecuteBatch(applySteps); + + Assert.Equal(previewDiff, applyDiff); + Assert.Equal(previewPreflightTransaction, applyPreflightTransaction); + Assert.Equal(previewMutationTransaction, applyMutationTransaction); + + Assert.True(preview.Preview); + Assert.False(applied.Preview); + Assert.Equal(preview.BaseVersion, applied.BaseVersion); + Assert.Equal(preview.ResultVersion, applied.ResultVersion); + Assert.Equal(preview.PackageHash, applied.PackageHash); + Assert.Equal( + preview.Steps.Select(Receipt), + applied.Steps.Select(Receipt)); + Assert.Equal(ChangeReceipt(preview.RevisionChanges), ChangeReceipt(applied.RevisionChanges)); + Assert.Equal(ChangeReceipt(preview.CommentChanges), ChangeReceipt(applied.CommentChanges)); + Assert.Equal(ChangeReceipt(preview.AnnotationChanges), ChangeReceipt(applied.AnnotationChanges)); + + static string Receipt(MutationBatchStepResult step) => + $"{step.Index}|{step.Tool}|{step.Action}|{step.Success}|" + + string.Join(";", step.Results.Select(result => + $"{result.Success}:{string.Join(',', result.Created.Select(a => a.Id))}:" + + $"{string.Join(',', result.Removed.Select(a => a.Id))}:" + + $"{string.Join(',', result.Modified.Select(a => a.Id))}")); + + static string ChangeReceipt(MutationBatchChangeSet changes) => + $"{changes.Added.Count}|{changes.Removed.Count}|{changes.Modified.Count}"; + } + + [Fact] + public void DS464_HandlePreviewFactory_CannotAccidentallyTargetTheLiveHandle() + { + var handle = DocxSessionOps.OpenSession(RichBytes(), new DocxSessionSettings + { + PersistAnchorIds = true, + UndoDepth = 1, + }); + try + { + using var projection = System.Text.Json.JsonDocument.Parse(DocxSessionOps.Project(handle)); + var anchor = projection.RootElement.GetProperty("anchorIndex") + .EnumerateObject().First(property => property.Name.StartsWith("p:body:", StringComparison.Ordinal)).Name; + _ = DocxSessionOps.Save(handle, persistAnchorIds: false); + _ = DocxSessionOps.Save(handle, persistAnchorIds: true); + var beforeNormal = DocxSessionOps.Save(handle, persistAnchorIds: false); + var beforePersisted = DocxSessionOps.Save(handle, persistAnchorIds: true); + var beforeVersion = DocxSessionOps.GetVersion(handle); + + var json = DocxSessionOps.PreviewBatch( + handle, + MutationBatchMode.Atomic, + shadowHandle => new[] + { + DocxSessionOps.SerializedBatchStep( + "docx_scalpel", + "replace_text", + () => DocxSessionOps.ReplaceText( + shadowHandle, anchor, "Handle-only predicted edit.")), + }); + + using var result = System.Text.Json.JsonDocument.Parse(json); + Assert.True(result.RootElement.GetProperty("preview").GetBoolean()); + Assert.True(result.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal(beforeVersion, DocxSessionOps.GetVersion(handle)); + var afterNormal = DocxSessionOps.Save(handle, persistAnchorIds: false); + var afterPersisted = DocxSessionOps.Save(handle, persistAnchorIds: true); + Assert.Equal(beforeNormal, afterNormal); + Assert.Equal(beforePersisted, afterPersisted); + } + finally + { + DocxSessionOps.CloseSession(handle); + } + } + + [Fact] + public void DS465_PostCommitInspectionFailure_IsWarningNotApparentMutationFailure() + { + var bytes = DocxSessionTests.BuildDS001_SimpleTwoParagraphs(); + using var stream = new MemoryStream(); + stream.Write(bytes); + stream.Position = 0; + using (var package = WordprocessingDocument.Open(stream, isEditable: true)) + { + const string paraId = "A1B2C3D4"; + var main = package.MainDocumentPart!; + var comments = main.AddNewPart(); + comments.PutXDocument(new XDocument( + new XElement(W.comments, + new XElement(W.comment, + new XAttribute(W.id, "1"), + new XAttribute(W.author, "Observer"), + new XElement(W.p, + new XAttribute(W14.paraId, paraId), + new XElement(W.r, new XElement(W.t, "comment"))))))); + package.Save(); + } + + using var session = new DocxSession(stream.ToArray()); + Assert.Single(session.ListComments()); + var anchor = BodyParagraphs(session)[0]; + var result = session.ExecuteBatch(new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => + { + var edit = s.ReplaceText(anchor, "The mutation still commits."); + if (!edit.Success) return edit; + + // Simulate a failure in optional receipt enrichment only after the mutation + // has committed its ordinary operation state. + var document = PrivateField(s, "_doc"); + var commentsEx = document.MainDocumentPart! + .AddNewPart(); + commentsEx.FeedData(new MemoryStream(Encoding.UTF8.GetBytes(" value.Contains("Comment delta inspection unavailable", StringComparison.Ordinal)); + Assert.Empty(result.CommentChanges.Added); + } + + [Fact] + public void DS466_InvalidPreviewHtmlMode_IsRejectedBeforeShadowExecution() + { + using var session = OpenRich(); + var invoked = false; + Assert.Throws(() => session.PreviewBatch(new[] + { + new MutationBatchStep("docx_edit", "never", + s => { invoked = true; return s.ReplaceText(BodyParagraphs(s)[0], "not run"); }), + }, options: new MutationBatchPreviewOptions + { + HtmlMode = (MutationPreviewHtmlMode)12345, + })); + Assert.False(invoked); + Assert.Equal(0, session.Version); + } + + [Fact] + public void DS467_CreatePreviewApply_AreSemanticallyEquivalentModuloGeneratedIds() + { + using var session = OpenRich(); + var anchor = BodyParagraphs(session)[0]; + var steps = new[] + { + new MutationBatchStep("docx_create", "insert_paragraph", + s => s.InsertParagraph(anchor, Position.After, "Generated-id paragraph.")), + }; + + var preview = session.PreviewBatch(steps, options: new MutationBatchPreviewOptions + { + HtmlMode = MutationPreviewHtmlMode.Full, + }); + var applied = session.ExecuteBatch(steps); + var appliedHtml = HtmlConversionOps.ConvertToHtml(session, new HtmlConversionOptions + { + CommentRenderMode = 0, + RenderAnnotations = true, + RenderFootnotesAndEndnotes = true, + RenderHeadersAndFooters = true, + RenderTrackedChanges = true, + StampAnchors = true, + }); + + var previewCreated = Assert.Single(Assert.Single(preview.Steps).Results).Created; + var appliedCreated = Assert.Single(Assert.Single(applied.Steps).Results).Created; + Assert.Equal(previewCreated.Select(anchor => (anchor.Kind, anchor.Scope)), + appliedCreated.Select(anchor => (anchor.Kind, anchor.Scope))); + Assert.NotEqual(previewCreated.Select(anchor => anchor.Id), appliedCreated.Select(anchor => anchor.Id)); + Assert.NotEqual(preview.PackageHash, applied.PackageHash); + Assert.Contains(preview.Warnings, + warning => warning.Contains("equivalence is semantic", StringComparison.Ordinal)); + Assert.Contains(applied.Warnings, + warning => warning.Contains("equivalence is semantic", StringComparison.Ordinal)); + Assert.Equal(NormalizeGeneratedIds(preview.Html!), NormalizeGeneratedIds(appliedHtml)); + Assert.Contains("Generated-id paragraph.", appliedHtml); + } + + [Fact] + public void DS468_ReceiptEnrichment_IsSerializedWithConcurrentMutations() + { + using var session = OpenRich(); + var anchors = BodyParagraphs(session); + using var receiptInspectionEntered = new ManualResetEventSlim(); + using var releaseReceiptInspection = new ManualResetEventSlim(); + using var concurrentMutationStarted = new ManualResetEventSlim(); + var blockingCreated = new BlockingAnchorList( + receiptInspectionEntered, releaseReceiptInspection); + + var batchTask = Task.Run(() => session.ExecuteBatch(new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => + { + var edit = s.ReplaceText(anchors[0], "Batch mutation."); + return new EditResult + { + Success = edit.Success, + Error = edit.Error, + Created = blockingCreated, + Removed = edit.Removed, + Modified = edit.Modified, + Patch = edit.Patch, + AnnotationId = edit.AnnotationId, + }; + }), + })); + + Task? concurrentTask = null; + try + { + Assert.True(receiptInspectionEntered.Wait(TimeSpan.FromSeconds(10)), + "batch did not reach receipt enrichment"); + concurrentTask = Task.Run(() => + { + concurrentMutationStarted.Set(); + return session.ExecuteMutation( + preconditions: null, + s => s.ReplaceText(anchors[1], "Concurrent mutation.")); + }); + Assert.True(concurrentMutationStarted.Wait(TimeSpan.FromSeconds(10)), + "concurrent mutation task did not start"); + Assert.False(concurrentTask.Wait(TimeSpan.FromSeconds(1)), + "concurrent mutation interleaved with batch receipt enrichment"); + } + finally + { + releaseReceiptInspection.Set(); + } + + var batch = batchTask.GetAwaiter().GetResult(); + var concurrent = concurrentTask!.GetAwaiter().GetResult(); + Assert.True(batch.Success); + Assert.True(concurrent.Success); + Assert.Equal(0, batch.BaseVersion); + Assert.Equal(1, batch.ResultVersion); + Assert.Equal(2, session.Version); + Assert.Contains("Batch mutation.", session.Project().Markdown); + Assert.Contains("Concurrent mutation.", session.Project().Markdown); + } + + private static string NormalizeGeneratedIds(string value) => + Regex.Replace(value, "[0-9a-fA-F]{32}", ""); + + private sealed class BlockingAnchorList : IReadOnlyList + { + private readonly ManualResetEventSlim _entered; + private readonly ManualResetEventSlim _release; + + internal BlockingAnchorList(ManualResetEventSlim entered, ManualResetEventSlim release) + { + _entered = entered; + _release = release; + } + + public int Count + { + get + { + _entered.Set(); + _release.Wait(); + return 0; + } + } + + public Anchor this[int index] => throw new ArgumentOutOfRangeException(nameof(index)); + + public IEnumerator GetEnumerator() => + Enumerable.Empty().GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + } + + private sealed record Fingerprint( + byte[] NormalBytes, + byte[] PersistedBytes, + IReadOnlyDictionary OpcEntries, + string Markdown, + string[] Anchors, + long Version, + int RevisionCounter, + long FormatRevisionTicks, + long NextTransactionId, + string Revisions, + string Comments, + string Annotations, + TrackedChangeMode TrackedChanges, + string? RevisionAuthor, + string Settings, + int UndoCount, + int RedoCount, + long UndoMemoryBytes, + bool UndoTrimmed, + object? CachedProjection, + object? InitialProjection, + object? CachedAnchorIndex, + object? RawOps) + { + internal static Fingerprint Capture(DocxSession session) + { + // Observe Save output on complete package clones. Calling Save on the live session + // would itself replace its read caches and make the invariant probe perturb state. + var projection = session.Project(); + _ = session.AnchorIndex(); + byte[] normal; + byte[] persisted; + using (var normalClone = session.CreateShadowSession()) + normal = normalClone.Save(persistAnchorIds: false); + using (var persistedClone = session.CreateShadowSession()) + persisted = persistedClone.Save(persistAnchorIds: true); + return new Fingerprint( + normal, + persisted, + HashOpcEntries(persisted), + projection.Markdown, + projection.AnchorIndex.Select(pair => + $"{pair.Key}|{pair.Value.Anchor.Kind}|{pair.Value.Anchor.Scope}|{pair.Value.Unid}|{pair.Value.PartUri}") + .OrderBy(value => value, StringComparer.Ordinal).ToArray(), + session.Version, + PrivateField(session, "_revisionCounter"), + PrivateField(session, "_lastFormatRevisionTicks"), + PrivateField(session, "_nextTransactionId"), + DocxSessionJson.SerializeRevisionList(session.ListRevisions()), + DocxSessionJson.SerializeCommentList(session.ListComments()), + DocxSessionJson.SerializeAnnotations(session.ListAnnotations()), + session.TrackedChanges, + session.RevisionAuthor, + SettingsReceipt(PrivateField(session, "_settings")), + session.UndoCount, + session.RedoCount, + session.UndoMemoryBytes, + session.UndoHistoryTrimmedForMemory, + PrivateField(session, "_cachedProjection"), + PrivateField(session, "_initialProjection"), + PrivateField(session, "_cachedAnchorIndex"), + PrivateField(session, "_raw")); + } + + internal void AssertUnchanged(DocxSession session) + { + // Check identity-sensitive state before any observational API is invoked. + Assert.Same(CachedProjection, PrivateField(session, "_cachedProjection")); + Assert.Same(InitialProjection, PrivateField(session, "_initialProjection")); + Assert.Same(CachedAnchorIndex, PrivateField(session, "_cachedAnchorIndex")); + Assert.Same(RawOps, PrivateField(session, "_raw")); + var after = Capture(session); + Assert.Equal(NormalBytes, after.NormalBytes); + Assert.Equal(PersistedBytes, after.PersistedBytes); + Assert.Equal( + OpcEntries.OrderBy(pair => pair.Key, StringComparer.Ordinal), + after.OpcEntries.OrderBy(pair => pair.Key, StringComparer.Ordinal)); + Assert.Equal(Markdown, after.Markdown); + Assert.Equal(Anchors, after.Anchors); + Assert.Equal(Version, after.Version); + Assert.Equal(RevisionCounter, after.RevisionCounter); + Assert.Equal(FormatRevisionTicks, after.FormatRevisionTicks); + Assert.Equal(NextTransactionId, after.NextTransactionId); + Assert.Equal(Revisions, after.Revisions); + Assert.Equal(Comments, after.Comments); + Assert.Equal(Annotations, after.Annotations); + Assert.Equal(TrackedChanges, after.TrackedChanges); + Assert.Equal(RevisionAuthor, after.RevisionAuthor); + Assert.Equal(Settings, after.Settings); + Assert.Equal(UndoCount, after.UndoCount); + Assert.Equal(RedoCount, after.RedoCount); + Assert.Equal(UndoMemoryBytes, after.UndoMemoryBytes); + Assert.Equal(UndoTrimmed, after.UndoTrimmed); + Assert.Same(CachedProjection, after.CachedProjection); + Assert.Same(InitialProjection, after.InitialProjection); + Assert.Same(CachedAnchorIndex, after.CachedAnchorIndex); + Assert.Same(RawOps, after.RawOps); + } + + private static string SettingsReceipt(DocxSessionSettings settings) + { + var projection = settings.ProjectionSettings; + return string.Join('|', + settings.UndoDepth, + settings.UndoMemoryBudgetBytes, + settings.ValidateRawOps, + settings.TrackedChanges, + settings.RevisionAuthor, + settings.PersistAnchorIds, + settings.SmartQuotes, + settings.EmitMarkdownPatch, + settings.CaptureInitialProjection, + projection.Scopes, + projection.HeadingLevelOffset, + projection.AnchorMode, + projection.TableMode, + projection.TableInlineCellMax, + projection.TrackedChanges, + projection.ResolveNumbering, + projection.EmptyParagraphs, + projection.AnchorIdRendering); + } + } + + private static IReadOnlyDictionary HashOpcEntries(byte[] bytes) + { + using var archive = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + return archive.Entries.OrderBy(entry => entry.FullName, StringComparer.Ordinal) + .ToDictionary( + entry => entry.FullName, + entry => + { + using var stream = entry.Open(); + using var copy = new MemoryStream(); + stream.CopyTo(copy); + return Convert.ToHexString(SHA256.HashData(copy.ToArray())); + }, + StringComparer.Ordinal); + } + + private static T PrivateField(DocxSession session, string name) => + (T)typeof(DocxSession).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(session)!; + + private static DocxSession OpenRich(DocxSessionSettings? settings = null) => + new(RichBytes(), settings ?? new DocxSessionSettings { PersistAnchorIds = true }); + + private static byte[] RichBytes() + { + using var stream = new MemoryStream(); + stream.Write(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + stream.Position = 0; + using (var package = WordprocessingDocument.Open(stream, isEditable: true)) + { + var main = package.MainDocumentPart!; + var custom = main.AddCustomXmlPart(CustomXmlPartType.CustomXml); + custom.FeedData(new MemoryStream(Encoding.UTF8.GetBytes( + "opaque"))); + var image = main.AddImagePart(ImagePartType.Png); + image.FeedData(new MemoryStream(Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="))); + main.AddHyperlinkRelationship(new Uri("https://example.test/preview"), true); + package.Save(); + } + return stream.ToArray(); + } + + private static string[] BodyParagraphs(DocxSession session) => + session.Project().AnchorIndex.Keys + .Where(id => id.StartsWith("p:body:", StringComparison.Ordinal)) + .ToArray(); +} diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs index fbf612d8..674fe487 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -962,6 +962,13 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged() var sessionArg = JsonSerializer.Serialize(sessionId); var anchor = FirstBodyAnchorId(sessionId, _store); + // Preserve a live redo cursor across preview; apply-then-undo used to destroy it. + Assert.True(ReplaceText(_store, sessionId, anchor, "redo target") + .GetProperty("success").GetBoolean()); + Assert.True(Parse(Dispatcher.Call(_store, "docxodus_edit", J( + $$"""{"sessionId":{{sessionArg}},"action":"undo"}"""))) + .GetProperty("success").GetBoolean()); + var before = Parse(Dispatcher.Call(_store, "docxodus_get_content", J($$"""{"sessionId":{{sessionArg}},"format":"markdown"}"""))) .GetProperty("markdown").GetString()!; var versionBefore = Parse(Dispatcher.Call(_store, "docxodus_get_content", J( @@ -973,12 +980,23 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged() { "sessionId": {{sessionArg}}, "mode": "preview", + "previewHtml": "full", "steps": [ { "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "should not stick" } } ] } """))); Assert.Equal("ok", batch.GetProperty("status").GetString()); + Assert.True(batch.GetProperty("preview").GetBoolean()); + Assert.True(batch.GetProperty("success").GetBoolean()); + Assert.Equal(versionBefore, batch.GetProperty("baseVersion").GetInt64()); + Assert.Equal(versionBefore + 1, batch.GetProperty("resultVersion").GetInt64()); + Assert.Equal(64, batch.GetProperty("packageHash").GetString()!.Length); + Assert.Single(batch.GetProperty("steps").EnumerateArray()); + Assert.True(batch.GetProperty("revisionChanges").TryGetProperty("added", out _)); + Assert.True(batch.GetProperty("commentChanges").TryGetProperty("added", out _)); + Assert.True(batch.GetProperty("annotationChanges").TryGetProperty("added", out _)); + Assert.Contains("should not stick", batch.GetProperty("html").GetString()); var after = Parse(Dispatcher.Call(_store, "docxodus_get_content", J($$"""{"sessionId":{{sessionArg}},"format":"markdown"}"""))) .GetProperty("markdown").GetString()!; @@ -987,6 +1005,57 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged() $$"""{"sessionId":{{sessionArg}},"format":"version"}"""))) .GetProperty("version").GetInt64(); Assert.Equal(versionBefore, versionAfter); + + var undo = Parse(Dispatcher.Call(_store, "docxodus_edit", J( + $$"""{"sessionId":{{sessionArg}},"action":"undo"}"""))); + Assert.False(undo.GetProperty("success").GetBoolean()); + var redo = Parse(Dispatcher.Call(_store, "docxodus_edit", J( + $$"""{"sessionId":{{sessionArg}},"action":"redo"}"""))); + Assert.True(redo.GetProperty("success").GetBoolean()); + var redone = Parse(Dispatcher.Call(_store, "docxodus_get_content", J( + $$"""{"sessionId":{{sessionArg}},"format":"markdown"}"""))) + .GetProperty("markdown").GetString(); + Assert.Contains("redo target", redone); + } + + [Fact] + public void MCP098_Mutations_PreviewFlagSupportsExplicitBestEffortWithoutLivePartialApply() + { + var sessionId = OpenSession(); + var sessionArg = JsonSerializer.Serialize(sessionId); + var anchor = FirstBodyAnchorId(sessionId, _store); + var before = Parse(Dispatcher.Call(_store, "docxodus_get_content", J( + $$"""{"sessionId":{{sessionArg}},"format":"markdown"}"""))) + .GetProperty("markdown").GetString(); + + var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J( + $$""" + { + "sessionId": {{sessionArg}}, + "mode": "best_effort", + "preview": true, + "steps": [ + { "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "{{anchor}}", "markdown": "shadow partial" } }, + { "tool": "docxodus_edit", "args": { "action": "replace_text", "anchorId": "p:body:missing", "markdown": "failure" } } + ] + } + """))); + + Assert.True(batch.GetProperty("preview").GetBoolean()); + Assert.Equal("best_effort", batch.GetProperty("mode").GetString()); + Assert.Equal("partial", batch.GetProperty("status").GetString()); + Assert.False(batch.GetProperty("success").GetBoolean()); + Assert.False(batch.GetProperty("rolledBack").GetBoolean()); + Assert.Equal(batch.GetProperty("baseVersion").GetInt64() + 1, + batch.GetProperty("resultVersion").GetInt64()); + Assert.Contains(batch.GetProperty("warnings").EnumerateArray(), + warning => warning.GetString()!.Contains("Best-effort", StringComparison.Ordinal)); + + var after = Parse(Dispatcher.Call(_store, "docxodus_get_content", J( + $$"""{"sessionId":{{sessionArg}},"format":"markdown"}"""))) + .GetProperty("markdown").GetString(); + Assert.Equal(before, after); + Assert.Equal(0, Docxodus.Internal.DocxSessionOps.GetVersion(_store.Get(sessionId).Handle)); } [Fact] diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 0198b481..1c55a8bd 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -4,9 +4,13 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Buffers.Binary; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Xml.Linq; using DocumentFormat.OpenXml.Packaging; using GridCell = Docxodus.Internal.TableGridCell; @@ -1231,15 +1235,59 @@ public sealed record MutationBatchFailure( EditError Error, bool RolledBack); +/// Added, removed, and modified semantic objects predicted or produced by a batch. +public sealed record MutationBatchChangeSet( + IReadOnlyList Added, + IReadOnlyList Removed, + IReadOnlyList Modified) +{ + public static MutationBatchChangeSet Empty { get; } = new( + Array.Empty(), Array.Empty(), Array.Empty()); +} + +/// Optional HTML projection generated only from an isolated preview session. +public enum MutationPreviewHtmlMode +{ + None, + Scoped, + Full, +} + +/// Optional outputs for . +public sealed record MutationBatchPreviewOptions +{ + public MutationPreviewHtmlMode HtmlMode { get; init; } + public string? HtmlAnchorId { get; init; } +} + /// Structured result of an atomic or explicit best-effort mutation batch. public sealed record MutationBatchResult { public MutationBatchMode Mode { get; init; } + public bool Preview { get; init; } public bool Success { get; init; } public bool RolledBack { get; init; } + public long BaseVersion { get; init; } + public long ResultVersion { get; init; } + /// + /// SHA-256 over ordered OPC entry names and uncompressed payload bytes. ZIP compression, + /// timestamps, and entry framing are deliberately excluded; XML payload timestamps remain. + /// A deterministic replay at should produce this hash. Generated + /// anchors/OOXML ids and execution timestamps make other batches only semantically equivalent; + /// callers must consult before using the hash as a replay assertion. + /// + public string PackageHash { get; init; } = string.Empty; public IReadOnlyList Steps { get; init; } = Array.Empty(); public MutationBatchFailure? Failure { get; init; } + public MutationBatchChangeSet RevisionChanges { get; init; } = + MutationBatchChangeSet.Empty; + public MutationBatchChangeSet CommentChanges { get; init; } = + MutationBatchChangeSet.Empty; + public MutationBatchChangeSet AnnotationChanges { get; init; } = + MutationBatchChangeSet.Empty; + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public string? Html { get; init; } } /// @@ -1545,6 +1593,14 @@ private sealed record TransactionState( Exception? LastRollbackError); public DocxSession(byte[] docxBytes, DocxSessionSettings? settings = null) + : this(docxBytes, settings, skipInitialProjectionCapture: false) + { + } + + private DocxSession( + byte[] docxBytes, + DocxSessionSettings? settings, + bool skipInitialProjectionCapture) { ArgumentNullException.ThrowIfNull(docxBytes); _settings = settings ?? new DocxSessionSettings(); @@ -1561,7 +1617,7 @@ public DocxSession(byte[] docxBytes, DocxSessionSettings? settings = null) _stream.Position = 0; _doc = WordprocessingDocument.Open(_stream, isEditable: true); - if (_settings.CaptureInitialProjection) + if (_settings.CaptureInitialProjection && !skipInitialProjectionCapture) _initialProjection = WmlToMarkdownConverter.Convert(_doc!, _settings.ProjectionSettings); } @@ -2829,17 +2885,359 @@ private void RestoreTransactionState(TransactionState state) public MutationBatchResult ExecuteBatch( IEnumerable steps, MutationBatchMode mode = MutationBatchMode.Atomic) + { + lock (_mutationGate) + { + var materialized = MaterializeBatchSteps(steps, mode); + var before = ObserveBatchSemantics(); + var baseVersion = _version; + var result = mode == MutationBatchMode.Atomic + ? ExecuteAtomicBatch(materialized) + : ExecuteBestEffortBatch(materialized); + return CompleteBatchResult(result, before, baseVersion); + } + } + + /// + /// Execute the identical batch delegates on a complete isolated clone. The live session is + /// used only long enough to clone its current logical package and scalar configuration under + /// the mutation gate; guards, mutations, history writes, semantic inspection, package hashing, + /// and optional HTML rendering all target the shadow. Abandoning or disposing the shadow can + /// therefore never require live rollback. + /// + public MutationBatchResult PreviewBatch( + IEnumerable steps, + MutationBatchMode mode = MutationBatchMode.Atomic, + MutationBatchPreviewOptions? options = null) + { + ValidatePreviewOptions(options); + var materialized = MaterializeBatchSteps(steps, mode); + using var shadow = CreateShadowSession(); + return shadow.FinalizePreviewResult(shadow.ExecuteBatch(materialized, mode), options); + } + + internal static void ValidatePreviewOptions(MutationBatchPreviewOptions? options) + { + if (options is not null && !Enum.IsDefined(options.HtmlMode)) + throw new ArgumentOutOfRangeException( + nameof(options), options.HtmlMode, "unknown preview HTML mode"); + } + + private static MutationBatchStep[] MaterializeBatchSteps( + IEnumerable steps, + MutationBatchMode mode) { ArgumentNullException.ThrowIfNull(steps); if (!Enum.IsDefined(mode)) throw new ArgumentOutOfRangeException(nameof(mode), mode, "unknown mutation batch mode"); var materialized = steps.ToArray(); - if (materialized.Any(s => s is null)) + if (materialized.Any(step => step is null)) throw new ArgumentException("batch steps cannot contain null", nameof(steps)); + return materialized; + } + + private sealed record BatchSemanticObservation( + IReadOnlyList? Revisions, + IReadOnlyList? Comments, + IReadOnlyList? Annotations, + IReadOnlyList Warnings); + + private BatchSemanticObservation ObserveBatchSemantics() + { + IReadOnlyList? revisions = null; + IReadOnlyList? comments = null; + IReadOnlyList? annotations = null; + var warnings = new List(); + try { revisions = ListRevisions(); } + catch (Exception ex) { warnings.Add($"Revision delta inspection unavailable: {ex.Message}"); } + try { comments = ListComments(); } + catch (Exception ex) { warnings.Add($"Comment delta inspection unavailable: {ex.Message}"); } + try { annotations = ListAnnotations(); } + catch (Exception ex) { warnings.Add($"Annotation delta inspection unavailable: {ex.Message}"); } + return new BatchSemanticObservation(revisions, comments, annotations, warnings); + } + + private MutationBatchResult CompleteBatchResult( + MutationBatchResult result, + BatchSemanticObservation before, + long baseVersion) + { + var after = ObserveBatchSemantics(); + var warnings = before.Warnings.Concat(after.Warnings).ToList(); + var revisionChanges = SafeChangeSet( + before.Revisions, after.Revisions, revision => revision.Id, + static (left, right) => left == right, "revision", warnings); + var commentChanges = SafeChangeSet( + before.Comments, after.Comments, comment => comment.DefAnchorId, + static (left, right) => left == right, "comment", warnings); + var annotationChanges = SafeChangeSet( + before.Annotations, after.Annotations, annotation => annotation.Id, + static (left, right) => string.Equals( + Internal.DocxSessionJson.SerializeAnnotations(new[] { left }), + Internal.DocxSessionJson.SerializeAnnotations(new[] { right }), + StringComparison.Ordinal), + "annotation", warnings); + if (revisionChanges.Added.Concat(revisionChanges.Modified).Any(revision => revision.Date is not null)) + { + warnings.Add( + "Tracked-revision date attributes may use the execution clock; compare revision " + + "ids, authors, types, text, and anchors across separate executions."); + } + if (commentChanges.Added.Concat(commentChanges.Modified).Any(comment => comment.Date is not null)) + { + warnings.Add( + "Comment date attributes may be generated from the execution clock; supply dates " + + "explicitly when byte-identical replay is required."); + } + if (annotationChanges.Added.Any(annotation => annotation.Created.HasValue)) + { + warnings.Add( + "Auto-generated annotation ids or creation timestamps are execution metadata; " + + "supply id and created explicitly when byte-identical replay is required."); + } + try + { + if (result.Steps.SelectMany(step => step.Results).Any(edit => edit.Created.Count > 0)) + { + warnings.Add( + "Created anchors and related OOXML ids may be generated independently on replay; " + + "preview/apply equivalence is semantic and packageHash or anchor ids may differ."); + } + } + catch (Exception ex) + { + warnings.Add($"Generated-field warning inspection unavailable: {ex.Message}"); + } + if (result.Mode == MutationBatchMode.BestEffort && !result.Success) + warnings.Add("Best-effort execution retains every successful step despite later failures."); + + var packageHash = string.Empty; + try { packageHash = GetPackageContentHash(); } + catch (Exception ex) { warnings.Add($"Package equivalence hash unavailable: {ex.Message}"); } - return mode == MutationBatchMode.Atomic - ? ExecuteAtomicBatch(materialized) - : ExecuteBestEffortBatch(materialized); + return result with + { + BaseVersion = baseVersion, + ResultVersion = _version, + PackageHash = packageHash, + RevisionChanges = revisionChanges, + CommentChanges = commentChanges, + AnnotationChanges = annotationChanges, + Warnings = warnings, + }; + } + + private static MutationBatchChangeSet SafeChangeSet( + IReadOnlyList? before, + IReadOnlyList? after, + Func key, + Func equivalent, + string kind, + List warnings) + { + if (before is null || after is null) + return MutationBatchChangeSet.Empty; + try { return ChangeSet(before, after, key, equivalent); } + catch (Exception ex) + { + warnings.Add($"{kind} delta comparison unavailable: {ex.Message}"); + return MutationBatchChangeSet.Empty; + } + } + + private static MutationBatchChangeSet ChangeSet( + IReadOnlyList before, + IReadOnlyList after, + Func key, + Func equivalent) + { + static Dictionary> IndexByKey( + IReadOnlyList items, + Func selectKey) + { + var groups = new Dictionary>(StringComparer.Ordinal); + for (var index = 0; index < items.Count; index++) + { + var itemKey = selectKey(items[index]) ?? string.Empty; + if (!groups.TryGetValue(itemKey, out var indices)) + groups[itemKey] = indices = new List(); + indices.Add(index); + } + return groups; + } + + // Real-world packages occasionally contain duplicate revision ids across story parts. + // Treat each identity as a multiset: match equivalent occurrences first, classify paired + // leftovers as modified, then classify cardinality differences as added/removed. This is + // deterministic and cannot throw merely because a package is malformed or unconventional. + var beforeGroups = IndexByKey(before, key); + var afterGroups = IndexByKey(after, key); + var beforeMatched = new bool[before.Count]; + var afterMatched = new bool[after.Count]; + var afterModified = new bool[after.Count]; + + foreach (var group in afterGroups) + { + if (!beforeGroups.TryGetValue(group.Key, out var beforeIndices)) + continue; + + foreach (var afterIndex in group.Value) + { + var beforeIndex = beforeIndices.FirstOrDefault( + candidate => !beforeMatched[candidate] + && equivalent(before[candidate], after[afterIndex]), + -1); + if (beforeIndex < 0) continue; + beforeMatched[beforeIndex] = true; + afterMatched[afterIndex] = true; + } + + var remainingBefore = beforeIndices.Where(index => !beforeMatched[index]).ToArray(); + var remainingAfter = group.Value.Where(index => !afterMatched[index]).ToArray(); + var modifiedCount = Math.Min(remainingBefore.Length, remainingAfter.Length); + for (var index = 0; index < modifiedCount; index++) + { + beforeMatched[remainingBefore[index]] = true; + afterMatched[remainingAfter[index]] = true; + afterModified[remainingAfter[index]] = true; + } + } + + return new MutationBatchChangeSet( + after.Where((_, index) => !afterMatched[index]).ToArray(), + before.Where((_, index) => !beforeMatched[index]).ToArray(), + after.Where((_, index) => afterModified[index]).ToArray()); + } + + /// Create a complete isolated clone for handle-based façades and abandonment tests. + internal DocxSession CreateShadowSession() + { + lock (_mutationGate) + { + ThrowIfDisposed(); + var snapshot = TakePackageSnapshot(); + var shadow = new DocxSession( + snapshot.PackageBytes!, + CloneSettingsForShadow(), + skipInitialProjectionCapture: true) + { + _version = _version, + _revisionCounter = snapshot.RevisionCounter ?? _revisionCounter, + _lastFormatRevisionTicks = snapshot.LastFormatRevisionTicks ?? _lastFormatRevisionTicks, + _nextTransactionId = _nextTransactionId, + _initialProjection = CloneProjection(_initialProjection), + _trackedChanges = _trackedChanges, + _revisionAuthor = _revisionAuthor, + }; + return shadow; + } + } + + private static MarkdownProjection? CloneProjection(MarkdownProjection? source) + { + if (source is null) return null; + return new MarkdownProjection + { + Markdown = source.Markdown, + AnchorIndex = source.AnchorIndex.ToDictionary( + pair => pair.Key, + pair => new AnchorTarget + { + Anchor = pair.Value.Anchor, + PartUri = pair.Value.PartUri, + Unid = pair.Value.Unid, + TextPreview = pair.Value.TextPreview, + AutoNumberPrefix = pair.Value.AutoNumberPrefix, + }, + StringComparer.Ordinal), + }; + } + + private DocxSessionSettings CloneSettingsForShadow() + { + var projection = _settings.ProjectionSettings; + return new DocxSessionSettings + { + UndoDepth = _settings.UndoDepth, + UndoMemoryBudgetBytes = _settings.UndoMemoryBudgetBytes, + ValidateRawOps = _settings.ValidateRawOps, + TrackedChanges = _settings.TrackedChanges, + RevisionAuthor = _settings.RevisionAuthor, + PersistAnchorIds = _settings.PersistAnchorIds, + SmartQuotes = _settings.SmartQuotes, + EmitMarkdownPatch = _settings.EmitMarkdownPatch, + CaptureInitialProjection = _settings.CaptureInitialProjection, + ProjectionSettings = new WmlToMarkdownConverterSettings + { + Scopes = projection.Scopes, + HeadingLevelOffset = projection.HeadingLevelOffset, + AnchorMode = projection.AnchorMode, + TableMode = projection.TableMode, + TableInlineCellMax = projection.TableInlineCellMax, + TrackedChanges = projection.TrackedChanges, + ResolveNumbering = projection.ResolveNumbering, + ImageUriBuilder = projection.ImageUriBuilder, + EmptyParagraphs = projection.EmptyParagraphs, + AnchorIdRendering = projection.AnchorIdRendering, + }, + }; + } + + /// Mark a result produced on this shadow and optionally render shadow-only HTML. + internal MutationBatchResult FinalizePreviewResult( + MutationBatchResult result, + MutationBatchPreviewOptions? options) + { + var warnings = result.Warnings.ToList(); + string? html = null; + try + { + switch (options?.HtmlMode ?? MutationPreviewHtmlMode.None) + { + case MutationPreviewHtmlMode.None: + break; + case MutationPreviewHtmlMode.Scoped when string.IsNullOrWhiteSpace(options?.HtmlAnchorId): + warnings.Add("Scoped HTML was requested without htmlAnchorId; no HTML was generated."); + break; + case MutationPreviewHtmlMode.Scoped: + html = Internal.HtmlConversionOps.RenderBlockHtml( + this, + options!.HtmlAnchorId!, + new Internal.HtmlConversionOptions + { + RenderTrackedChanges = true, + RenderFootnotesAndEndnotes = true, + StampAnchors = true, + }); + break; + case MutationPreviewHtmlMode.Full: + html = Internal.HtmlConversionOps.ConvertToHtml( + this, + new Internal.HtmlConversionOptions + { + CommentRenderMode = 0, + RenderAnnotations = true, + RenderFootnotesAndEndnotes = true, + RenderHeadersAndFooters = true, + RenderTrackedChanges = true, + StampAnchors = true, + }); + break; + default: + throw new ArgumentOutOfRangeException(nameof(options), "unknown preview HTML mode"); + } + } + catch (Exception ex) + { + warnings.Add($"Preview HTML could not be generated: {ex.Message}"); + } + + return result with + { + Preview = true, + Warnings = warnings, + Html = html, + }; } private MutationBatchResult ExecuteAtomicBatch(IReadOnlyList steps) @@ -10502,7 +10900,7 @@ private void OnHistoryPopUndo(DocumentSnapshot snapshot) /// Restore the caller-visible version after rolling back speculative preview work. /// Internal by design: committed undo/redo must remain monotonic. - internal void RestorePreviewVersion(long version) => _version = version; + internal void RestoreVersionAfterRebind(long version) => _version = version; /// /// Dispose the session and abandon any active transactions. Active scopes may only be @@ -10687,6 +11085,44 @@ private byte[] SerializePackageCheckpoint() return ZipPackageOutputNormalizer.Normalize(stream.ToArray()); } + /// + /// Deterministic digest of the current logical OPC package. The checkpoint clone overlays + /// dirty XDocument caches without writing them to this session. Hashing ordered uncompressed + /// entry payloads excludes ZIP timestamps/compression while retaining every part byte and + /// relationship payload, including media and opaque custom XML. + /// + internal string GetPackageContentHash() + { + var packageBytes = SerializePackageCheckpoint(); + try + { + using var stream = new MemoryStream(packageBytes, writable: false); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[81920]; + Span intBuffer = stackalloc byte[sizeof(int)]; + Span longBuffer = stackalloc byte[sizeof(long)]; + foreach (var entry in archive.Entries.OrderBy(entry => entry.FullName, StringComparer.Ordinal)) + { + var name = Encoding.UTF8.GetBytes(entry.FullName); + BinaryPrimitives.WriteInt32LittleEndian(intBuffer, name.Length); + hash.AppendData(intBuffer); + hash.AppendData(name); + BinaryPrimitives.WriteInt64LittleEndian(longBuffer, entry.Length); + hash.AppendData(longBuffer); + using var input = entry.Open(); + int read; + while ((read = input.Read(buffer, 0, buffer.Length)) > 0) + hash.AppendData(buffer, 0, read); + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + catch (InvalidDataException) + { + return Convert.ToHexString(SHA256.HashData(packageBytes)).ToLowerInvariant(); + } + } + private static IEnumerable EnumeratePackageParts(OpenXmlPackage package) { var pending = new Stack(package.Parts.Select(pair => pair.OpenXmlPart)); diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs index 1964af50..e7439404 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -1015,8 +1015,12 @@ public static string SerializeMutationBatchResult(MutationBatchResult result) ? "partial" : "failed"; sb.Append("{\"mode\":").Append(JsonString(mode)) .Append(",\"status\":").Append(JsonString(status)) + .Append(",\"preview\":").Append(result.Preview ? "true" : "false") .Append(",\"success\":").Append(result.Success ? "true" : "false") .Append(",\"rolledBack\":").Append(result.RolledBack ? "true" : "false") + .Append(",\"baseVersion\":").Append(result.BaseVersion) + .Append(",\"resultVersion\":").Append(result.ResultVersion) + .Append(",\"packageHash\":").Append(JsonString(result.PackageHash)) .Append(",\"steps\":["); for (int i = 0; i < result.Steps.Count; i++) { @@ -1066,10 +1070,36 @@ public static string SerializeMutationBatchResult(MutationBatchResult result) .Append(",\"rolledBack\":").Append(failure.RolledBack ? "true" : "false") .Append('}'); } + sb.Append(",\"revisionChanges\":"); + AppendChangeSet(sb, result.RevisionChanges, SerializeRevisionList); + sb.Append(",\"commentChanges\":"); + AppendChangeSet(sb, result.CommentChanges, SerializeCommentList); + sb.Append(",\"annotationChanges\":"); + AppendChangeSet(sb, result.AnnotationChanges, SerializeAnnotations); + sb.Append(",\"warnings\":["); + for (int i = 0; i < result.Warnings.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append(JsonString(result.Warnings[i])); + } + sb.Append(']') + .Append(",\"html\":") + .Append(result.Html is null ? "null" : JsonString(result.Html)); sb.Append('}'); return sb.ToString(); } + private static void AppendChangeSet( + StringBuilder sb, + MutationBatchChangeSet changes, + System.Func, string> serialize) + { + sb.Append("{\"added\":").Append(serialize(changes.Added)) + .Append(",\"removed\":").Append(serialize(changes.Removed)) + .Append(",\"modified\":").Append(serialize(changes.Modified)) + .Append('}'); + } + public static void AppendAnchorArray(StringBuilder sb, IReadOnlyList anchors) { sb.Append('['); diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs index 551bca1d..8e306b4e 100644 --- a/Docxodus/Internal/DocxSessionOps.cs +++ b/Docxodus/Internal/DocxSessionOps.cs @@ -84,8 +84,8 @@ public static string CheckPreconditions(int handle, MutationPreconditions? preco : new EditResult { Success = false, Error = error }); } - internal static void RestorePreviewVersion(int handle, long version) => - SessionRegistry.Get(handle).RestorePreviewVersion(version); + internal static void RestoreVersionAfterRebind(int handle, long version) => + SessionRegistry.Get(handle).RestoreVersionAfterRebind(version); public static string ExecuteBatch( int handle, @@ -94,6 +94,38 @@ public static string ExecuteBatch( DocxSessionJson.SerializeMutationBatchResult( SessionRegistry.Get(handle).ExecuteBatch(steps, mode)); + /// + /// Execute a serialized/handle-addressed batch against a complete isolated clone. The step + /// factory receives only the temporary shadow handle, which makes accidentally targeting the + /// live handle impossible at this central transport seam. The shadow is disposed on every + /// return/throw path; process abandonment is also safe because the live package was never a + /// mutation target. + /// + public static string PreviewBatch( + int liveHandle, + MutationBatchMode mode, + System.Func> shadowSteps, + MutationBatchPreviewOptions? options = null) + { + DocxSession.ValidatePreviewOptions(options); + var shadowHandle = SessionRegistry.CloneSessionForPreview(liveHandle); + try + { + var shadow = SessionRegistry.Get(shadowHandle); + return DocxSessionJson.SerializeMutationBatchResult( + shadow.FinalizePreviewResult( + shadow.ExecuteBatch(shadowSteps(shadowHandle), mode), + options)); + } + finally + { + SessionRegistry.CloseSession(shadowHandle); + } + } + + public static string GetPackageContentHash(int handle) => + SessionRegistry.Get(handle).GetPackageContentHash(); + public static DocxSessionTransaction BeginTransaction(int handle) => SessionRegistry.Get(handle).BeginTransaction(); diff --git a/Docxodus/Internal/SessionRegistry.cs b/Docxodus/Internal/SessionRegistry.cs index 235d2fd7..5a58b4db 100644 --- a/Docxodus/Internal/SessionRegistry.cs +++ b/Docxodus/Internal/SessionRegistry.cs @@ -20,6 +20,30 @@ internal static class SessionRegistry public static int OpenSession(byte[] bytes, DocxSessionSettings? settings) { var session = new DocxSession(bytes, settings); + return Register(session); + } + + /// + /// Register a complete isolated clone of an existing session. The returned handle owns only + /// the shadow; callers must close it in a finally block. No live history/cache/config object is + /// shared with the clone. + /// + public static int CloneSessionForPreview(int handle) + { + var shadow = Get(handle).CreateShadowSession(); + try + { + return Register(shadow); + } + catch + { + shadow.Dispose(); + throw; + } + } + + private static int Register(DocxSession session) + { var id = Interlocked.Increment(ref _nextId); _sessions[id] = session; return id; diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md index 5e863ad6..3a7e2de5 100644 --- a/docs/architecture/docx_agent_server.md +++ b/docs/architecture/docx_agent_server.md @@ -411,7 +411,7 @@ whole-document resolution: they transform via `RevisionProcessor` and swap the s underlying handle in place (`SessionStore.Rebind`), which also covers the exotic families the per-revision listing does not enumerate (see Known gaps). -### `docxodus_mutations` — atomic batches, explicit partial apply, or legacy preview +### `docxodus_mutations` — atomic batches, explicit partial apply, or isolated preview `steps: [{ tool, args }]` where `tool` is one of `docxodus_edit`/`docxodus_format`/ `docxodus_create`/`docxodus_table`/`docxodus_list`/`docxodus_comment` (their `undo`/`redo` and @@ -429,9 +429,34 @@ state, version, and undo/redo cursors; the receipt identifies the failing order and evaluates a step preflight immediately before that step, returning a `{ status, editsApplied, results, errors }`-compatible receipt (`status` is `ok`/`partial`/`failed`). `mode: apply` is a deprecated compatibility alias for -`best_effort`; new clients should use the risk-signaling spelling. `mode: preview` runs every step exactly the same way, then calls -`DocxSessionOps.Undo` once per step that actually mutated before returning — see Known gaps for -why this is "apply-then-undo" rather than a true no-op dry run. +`best_effort`; new clients should use the risk-signaling spelling. + +Preview never executes against the live handle. `mode: preview` is an atomic-preview shorthand; +`previewPolicy: best_effort` opts into partial-success prediction. New clients may instead combine +`preview: true` with `mode: atomic` or `mode: best_effort`. The server captures the complete logical +OPC package (all parts, relationships, media, and opaque custom XML), mutable session configuration, +version and revision/transaction generators, and the original `GetDiff()` baseline into an isolated +shadow session with independent caches and empty undo/redo history. It then builds the same step +delegates and invokes the same `ExecuteBatch` path used by apply. Success, structured failure, thrown +exceptions, timeout/abandonment, and disposal therefore need no live rollback: live bytes, anchor +state, version, caches, settings, and both history cursors were never mutation targets. + +Preview receipts use the same typed result as apply: `baseVersion`, predicted `resultVersion`, each +step's `created`/`removed`/`modified` anchors and markdown patch, revision/comment/annotation +`{ added, removed, modified }` deltas, warnings, and a `packageHash`. `previewHtml` may be `scoped` +(with `previewAnchorId`) or `full`; rendering occurs only from the final shadow package, and an +optional rendering failure is a warning rather than turning a committed/predicted mutation into an +apparent failure. The content hash is SHA-256 over sorted OPC entry names plus their uncompressed +payload bytes with fixed little-endian framing, excluding ZIP timestamps/compression but not XML +timestamps or generated OOXML ids. + +Equivalence is exact for deterministic batches: replaying at the same base state produces the same +step outcomes, semantic deltas, and package hash. For create/comment/note/image operations that +generate anchors or OOXML ids, and operations that stamp the execution clock, equivalence means the +same success/failure outcomes and the same document structure, content, and relationship semantics +modulo those generated ids/timestamps. Such receipts carry warnings; clients must not require anchor +id or `packageHash` equality unless the operation supplies stable ids/timestamps or is otherwise +known deterministic. The batch itself and each step's `args` may carry `preconditions`, using the same camel-case guard object as the core API (`expectedVersion`, `anchorId`, @@ -440,8 +465,8 @@ failure is the standard structured `precondition_failed` result. Atomic mode evaluates all step guards at the common batch-start boundary; best-effort mode evaluates them sequentially. `docxodus_get_content` with `format: "version"` reads the current monotonic document version; `format: "check_preconditions"` evaluates guards -without mutating. Preview restores its starting version after undoing speculative -steps, so a dry-run does not make an otherwise-current plan stale. +without mutating. Preview evaluates these guards and predicts versions entirely on the shadow, so a +dry-run does not make an otherwise-current live plan stale. ### `docxodus_table` — tables @@ -531,12 +556,13 @@ never claiming a capability it doesn't have: composing `InsertParagraph` (plain text) + `ApplyListFormat` (which *does* write real `w:numPr` via `NumberingFactory.EnsureNumbering`) — two calls, not a gap in what's reachable, just not a single one-shot "insert a numbered list" primitive. -- **`docxodus_mutations`'s `preview` mode is apply-then-undo, not a true dry run.** It runs every - step for real, then calls the session's `Undo()` once per step that mutated. This composes - correctly with everything else (bounded undo ring, anchor lifecycle) but consumes undo-ring - depth like any other edit sequence, and a crash between "apply" and "undo" would leave the - session mutated — acceptable for a local, single-process tool server, worth knowing if this - surface is ever exposed somewhere more failure-sensitive. +- **Generated-id previews are semantically, not necessarily byte-for-byte, replay-equivalent.** + Create/comment/note/image paths can allocate fresh anchors or OOXML ids, and tracked revisions + can stamp the execution clock. Preview and apply still take the identical dispatch path and + predict the same structure/content/relationship effects, but a later apply may return different + generated anchor ids and a different `packageHash`. The receipt warns whenever a successful step + reports created anchors or another known execution-generated field. Deterministic mutation-only + batches retain exact receipt/hash equivalence. ## Testing diff --git a/npm/src/session.ts b/npm/src/session.ts index f55e7e54..ac061d7b 100644 --- a/npm/src/session.ts +++ b/npm/src/session.ts @@ -43,7 +43,10 @@ import type { GrepOptions, ListMembership, MutationBatchFailure, + MutationBatchChangeSet, MutationBatchMode, + MutationBatchPreviewOptions, + MutationBatchPreviewStep, MutationBatchResult, MutationBatchStep, MutationBatchStepResult, @@ -57,6 +60,52 @@ import type { import type { PageMap } from "./pagination.js"; import { ContextBoundary, DiffFormat, PlaceholderKinds, ProjectionDepth, TrackedChangeMode } from "./types.js"; +function mutationBatchChangeSet( + before: readonly T[], + after: readonly T[], + key: (value: T) => string, +): MutationBatchChangeSet { + const beforeGroups = new Map(); + const afterGroups = new Map(); + const group = (items: readonly T[], target: Map): void => { + items.forEach((item, index) => { + const identity = key(item); + const indices = target.get(identity) ?? []; + indices.push(index); + target.set(identity, indices); + }); + }; + group(before, beforeGroups); + group(after, afterGroups); + + const beforeMatched = before.map(() => false); + const afterMatched = after.map(() => false); + const modified = after.map(() => false); + for (const [identity, afterIndices] of afterGroups) { + const beforeIndices = beforeGroups.get(identity) ?? []; + for (const afterIndex of afterIndices) { + const beforeIndex = beforeIndices.find(index => + !beforeMatched[index] && JSON.stringify(before[index]) === JSON.stringify(after[afterIndex])); + if (beforeIndex === undefined) continue; + beforeMatched[beforeIndex] = true; + afterMatched[afterIndex] = true; + } + const remainingBefore = beforeIndices.filter(index => !beforeMatched[index]); + const remainingAfter = afterIndices.filter(index => !afterMatched[index]); + const modifiedCount = Math.min(remainingBefore.length, remainingAfter.length); + for (let index = 0; index < modifiedCount; index++) { + beforeMatched[remainingBefore[index]!] = true; + afterMatched[remainingAfter[index]!] = true; + modified[remainingAfter[index]!] = true; + } + } + return { + added: after.filter((_, index) => !afterMatched[index]), + removed: before.filter((_, index) => !beforeMatched[index]), + modified: after.filter((_, index) => modified[index]), + }; +} + /** * Stateful in-memory DOCX editing session keyed by markdown-projection anchor ids. * Mirror of the .NET `DocxSession` surface. See @@ -142,6 +191,100 @@ export class DocxSession { if (mode !== "atomic" && mode !== "best_effort") { throw new RangeError(`unknown mutation batch mode: ${String(mode)}`); } + const baseVersion = this.getVersion(); + const observationWarnings: string[] = []; + const inspect = (label: string, read: () => T, fallback: T): T => { + try { return read(); } catch (error) { + observationWarnings.push(`${label} unavailable: ${error instanceof Error ? error.message : String(error)}`); + return fallback; + } + }; + const beforeRevisions = inspect("Revision delta inspection", () => this.listRevisions(), []); + const beforeComments = inspect("Comment delta inspection", () => this.listComments(), []); + const beforeAnnotations = inspect("Annotation delta inspection", () => this.listAnnotations(), []); + const complete = (result: { + mode: MutationBatchMode; + status: "ok" | "failed" | "partial"; + success: boolean; + rolledBack: boolean; + steps: readonly MutationBatchStepResult[]; + failure?: MutationBatchFailure; + }): MutationBatchResult => { + try { + const revisionChanges = mutationBatchChangeSet( + beforeRevisions, + inspect("Revision delta inspection", () => this.listRevisions(), beforeRevisions), + revision => revision.id, + ); + const commentChanges = mutationBatchChangeSet( + beforeComments, + inspect("Comment delta inspection", () => this.listComments(), beforeComments), + comment => comment.anchorId, + ); + const annotationChanges = mutationBatchChangeSet( + beforeAnnotations, + inspect("Annotation delta inspection", () => this.listAnnotations(), beforeAnnotations), + annotation => annotation.id ?? "", + ); + const resultVersion = inspect( + "Result version inspection", () => this.getVersion(), baseVersion, + ); + const warnings: string[] = [...observationWarnings]; + if ([...revisionChanges.added, ...revisionChanges.modified] + .some(revision => revision.date !== undefined && revision.date !== null)) { + warnings.push("Tracked-revision date attributes may use the execution clock; compare revision ids, authors, types, text, and anchors across separate executions."); + } + if ([...commentChanges.added, ...commentChanges.modified] + .some(comment => comment.date !== undefined && comment.date !== null)) { + warnings.push("Comment date attributes may be generated from the execution clock; supply dates explicitly when byte-identical replay is required."); + } + if (annotationChanges.added.length > 0) { + warnings.push("Auto-generated annotation ids or creation timestamps are execution metadata; supply id and created explicitly when byte-identical replay is required."); + } + if (result.steps.some(step => step.results.some(edit => edit.created.length > 0))) { + warnings.push("Created anchors and related OOXML ids may be generated independently on replay; preview/apply equivalence is semantic and packageHash or anchor ids may differ."); + } + if (mode === "best_effort" && !result.success) { + warnings.push("Best-effort execution retains every successful step despite later failures."); + } + let packageHash = ""; + if (!this.wasm.GetPackageContentHash) { + warnings.push("This WASM bundle predates package equivalence hashes; packageHash is unavailable."); + } else { + try { packageHash = this.wasm.GetPackageContentHash(this.handle); } catch (error) { + warnings.push(`Package equivalence hash unavailable: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { + ...result, + preview: false, + baseVersion, + resultVersion, + packageHash, + revisionChanges, + commentChanges, + annotationChanges, + warnings, + html: null, + }; + } catch (error) { + return { + ...result, + preview: false, + baseVersion, + resultVersion: inspect("Result version inspection", () => this.getVersion(), baseVersion), + packageHash: "", + revisionChanges: { added: [], removed: [], modified: [] }, + commentChanges: { added: [], removed: [], modified: [] }, + annotationChanges: { added: [], removed: [], modified: [] }, + warnings: [ + ...observationWarnings, + `Batch receipt enrichment unavailable: ${error instanceof Error ? error.message : String(error)}`, + ], + html: null, + }; + } + }; const internalFailure = (value: unknown): EditResult => ({ success: false, error: { code: "internal_error", message: value instanceof Error ? value.message : String(value) }, @@ -185,8 +328,8 @@ export class DocxSession { success: false, rolledBack: true, results: [{ success: false, error: preflight[failedPreflight]!, created: [], removed: [], modified: [] }], }; - return { mode, status: "failed", success: false, rolledBack: true, - steps: [failed], failure: failureOf(failed, true) }; + return complete({ mode, status: "failed", success: false, rolledBack: true, + steps: [failed], failure: failureOf(failed, true) }); } const transaction = this.wasm.BeginTransaction(this.handle); @@ -204,12 +347,12 @@ export class DocxSession { this.wasm.RollbackTransaction(transaction); const rolledBack = completed.map(value => ({ ...value, rolledBack: true })); const failed = rolledBack[rolledBack.length - 1]!; - return { mode, status: "failed", success: false, rolledBack: true, - steps: rolledBack, failure: failureOf(failed, true) }; + return complete({ mode, status: "failed", success: false, rolledBack: true, + steps: rolledBack, failure: failureOf(failed, true) }); } } this.wasm.CommitTransaction(transaction); - return { mode, status: "ok", success: true, rolledBack: false, steps: completed }; + return complete({ mode, status: "ok", success: true, rolledBack: false, steps: completed }); } catch (error) { try { this.wasm.RollbackTransaction(transaction); } catch { /* preserve the original */ } throw error; @@ -229,14 +372,79 @@ export class DocxSession { }; }); const failed = completed.find(step => !step.success); - return { + return complete({ mode, status: failed ? (completed.some(step => step.success) ? "partial" : "failed") : "ok", success: failed === undefined, rolledBack: false, steps: completed, failure: failed ? failureOf(failed, false) : undefined, - }; + }); + } + + /** + * Execute the same callback batch algorithm against a complete isolated package clone. + * Callbacks receive the shadow session explicitly; mutate that argument. The live session's + * package, caches, version, configuration, and undo/redo history are never execution targets. + */ + previewBatch( + steps: readonly MutationBatchPreviewStep[], + mode: MutationBatchMode = "atomic", + options?: MutationBatchPreviewOptions, + ): MutationBatchResult { + if (mode !== "atomic" && mode !== "best_effort") { + throw new RangeError(`unknown mutation batch mode: ${String(mode)}`); + } + const htmlMode = options?.html ?? "none"; + if (htmlMode !== "none" && htmlMode !== "scoped" && htmlMode !== "full") { + throw new RangeError(`unknown preview HTML mode: ${String(htmlMode)}`); + } + if (!this.wasm.OpenPreviewSession) { + throw new Error("This WASM bundle does not support isolated mutation previews."); + } + + const shadow = new DocxSession(this.wasm.OpenPreviewSession(this.handle), this.wasm); + try { + const result = shadow.executeBatch( + steps.map(step => ({ + tool: step.tool, + action: step.action, + mutation: () => step.mutation(shadow), + preflight: step.preflight ? () => step.preflight!(shadow) : undefined, + })), + mode, + ); + const warnings = [...result.warnings]; + let html: string | null = null; + try { + if (htmlMode === "scoped") { + if (!options?.htmlAnchorId) { + warnings.push("Scoped HTML was requested without htmlAnchorId; no HTML was generated."); + } else { + html = shadow.renderBlock(options.htmlAnchorId); + } + } else if (htmlMode === "full") { + const rendered = this.wasm.RenderHtmlForReview + ? this.wasm.RenderHtmlForReview(shadow.handle, "docx-", false, false, 1, true) + : this.wasm.RenderHtml(shadow.handle, "docx-", false, false, 1); + if (rendered.trimStart().startsWith("{")) { + const envelope = JSON.parse(rendered) as { error?: string }; + if (envelope.error) { + warnings.push(`Preview HTML could not be generated: ${envelope.error}`); + } else { + html = rendered; + } + } else { + html = rendered; + } + } + } catch (error) { + warnings.push(`Preview HTML could not be generated: ${error instanceof Error ? error.message : String(error)}`); + } + return { ...result, preview: true, warnings, html }; + } finally { + shadow.close(); + } } /** @@ -1446,5 +1654,5 @@ export function openDocxSession( return new DocxSession(handle, bridge); } -export type { AnchorInfo, AnchorRef, AnchorTargetRef, BlockSlice, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, DocxSessionProjection, DocxSessionSettings, EditError, EditErrorCode, EditResult, FindOptions, FormatOp, GrepOptions, MarkdownPatch, MutationBatchFailure, MutationBatchMode, MutationBatchResult, MutationBatchStep, MutationBatchStepResult, MutationPreconditions, PageCitation, PageCitationRequest, PageMapRegistrationResult, PageMapStatus, PlaceholderKind, PreconditionFailure, PreconditionTarget, ReplaceOptions, RunFormatting, RunFragment, TemplatePlaceholder, TextMatch, TextRangePrecondition } from "./types.js"; +export type { AnchorInfo, AnchorRef, AnchorTargetRef, BlockSlice, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, DocxSessionProjection, DocxSessionSettings, EditError, EditErrorCode, EditResult, FindOptions, FormatOp, GrepOptions, MarkdownPatch, MutationBatchChangeSet, MutationBatchFailure, MutationBatchMode, MutationBatchPreviewOptions, MutationBatchPreviewStep, MutationBatchResult, MutationBatchStep, MutationBatchStepResult, MutationPreconditions, PageCitation, PageCitationRequest, PageMapRegistrationResult, PageMapStatus, PlaceholderKind, PreconditionFailure, PreconditionTarget, ReplaceOptions, RunFormatting, RunFragment, TemplatePlaceholder, TextMatch, TextRangePrecondition } from "./types.js"; export { ContextBoundary, PlaceholderKinds } from "./types.js"; diff --git a/npm/src/types.ts b/npm/src/types.ts index 0d4e06ea..6ba0b6a4 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1046,6 +1046,7 @@ export interface DocxodusWasmExports { }; DocxSessionBridge: { OpenSession: (bytes: Uint8Array, settingsJson: string) => number; + OpenPreviewSession?: (liveHandle: number) => number; CloseSession: (handle: number) => void; CreateBlankDocx: () => Uint8Array; Project: (handle: number) => string; @@ -1053,6 +1054,7 @@ export interface DocxodusWasmExports { RegisterPageMap: (handle: number, pageMapJson: string, expectedRendererFingerprint: string) => string; GetPageMapStatus: (handle: number, requestJson: string) => string; GetPageCitation: (handle: number, anchorId: string, requestJson: string) => string; + GetPackageContentHash?: (handle: number) => string; CheckPreconditions: (handle: number, preconditionsJson: string) => string; BeginTransaction: (handle: number) => number; CommitTransaction: (transactionHandle: number) => void; @@ -1384,6 +1386,20 @@ export interface MutationBatchStep { preflight?: () => EditError | undefined; } +/** A preview callback receives the isolated shadow session it must mutate/read. */ +export interface MutationBatchPreviewStep { + tool: string; + action: string; + mutation: (shadow: import("./session.js").DocxSession) => EditResult | readonly EditResult[]; + preflight?: (shadow: import("./session.js").DocxSession) => EditError | undefined; +} + +export interface MutationBatchPreviewOptions { + html?: "none" | "scoped" | "full"; + /** Required for scoped HTML. */ + htmlAnchorId?: string; +} + export interface MutationBatchStepResult { index: number; tool: string; @@ -1401,13 +1417,30 @@ export interface MutationBatchFailure { rolledBack: boolean; } +export interface MutationBatchChangeSet { + added: readonly T[]; + removed: readonly T[]; + modified: readonly T[]; +} + export interface MutationBatchResult { mode: MutationBatchMode; status: "ok" | "failed" | "partial"; + preview: boolean; success: boolean; rolledBack: boolean; + baseVersion: number; + resultVersion: number; + /** Canonical SHA-256 of this result package; exact replay equality is guaranteed only for deterministic batches. */ + packageHash: string; steps: readonly MutationBatchStepResult[]; failure?: MutationBatchFailure; + revisionChanges: MutationBatchChangeSet; + commentChanges: MutationBatchChangeSet; + annotationChanges: MutationBatchChangeSet; + warnings: readonly string[]; + /** Shadow-only preview HTML when requested; null otherwise. */ + html: string | null; } export interface MarkdownPatch { diff --git a/npm/tests/atomic-batch.spec.ts b/npm/tests/atomic-batch.spec.ts index ad7daae5..1d3ec923 100644 --- a/npm/tests/atomic-batch.spec.ts +++ b/npm/tests/atomic-batch.spec.ts @@ -110,4 +110,126 @@ test.describe('DocxSession atomic batches (#445)', () => { expect(result.success).toBe(true); expect(result.steps).toHaveLength(2); }); + + test('isolated preview returns a rich shadow receipt and preserves live redo', async ({ page }) => { + const result = await page.evaluate((bytes: number[]) => { + const session = (window as any).Docxodus.openTypedSession( + new Uint8Array(bytes), + JSON.stringify({ undoDepth: 1, persistAnchorIds: true }), + ); + try { + const projection = session.project(); + const anchors = (Object.entries(projection.anchorIndex) as [string, any][]) + .filter(([id, value]) => value.scope === 'body' + && ['p', 'h', 'li'].includes(value.kind) + && projection.markdown.includes(`{#${id}}`)) + .map(([id]) => id); + session.replaceText(anchors[0], 'npm redo target'); + session.undo(); + const beforeVersion = session.getVersion(); + const before = Array.from(session.save()); + + const preview = session.previewBatch([ + { tool: 'docx_edit', action: 'replace_text', + mutation: (shadow: any) => shadow.replaceText(anchors[0], 'Predicted npm first.') }, + { tool: 'docx_edit', action: 'replace_text', + mutation: (shadow: any) => shadow.replaceText(anchors[1], 'Predicted npm second.') }, + ], 'atomic', { html: 'full' }); + + const after = Array.from(session.save()); + const liveMarkdown = session.project().markdown; + const liveVersion = session.getVersion(); + const undo = session.undo(); + const redo = session.redo(); + return { + preview, + beforeVersion, + bytesEqual: before.length === after.length + && before.every((value, index) => value === after[index]), + liveMarkdown, + liveVersion, + undo, + redo, + redoneMarkdown: session.project().markdown, + }; + } finally { + session.close(); + } + }, Array.from(fixture)); + + expect(result.preview.preview).toBe(true); + expect(result.preview.success).toBe(true); + expect(result.preview.baseVersion).toBe(result.beforeVersion); + expect(result.preview.resultVersion).toBe(result.beforeVersion + 1); + expect(result.preview.packageHash).toMatch(/^[0-9a-f]{64}$/); + expect(result.preview.steps).toHaveLength(2); + expect(result.preview.revisionChanges).toEqual({ added: [], removed: [], modified: [] }); + expect(result.preview.commentChanges).toEqual({ added: [], removed: [], modified: [] }); + expect(result.preview.annotationChanges).toEqual({ added: [], removed: [], modified: [] }); + expect(result.preview.html).toContain('Predicted npm first.'); + expect(result.bytesEqual).toBe(true); + expect(result.liveVersion).toBe(result.beforeVersion); + expect(result.liveMarkdown).not.toContain('Predicted npm'); + expect(result.undo).toBe(false); + expect(result.redo).toBe(true); + expect(result.redoneMarkdown).toContain('npm redo target'); + }); + + test('preview validates HTML mode first and treats renderer error envelopes as warnings', async ({ page }) => { + const result = await page.evaluate((bytes: number[]) => { + const api = (window as any).Docxodus; + const live = api.openTypedSession(new Uint8Array(bytes)); + try { + const anchor = Object.keys(live.project().anchorIndex) + .find(id => id.startsWith('p:body:'))!; + let invalidInvoked = false; + let invalidError = ''; + try { + live.previewBatch([ + { tool: 'docx_edit', action: 'replace_text', mutation: (shadow: any) => { + invalidInvoked = true; + return shadow.replaceText(anchor, 'must not execute'); + } }, + ], 'atomic', { html: 'invalid' as any }); + } catch (error) { + invalidError = error instanceof Error ? error.message : String(error); + } + + const bridge = new Proxy(api.DocxSessionBridge, { + get(target, property, receiver) { + if (property === 'RenderHtmlForReview') { + return () => JSON.stringify({ error: 'simulated renderer failure' }); + } + return Reflect.get(target, property, receiver); + }, + }); + const wrapped = new api.DocxSession( + bridge.OpenSession(new Uint8Array(bytes), ''), + bridge, + ); + try { + const wrappedAnchor = Object.keys(wrapped.project().anchorIndex) + .find(id => id.startsWith('p:body:'))!; + const preview = wrapped.previewBatch([ + { tool: 'docx_edit', action: 'replace_text', + mutation: (shadow: any) => shadow.replaceText(wrappedAnchor, 'shadow only') }, + ], 'atomic', { html: 'full' }); + return { invalidInvoked, invalidError, preview, liveVersion: live.getVersion() }; + } finally { + wrapped.close(); + } + } finally { + live.close(); + } + }, Array.from(fixture)); + + expect(result.invalidInvoked).toBe(false); + expect(result.invalidError).toContain('unknown preview HTML mode'); + expect(result.liveVersion).toBe(0); + expect(result.preview.success).toBe(true); + expect(result.preview.html).toBeNull(); + expect(result.preview.warnings).toContain( + 'Preview HTML could not be generated: simulated renderer failure', + ); + }); }); diff --git a/python/src/docx_scalpel/__init__.py b/python/src/docx_scalpel/__init__.py index 695b8798..5eaded06 100644 --- a/python/src/docx_scalpel/__init__.py +++ b/python/src/docx_scalpel/__init__.py @@ -110,6 +110,7 @@ MarkdownPatch, MarkdownProjection, MutationBatchFailure, + MutationBatchChangeSet, MutationBatchResult, MutationBatchStep, MutationBatchStepResult, @@ -201,6 +202,7 @@ "MarkdownPatch", "MarkdownProjection", "MutationBatchFailure", + "MutationBatchChangeSet", "MutationBatchResult", "MutationBatchStep", "MutationBatchStepResult", diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py index a7f94e7e..ab5386db 100644 --- a/python/src/docx_scalpel/session.py +++ b/python/src/docx_scalpel/session.py @@ -523,6 +523,35 @@ def execute_batch( raise TypeError(f"execute_batch: expected object, got {result!r}") return MutationBatchResult._from_wire(result) + def preview_batch( + self, + steps: Iterable[MutationBatchStep], + mode: MutationBatchMode = MutationBatchMode.ATOMIC, + *, + html_mode: str = "none", + html_anchor_id: str | None = None, + ) -> MutationBatchResult: + """Predict a batch on a complete clone without touching this live session. + + ``atomic`` is the safe default; choose ``best_effort`` explicitly to inspect + partial-success semantics. Optional ``scoped``/``full`` HTML is rendered only + from the predicted shadow package. + """ + if html_mode not in ("none", "scoped", "full"): + raise ValueError(f"unknown preview html mode: {html_mode}") + result = self._call( + "preview_batch", + { + "mode": mode.value, + "steps": [step.to_wire() for step in steps], + "htmlMode": html_mode, + "htmlAnchorId": html_anchor_id, + }, + ) + if not isinstance(result, Mapping): + raise TypeError(f"preview_batch: expected object, got {result!r}") + return MutationBatchResult._from_wire(result) + def check_preconditions(self, preconditions: MutationPreconditions) -> EditResult: """Evaluate guards without mutating the document or advancing its version.""" return EditResult._from_wire( diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py index 899d2cd6..f9d8f002 100644 --- a/python/src/docx_scalpel/types.py +++ b/python/src/docx_scalpel/types.py @@ -16,7 +16,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Mapping, Sequence +from typing import Any, Callable, Generic, Mapping, Sequence, TypeVar from .enums import ( AnchorIdRendering, @@ -61,6 +61,7 @@ "MutationBatchStep", "MutationBatchStepResult", "MutationBatchFailure", + "MutationBatchChangeSet", "MutationBatchResult", "BlockMetadata", "BulkEditResult", @@ -1179,6 +1180,31 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "MutationBatchFailure": ) +_BatchItem = TypeVar("_BatchItem") + + +@dataclass(frozen=True, slots=True) +class MutationBatchChangeSet(Generic[_BatchItem]): + """Added, removed, and modified semantic objects produced by apply or preview.""" + + added: tuple[_BatchItem, ...] = () + removed: tuple[_BatchItem, ...] = () + modified: tuple[_BatchItem, ...] = () + + @classmethod + def _from_wire( + cls, + d: Mapping[str, Any] | None, + decode: Callable[[Mapping[str, Any]], _BatchItem], + ) -> "MutationBatchChangeSet[_BatchItem]": + value = d or {} + return cls( + added=tuple(decode(item) for item in value.get("added", ())), + removed=tuple(decode(item) for item in value.get("removed", ())), + modified=tuple(decode(item) for item in value.get("modified", ())), + ) + + @dataclass(frozen=True, slots=True) class MutationBatchResult: mode: MutationBatchMode @@ -1187,6 +1213,21 @@ class MutationBatchResult: rolled_back: bool steps: tuple[MutationBatchStepResult, ...] failure: MutationBatchFailure | None = None + preview: bool = False + base_version: int = 0 + result_version: int = 0 + package_hash: str = "" + revision_changes: MutationBatchChangeSet[RevisionListEntry] = field( + default_factory=MutationBatchChangeSet + ) + comment_changes: MutationBatchChangeSet[CommentListEntry] = field( + default_factory=MutationBatchChangeSet + ) + annotation_changes: MutationBatchChangeSet[DocumentAnnotation] = field( + default_factory=MutationBatchChangeSet + ) + warnings: tuple[str, ...] = () + html: str | None = None @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "MutationBatchResult": @@ -1198,6 +1239,21 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "MutationBatchResult": rolled_back=bool(d.get("rolledBack", False)), steps=tuple(MutationBatchStepResult._from_wire(s) for s in d.get("steps", ())), failure=MutationBatchFailure._from_wire(failure) if failure else None, + preview=bool(d.get("preview", False)), + base_version=int(d.get("baseVersion", 0)), + result_version=int(d.get("resultVersion", 0)), + package_hash=str(d.get("packageHash", "")), + revision_changes=MutationBatchChangeSet._from_wire( + d.get("revisionChanges"), RevisionListEntry._from_wire + ), + comment_changes=MutationBatchChangeSet._from_wire( + d.get("commentChanges"), CommentListEntry._from_wire + ), + annotation_changes=MutationBatchChangeSet._from_wire( + d.get("annotationChanges"), DocumentAnnotation._from_wire + ), + warnings=tuple(str(value) for value in d.get("warnings", ())), + html=d.get("html"), ) diff --git a/python/tests/test_atomic_batches.py b/python/tests/test_atomic_batches.py index c5dcd9c1..9fb7c6e8 100644 --- a/python/tests/test_atomic_batches.py +++ b/python/tests/test_atomic_batches.py @@ -5,6 +5,7 @@ from docx_scalpel import ( EditErrorCode, DocxSession, + DocxSessionSettings, MutationBatchMode, MutationBatchStep, open_session, @@ -112,3 +113,57 @@ def test_best_effort_is_explicit_and_invalid_steps_are_structured( assert invalid.failure is not None assert invalid.failure.error.code is EditErrorCode.INVALID_BATCH_STEP assert session.get_version() == 0 + + +def test_preview_batch_is_rich_and_preserves_live_bytes_version_and_redo( + tour_plan_bytes: bytes, +) -> None: + with open_session( + tour_plan_bytes, + DocxSessionSettings(undo_depth=1, persist_anchor_ids=True), + ) as session: + targets = _body_paragraphs(session)[:2] + assert session.replace_text(targets[0], "Python redo target.").success + assert session.undo() + before_version = session.get_version() + # Match Save call sequences on each side; Save itself warms serialization caches. + session.save(False) + session.save(True) + before_clean = session.save(False) + before_persisted = session.save(True) + + result = session.preview_batch( + [ + MutationBatchStep( + "replace_text", + {"anchorId": targets[0], "markdown": "Predicted Python first."}, + ), + MutationBatchStep( + "replace_text", + {"anchorId": targets[1], "markdown": "Predicted Python second."}, + ), + ], + html_mode="scoped", + html_anchor_id=targets[0], + ) + + assert result.preview + assert result.success + assert result.mode is MutationBatchMode.ATOMIC + assert result.base_version == before_version + assert result.result_version == before_version + 1 + assert len(result.package_hash) == 64 + assert len(result.steps) == 2 + assert result.revision_changes.added == () + assert result.comment_changes.added == () + assert result.annotation_changes.added == () + assert result.html is not None + assert "Predicted Python first." in result.html + + assert session.get_version() == before_version + assert session.save(False) == before_clean + assert session.save(True) == before_persisted + assert "Predicted Python" not in session.project().markdown + assert not session.undo() + assert session.redo() + assert "Python redo target." in session.project().markdown diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs index d315f905..ada4db16 100644 --- a/tools/mcp-server/Dispatcher.cs +++ b/tools/mcp-server/Dispatcher.cs @@ -605,7 +605,7 @@ private static string TrackChanges(SessionStore store, JsonElement args) var bytes = DocxSessionOps.SaveWithAnchorIds(session.Handle); var accepted = RevisionProcessor.AcceptRevisions(new WmlDocument("session.docx", bytes)); store.Rebind(session, accepted.DocumentByteArray); - DocxSessionOps.RestorePreviewVersion(session.Handle, nextVersion); + DocxSessionOps.RestoreVersionAfterRebind(session.Handle, nextVersion); return "{\"success\":true}"; } case "reject_all": @@ -617,7 +617,7 @@ private static string TrackChanges(SessionStore store, JsonElement args) var bytes = DocxSessionOps.SaveWithAnchorIds(session.Handle); var rejected = RevisionProcessor.RejectRevisions(new WmlDocument("session.docx", bytes)); store.Rebind(session, rejected.DocumentByteArray); - DocxSessionOps.RestorePreviewVersion(session.Handle, nextVersion); + DocxSessionOps.RestoreVersionAfterRebind(session.Handle, nextVersion); return "{\"success\":true}"; } case "set_mode": @@ -664,15 +664,9 @@ private static string FilterRevisions(string revisionsJson, string? author, stri // ─── Mutations (batch) ────────────────────────────────────────────── - private static readonly HashSet BatchableTools = new() - { - "docxodus_edit", "docxodus_format", "docxodus_create", "docxodus_table", "docxodus_list", - "docxodus_comment", - }; - private static string Mutations(SessionStore store, JsonElement args) { - var session = Session(store, args); + var liveSession = Session(store, args); var mode = args.TryGetProperty("mode", out _) ? Str(args, "mode") : "atomic"; @@ -681,106 +675,63 @@ private static string Mutations(SessionStore store, JsonElement args) if (!args.TryGetProperty("steps", out var stepsEl) || stepsEl.ValueKind != JsonValueKind.Array) throw new McpToolException("docxodus_mutations requires an array \"steps\""); - var batchCheck = Check(session, ParsePreconditions(args, MutationTarget(args))); - if (batchCheck is not null) return batchCheck; - - // #445: committed batch modes run through the core transaction primitive. `apply` is the - // backward-compatible alias for the old partial executor and is reported as best_effort; - // new callers should spell that risk explicitly. Preview remains the pre-existing - // apply-then-undo path until isolated previews land separately in #446. - if (mode != "preview") + var preview = mode == "preview" || BoolOpt(args, "preview", false); + if (mode == "apply" && preview) + throw new McpToolException("docxodus_mutations preview cannot be combined with deprecated mode 'apply'"); + var policyName = mode == "preview" + ? OptStr(args, "previewPolicy") ?? "atomic" + : mode; + if (policyName is not ("atomic" or "best_effort" or "apply")) + throw new McpToolException($"unknown docxodus_mutations previewPolicy: {policyName}"); + var coreMode = policyName == "atomic" + ? MutationBatchMode.Atomic + : MutationBatchMode.BestEffort; + var htmlMode = OptStr(args, "previewHtml") switch { - var steps = BuildMutationBatchSteps(session, stepsEl, legacyApply: mode == "apply"); - var coreMode = mode == "atomic" - ? MutationBatchMode.Atomic - : MutationBatchMode.BestEffort; - return DocxSessionOps.ExecuteBatch(session.Handle, coreMode, steps); - } - - var results = new List(); - var errors = new List(); - var startingVersion = DocxSessionOps.GetVersion(session.Handle); - int applied = 0; - int committed = 0; + null or "none" => MutationPreviewHtmlMode.None, + "scoped" => MutationPreviewHtmlMode.Scoped, + "full" => MutationPreviewHtmlMode.Full, + var value => throw new McpToolException($"unknown docxodus_mutations previewHtml: {value}"), + }; + if (!preview && htmlMode != MutationPreviewHtmlMode.None) + throw new McpToolException("previewHtml is only valid for a preview batch"); - foreach (var step in stepsEl.EnumerateArray()) + if (preview) { - var stepTool = step.TryGetProperty("tool", out var toolEl) && toolEl.ValueKind == JsonValueKind.String - ? toolEl.GetString()! : throw new McpToolException("mutation step missing string \"tool\""); - if (!BatchableTools.Contains(stepTool)) - throw new McpToolException($"docxodus_mutations does not accept \"{stepTool}\" as a step (undo/redo and read-only actions are not batchable)"); - var stepArgs = step.TryGetProperty("args", out var a) && a.ValueKind == JsonValueKind.Object - ? a : throw new McpToolException("mutation step missing object \"args\""); - var stepAction = stepArgs.TryGetProperty("action", out var actEl) && actEl.ValueKind == JsonValueKind.String - ? actEl.GetString()! : throw new McpToolException("mutation step args missing string \"action\""); - - bool isMutating = stepTool switch - { - "docxodus_edit" => IsMutatingEditAction(stepAction), - "docxodus_list" => IsMutatingListAction(stepAction), - "docxodus_comment" => IsMutatingCommentAction(stepAction), - _ => true, // every docxodus_format/docxodus_create/docxodus_table action mutates - }; - if (!isMutating) - throw new McpToolException($"docxodus_mutations does not accept the read-only action \"{stepAction}\" on {stepTool}"); - - string resultJson; - var stepStartingVersion = DocxSessionOps.GetVersion(session.Handle); - try - { - resultJson = stepTool switch + return DocxSessionOps.PreviewBatch( + liveSession.Handle, + coreMode, + shadowHandle => { - "docxodus_edit" => RunEditAction(session, stepAction, stepArgs), - "docxodus_format" => RunFormatAction(session, stepAction, stepArgs), - "docxodus_create" => RunCreateAction(session, stepAction, stepArgs), - "docxodus_table" => RunTableAction(session, stepAction, stepArgs), - "docxodus_list" => RunListAction(session, stepAction, stepArgs), - "docxodus_comment" => RunCommentAction(session, stepAction, stepArgs), - _ => throw new McpToolException($"unreachable: {stepTool}"), - }; - } - catch (McpToolException ex) - { - errors.Add(JsonRpcIo.JsonString(ex.Message)); - results.Add($"{{\"success\":false,\"error\":{{\"message\":{JsonRpcIo.JsonString(ex.Message)}}}}}"); - continue; - } - - results.Add(resultJson); - bool succeeded = true; - try - { - using var rdoc = JsonDocument.Parse(resultJson); - if (rdoc.RootElement.ValueKind == JsonValueKind.Object - && rdoc.RootElement.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.False) - succeeded = false; - } - catch (JsonException) { /* non-EditResult shape (shouldn't happen for batchable tools); assume success */ } - - if (succeeded) - { - applied++; - var versionDelta = DocxSessionOps.GetVersion(session.Handle) - stepStartingVersion; - committed = checked(committed + checked((int)versionDelta)); - } - else - { - using var rdoc = JsonDocument.Parse(resultJson); - errors.Add(rdoc.RootElement.TryGetProperty("error", out var e) ? e.GetRawText() : "\"step failed\""); - } - } - - if (mode == "preview") - { - for (int i = 0; i < committed; i++) - DocxSessionOps.Undo(session.Handle); - DocxSessionOps.RestorePreviewVersion(session.Handle, startingVersion); + var shadow = new DocSession + { + Id = liveSession.Id, + Handle = shadowHandle, + }; + var batchCheck = Check(shadow, ParsePreconditions(args, MutationTarget(args))); + if (batchCheck is not null) + { + return new[] + { + DocxSessionOps.SerializedBatchStep( + "docxodus_mutations", + "preconditions", + () => batchCheck), + }; + } + return BuildMutationBatchSteps(shadow, stepsEl, legacyApply: false); + }, + new MutationBatchPreviewOptions + { + HtmlMode = htmlMode, + HtmlAnchorId = OptStr(args, "previewAnchorId"), + }); } - var status = errors.Count == 0 ? "ok" : applied == 0 ? "failed" : "partial"; - return "{\"status\":\"" + status + "\",\"editsApplied\":" + applied - + ",\"results\":[" + string.Join(",", results) + "]" - + ",\"errors\":[" + string.Join(",", errors) + "]}"; + var liveBatchCheck = Check(liveSession, ParsePreconditions(args, MutationTarget(args))); + if (liveBatchCheck is not null) return liveBatchCheck; + var liveSteps = BuildMutationBatchSteps(liveSession, stepsEl, legacyApply: mode == "apply"); + return DocxSessionOps.ExecuteBatch(liveSession.Handle, coreMode, liveSteps); } private static IReadOnlyList BuildMutationBatchSteps( diff --git a/tools/mcp-server/README.md b/tools/mcp-server/README.md index 47d4e4fe..2294952a 100644 --- a/tools/mcp-server/README.md +++ b/tools/mcp-server/README.md @@ -94,7 +94,7 @@ markdown projection and search tools return: | `docxodus_comment` | Native Word review comments (real `w:comment` markup): add on an anchor/span or tracked revision id, reply in-thread, resolve/reopen, update, remove, list | | `docxodus_annotate` | Anchor-addressed highlight/label annotations (a custom-XML overlay for external tools, distinct from comments) | | `docxodus_track_changes` | List tracked changes; accept/reject one by id, or all | -| `docxodus_mutations` | Apply a batch atomically by default; opt into best-effort; legacy apply/preview remain available | +| `docxodus_mutations` | Apply or safely preview a batch atomically by default; opt explicitly into best-effort | | `docxodus_table` | Create/read tables; resolve canonical cell anchors ↔ grid coordinates; edit rows/columns/cell content/style | ## Known gaps @@ -111,10 +111,12 @@ them): `reject_all` still resolve those. - **New lists inserted via markdown don't get real numbering** unless promoted afterward with `docxodus_list`'s `apply_format` action (which does write real `w:numPr`). -- **`docxodus_mutations`'s `preview` mode is apply-then-undo**, not a true no-op dry run; - it uses the session's bounded undo ring, so it composes with everything else but is not - free of history-depth pressure. Its caller-visible document version is restored after - rollback, so merely previewing does not stale a guarded edit plan. +- **Generated-id previews are semantic rather than necessarily byte-identical.** Preview runs + the same batch path on a complete isolated package clone and never touches live bytes, + version, caches, configuration, or undo/redo history. Create/comment/note/image operations + may allocate different anchors or OOXML ids when later applied, and tracked changes may stamp + a different execution time, so receipts warn when exact anchor or package-hash equality is + unsafe. Deterministic mutation-only batches retain exact result/hash equivalence. - **Optimistic guards are common to every mutation tool.** Pass `preconditions` with `expectedVersion` and/or an anchor hash/exact text/range/kind/scope; replacement may also require `expectedMatchCount`. `docxodus_get_content` formats `version` and diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 652efcb2..d9ed6a47 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -425,14 +425,18 @@ internal static class ToolCatalog """), new ToolDefinition( "docxodus_mutations", - "Apply a batch of docxodus_edit/docxodus_format/docxodus_create/docxodus_table/docxodus_list/docxodus_comment actions atomically by default, with explicit best-effort and legacy preview modes.", + "Apply or safely preview a batch of docxodus_edit/docxodus_format/docxodus_create/docxodus_table/docxodus_list/docxodus_comment actions. Preview executes the identical batch path against an isolated complete package clone and never mutates the live session or its undo/redo history.", """ { "type": "object", "properties": { "sessionId": { "type": "string" }, "preconditions": { "type": "object", "description": "Optional batch-start guards. Each step args object may also carry its own preconditions." }, - "mode": { "type": "string", "enum": ["atomic", "best_effort", "apply", "preview"], "default": "atomic", "description": "atomic (default): all steps commit as one undo/version unit or fully roll back. best_effort: explicitly retain successful steps after failures. apply: deprecated alias for best_effort. preview: legacy apply-then-undo behavior; isolated previews are tracked separately in #446." }, + "mode": { "type": "string", "enum": ["atomic", "best_effort", "apply", "preview"], "default": "atomic", "description": "atomic (default): all steps commit as one undo/version unit or fully roll back. best_effort: explicitly retain successful steps after failures. apply: deprecated alias for best_effort. preview: isolated dry-run shorthand using atomic policy unless previewPolicy says best_effort." }, + "preview": { "type": "boolean", "default": false, "description": "Dry-run mode for mode=atomic or mode=best_effort. The complete package is cloned and the live document, version, caches, configuration, and undo/redo history are never touched." }, + "previewPolicy": { "type": "string", "enum": ["atomic", "best_effort"], "default": "atomic", "description": "Policy used by legacy mode=preview. Ignored when mode itself is atomic/best_effort." }, + "previewHtml": { "type": "string", "enum": ["none", "scoped", "full"], "default": "none", "description": "Optionally render shadow-only HTML from the predicted package." }, + "previewAnchorId": { "type": "string", "description": "Required when previewHtml=scoped; the live anchor is resolved only inside the cloned package." }, "steps": { "type": "array", "items": { diff --git a/tools/python-host/Dispatcher.cs b/tools/python-host/Dispatcher.cs index 0cb4af0a..3e904177 100644 --- a/tools/python-host/Dispatcher.cs +++ b/tools/python-host/Dispatcher.cs @@ -76,6 +76,7 @@ public static string Dispatch(string op, JsonElement args) ?? throw new FormatException("args missing object \"citation\"")), "check_preconditions" => DocxSessionOps.CheckPreconditions(Handle(args), ParsePreconditions(args)), "execute_batch" => ExecuteBatch(args), + "preview_batch" => ExecuteBatch(args, preview: true), "replace_text" => DocxSessionOps.ReplaceText(Handle(args), Str(args, "anchorId"), Str(args, "markdown")), "delete_block" => DocxSessionOps.DeleteBlock(Handle(args), Str(args, "anchorId")), @@ -615,9 +616,9 @@ private static string[] ParseAnchorIdArray(JsonElement args) }; } - private static string ExecuteBatch(JsonElement args) + private static string ExecuteBatch(JsonElement args, bool preview = false) { - var handle = Handle(args); + var liveHandle = Handle(args); var mode = args.TryGetProperty("mode", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : "atomic"; var batchMode = mode switch @@ -629,26 +630,52 @@ private static string ExecuteBatch(JsonElement args) if (!args.TryGetProperty("steps", out var steps) || steps.ValueKind != JsonValueKind.Array) throw new ArgumentException("execute_batch requires an array 'steps'"); - var parsed = new List(); - foreach (var step in steps.EnumerateArray()) + IEnumerable ParseSteps(int targetHandle) { - if (step.ValueKind != JsonValueKind.Object) - throw new ArgumentException("each batch step must be an object"); - var operation = step.TryGetProperty("operation", out var op) && op.ValueKind == JsonValueKind.String - ? op.GetString()! : throw new ArgumentException("batch step missing string 'operation'"); - var stepArgs = step.TryGetProperty("args", out var a) && a.ValueKind == JsonValueKind.Object - ? WithHandle(a, handle) : WithHandle(default, handle); - EditError? preflight = !IsMutation(operation) || operation is "undo" or "redo" - ? new EditError(EditErrorCode.InvalidBatchStep, - $"unsupported or non-mutation batch operation: {operation}") - : null; - parsed.Add(DocxSessionOps.SerializedBatchStep( - "docx_scalpel", - operation, - () => Dispatch(operation, stepArgs), - preflight is null ? null : () => preflight)); + var parsed = new List(); + foreach (var step in steps.EnumerateArray()) + { + if (step.ValueKind != JsonValueKind.Object) + throw new ArgumentException("each batch step must be an object"); + var operation = step.TryGetProperty("operation", out var op) && op.ValueKind == JsonValueKind.String + ? op.GetString()! : throw new ArgumentException("batch step missing string 'operation'"); + var stepArgs = step.TryGetProperty("args", out var a) && a.ValueKind == JsonValueKind.Object + ? WithHandle(a, targetHandle) : WithHandle(default, targetHandle); + EditError? preflight = !IsMutation(operation) || operation is "undo" or "redo" + ? new EditError(EditErrorCode.InvalidBatchStep, + $"unsupported or non-mutation batch operation: {operation}") + : null; + parsed.Add(DocxSessionOps.SerializedBatchStep( + "docx_scalpel", + operation, + () => Dispatch(operation, stepArgs), + preflight is null ? null : () => preflight)); + } + return parsed; } - return DocxSessionOps.ExecuteBatch(handle, batchMode, parsed); + + if (!preview) + return DocxSessionOps.ExecuteBatch(liveHandle, batchMode, ParseSteps(liveHandle)); + + var htmlMode = args.TryGetProperty("htmlMode", out var html) && html.ValueKind == JsonValueKind.String + ? html.GetString() switch + { + "scoped" => MutationPreviewHtmlMode.Scoped, + "full" => MutationPreviewHtmlMode.Full, + null or "none" => MutationPreviewHtmlMode.None, + var value => throw new ArgumentException($"unknown preview html mode: {value}"), + } + : MutationPreviewHtmlMode.None; + return DocxSessionOps.PreviewBatch( + liveHandle, + batchMode, + ParseSteps, + new MutationBatchPreviewOptions + { + HtmlMode = htmlMode, + HtmlAnchorId = args.TryGetProperty("htmlAnchorId", out var anchor) + && anchor.ValueKind == JsonValueKind.String ? anchor.GetString() : null, + }); } private static JsonElement WithHandle(JsonElement args, int handle) diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs index 373317c1..686a2c9e 100644 --- a/wasm/DocxodusWasm/DocxSessionBridge.cs +++ b/wasm/DocxodusWasm/DocxSessionBridge.cs @@ -30,6 +30,15 @@ public static partial class DocxSessionBridge public static int OpenSession(byte[] bytes, string settingsJson) => DocxSessionOps.OpenSession(bytes, DocxSessionJson.ParseSettings(settingsJson)); + /// + /// Open a complete temporary clone of a live session for callback-based npm preview. The + /// returned handle has independent package bytes, caches, configuration scalars, and fresh + /// undo/redo history; callers must close it. Abandonment cannot affect the live handle. + /// + [JSExport] + public static int OpenPreviewSession(int liveHandle) => + SessionRegistry.CloneSessionForPreview(liveHandle); + [JSExport] public static void CloseSession(int handle) { @@ -83,6 +92,11 @@ public static string GetPageCitation(int handle, string anchorId, string request return DocxSessionOps.GetPageCitation(handle, anchorId, ParseRequiredCitationRequest(requestJson)); } + /// Canonical content SHA-256 used by apply/preview equivalence receipts. + [JSExport] + public static string GetPackageContentHash(int handle) => + DocxSessionOps.GetPackageContentHash(handle); + /// Read-only optimistic guard evaluation. A successful result applies no mutation. [JSExport] public static string CheckPreconditions(int handle, string preconditionsJson) => From 07a40406556285241cf3cd4b54687a86641d6d37 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 06:33:36 -0500 Subject: [PATCH 2/3] test(preview): ignore ZIP timestamps in isolation checks --- .../DocxSessionPreviewBatchTests.cs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Docxodus.Tests/DocxSessionPreviewBatchTests.cs b/Docxodus.Tests/DocxSessionPreviewBatchTests.cs index 06044a81..a0a025bf 100644 --- a/Docxodus.Tests/DocxSessionPreviewBatchTests.cs +++ b/Docxodus.Tests/DocxSessionPreviewBatchTests.cs @@ -500,9 +500,8 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => } private sealed record Fingerprint( - byte[] NormalBytes, - byte[] PersistedBytes, - IReadOnlyDictionary OpcEntries, + IReadOnlyDictionary NormalOpcEntries, + IReadOnlyDictionary PersistedOpcEntries, string Markdown, string[] Anchors, long Version, @@ -528,6 +527,9 @@ internal static Fingerprint Capture(DocxSession session) { // Observe Save output on complete package clones. Calling Save on the live session // would itself replace its read caches and make the invariant probe perturb state. + // Compare entry payloads rather than raw ZIP bytes: DOS entry timestamps are transport + // metadata and may advance across successive clone saves. This matches + // GetPackageContentHash's policy of excluding ZIP timestamps and compression details. var projection = session.Project(); _ = session.AnchorIndex(); byte[] normal; @@ -537,8 +539,7 @@ internal static Fingerprint Capture(DocxSession session) using (var persistedClone = session.CreateShadowSession()) persisted = persistedClone.Save(persistAnchorIds: true); return new Fingerprint( - normal, - persisted, + HashOpcEntries(normal), HashOpcEntries(persisted), projection.Markdown, projection.AnchorIndex.Select(pair => @@ -572,11 +573,12 @@ internal void AssertUnchanged(DocxSession session) Assert.Same(CachedAnchorIndex, PrivateField(session, "_cachedAnchorIndex")); Assert.Same(RawOps, PrivateField(session, "_raw")); var after = Capture(session); - Assert.Equal(NormalBytes, after.NormalBytes); - Assert.Equal(PersistedBytes, after.PersistedBytes); Assert.Equal( - OpcEntries.OrderBy(pair => pair.Key, StringComparer.Ordinal), - after.OpcEntries.OrderBy(pair => pair.Key, StringComparer.Ordinal)); + NormalOpcEntries.OrderBy(pair => pair.Key, StringComparer.Ordinal), + after.NormalOpcEntries.OrderBy(pair => pair.Key, StringComparer.Ordinal)); + Assert.Equal( + PersistedOpcEntries.OrderBy(pair => pair.Key, StringComparer.Ordinal), + after.PersistedOpcEntries.OrderBy(pair => pair.Key, StringComparer.Ordinal)); Assert.Equal(Markdown, after.Markdown); Assert.Equal(Anchors, after.Anchors); Assert.Equal(Version, after.Version); From 983c5bf7a41650f92b2e2a67e7db2a22eb69086e Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 21:09:06 -0500 Subject: [PATCH 3/3] fix(preview): give batch previews one HTML profile and honest receipt sentinels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-surface review findings on the isolated-preview work (#446). Preview HTML had two definitions. The typed core, stdio, Python and MCP rendered through HtmlConversionOps with comments, annotations, notes and headers/footers on; npm routed its shadow through DocxSessionOps.RenderHtml — the EDITOR's authoring profile — where comments and annotations are off and headers/footers follow pagination. The same batch previewed from a browser and from an agent therefore described materially different documents. The profile now has exactly one owner, HtmlConversionOps.PreviewDocumentOptions()/PreviewBlockOptions(), which the core consumes directly and the browser reaches through two new bridge exports, RenderPreviewHtml and RenderPreviewBlockHtml. Scoped preview diverged the same way and additionally rendered with tracked changes off — a redline preview that hid its own redlines. The npm client raised the annotation execution-metadata warning for any added annotation while .NET requires a created timestamp; npm now uses the .NET predicate. packageHash used "" as its unavailable sentinel on every surface, so a preview.packageHash == applied.packageHash replay assertion passed vacuously when neither could be hashed. It is null/None now and serializes as JSON null. Receipt change sets compared entries with CLR equality while npm compared their serialized wire objects; both sides now compare the serialized projection, the shape the transports actually publish. Docs and enum ripple the feature had not carried: PreviewBatch/previewBatch/ preview_batch in docx_mutation_api.md, npm/README.md and python/README.md (including its Lifecycle table), and MutationPreviewHtmlMode as a Python enum. Two costs are documented rather than changed, because both are public-API decisions rather than defects: receipt enrichment runs unconditionally on the apply path as well as the preview path, with no opt-out, and a preview pays a package clone plus a second open package on top of it; and the typed PreviewBatch overload is isolated only insofar as a callback addresses the shadow session it is handed, which its doc comment now states. Tests: DS469 pins revision classification against pre-existing revisions — the blind spot that made this code hard to reason about — DS470 pins the preview HTML profile to the facade rather than the editor render, DS471 pins the null packageHash on the wire, plus Python wire-decode and enum coverage. --- CHANGELOG.md | 19 ++ .../DocxSessionPreviewBatchTests.cs | 188 ++++++++++++++++++ Docxodus/DocxSession.cs | 58 ++++-- Docxodus/Internal/DocxSessionJson.cs | 3 +- Docxodus/Internal/DocxSessionOps.cs | 19 ++ Docxodus/Internal/HtmlConversionOps.cs | 32 +++ docs/architecture/docx_agent_server.md | 26 ++- docs/architecture/docx_mutation_api.md | 59 +++++- npm/README.md | 19 ++ npm/src/session.ts | 51 +++-- npm/src/types.ts | 12 +- npm/tests/atomic-batch.spec.ts | 9 +- python/README.md | 30 ++- python/src/docx_scalpel/__init__.py | 2 + python/src/docx_scalpel/enums.py | 15 ++ python/src/docx_scalpel/session.py | 14 +- python/src/docx_scalpel/types.py | 8 +- python/tests/test_atomic_batches.py | 19 ++ wasm/DocxodusWasm/DocxSessionBridge.cs | 27 +++ 19 files changed, 558 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3628564..e0c42863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,25 @@ All notable changes to this project will be documented in this file. explicitly semantic-equivalence-only (same outcomes and structure/content/relationship effects modulo generated metadata) and emit warnings. This supersedes the undo-depth, redo-destruction, and crash window described in #468. + + The preview HTML profile has a single owner (`HtmlConversionOps.PreviewDocumentOptions` + / `PreviewBlockOptions`), reached from the browser through the new + `RenderPreviewHtml` / `RenderPreviewBlockHtml` bridge exports, so every surface's + preview of the same batch describes the same document (tracked changes, comments, + annotations, notes, and headers/footers shown) rather than the editor's authoring + render. Receipt change-set membership is compared on each entry's serialized wire + projection rather than CLR equality, matching what the browser client compares. + `packageHash` is `null`, never `""`, when it could not be computed, so an absent hash + cannot satisfy a replay-equality assertion. `MutationPreviewHtmlMode` is exposed to + Python as an enum (`docx_scalpel.MutationPreviewHtmlMode`). + + **Cost note.** Receipt enrichment is unconditional on both the apply and the preview + path: each batch inspects revisions, comments, and annotations twice (each forcing an + anchor index) and computes a package-content hash, which serializes and hashes a full + package checkpoint. A preview additionally clones the package and opens a second + `WordprocessingDocument`, roughly doubling peak memory for its duration — material for a + large document on a browser WASM heap. There is deliberately no opt-out in this release; + whether to gate enrichment behind a setting remains an open public-API decision. - **Atomic multi-step mutation batches** (issue #445). `DocxSession.ExecuteBatch` and the reusable nested-safe `BeginTransaction` primitive checkpoint the complete OPC package, relationship topology, anchor/revision generators, mutable session diff --git a/Docxodus.Tests/DocxSessionPreviewBatchTests.cs b/Docxodus.Tests/DocxSessionPreviewBatchTests.cs index a0a025bf..e5c2e47b 100644 --- a/Docxodus.Tests/DocxSessionPreviewBatchTests.cs +++ b/Docxodus.Tests/DocxSessionPreviewBatchTests.cs @@ -74,6 +74,7 @@ success.Failure is null : $"{success.Failure.Index}:{success.Failure.Action}:{success.Failure.Error.Code}:{success.Failure.Error.Message}"); Assert.Equal(before.Version, success.BaseVersion); Assert.Equal(before.Version + 1, success.ResultVersion); + Assert.NotNull(success.PackageHash); Assert.NotEmpty(success.PackageHash); Assert.Equal(4, success.Steps.Count); Assert.NotEmpty(success.RevisionChanges.Added); @@ -466,6 +467,193 @@ public void DS468_ReceiptEnrichment_IsSerializedWithConcurrentMutations() Assert.Contains("Concurrent mutation.", session.Project().Markdown); } + /// + /// Revision classification on an ALREADY-REDLINED document. The receipt's change sets are a + /// before∩after comparison, so a comparison that is not value-based reports every surviving + /// pre-existing revision as modified — and then cascades into the execution-clock warning + /// that tells callers not to trust packageHash. Both the apply and the preview path + /// run the same enrichment, so both are asserted. + /// + [Fact] + public void DS469_PreExistingRevisions_AreNeverReclassifiedByAnUnrelatedBatch() + { + var redlined = RedlinedBytes(out var redlinedAnchors); + + // Untracked batch: nothing about the document's revisions changes, so every change set + // must be empty and the revision-date warning must not fire. + using (var untracked = new DocxSession(redlined, new DocxSessionSettings + { + PersistAnchorIds = true, + TrackedChanges = TrackedChangeMode.Accept, + })) + { + var existing = untracked.ListRevisions(); + Assert.NotEmpty(existing); + + var preview = untracked.PreviewBatch(new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(redlinedAnchors[1], "Untouched by the redlines.")), + }); + + Assert.True(preview.Success); + Assert.Empty(preview.RevisionChanges.Added); + Assert.Empty(preview.RevisionChanges.Removed); + Assert.Empty(preview.RevisionChanges.Modified); + Assert.DoesNotContain(preview.Warnings, + warning => warning.Contains("Tracked-revision date attributes", StringComparison.Ordinal)); + } + + // Tracked batch: the batch's own revision is added; the pre-existing ones it never + // touched stay out of every bucket. + using (var tracked = new DocxSession(redlined, new DocxSessionSettings + { + PersistAnchorIds = true, + TrackedChanges = TrackedChangeMode.RenderInline, + RevisionAuthor = "Batch Author", + })) + { + var existingIds = tracked.ListRevisions() + .Select(revision => revision.Id).ToHashSet(StringComparer.Ordinal); + Assert.NotEmpty(existingIds); + + var applied = tracked.ExecuteBatch(new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(redlinedAnchors[1], "Tracked batch replacement.")), + }); + + Assert.True(applied.Success); + Assert.NotEmpty(applied.RevisionChanges.Added); + Assert.Empty(applied.RevisionChanges.Removed); + Assert.Empty(applied.RevisionChanges.Modified); + Assert.DoesNotContain(applied.RevisionChanges.Added, + revision => existingIds.Contains(revision.Id)); + } + } + + /// + /// Cross-surface preview HTML profile. The typed core renders preview HTML directly; the + /// callback-shaped npm client cannot, so it renders its shadow through the handle façade. + /// Both must resolve to ONE profile, or the same batch previewed from a browser and from + /// stdio/MCP describes two different documents. The editor's own render profile is asserted + /// to be the wrong answer here on purpose — that is what npm used to call, and it silently + /// drops comments, annotations and headers/footers. + /// + [Fact] + public void DS470_PreviewHtmlProfile_IsOwnedByTheFacadeAndNotTheEditorRenderProfile() + { + var commented = CommentedBytes(out var commentedAnchors); + var settings = new DocxSessionSettings { PersistAnchorIds = true }; + + using var typed = new DocxSession(commented, settings); + var typedPreview = typed.PreviewBatch( + new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(commentedAnchors[1], "Predicted body text.")), + }, + options: new MutationBatchPreviewOptions { HtmlMode = MutationPreviewHtmlMode.Full }); + Assert.True(typedPreview.Success); + + // The façade path the browser client uses: clone, mutate the clone, render the clone. + var liveHandle = SessionRegistry.OpenSession(commented, settings); + try + { + var shadowHandle = SessionRegistry.CloneSessionForPreview(liveHandle); + string facadeHtml; + string editorHtml; + try + { + Assert.True(SessionRegistry.Get(shadowHandle) + .ReplaceText(commentedAnchors[1], "Predicted body text.").Success); + facadeHtml = DocxSessionOps.RenderPreviewHtml(shadowHandle); + editorHtml = DocxSessionOps.RenderHtml(shadowHandle, "docx-", false, false, 1); + } + finally + { + SessionRegistry.CloseSession(shadowHandle); + } + + Assert.Equal(typedPreview.Html, facadeHtml); + Assert.Contains("Predicted body text.", facadeHtml, StringComparison.Ordinal); + + // What the profiles disagree about, stated rather than implied. + Assert.Contains("Reviewer comment body.", facadeHtml, StringComparison.Ordinal); + Assert.Contains("Preview header.", facadeHtml, StringComparison.Ordinal); + Assert.DoesNotContain("Reviewer comment body.", editorHtml, StringComparison.Ordinal); + Assert.DoesNotContain("Preview header.", editorHtml, StringComparison.Ordinal); + } + finally + { + SessionRegistry.CloseSession(liveHandle); + } + } + + /// + /// An unavailable package hash is null on the wire, never "". Two receipts + /// that both failed to hash must NOT satisfy + /// preview.packageHash == applied.packageHash — the replay assertion the docs + /// describe has to fail loudly when it has nothing to compare. + /// + [Fact] + public void DS471_UnavailablePackageHash_IsNullOnTheWireNotAnEmptySentinel() + { + var unavailable = DocxSessionJson.SerializeMutationBatchResult(new MutationBatchResult + { + Mode = MutationBatchMode.Atomic, + Success = true, + Warnings = new[] { "Package equivalence hash unavailable: simulated." }, + }); + Assert.Contains("\"packageHash\":null", unavailable, StringComparison.Ordinal); + Assert.DoesNotContain("\"packageHash\":\"\"", unavailable, StringComparison.Ordinal); + + using var session = OpenRich(); + var applied = session.ExecuteBatch(new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(BodyParagraphs(s)[0], "Hashed.")), + }); + Assert.NotNull(applied.PackageHash); + Assert.Contains($"\"packageHash\":\"{applied.PackageHash}\"", + DocxSessionJson.SerializeMutationBatchResult(applied), StringComparison.Ordinal); + } + + /// Bytes carrying a comment and a default header — the parts the editor's render + /// profile drops and a preview's must keep. + private static byte[] CommentedBytes(out string[] anchors) + { + using var seed = OpenRich(new DocxSessionSettings { PersistAnchorIds = true }); + var seedAnchors = BodyParagraphs(seed); + Assert.True(seed.AddComment(seedAnchors[0], null, "Reviewer", "Reviewer comment body.", + date: new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc)).Success); + Assert.True(seed.SetHeaderText( + seedAnchors[0], HeaderFooterKind.Default, "Preview header.").Success); + var bytes = seed.Save(persistAnchorIds: true); + + using var probe = new DocxSession(bytes, new DocxSessionSettings { PersistAnchorIds = true }); + anchors = BodyParagraphs(probe); + return bytes; + } + + /// Bytes carrying tracked revisions authored into the FIRST body paragraph only. + private static byte[] RedlinedBytes(out string[] anchors) + { + using var seed = OpenRich(new DocxSessionSettings + { + PersistAnchorIds = true, + TrackedChanges = TrackedChangeMode.RenderInline, + RevisionAuthor = "Original Reviewer", + }); + var seedAnchors = BodyParagraphs(seed); + Assert.True(seed.ReplaceText(seedAnchors[0], "Redlined first paragraph.").Success); + var bytes = seed.Save(persistAnchorIds: true); + + using var probe = new DocxSession(bytes, new DocxSessionSettings { PersistAnchorIds = true }); + anchors = BodyParagraphs(probe); + return bytes; + } + private static string NormalizeGeneratedIds(string value) => Regex.Replace(value, "[0-9a-fA-F]{32}", ""); diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 1c55a8bd..5e9f384c 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -1275,8 +1275,13 @@ public sealed record MutationBatchResult /// A deterministic replay at should produce this hash. Generated /// anchors/OOXML ids and execution timestamps make other batches only semantically equivalent; /// callers must consult before using the hash as a replay assertion. + /// + /// null — never an empty string — when the hash could not be computed (the reason is + /// in ). An absent hash must not compare equal to another absent hash: + /// a sentinel that does turns preview.PackageHash == applied.PackageHash into an + /// assertion that passes precisely when it has nothing to assert. /// - public string PackageHash { get; init; } = string.Empty; + public string? PackageHash { get; init; } public IReadOnlyList Steps { get; init; } = Array.Empty(); public MutationBatchFailure? Failure { get; init; } @@ -2905,6 +2910,20 @@ public MutationBatchResult ExecuteBatch( /// and optional HTML rendering all target the shadow. Abandoning or disposing the shadow can /// therefore never require live rollback. /// + /// + /// Caller contract. Each step's callbacks receive the shadow session as their + /// DocxSession argument — address that argument. A callback written as + /// s => liveSession.ReplaceText(...), closing over the live session instead of using + /// s, mutates the LIVE document, and this overload cannot prevent it: a delegate may + /// call anything it can reach. + /// The handle-shaped seams are intrinsically safe by construction, because a step + /// factory there is handed only the temporary shadow handle and never sees the live one: + /// DocxSessionOps.PreviewBatch for stdio/MCP, and the OpenPreviewSession bridge + /// export for the browser client. Prefer those when the steps are not written by the same + /// author as the call. + /// Enrichment cost is not free and has no opt-out: see the receipt cost note in + /// docs/architecture/docx_mutation_api.md. + /// public MutationBatchResult PreviewBatch( IEnumerable steps, MutationBatchMode mode = MutationBatchMode.Atomic, @@ -2964,12 +2983,26 @@ private MutationBatchResult CompleteBatchResult( { var after = ObserveBatchSemantics(); var warnings = before.Warnings.Concat(after.Warnings).ToList(); + // Equivalence is decided on the SERIALIZED projection of each entry, never on CLR + // equality. That is the shape every transport actually publishes, it is exactly what + // npm's `mutationBatchChangeSet` compares (JSON.stringify of the same wire objects), and + // it stays correct if an entry type ever grows a collection member — record `==` would + // then fall back to reference equality per element and report every surviving object as + // modified. var revisionChanges = SafeChangeSet( before.Revisions, after.Revisions, revision => revision.Id, - static (left, right) => left == right, "revision", warnings); + static (left, right) => string.Equals( + Internal.DocxSessionJson.SerializeRevisionList(new[] { left }), + Internal.DocxSessionJson.SerializeRevisionList(new[] { right }), + StringComparison.Ordinal), + "revision", warnings); var commentChanges = SafeChangeSet( before.Comments, after.Comments, comment => comment.DefAnchorId, - static (left, right) => left == right, "comment", warnings); + static (left, right) => string.Equals( + Internal.DocxSessionJson.SerializeCommentList(new[] { left }), + Internal.DocxSessionJson.SerializeCommentList(new[] { right }), + StringComparison.Ordinal), + "comment", warnings); var annotationChanges = SafeChangeSet( before.Annotations, after.Annotations, annotation => annotation.Id, static (left, right) => string.Equals( @@ -3011,7 +3044,7 @@ private MutationBatchResult CompleteBatchResult( if (result.Mode == MutationBatchMode.BestEffort && !result.Success) warnings.Add("Best-effort execution retains every successful step despite later failures."); - var packageHash = string.Empty; + string? packageHash = null; try { packageHash = GetPackageContentHash(); } catch (Exception ex) { warnings.Add($"Package equivalence hash unavailable: {ex.Message}"); } @@ -3203,25 +3236,12 @@ internal MutationBatchResult FinalizePreviewResult( html = Internal.HtmlConversionOps.RenderBlockHtml( this, options!.HtmlAnchorId!, - new Internal.HtmlConversionOptions - { - RenderTrackedChanges = true, - RenderFootnotesAndEndnotes = true, - StampAnchors = true, - }); + Internal.HtmlConversionOps.PreviewBlockOptions()); break; case MutationPreviewHtmlMode.Full: html = Internal.HtmlConversionOps.ConvertToHtml( this, - new Internal.HtmlConversionOptions - { - CommentRenderMode = 0, - RenderAnnotations = true, - RenderFootnotesAndEndnotes = true, - RenderHeadersAndFooters = true, - RenderTrackedChanges = true, - StampAnchors = true, - }); + Internal.HtmlConversionOps.PreviewDocumentOptions()); break; default: throw new ArgumentOutOfRangeException(nameof(options), "unknown preview HTML mode"); diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs index e7439404..66c13b08 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -1020,7 +1020,8 @@ public static string SerializeMutationBatchResult(MutationBatchResult result) .Append(",\"rolledBack\":").Append(result.RolledBack ? "true" : "false") .Append(",\"baseVersion\":").Append(result.BaseVersion) .Append(",\"resultVersion\":").Append(result.ResultVersion) - .Append(",\"packageHash\":").Append(JsonString(result.PackageHash)) + .Append(",\"packageHash\":") + .Append(result.PackageHash is null ? "null" : JsonString(result.PackageHash)) .Append(",\"steps\":["); for (int i = 0; i < result.Steps.Count; i++) { diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs index 8e306b4e..8049a707 100644 --- a/Docxodus/Internal/DocxSessionOps.cs +++ b/Docxodus/Internal/DocxSessionOps.cs @@ -126,6 +126,25 @@ public static string PreviewBatch( public static string GetPackageContentHash(int handle) => SessionRegistry.Get(handle).GetPackageContentHash(); + /// + /// Render a preview shadow to the SAME complete-document profile + /// uses (). + /// Exists so the callback-shaped npm preview — which drives its shadow from JS and therefore + /// cannot reuse the typed core's render call — does not have to restate the profile and drift + /// from it. Use this, never , for preview HTML: RenderHtml is the + /// EDITOR's authoring view (comments and annotations off, headers/footers tied to pagination), + /// which answers a different question. + /// + public static string RenderPreviewHtml(int handle) => + HtmlConversionOps.ConvertToHtml( + SessionRegistry.Get(handle), HtmlConversionOps.PreviewDocumentOptions()); + + /// Scoped counterpart of ; see + /// . + public static string RenderPreviewBlockHtml(int handle, string anchorId) => + HtmlConversionOps.RenderBlockHtml( + SessionRegistry.Get(handle), anchorId, HtmlConversionOps.PreviewBlockOptions()); + public static DocxSessionTransaction BeginTransaction(int handle) => SessionRegistry.Get(handle).BeginTransaction(); diff --git a/Docxodus/Internal/HtmlConversionOps.cs b/Docxodus/Internal/HtmlConversionOps.cs index 97a0adb1..85fd83c6 100644 --- a/Docxodus/Internal/HtmlConversionOps.cs +++ b/Docxodus/Internal/HtmlConversionOps.cs @@ -167,6 +167,38 @@ public static string ConvertToHtml(DocxSession session, HtmlConversionOptions op public static string ConvertToHtml(int handle, HtmlConversionOptions options) => ConvertToHtml(SessionRegistry.Get(handle), options); + /// + /// The single definition of the option profile a mutation-batch preview renders with + /// (). A preview answers "what would the document + /// become", so it shows everything the applied document would carry — tracked changes, + /// comments, annotations, notes, headers/footers — rather than the editor's authoring view. + /// Every surface MUST consume this rather than restating the flags: the typed core, the + /// handle façade, both bridges and both clients must agree about what a preview shows, or + /// two callers previewing the same batch see materially different documents. + /// + public static HtmlConversionOptions PreviewDocumentOptions() => new() + { + CommentRenderMode = 0, + RenderAnnotations = true, + RenderFootnotesAndEndnotes = true, + RenderHeadersAndFooters = true, + RenderTrackedChanges = true, + StampAnchors = true, + }; + + /// + /// The single definition of the option profile a scoped + /// () preview renders one block with. Tracked + /// changes stay on for the same reason as : a scoped + /// redline preview that hides its own redlines shows the caller nothing. + /// + public static HtmlConversionOptions PreviewBlockOptions() => new() + { + RenderTrackedChanges = true, + RenderFootnotesAndEndnotes = true, + StampAnchors = true, + }; + /// /// Render a single block (addressed by a kind:scope:unid anchor) to faithful /// HTML. Builds a throwaway document that copies the source's styles/numbering/theme diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md index 3a7e2de5..c058859e 100644 --- a/docs/architecture/docx_agent_server.md +++ b/docs/architecture/docx_agent_server.md @@ -443,12 +443,26 @@ state, version, caches, settings, and both history cursors were never mutation t Preview receipts use the same typed result as apply: `baseVersion`, predicted `resultVersion`, each step's `created`/`removed`/`modified` anchors and markdown patch, revision/comment/annotation -`{ added, removed, modified }` deltas, warnings, and a `packageHash`. `previewHtml` may be `scoped` -(with `previewAnchorId`) or `full`; rendering occurs only from the final shadow package, and an -optional rendering failure is a warning rather than turning a committed/predicted mutation into an -apparent failure. The content hash is SHA-256 over sorted OPC entry names plus their uncompressed -payload bytes with fixed little-endian framing, excluding ZIP timestamps/compression but not XML -timestamps or generated OOXML ids. +`{ added, removed, modified }` deltas, warnings, and a `packageHash`. Delta membership is decided on +each entry's serialized wire projection, never on CLR object equality, so a pre-existing revision or +comment a batch never touched is reported in no bucket at all. `packageHash` is `null` — never an +empty string — when it could not be computed, so an unavailable hash cannot compare equal to another +unavailable hash. `previewHtml` may be `scoped` (with `previewAnchorId`) or `full`; rendering occurs +only from the final shadow package, and an optional rendering failure is a warning rather than +turning a committed/predicted mutation into an apparent failure. The render profile is owned once, by +`HtmlConversionOps.PreviewDocumentOptions()`/`PreviewBlockOptions()`, and every surface (typed core, +handle façade, WASM bridge, npm, stdio/Python, this server) consumes it — a preview shows tracked +changes, comments, annotations, notes and headers/footers, which is deliberately NOT the editor's +authoring render profile. The content hash is SHA-256 over sorted OPC entry names plus their +uncompressed payload bytes with fixed little-endian framing, excluding ZIP timestamps/compression but +not XML timestamps or generated OOXML ids. + +Receipt enrichment is unconditional and is not free. Every batch — applied or previewed — inspects +revisions, comments and annotations twice (before and after, each forcing an anchor index) and +computes `packageHash`, which serializes a full package checkpoint and hashes it. A preview adds a +package clone and a second open `WordprocessingDocument` on top, roughly doubling peak memory for its +duration. On a large document this is the dominant cost of a small batch. There is currently no +opt-out; whether to gate it behind a setting is an open public-API decision. Equivalence is exact for deterministic batches: replaying at the same base state produces the same step outcomes, semantic deltas, and package hash. For create/comment/note/image operations that diff --git a/docs/architecture/docx_mutation_api.md b/docs/architecture/docx_mutation_api.md index 30f7ea80..073131bf 100644 --- a/docs/architecture/docx_mutation_api.md +++ b/docs/architecture/docx_mutation_api.md @@ -117,7 +117,64 @@ the owner thread abandons all scopes and releases their mutation-gate entries. The same semantics reach `DocxSessionOps`/JSON, WASM and npm (`session.executeBatch`), stdio and Python (`session.execute_batch`), and MCP -(`docxodus_mutations`). Preview isolation is intentionally separate work in #446. +(`docxodus_mutations`). + +### Isolated previews + +`PreviewBatch(steps, mode, options)` runs the identical step delegates against a complete +clone of the live package (`CreateShadowSession`). Guards, mutations, history writes, +semantic inspection, package hashing, and any HTML render all target the shadow, which is +disposed on every return and throw path. Abandoning a preview therefore cannot require a +live rollback — the live session's bytes, caches, version, configuration and undo/redo +cursors were never mutation targets. + +```csharp +var preview = session.PreviewBatch( + new[] + { + new MutationBatchStep("docx_edit", "replace_text", + s => s.ReplaceText(firstAnchor, "Proposed replacement")), + }, + options: new MutationBatchPreviewOptions + { + HtmlMode = MutationPreviewHtmlMode.Full, + }); +``` + +**Caller contract for the typed overload.** The `s` argument each callback receives IS the +shadow. Isolation comes from addressing that argument; a callback that closes over the live +session and calls `liveSession.ReplaceText(...)` instead mutates the live document, and +nothing in the typed API can prevent it. The handle-shaped seams — `DocxSessionOps.PreviewBatch` +and the `OpenPreviewSession` bridge — are intrinsically safe because a step factory there is +handed only the temporary shadow handle and never sees the live one. + +`MutationBatchPreviewOptions.HtmlMode` (`MutationPreviewHtmlMode`: `None`, `Scoped`, `Full`; +`Scoped` additionally requires `HtmlAnchorId`) renders the predicted document from the shadow. +The option profile lives in exactly one place — `HtmlConversionOps.PreviewDocumentOptions()` +and `PreviewBlockOptions()` — and every surface consumes it, so a browser preview and an +MCP preview of the same batch describe the same document. A preview shows tracked changes, +comments, annotations, notes and headers/footers: it answers "what would this document +become", which is not the editor's authoring view (`DocxSessionOps.RenderHtml`, where +comments and annotations are off). + +Both preview and apply return the same enriched receipt: `baseVersion`/`resultVersion`, +`packageHash`, `{added, removed, modified}` change sets for revisions, comments and +annotations, and `warnings`. Change-set membership is decided on each entry's SERIALIZED +projection — the shape the transports actually publish, and the same comparison npm makes — +never on CLR equality. `packageHash` is `null`, never `""`, when it could not be computed; +an absent hash must not compare equal to another absent hash. + +**Cost.** Enrichment is unconditional on BOTH paths. Every batch — applied or previewed — +runs `ListRevisions` + `ListComments` + `ListAnnotations` twice (before and after, each +forcing an anchor index) plus one `GetPackageContentHash()`, which serializes a full package +checkpoint and SHA-256s it. A preview additionally pays a package clone and a second open +`WordprocessingDocument`, roughly doubling peak memory for the duration. That is material on +a constrained heap (a browser WASM session holding a large DOCX). There is deliberately no +opt-out today; gating it behind a setting is a public-API decision that has not been taken. + +npm exposes this as `session.previewBatch(steps, mode, { html, htmlAnchorId })` with callbacks +that receive the shadow session, stdio/Python as `session.preview_batch(steps, mode, +html_mode=…, html_anchor_id=…)`, and MCP as `docxodus_mutations` with `"mode": "preview"`. ## Architecture diff --git a/npm/README.md b/npm/README.md index 4df6ccd5..4e611a79 100644 --- a/npm/README.md +++ b/npm/README.md @@ -116,6 +116,25 @@ if (!result.success) console.error(result.failure); Pass `'best_effort'` explicitly only when partial successes should be retained. +`previewBatch` answers "what would this do?" without touching the live session. It runs the +same steps against a complete isolated clone — each callback is handed the shadow session to +mutate — and returns the same receipt plus optional predicted HTML: + +```ts +const preview = session.previewBatch([ + { tool: 'docx_edit', action: 'replace_text', + mutation: shadow => shadow.replaceText(firstAnchor, 'Proposed replacement') }, +], 'atomic', { html: 'full' }); + +console.log(preview.html, preview.revisionChanges.added, preview.warnings); +``` + +The live document's bytes, version and undo/redo history are unchanged either way. Preview +HTML shows tracked changes, comments, annotations, notes and headers/footers — the document +the batch would produce, matching what the Python and MCP clients render for the same batch. +`packageHash` is `null` when it could not be computed, so never assert replay equality +without checking for it. + ![Markdown projection beside the rendered document](https://raw.githubusercontent.com/JSv4/Docxodus/main/docs/images/projection.png) --- diff --git a/npm/src/session.ts b/npm/src/session.ts index ac061d7b..e55622b6 100644 --- a/npm/src/session.ts +++ b/npm/src/session.ts @@ -238,7 +238,11 @@ export class DocxSession { .some(comment => comment.date !== undefined && comment.date !== null)) { warnings.push("Comment date attributes may be generated from the execution clock; supply dates explicitly when byte-identical replay is required."); } - if (annotationChanges.added.length > 0) { + // Same predicate as the .NET receipt (`annotation.Created.HasValue`): the warning is + // about an execution CLOCK, so an annotation added with no created timestamp is + // deterministic and must not raise it on one surface and not the other. + if (annotationChanges.added + .some(annotation => annotation.created !== undefined && annotation.created !== null)) { warnings.push("Auto-generated annotation ids or creation timestamps are execution metadata; supply id and created explicitly when byte-identical replay is required."); } if (result.steps.some(step => step.results.some(edit => edit.created.length > 0))) { @@ -247,7 +251,9 @@ export class DocxSession { if (mode === "best_effort" && !result.success) { warnings.push("Best-effort execution retains every successful step despite later failures."); } - let packageHash = ""; + // null, never "": an absent hash must not compare equal to another absent hash, or a + // naive `preview.packageHash === applied.packageHash` replay assertion passes vacuously. + let packageHash: string | null = null; if (!this.wasm.GetPackageContentHash) { warnings.push("This WASM bundle predates package equivalence hashes; packageHash is unavailable."); } else { @@ -273,7 +279,7 @@ export class DocxSession { preview: false, baseVersion, resultVersion: inspect("Result version inspection", () => this.getVersion(), baseVersion), - packageHash: "", + packageHash: null, revisionChanges: { added: [], removed: [], modified: [] }, commentChanges: { added: [], removed: [], modified: [] }, annotationChanges: { added: [], removed: [], modified: [] }, @@ -416,26 +422,43 @@ export class DocxSession { ); const warnings = [...result.warnings]; let html: string | null = null; + // A rendered document always starts with '<'; a leading '{' is the bridge's error object. + const unwrapRendered = (rendered: string): string | null => { + if (rendered.trimStart().startsWith("{")) { + const envelope = JSON.parse(rendered) as { error?: string }; + if (envelope.error) { + warnings.push(`Preview HTML could not be generated: ${envelope.error}`); + return null; + } + } + return rendered; + }; + // Preview HTML MUST come from the façade's preview profile (DocxSessionOps.RenderPreview*), + // not the editor's authoring profile: the editor render hides comments, annotations and + // headers/footers, so routing a preview through it would show this surface a materially + // different document than the stdio/Python/MCP surfaces show for the identical batch. + const legacyProfileWarning = + "This WASM bundle predates the shared preview HTML profile; preview HTML omits comments, " + + "annotations and headers/footers and may differ from other surfaces."; try { if (htmlMode === "scoped") { if (!options?.htmlAnchorId) { warnings.push("Scoped HTML was requested without htmlAnchorId; no HTML was generated."); + } else if (this.wasm.RenderPreviewBlockHtml) { + html = unwrapRendered( + this.wasm.RenderPreviewBlockHtml(shadow.handle, options.htmlAnchorId)); } else { + warnings.push(legacyProfileWarning); html = shadow.renderBlock(options.htmlAnchorId); } } else if (htmlMode === "full") { - const rendered = this.wasm.RenderHtmlForReview - ? this.wasm.RenderHtmlForReview(shadow.handle, "docx-", false, false, 1, true) - : this.wasm.RenderHtml(shadow.handle, "docx-", false, false, 1); - if (rendered.trimStart().startsWith("{")) { - const envelope = JSON.parse(rendered) as { error?: string }; - if (envelope.error) { - warnings.push(`Preview HTML could not be generated: ${envelope.error}`); - } else { - html = rendered; - } + if (this.wasm.RenderPreviewHtml) { + html = unwrapRendered(this.wasm.RenderPreviewHtml(shadow.handle)); } else { - html = rendered; + warnings.push(legacyProfileWarning); + html = unwrapRendered(this.wasm.RenderHtmlForReview + ? this.wasm.RenderHtmlForReview(shadow.handle, "docx-", false, false, 1, true) + : this.wasm.RenderHtml(shadow.handle, "docx-", false, false, 1)); } } } catch (error) { diff --git a/npm/src/types.ts b/npm/src/types.ts index 6ba0b6a4..0e98d39e 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1055,6 +1055,8 @@ export interface DocxodusWasmExports { GetPageMapStatus: (handle: number, requestJson: string) => string; GetPageCitation: (handle: number, anchorId: string, requestJson: string) => string; GetPackageContentHash?: (handle: number) => string; + RenderPreviewHtml?: (handle: number) => string; + RenderPreviewBlockHtml?: (handle: number, anchorId: string) => string; CheckPreconditions: (handle: number, preconditionsJson: string) => string; BeginTransaction: (handle: number) => number; CommitTransaction: (transactionHandle: number) => void; @@ -1431,8 +1433,14 @@ export interface MutationBatchResult { rolledBack: boolean; baseVersion: number; resultVersion: number; - /** Canonical SHA-256 of this result package; exact replay equality is guaranteed only for deterministic batches. */ - packageHash: string; + /** + * Canonical SHA-256 of this result package, or `null` when it could not be computed. + * Exact replay equality is guaranteed only for deterministic batches, so consult + * {@link MutationBatchResult.warnings} before asserting on it — and note that `null` + * never equals `null` for the purposes of a replay assertion: an absent hash proves + * nothing and must be handled explicitly rather than compared. + */ + packageHash: string | null; steps: readonly MutationBatchStepResult[]; failure?: MutationBatchFailure; revisionChanges: MutationBatchChangeSet; diff --git a/npm/tests/atomic-batch.spec.ts b/npm/tests/atomic-batch.spec.ts index 1d3ec923..b93bf979 100644 --- a/npm/tests/atomic-batch.spec.ts +++ b/npm/tests/atomic-batch.spec.ts @@ -195,9 +195,16 @@ test.describe('DocxSession atomic batches (#445)', () => { invalidError = error instanceof Error ? error.message : String(error); } + // Intercept EVERY export the full-preview path may reach: the client prefers the + // shared preview profile (RenderPreviewHtml) and falls back to the editor-profile + // exports only on a bundle that predates it. Naming just one leaves this test green + // for the wrong reason on whichever bundle the fallback does not apply to. + const renderFailure = new Set([ + 'RenderPreviewHtml', 'RenderHtmlForReview', 'RenderHtml', + ]); const bridge = new Proxy(api.DocxSessionBridge, { get(target, property, receiver) { - if (property === 'RenderHtmlForReview') { + if (typeof property === 'string' && renderFailure.has(property)) { return () => JSON.stringify({ error: 'simulated renderer failure' }); } return Reflect.get(target, property, receiver); diff --git a/python/README.md b/python/README.md index 5afa7924..9803c5a9 100644 --- a/python/README.md +++ b/python/README.md @@ -77,6 +77,34 @@ if not result.success: Select `MutationBatchMode.BEST_EFFORT` explicitly only when retaining successful steps after another step fails is intended. +### Isolated previews + +`preview_batch` takes the same steps and predicts their outcome on a complete clone of the +package. The live session is never a mutation target, so its bytes, version and undo/redo +history are untouched whatever the steps do: + +```python +from docx_scalpel import MutationPreviewHtmlMode + +preview = session.preview_batch( + [ + MutationBatchStep("replace_text", { + "anchorId": first_p.id, + "markdown": "Proposed replacement", + }), + ], + html_mode=MutationPreviewHtmlMode.FULL, +) +print(preview.html) +print(preview.revision_changes.added, preview.comment_changes.added, preview.warnings) +``` + +Both `preview_batch` and `execute_batch` return the enriched receipt: `base_version`, +`result_version`, `package_hash`, `{added, removed, modified}` change sets for revisions, +comments and annotations, and `warnings`. `MutationPreviewHtmlMode.SCOPED` renders one block +and requires `html_anchor_id`. `package_hash` is `None` — never `""` — when it could not be +computed, so check it before using it as a replay assertion. + The `with` block is the documented lifecycle path — it calls `session.close()` on the way out, which releases the session from the host's `SessionRegistry`. A `__del__` finalizer is a fallback for forgotten sessions but should not be relied on; interpreter shutdown may skip it. ## Why a subprocess? @@ -144,7 +172,7 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a | Tier | Methods | |---|---| -| **Lifecycle** | `save`, `close`, `undo`, `redo`, `get_version`, `execute_batch`, `to_html`, `register_page_map`, `get_page_map_status`, `get_page_citation` | +| **Lifecycle** | `save`, `close`, `undo`, `redo`, `get_version`, `execute_batch`, `preview_batch`, `to_html`, `register_page_map`, `get_page_map_status`, `get_page_citation` | | **Projection** | `project`, `project_anchor` | | **Discovery** | `grep`, `grep_cross_block`, `find_placeholders`, `find_by_text`, `find_all_by_text`, `find_by_regex`, `find_by_kind`, `find_by_annotation`, `find_by_label`, `find_by_bookmark`, `list_annotations`, `exists`, `get_anchor_info`, `get_anchor_infos`, `get_edit_summary`, `remaining_placeholders`, `get_diff` | | **Inspection** | `get_block_metadata`, `get_block_metadatas`, `get_list_membership`, `get_section_info` | diff --git a/python/src/docx_scalpel/__init__.py b/python/src/docx_scalpel/__init__.py index 5eaded06..432d225a 100644 --- a/python/src/docx_scalpel/__init__.py +++ b/python/src/docx_scalpel/__init__.py @@ -46,6 +46,7 @@ LineSpacingRule, ListFormat, MutationBatchMode, + MutationPreviewHtmlMode, PageNumberField, ParagraphAlignment, PlaceholderKind, @@ -263,6 +264,7 @@ "HeaderFooterKind", "LineSpacingRule", "MutationBatchMode", + "MutationPreviewHtmlMode", "ListFormat", "PageNumberField", "ParagraphAlignment", diff --git a/python/src/docx_scalpel/enums.py b/python/src/docx_scalpel/enums.py index e75018a6..957a8f52 100644 --- a/python/src/docx_scalpel/enums.py +++ b/python/src/docx_scalpel/enums.py @@ -18,6 +18,7 @@ "ListFormat", "EditErrorCode", "MutationBatchMode", + "MutationPreviewHtmlMode", "PlaceholderKind", "PlaceholderKinds", "ProjectionScopes", @@ -195,6 +196,20 @@ class MutationBatchMode(str, Enum): BEST_EFFORT = "best_effort" +class MutationPreviewHtmlMode(str, Enum): + """Optional HTML a ``preview_batch`` renders from its isolated shadow package. + + ``SCOPED`` requires ``html_anchor_id`` and renders that one block; ``FULL`` + renders the whole predicted document. Both render with tracked changes, + comments, annotations and notes shown — a preview describes the document the + batch would produce, not an authoring view of it. + """ + + NONE = "none" + SCOPED = "scoped" + FULL = "full" + + class PlaceholderKind(str, Enum): """Discriminator for a single ``TemplatePlaceholder``.""" diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py index ab5386db..3496687d 100644 --- a/python/src/docx_scalpel/session.py +++ b/python/src/docx_scalpel/session.py @@ -34,6 +34,7 @@ HeaderFooterKind, ListFormat, MutationBatchMode, + MutationPreviewHtmlMode, PageNumberField, PlaceholderKinds, Position, @@ -528,23 +529,26 @@ def preview_batch( steps: Iterable[MutationBatchStep], mode: MutationBatchMode = MutationBatchMode.ATOMIC, *, - html_mode: str = "none", + html_mode: MutationPreviewHtmlMode | str = MutationPreviewHtmlMode.NONE, html_anchor_id: str | None = None, ) -> MutationBatchResult: """Predict a batch on a complete clone without touching this live session. ``atomic`` is the safe default; choose ``best_effort`` explicitly to inspect partial-success semantics. Optional ``scoped``/``full`` HTML is rendered only - from the predicted shadow package. + from the predicted shadow package. ``html_mode`` accepts a + :class:`MutationPreviewHtmlMode` or its wire string. """ - if html_mode not in ("none", "scoped", "full"): - raise ValueError(f"unknown preview html mode: {html_mode}") + try: + html = MutationPreviewHtmlMode(html_mode) + except ValueError: + raise ValueError(f"unknown preview html mode: {html_mode}") from None result = self._call( "preview_batch", { "mode": mode.value, "steps": [step.to_wire() for step in steps], - "htmlMode": html_mode, + "htmlMode": html.value, "htmlAnchorId": html_anchor_id, }, ) diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py index f9d8f002..dbe9bbb1 100644 --- a/python/src/docx_scalpel/types.py +++ b/python/src/docx_scalpel/types.py @@ -1216,7 +1216,9 @@ class MutationBatchResult: preview: bool = False base_version: int = 0 result_version: int = 0 - package_hash: str = "" + #: ``None`` — never ``""`` — when the hash could not be computed (the reason is in + #: ``warnings``). An absent hash must not compare equal to another absent hash. + package_hash: str | None = None revision_changes: MutationBatchChangeSet[RevisionListEntry] = field( default_factory=MutationBatchChangeSet ) @@ -1242,7 +1244,9 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "MutationBatchResult": preview=bool(d.get("preview", False)), base_version=int(d.get("baseVersion", 0)), result_version=int(d.get("resultVersion", 0)), - package_hash=str(d.get("packageHash", "")), + package_hash=( + None if d.get("packageHash") is None else str(d["packageHash"]) + ), revision_changes=MutationBatchChangeSet._from_wire( d.get("revisionChanges"), RevisionListEntry._from_wire ), diff --git a/python/tests/test_atomic_batches.py b/python/tests/test_atomic_batches.py index 9fb7c6e8..f6e02eb2 100644 --- a/python/tests/test_atomic_batches.py +++ b/python/tests/test_atomic_batches.py @@ -7,7 +7,9 @@ DocxSession, DocxSessionSettings, MutationBatchMode, + MutationBatchResult, MutationBatchStep, + MutationPreviewHtmlMode, open_session, ) @@ -167,3 +169,20 @@ def test_preview_batch_is_rich_and_preserves_live_bytes_version_and_redo( assert not session.undo() assert session.redo() assert "Python redo target." in session.project().markdown + + +def test_absent_package_hash_decodes_to_none_not_an_empty_sentinel() -> None: + """An unavailable hash must never satisfy a replay-equality assertion.""" + absent = MutationBatchResult._from_wire({"mode": "atomic", "packageHash": None}) + other_absent = MutationBatchResult._from_wire({"mode": "atomic"}) + assert absent.package_hash is None + assert other_absent.package_hash is None + + present = MutationBatchResult._from_wire({"mode": "atomic", "packageHash": "ab" * 32}) + assert present.package_hash == "ab" * 32 + + +def test_preview_html_mode_is_an_enum_matching_the_wire_strings() -> None: + assert MutationPreviewHtmlMode.NONE.value == "none" + assert MutationPreviewHtmlMode("scoped") is MutationPreviewHtmlMode.SCOPED + assert MutationPreviewHtmlMode("full") is MutationPreviewHtmlMode.FULL diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs index 686a2c9e..508aec74 100644 --- a/wasm/DocxodusWasm/DocxSessionBridge.cs +++ b/wasm/DocxodusWasm/DocxSessionBridge.cs @@ -275,6 +275,33 @@ public static string RenderHtmlForReview( } } + /// + /// Render a preview shadow with the façade's own preview profile, so a browser preview and a + /// stdio/MCP preview of the same batch describe the same document. The editor's + /// profile is deliberately NOT reused here — it hides comments and + /// annotations. Same error convention: HTML starts with '<', an error object with '{'. + /// + [JSExport] + public static string RenderPreviewHtml(int h) + { + try { return DocxSessionOps.RenderPreviewHtml(h); } + catch (System.Exception ex) + { + return $"{{\"error\":\"{JsonEncodedText.Encode(ex.Message ?? string.Empty)}\"}}"; + } + } + + /// Scoped counterpart of . + [JSExport] + public static string RenderPreviewBlockHtml(int h, string anchorId) + { + try { return DocxSessionOps.RenderPreviewBlockHtml(h, anchorId); } + catch (System.Exception ex) + { + return $"{{\"error\":\"{JsonEncodedText.Encode(ex.Message ?? string.Empty)}\"}}"; + } + } + [JSExport] public static string ReplaceText(int h, string anchor, string md) => DocxSessionOps.ReplaceText(h, anchor, md);