From a666aa3ca4709d82e2091c7ba33672a13f36b571 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 00:43:40 -0500 Subject: [PATCH] Add mutation preconditions and versioning --- CHANGELOG.md | 14 + .../DocxSessionPreconditionTests.cs | 236 +++++++++++ Docxodus.Tests/McpServerDispatcherTests.cs | 37 ++ Docxodus/DocxSession.cs | 252 +++++++++++- Docxodus/Internal/DocxSessionJson.cs | 79 +++- Docxodus/Internal/DocxSessionOps.cs | 380 ++++++++++++------ Docxodus/Internal/UndoRing.cs | 13 +- docs/architecture/docx_agent_server.md | 9 + docs/architecture/docx_mutation_api.md | 68 +++- npm/src/session.ts | 50 ++- npm/src/types.ts | 48 +++ python/README.md | 10 +- python/src/docx_scalpel/__init__.py | 8 + python/src/docx_scalpel/enums.py | 1 + python/src/docx_scalpel/session.py | 45 ++- python/src/docx_scalpel/types.py | 96 +++++ python/tests/test_preconditions.py | 79 ++++ tools/mcp-server/Dispatcher.cs | 206 +++++++--- tools/mcp-server/README.md | 7 +- tools/mcp-server/ToolCatalog.cs | 14 +- tools/python-host/Dispatcher.cs | 76 +++- wasm/DocxodusWasm/DocxSessionBridge.cs | 13 + 22 files changed, 1524 insertions(+), 217 deletions(-) create mode 100644 Docxodus.Tests/DocxSessionPreconditionTests.cs create mode 100644 python/tests/test_preconditions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa8351c..ef7a7716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,20 @@ All notable changes to this project will be documented in this file. preserved; emitting new tracked table revisions remains #455. Coverage: `DocxSessionTableAddressingTests` DT250–DT257, the existing table/MCP suites, and `python/tests/test_table_addressing.py`. +- **Optimistic mutation preconditions and a monotonic document version** (issue + #447). Every `DocxSession` starts at version `0` and advances exactly once for + each committed mutation, undo, or redo; failures and successful no-ops leave it + unchanged. `MutationPreconditions` can guard the expected version, target + anchor/hash/exact visible text or range/kind/scope, and find/replace occurrence + count. A mismatch returns `PreconditionFailed` with structured expected/actual + values plus the current version and target metadata, without changing bytes or + undo history. The same camel-case shape is exposed by the WASM/npm, stdio/Python, + and MCP transports; `AnchorInfo` now includes `contentHash` and `visibleText`. +- **Exact occurrence-count replacement.** `ReplaceOptions.ExpectedMatchCount` + requires the live literal-match count before `ReplaceTextRange` proceeds. Guard + evaluation, counting, and the whole multi-match rewrite share one mutation gate + and one undo snapshot, so duplicate text cannot turn a stale plan into a partial + replacement. - **`DocxSessionSettings.UndoMemoryBudgetBytes`** (wire `undoMemoryBudgetBytes`, Python `undo_memory_budget_bytes`) — an approximate ceiling on the memory held by undo/redo snapshots, default **128 MiB**. `UndoDepth` never bounded memory: diff --git a/Docxodus.Tests/DocxSessionPreconditionTests.cs b/Docxodus.Tests/DocxSessionPreconditionTests.cs new file mode 100644 index 00000000..2eb92142 --- /dev/null +++ b/Docxodus.Tests/DocxSessionPreconditionTests.cs @@ -0,0 +1,236 @@ +#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.Linq; +using Docxodus.Internal; +using Xunit; + +namespace Docxodus.Tests; + +/// Document-version and optimistic-mutation regression coverage (issue #447). +public class DocxSessionPreconditionTests +{ + [Fact] + public void DS447_Version_AdvancesOnlyForCommittedMutationUndoAndRedo() + { + using var session = Open(); + var first = BodyParagraphs(session)[0]; + + Assert.Equal(0, session.Version); + Assert.False(session.ReplaceText("p:body:missing", "x").Success); + Assert.Equal(0, session.Version); + Assert.False(session.Undo()); + + Assert.True(session.ReplaceText(first, "Changed").Success); + Assert.Equal(1, session.Version); + + Assert.Equal(0, session.CompactRuns().RunsRemoved); + Assert.Equal(1, session.Version); + + Assert.True(session.Undo()); + Assert.Equal(2, session.Version); + Assert.True(session.Redo()); + Assert.Equal(3, session.Version); + Assert.False(session.Redo()); + Assert.Equal(3, session.Version); + } + + [Fact] + public void DS448_StaleVersion_FailsWithCurrentMetadataWithoutMutationOrHistoryLoss() + { + using var session = Open(); + var paragraphs = BodyParagraphs(session); + var first = paragraphs[0]; + var info = Assert.IsType(session.GetAnchorInfo(first)); + + Assert.True(session.ReplaceText(paragraphs[1], "Committed change").Success); + Assert.Equal(1, session.Version); + var before = session.Save(persistAnchorIds: true); + + var result = session.ExecuteMutation( + new MutationPreconditions + { + ExpectedVersion = 0, + AnchorId = first, + ExpectedContentHash = info.ContentHash, + }, + s => s.ReplaceText(first, "must not apply")); + + Assert.False(result.Success); + Assert.Equal(EditErrorCode.PreconditionFailed, result.Error?.Code); + var detail = Assert.IsType(result.Error?.Precondition); + Assert.Equal("document_version", detail.Condition); + Assert.Equal(0L, detail.Expected); + Assert.Equal(1L, detail.Actual); + Assert.Equal(1, detail.CurrentVersion); + Assert.True(detail.CurrentTarget?.Exists); + Assert.Equal(info.ContentHash, detail.CurrentTarget?.ContentHash); + Assert.Equal(before, session.Save(persistAnchorIds: true)); + Assert.Equal(1, session.Version); + + // The failed guarded call did not add or consume a history entry: this undoes + // the one earlier committed mutation. + Assert.True(session.Undo()); + Assert.Contains("Second paragraph.", session.Project().Markdown); + } + + [Fact] + public void DS449_AnchorTextHashAndRangeGuards_ReportExpectedAndActual() + { + using var session = Open(); + var first = BodyParagraphs(session)[0]; + var info = Assert.IsType(session.GetAnchorInfo(first)); + + Assert.Equal("First paragraph.", info.VisibleText); + Assert.False(string.IsNullOrWhiteSpace(info.ContentHash)); + Assert.Null(session.EvaluatePreconditions(new MutationPreconditions + { + AnchorId = first, + ExpectedContentHash = info.ContentHash, + ExpectedText = info.VisibleText, + ExpectedTextRange = new TextRangePrecondition(0, 5, "First"), + ExpectedKind = info.Kind, + ExpectedScope = info.Scope, + })); + + var error = Assert.IsType(session.EvaluatePreconditions( + new MutationPreconditions + { + AnchorId = first, + ExpectedTextRange = new TextRangePrecondition(6, 9, "different"), + })); + Assert.Equal(EditErrorCode.PreconditionFailed, error.Code); + Assert.Equal("anchor_text_range", error.Precondition?.Condition); + Assert.Equal("paragraph", error.Precondition?.Actual); + Assert.Equal(0, session.Version); + Assert.False(session.Undo()); + } + + [Fact] + public void DS450_KindChangeAndDeletedAnchor_ReturnCurrentLiveState() + { + using var session = Open(); + var paragraphs = BodyParagraphs(session); + var oldParagraphAnchor = paragraphs[0]; + var deletedAnchor = paragraphs[1]; + + Assert.True(session.SetParagraphStyle(oldParagraphAnchor, "Heading1").Success); + var kindError = Assert.IsType(session.EvaluatePreconditions( + new MutationPreconditions { AnchorId = oldParagraphAnchor, ExpectedKind = "p" })); + Assert.Equal("anchor_kind", kindError.Precondition?.Condition); + Assert.Equal("h", kindError.Precondition?.Actual); + Assert.Equal("h", kindError.Precondition?.CurrentTarget?.Kind); + + Assert.True(session.DeleteBlock(deletedAnchor).Success); + var deletedError = Assert.IsType(session.EvaluatePreconditions( + new MutationPreconditions { AnchorId = deletedAnchor, ExpectedText = "Second paragraph." })); + Assert.Equal("anchor_exists", deletedError.Precondition?.Condition); + Assert.Equal(false, deletedError.Precondition?.Actual); + Assert.False(deletedError.Precondition?.CurrentTarget?.Exists); + } + + [Fact] + public void DS451_ExactMatchCount_IsAtomicAndOneUndoUnit() + { + using var session = Open(); + var first = BodyParagraphs(session)[0]; + Assert.True(session.ReplaceText(first, "cat cat").Success); + Assert.Equal(1, session.Version); + var beforeFailure = session.Save(persistAnchorIds: true); + + var failed = Assert.Single(session.ReplaceTextRange(first, "cat", "dog", + new ReplaceOptions { ExpectedMatchCount = 1 })); + Assert.False(failed.Success); + Assert.Equal(EditErrorCode.PreconditionFailed, failed.Error?.Code); + Assert.Equal("match_count", failed.Error?.Precondition?.Condition); + Assert.Equal(1, failed.Error?.Precondition?.Expected); + Assert.Equal(2, failed.Error?.Precondition?.Actual); + Assert.Equal(beforeFailure, session.Save(persistAnchorIds: true)); + Assert.Equal(1, session.Version); + + var replaced = session.ReplaceTextRange(first, "cat", "dog", + new ReplaceOptions + { + Preconditions = new MutationPreconditions + { + ExpectedVersion = 1, + ExpectedMatchCount = 2, + }, + }); + Assert.Equal(2, replaced.Count); + Assert.All(replaced, r => Assert.True(r.Success)); + Assert.Equal(2, session.Version); // one committed operation, not one per match + Assert.Contains("dog dog", session.Project().Markdown); + + Assert.True(session.Undo()); + Assert.Equal(3, session.Version); + Assert.Contains("cat cat", session.Project().Markdown); + } + + [Fact] + public void DS452_CrossPartAnchor_GuardsUseExactHeaderTextAndHash() + { + using var session = Open(); + var body = BodyParagraphs(session)[0]; + var created = session.SetHeaderText(body, HeaderFooterKind.Default, "Confidential"); + Assert.True(created.Success); + var header = Assert.Single(created.Created, a => a.Scope.StartsWith("hdr")).Id; + var info = Assert.IsType(session.GetAnchorInfo(header)); + + Assert.Equal("Confidential", info.VisibleText); + var success = session.ExecuteMutation( + new MutationPreconditions + { + ExpectedVersion = session.Version, + AnchorId = header, + ExpectedContentHash = info.ContentHash, + ExpectedText = "Confidential", + ExpectedScope = info.Scope, + }, + s => s.ReplaceText(header, "Privileged")); + Assert.True(success.Success); + + var staleHash = session.ExecuteMutation( + new MutationPreconditions { AnchorId = header, ExpectedContentHash = info.ContentHash }, + s => s.ReplaceText(header, "must not apply")); + Assert.False(staleHash.Success); + Assert.Equal("anchor_content_hash", staleHash.Error?.Precondition?.Condition); + Assert.Equal("Privileged", staleHash.Error?.Precondition?.CurrentTarget?.VisibleText); + } + + [Fact] + public void DS453_PreconditionFailure_JsonCarriesStructuredCurrentState() + { + var error = new EditError(EditErrorCode.PreconditionFailed, "stale", "p:body:u") + { + Precondition = new PreconditionFailure( + "document_version", 4L, 5L, 5L, + new PreconditionTarget + { + Exists = true, + AnchorId = "p:body:u", + Kind = "p", + Scope = "body", + ContentHash = "abc", + VisibleText = "current", + }), + }; + + var json = DocxSessionJson.Serialize(new EditResult { Success = false, Error = error }); + Assert.Contains("\"code\":\"precondition_failed\"", json); + Assert.Contains("\"currentVersion\":5", json); + Assert.Contains("\"contentHash\":\"abc\"", json); + Assert.Contains("\"visibleText\":\"current\"", json); + } + + private static DocxSession Open() => + new(DocxSessionTests.BuildDS001_SimpleTwoParagraphs(), + new DocxSessionSettings { PersistAnchorIds = true }); + + private static string[] BodyParagraphs(DocxSession session) => + session.Project().AnchorIndex.Keys + .Where(id => id.StartsWith("p:body:")) + .ToArray(); +} diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs index 5ca51a49..f92840d3 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -886,6 +886,9 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged() 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( + $$"""{"sessionId":{{sessionArg}},"format":"version"}"""))) + .GetProperty("version").GetInt64(); var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J( $$""" @@ -902,6 +905,40 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged() var after = Parse(Dispatcher.Call(_store, "docxodus_get_content", J($$"""{"sessionId":{{sessionArg}},"format":"markdown"}"""))) .GetProperty("markdown").GetString()!; Assert.Equal(before, after); + var versionAfter = Parse(Dispatcher.Call(_store, "docxodus_get_content", J( + $$"""{"sessionId":{{sessionArg}},"format":"version"}"""))) + .GetProperty("version").GetInt64(); + Assert.Equal(versionBefore, versionAfter); + } + + [Fact] + public void MCP093_StalePrecondition_ReturnsStructuredFailureWithoutMutation() + { + var sessionId = OpenSession(); + var sessionArg = JsonSerializer.Serialize(sessionId); + var anchor = FirstBodyAnchorId(sessionId, _store); + Assert.True(ReplaceText(_store, sessionId, anchor, "committed").GetProperty("success").GetBoolean()); + + var failed = Parse(Dispatcher.Call(_store, "docxodus_edit", J( + $$""" + { + "sessionId": {{sessionArg}}, + "action": "replace_text", + "anchorId": "{{anchor}}", + "markdown": "must not apply", + "preconditions": { "expectedVersion": 0 } + } + """))); + + Assert.False(failed.GetProperty("success").GetBoolean()); + var error = failed.GetProperty("error"); + Assert.Equal("precondition_failed", error.GetProperty("code").GetString()); + Assert.Equal(1, error.GetProperty("precondition").GetProperty("currentVersion").GetInt64()); + var markdown = Parse(Dispatcher.Call(_store, "docxodus_get_content", J( + $$"""{"sessionId":{{sessionArg}},"format":"markdown"}"""))) + .GetProperty("markdown").GetString()!; + Assert.Contains("committed", markdown); + Assert.DoesNotContain("must not apply", markdown); } [Fact] diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index fe4b48f4..8e8b290c 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -523,6 +523,12 @@ public sealed record ReplaceOptions /// Cap the number of replacements; null = unlimited. public int? MaxReplacements { get; init; } + + /// Require exactly this many occurrences before applying any replacement. + public int? ExpectedMatchCount { get; init; } + + /// Optional optimistic session/anchor guards evaluated before searching. + public MutationPreconditions? Preconditions { get; init; } } /// @@ -717,6 +723,12 @@ public sealed record AnchorInfo(string Id, string Kind, string Scope, string Tex /// public string? AutoNumberPrefix { get; init; } + /// Hash of the live anchor subtree, excluding projector Unids and note ids. + public string ContentHash { get; init; } = string.Empty; + + /// Exact visible text used by optimistic preconditions (never preview-truncated). + public string VisibleText { get; init; } = string.Empty; + /// What a reader sees: + space + /// when a prefix is present, otherwise just . public string FullText => @@ -1106,7 +1118,50 @@ public sealed record CompactResult public int RunsRemoved { get; init; } } -public sealed record EditError(EditErrorCode Code, string Message, string? AnchorId = null); +/// The current state of a precondition target, returned even when it no longer exists. +public sealed record PreconditionTarget +{ + public bool Exists { get; init; } + public string? AnchorId { get; init; } + public string? Kind { get; init; } + public string? Scope { get; init; } + public string? ContentHash { get; init; } + public string? VisibleText { get; init; } +} + +/// Structured expected/actual detail for . +public sealed record PreconditionFailure( + string Condition, + object? Expected, + object? Actual, + long CurrentVersion, + PreconditionTarget? CurrentTarget); + +/// +/// Optimistic guards evaluated immediately before a mutation. Anchor-specific fields use +/// as their target; a stale kind prefix still resolves by Unid, just like +/// ordinary mutation addressing. is measured against the exact +/// value. +/// +public sealed record MutationPreconditions +{ + public long? ExpectedVersion { get; init; } + public string? AnchorId { get; init; } + public string? ExpectedContentHash { get; init; } + public string? ExpectedText { get; init; } + public TextRangePrecondition? ExpectedTextRange { get; init; } + public string? ExpectedKind { get; init; } + public string? ExpectedScope { get; init; } + public int? ExpectedMatchCount { get; init; } +} + +/// An exact substring assertion within an anchor's visible text. +public sealed record TextRangePrecondition(int Start, int Length, string Text); + +public sealed record EditError(EditErrorCode Code, string Message, string? AnchorId = null) +{ + public PreconditionFailure? Precondition { get; init; } +} public enum EditErrorCode { @@ -1187,6 +1242,9 @@ public enum EditErrorCode /// revision. Re- for the current set. RevisionNotFound, + /// An optimistic mutation guard did not match the current session or target state. + PreconditionFailed, + InternalError, } @@ -1310,6 +1368,8 @@ public sealed class DocxSession : IDisposable private MarkdownProjection? _cachedProjection; private MarkdownProjection? _initialProjection; private bool _disposed; + private long _version; + private readonly object _mutationGate = new(); private int _revisionCounter = 1000; private long _lastFormatRevisionTicks; private RawDocxOps? _raw; @@ -1329,7 +1389,9 @@ public DocxSession(byte[] docxBytes, DocxSessionSettings? settings = null) _history = new Internal.UndoRing( _settings.UndoDepth, _settings.UndoMemoryBudgetBytes, - static snapshot => snapshot.ApproximateBytes); + static snapshot => snapshot.ApproximateBytes, + onRecordPreOp: _ => AdvanceVersion(), + onPopUndo: snapshot => _version = snapshot.Version); _stream = new MemoryStream(); _stream.Write(docxBytes, 0, docxBytes.Length); _stream.Position = 0; @@ -1341,6 +1403,13 @@ public DocxSession(byte[] docxBytes, DocxSessionSettings? settings = null) public Exception? LastInternalError { get; private set; } + /// + /// Monotonic in-session document version. Starts at 0 and advances once after each + /// committed mutation and each successful undo/redo. Failed calls, failed preconditions, + /// and successful no-ops leave it unchanged. + /// + public long Version => _version; + /// /// Set when a mutation threw AND the subsequent rollback to its pre-op snapshot ALSO threw — /// the one case in which a failed op can leave the document partially mutated. Null on a @@ -2011,9 +2080,12 @@ public bool Exists(string anchorId) _ = Project(); // AnchorInfo's product IS the enrichment — never serve the index-only (empty-preview) entries. var target = FindAnchor(anchorId); if (target is null) return null; + var element = target.Resolve(_doc!); return new AnchorInfo(target.Anchor.Id, target.Anchor.Kind, target.Anchor.Scope, target.TextPreview) { AutoNumberPrefix = target.AutoNumberPrefix, + ContentHash = element is null ? string.Empty : UnidHelper.ContentHash(element), + VisibleText = element is null ? string.Empty : ExactVisibleText(target, element), }; } @@ -2035,16 +2107,148 @@ public bool Exists(string anchorId) if (id is null) continue; if (result.ContainsKey(id)) continue; var target = FindAnchor(id); - result[id] = target is null + var element = target?.Resolve(_doc!); + result[id] = target is null || element is null ? null : new AnchorInfo(target.Anchor.Id, target.Anchor.Kind, target.Anchor.Scope, target.TextPreview) { AutoNumberPrefix = target.AutoNumberPrefix, + ContentHash = UnidHelper.ContentHash(element), + VisibleText = ExactVisibleText(target, element), }; } return result; } + private static string FlatElementText(XElement element) => + string.Concat(element.Descendants(W.t).Select(t => (string)t)); + + private string ExactVisibleText(AnchorTarget target, XElement element) + { + var text = FlatElementText(element); + var prefix = target.Anchor.Kind is "p" or "h" or "li" && target.Anchor.Scope == "body" + ? target.AutoNumberPrefix ?? Internal.ListNumberResolver.Resolve(element, _doc!) + : null; + return string.IsNullOrEmpty(prefix) + ? text + : string.IsNullOrEmpty(text) ? prefix : prefix + " " + text; + } + + private PreconditionTarget CurrentPreconditionTarget(string? anchorId) + { + if (string.IsNullOrEmpty(anchorId)) return new PreconditionTarget { Exists = false }; + var target = FindAnchor(anchorId); + var element = target?.Resolve(_doc!); + if (target is null || element is null) + return new PreconditionTarget { Exists = false, AnchorId = anchorId }; + return new PreconditionTarget + { + Exists = true, + AnchorId = target.Anchor.Id, + Kind = target.Anchor.Kind, + Scope = target.Anchor.Scope, + ContentHash = UnidHelper.ContentHash(element), + VisibleText = ExactVisibleText(target, element), + }; + } + + private EditError PreconditionError( + string condition, object? expected, object? actual, string? anchorId, + PreconditionTarget? currentTarget = null) => + new(EditErrorCode.PreconditionFailed, + $"precondition failed: {condition} expected {expected ?? "null"}, actual {actual ?? "null"}", + anchorId) + { + Precondition = new PreconditionFailure( + condition, expected, actual, _version, + currentTarget ?? (anchorId is null ? null : CurrentPreconditionTarget(anchorId))), + }; + + /// + /// Evaluate optimistic guards without mutating the document, consuming undo history, or + /// advancing . Returns null when every supplied guard matches. + /// is supplied by find/replace after it has enumerated + /// the live matches; other callers leave it null. + /// + public EditError? EvaluatePreconditions( + MutationPreconditions? preconditions, + int? actualMatchCount = null) + { + if (preconditions is null) return null; + if (_disposed) return new EditError(EditErrorCode.SessionDisposed, "session disposed"); + + var target = !string.IsNullOrEmpty(preconditions.AnchorId) + ? CurrentPreconditionTarget(preconditions.AnchorId) + : null; + if (preconditions.ExpectedVersion is { } expectedVersion && expectedVersion != _version) + return PreconditionError("document_version", expectedVersion, _version, + preconditions.AnchorId, target); + + bool hasAnchorGuard = preconditions.ExpectedContentHash is not null + || preconditions.ExpectedText is not null + || preconditions.ExpectedTextRange is not null + || preconditions.ExpectedKind is not null + || preconditions.ExpectedScope is not null; + if (hasAnchorGuard && string.IsNullOrEmpty(preconditions.AnchorId)) + return PreconditionError("anchor_id", "present", "missing", null); + if (hasAnchorGuard && target is not { Exists: true }) + return PreconditionError("anchor_exists", true, false, preconditions.AnchorId, target); + + if (preconditions.ExpectedKind is { } expectedKind + && !string.Equals(expectedKind, target!.Kind, StringComparison.Ordinal)) + return PreconditionError("anchor_kind", expectedKind, target.Kind, + preconditions.AnchorId, target); + if (preconditions.ExpectedScope is { } expectedScope + && !string.Equals(expectedScope, target!.Scope, StringComparison.Ordinal)) + return PreconditionError("anchor_scope", expectedScope, target.Scope, + preconditions.AnchorId, target); + if (preconditions.ExpectedContentHash is { } expectedHash + && !string.Equals(expectedHash, target!.ContentHash, StringComparison.OrdinalIgnoreCase)) + return PreconditionError("anchor_content_hash", expectedHash, target.ContentHash, + preconditions.AnchorId, target); + if (preconditions.ExpectedText is { } expectedText + && !string.Equals(expectedText, target!.VisibleText, StringComparison.Ordinal)) + return PreconditionError("anchor_text", expectedText, target.VisibleText, + preconditions.AnchorId, target); + if (preconditions.ExpectedTextRange is { } range) + { + var visible = target!.VisibleText ?? string.Empty; + object actual; + if (range.Start < 0 || range.Length < 0 || range.Start > visible.Length - range.Length) + actual = new { start = range.Start, length = range.Length, availableLength = visible.Length }; + else + actual = visible.Substring(range.Start, range.Length); + if (actual is not string actualText + || !string.Equals(range.Text, actualText, StringComparison.Ordinal)) + return PreconditionError("anchor_text_range", range.Text, actual, + preconditions.AnchorId, target); + } + if (preconditions.ExpectedMatchCount is { } expectedCount + && actualMatchCount is { } count && expectedCount != count) + return PreconditionError("match_count", expectedCount, count, + preconditions.AnchorId, target); + return null; + } + + /// + /// Atomically evaluate guards and invoke one synchronous mutation. This is the direct .NET + /// primitive shared facades use; future atomic batches can evaluate their guards under the + /// same gate before taking their aggregate snapshot. + /// + public EditResult ExecuteMutation( + MutationPreconditions? preconditions, + Func mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + lock (_mutationGate) + { + var error = EvaluatePreconditions(preconditions); + return error is null + ? mutation(this) + : new EditResult { Success = false, Error = error }; + } + } + /// /// Resolves block-level metadata (style id + name, outline level, list /// membership, formatting probe) for . Returns @@ -2719,12 +2923,34 @@ public IReadOnlyList ReplaceTextRange( string find, string replace, ReplaceOptions? options = null) + { + lock (_mutationGate) + return ReplaceTextRangeCore(anchorId, find, replace, options); + } + + private IReadOnlyList ReplaceTextRangeCore( + string anchorId, + string find, + string replace, + ReplaceOptions? options) { if (_disposed) return new[] { EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed") }; if (string.IsNullOrEmpty(find)) return new[] { EditResult.Fail(EditErrorCode.MalformedMarkdown, "find must be non-empty", anchorId) }; + var opts = options ?? new ReplaceOptions(); + var guards = opts.Preconditions; + if (guards is not null && guards.AnchorId is null) + guards = guards with { AnchorId = anchorId }; + if (opts.ExpectedMatchCount is { } expectedCount) + guards = (guards ?? new MutationPreconditions { AnchorId = anchorId }) with + { + ExpectedMatchCount = expectedCount, + }; + if (EvaluatePreconditions(guards) is { } initialPreconditionError) + return new[] { new EditResult { Success = false, Error = initialPreconditionError } }; + var target = FindAnchor(anchorId); if (target is null) return new[] { EditResult.Fail(EditErrorCode.AnchorNotFound, $"anchor not found: {anchorId}", anchorId) }; @@ -2732,7 +2958,6 @@ public IReadOnlyList ReplaceTextRange( return new[] { EditResult.Fail(EditErrorCode.AnchorWrongKind, $"ReplaceTextRange requires a paragraph/heading/list-item anchor; got kind={target.Anchor.Kind}", anchorId) }; - var opts = options ?? new ReplaceOptions(); var regexOpts = opts.IgnoreCase ? System.Text.RegularExpressions.RegexOptions.IgnoreCase : System.Text.RegularExpressions.RegexOptions.None; @@ -2742,6 +2967,8 @@ public IReadOnlyList ReplaceTextRange( var matches = Grep(pattern, regexOpts) .Where(m => m.EnclosingAnchor.Anchor.Id == target.Anchor.Id) .ToList(); + if (EvaluatePreconditions(guards, matches.Count) is { } countPreconditionError) + return new[] { new EditResult { Success = false, Error = countPreconditionError } }; if (opts.MaxReplacements is int cap) matches = matches.Take(cap).ToList(); if (matches.Count == 0) return Array.Empty(); @@ -9359,6 +9586,7 @@ public CompactResult CompactRuns(ProjectionScopes scopes = ProjectionScopes.All) part.PutXDocument(); } if (removed > 0) InvalidateProjectionCache(); + else _ = _history.PopForUndo(); return new CompactResult { RunsRemoved = removed }; } @@ -9434,23 +9662,35 @@ private void RollbackFailedOp() public bool Undo() { if (_disposed) return false; + var nextVersion = NextVersion(); var (preOp, ok) = _history.PopForUndo(); if (!ok) return false; _history.RecordForRedo(TakeSnapshot()); RestoreSnapshot(preOp); + _version = nextVersion; return true; } public bool Redo() { if (_disposed) return false; + var nextVersion = NextVersion(); var (postOp, ok) = _history.PopForRedo(); if (!ok) return false; _history.PushBackForUndo(TakeSnapshot()); RestoreSnapshot(postOp); + _version = nextVersion; return true; } + private long NextVersion() => checked(_version + 1); + + private void AdvanceVersion() => _version = NextVersion(); + + /// 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; + public void Dispose() { if (_disposed) return; @@ -9489,6 +9729,7 @@ internal void InvalidateProjectionCache() /// The same, for commentsExtended/commentsIds, which /// AddCommentReply/SetCommentResolved create when upgrading a flat comment. internal sealed record DocumentSnapshot( + long Version, System.Collections.Generic.IReadOnlyList<(string PartUri, XDocument Xml)> Parts, System.Collections.Generic.IReadOnlyList<(string RelId, bool IsHeader, string PartUri)> HeaderFooterParts, System.Collections.Generic.IReadOnlyList<(string RelId, bool IsFootnote, string PartUri)> NoteParts, @@ -9534,7 +9775,7 @@ internal DocumentSnapshot TakeSnapshot() commentThreadingParts.Add((main.GetIdOfPart(main.WordprocessingCommentsIdsPart), false, main.WordprocessingCommentsIdsPart.Uri.ToString())); } - return new DocumentSnapshot(parts, hfParts, noteParts, commentParts, commentThreadingParts); + return new DocumentSnapshot(_version, parts, hfParts, noteParts, commentParts, commentThreadingParts); } internal void RestoreSnapshot(DocumentSnapshot snapshot) @@ -9614,6 +9855,7 @@ internal void RestoreSnapshot(DocumentSnapshot snapshot) } } + _version = snapshot.Version; InvalidateProjectionCache(); } diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs index 6f29ca18..a7ee32b0 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -184,6 +184,39 @@ public static FormatOp ParseFormatOp(string json) }; } + /// Parse the common optimistic-mutation guard object used by every transport. + public static MutationPreconditions? ParseMutationPreconditions(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + using var doc = JsonDocument.Parse(json); + return ParseMutationPreconditions(doc.RootElement); + } + + public static MutationPreconditions? ParseMutationPreconditions(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) return null; + TextRangePrecondition? range = null; + if (root.TryGetProperty("expectedTextRange", out var r) && r.ValueKind == JsonValueKind.Object + && r.TryGetProperty("start", out var start) && start.ValueKind == JsonValueKind.Number + && r.TryGetProperty("length", out var length) && length.ValueKind == JsonValueKind.Number + && r.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String) + { + range = new TextRangePrecondition(start.GetInt32(), length.GetInt32(), text.GetString() ?? string.Empty); + } + return new MutationPreconditions + { + ExpectedVersion = root.TryGetProperty("expectedVersion", out var version) + && version.ValueKind == JsonValueKind.Number ? version.GetInt64() : null, + AnchorId = TryGetString(root, "anchorId", null), + ExpectedContentHash = TryGetString(root, "expectedContentHash", null), + ExpectedText = TryGetString(root, "expectedText", null), + ExpectedTextRange = range, + ExpectedKind = TryGetString(root, "expectedKind", null), + ExpectedScope = TryGetString(root, "expectedScope", null), + ExpectedMatchCount = TryGetIntNullable(root, "expectedMatchCount"), + }; + } + /// /// Parse a ParagraphFormatOp wire object: { alignment?: "left"|"center"|"right"|"justify", /// indentDelta?: int (twips), firstLineIndent?/hangingIndent?: int (twips, mutually exclusive), @@ -416,6 +449,33 @@ public static string Serialize(EditResult r) .Append(",\"message\":").Append(JsonString(r.Error.Message)); if (r.Error.AnchorId is not null) sb.Append(",\"anchorId\":").Append(JsonString(r.Error.AnchorId)); + if (r.Error.Precondition is { } p) + { + sb.Append(",\"precondition\":{") + .Append("\"condition\":").Append(JsonString(p.Condition)) + .Append(",\"expected\":"); + AppendJsonValue(sb, p.Expected); + sb.Append(",\"actual\":"); + AppendJsonValue(sb, p.Actual); + sb.Append(",\"currentVersion\":").Append(p.CurrentVersion); + if (p.CurrentTarget is { } target) + { + sb.Append(",\"currentTarget\":{") + .Append("\"exists\":").Append(target.Exists ? "true" : "false"); + if (target.AnchorId is not null) + sb.Append(",\"anchorId\":").Append(JsonString(target.AnchorId)); + if (target.Kind is not null) + sb.Append(",\"kind\":").Append(JsonString(target.Kind)); + if (target.Scope is not null) + sb.Append(",\"scope\":").Append(JsonString(target.Scope)); + if (target.ContentHash is not null) + sb.Append(",\"contentHash\":").Append(JsonString(target.ContentHash)); + if (target.VisibleText is not null) + sb.Append(",\"visibleText\":").Append(JsonString(target.VisibleText)); + sb.Append('}'); + } + sb.Append('}'); + } sb.Append('}'); } sb.Append(",\"created\":"); AppendAnchorArray(sb, r.Created); @@ -559,6 +619,21 @@ private static void AppendAnchorValue(StringBuilder sb, Anchor anchor) => .Append(",\"scope\":").Append(JsonString(anchor.Scope)) .Append(",\"unid\":").Append(JsonString(anchor.Unid)) .Append('}'); + private static void AppendJsonValue(StringBuilder sb, object? value) + { + switch (value) + { + case null: sb.Append("null"); break; + case string s: sb.Append(JsonString(s)); break; + case bool b: sb.Append(b ? "true" : "false"); break; + case int i: sb.Append(i); break; + case long l: sb.Append(l); break; + default: sb.Append(JsonSerializer.Serialize(value)); break; + } + } + + public static string SerializeVersion(long version) => + "{\"version\":" + version.ToString(System.Globalization.CultureInfo.InvariantCulture) + "}"; public static string SerializeEditResults(IReadOnlyList results) { @@ -1060,7 +1135,9 @@ public static string SerializeAnchorInfoOrNull(AnchorInfo? info) sb.Append("{\"id\":").Append(JsonString(info.Id)) .Append(",\"kind\":").Append(JsonString(info.Kind)) .Append(",\"scope\":").Append(JsonString(info.Scope)) - .Append(",\"textPreview\":").Append(JsonString(info.TextPreview)); + .Append(",\"textPreview\":").Append(JsonString(info.TextPreview)) + .Append(",\"contentHash\":").Append(JsonString(info.ContentHash)) + .Append(",\"visibleText\":").Append(JsonString(info.VisibleText)); if (info.AutoNumberPrefix is { } prefix) sb.Append(",\"autoNumberPrefix\":").Append(JsonString(prefix)); sb.Append('}'); diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs index de09063f..1d2e6cf4 100644 --- a/Docxodus/Internal/DocxSessionOps.cs +++ b/Docxodus/Internal/DocxSessionOps.cs @@ -13,6 +13,22 @@ namespace Docxodus.Internal; /// internal static class DocxSessionOps { + private static MutationPreconditions? ForTarget(MutationPreconditions? preconditions, string? anchorId) => + preconditions is not null && preconditions.AnchorId is null && anchorId is not null + ? preconditions with { AnchorId = anchorId } + : preconditions; + + private static string Mutate( + int handle, + MutationPreconditions? preconditions, + string? targetAnchorId, + System.Func mutation) + { + var session = SessionRegistry.Get(handle); + return DocxSessionJson.Serialize( + session.ExecuteMutation(ForTarget(preconditions, targetAnchorId), mutation)); + } + // ─── Lifecycle ────────────────────────────────────────────────────── public static int OpenSession(byte[] bytes, DocxSessionSettings? settings) => @@ -42,6 +58,23 @@ public static byte[] Save(int handle, bool persistAnchorIds) => public static byte[] SaveWithAnchorIds(int handle) => SessionRegistry.Get(handle).Save(persistAnchorIds: true); + public static long GetVersion(int handle) => SessionRegistry.Get(handle).Version; + + public static string GetVersionJson(int handle) => + DocxSessionJson.SerializeVersion(GetVersion(handle)); + + /// Read-only optimistic guard evaluation for dry runs and transport diagnostics. + public static string CheckPreconditions(int handle, MutationPreconditions? preconditions) + { + var error = SessionRegistry.Get(handle).EvaluatePreconditions(preconditions); + return DocxSessionJson.Serialize(error is null + ? new EditResult { Success = true } + : new EditResult { Success = false, Error = error }); + } + + internal static void RestorePreviewVersion(int handle, long version) => + SessionRegistry.Get(handle).RestorePreviewVersion(version); + // ─── Projection + discovery ───────────────────────────────────────── public static string Project(int handle) => @@ -208,27 +241,39 @@ public static string GetDiff(int handle, DiffFormat format) => // ─── Tier A: text mutations ───────────────────────────────────────── - public static string ReplaceText(int handle, string anchorId, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).ReplaceText(anchorId, markdown)); + public static string ReplaceText(int handle, string anchorId, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.ReplaceText(anchorId, markdown)); - public static string DeleteBlock(int handle, string anchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteBlock(anchorId)); + public static string DeleteBlock(int handle, string anchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.DeleteBlock(anchorId)); - public static string DeleteRange(int handle, string fromAnchorId, string toAnchorIdExclusive) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteRange(fromAnchorId, toAnchorIdExclusive)); + public static string DeleteRange(int handle, string fromAnchorId, string toAnchorIdExclusive, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, fromAnchorId, + s => s.DeleteRange(fromAnchorId, toAnchorIdExclusive)); - public static string DeleteSection(int handle, string headingAnchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteSection(headingAnchorId)); + public static string DeleteSection(int handle, string headingAnchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, headingAnchorId, s => s.DeleteSection(headingAnchorId)); public static string ReplaceTextRange(int handle, string anchorId, string find, string replace, - ReplaceOptions? options) => - DocxSessionJson.SerializeEditResults( + ReplaceOptions? options, MutationPreconditions? preconditions = null) + { + if (preconditions is not null) + options = (options ?? new ReplaceOptions()) with + { + Preconditions = ForTarget(preconditions, anchorId), + }; + return DocxSessionJson.SerializeEditResults( SessionRegistry.Get(handle).ReplaceTextRange(anchorId, find, replace, options)); + } public static string ReplaceTextAtSpan(int handle, string anchorId, int spanStart, int spanLength, - string replace) => - DocxSessionJson.Serialize( - SessionRegistry.Get(handle).ReplaceTextAtSpan(anchorId, spanStart, spanLength, replace)); + string replace, MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, + s => s.ReplaceTextAtSpan(anchorId, spanStart, spanLength, replace)); /// /// Bracket-aware variant of . Parses the brackets out @@ -241,29 +286,31 @@ public static string ReplaceTextAtSpan(int handle, string anchorId, int spanStar /// (Fragments, ContextBefore, …) would be wasteful. /// public static string ReplaceInner(int handle, string matchText, string anchorId, - int spanStart, int spanLength, string newInner) + int spanStart, int spanLength, string newInner, MutationPreconditions? preconditions = null) { - int lb = matchText.IndexOf('['); - int rb = matchText.LastIndexOf(']'); - if (lb < 0 || rb <= lb) - return DocxSessionJson.Serialize(new EditResult - { - Success = false, - Error = new EditError(EditErrorCode.MalformedMarkdown, - $"match text has no balanced brackets: '{matchText}'", anchorId), - }); - var prefix = matchText[..lb]; - var suffix = matchText[(rb + 1)..]; - return DocxSessionJson.Serialize( - SessionRegistry.Get(handle).ReplaceTextAtSpan(anchorId, spanStart, spanLength, prefix + newInner + suffix)); + return Mutate(handle, preconditions, anchorId, s => + { + int lb = matchText.IndexOf('['); + int rb = matchText.LastIndexOf(']'); + if (lb < 0 || rb <= lb) + return new EditResult + { + Success = false, + Error = new EditError(EditErrorCode.MalformedMarkdown, + $"match text has no balanced brackets: '{matchText}'", anchorId), + }; + var prefix = matchText[..lb]; + var suffix = matchText[(rb + 1)..]; + return s.ReplaceTextAtSpan(anchorId, spanStart, spanLength, prefix + newInner + suffix); + }); } // ─── Tier B: structural ───────────────────────────────────────────── public static string MoveBlock(int handle, string sourceAnchorId, string targetAnchorId, - Position position) => - DocxSessionJson.Serialize( - SessionRegistry.Get(handle).MoveBlock(sourceAnchorId, targetAnchorId, position)); + Position position, MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, sourceAnchorId, + s => s.MoveBlock(sourceAnchorId, targetAnchorId, position)); /// The blocks may legally move next to, and on which /// side — what a drag UI gates its drop targets on, so it never offers a drop the engine @@ -272,19 +319,25 @@ public static string ValidMoveTargets(int handle, string sourceAnchorId) => DocxSessionJson.SerializeMoveTargets( SessionRegistry.Get(handle).ValidMoveTargets(sourceAnchorId)); - public static string InsertParagraph(int handle, string anchorId, Position position, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertParagraph(anchorId, position, markdown)); + public static string InsertParagraph(int handle, string anchorId, Position position, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, + s => s.InsertParagraph(anchorId, position, markdown)); - public static string SplitParagraph(int handle, string anchorId, int characterOffset) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SplitParagraph(anchorId, characterOffset)); + public static string SplitParagraph(int handle, string anchorId, int characterOffset, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, + s => s.SplitParagraph(anchorId, characterOffset)); - public static string MergeParagraphs(int handle, string firstAnchorId, string secondAnchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).MergeParagraphs(firstAnchorId, secondAnchorId)); + public static string MergeParagraphs(int handle, string firstAnchorId, string secondAnchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, firstAnchorId, + s => s.MergeParagraphs(firstAnchorId, secondAnchorId)); - public static string InsertHorizontalRule(int handle, string anchorId, Position position, string ruleJson) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertHorizontalRule( - anchorId, position, - string.IsNullOrEmpty(ruleJson) ? null : ParseRuleEdge(ruleJson))); + public static string InsertHorizontalRule(int handle, string anchorId, Position position, string ruleJson, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.InsertHorizontalRule( + anchorId, position, string.IsNullOrEmpty(ruleJson) ? null : ParseRuleEdge(ruleJson))); private static ParagraphBorderEdge? ParseRuleEdge(string json) { @@ -296,93 +349,124 @@ public static string InsertHorizontalRule(int handle, string anchorId, Position // ─── Headers / footers / page numbers ─────────────────────────────── - public static string SetHeaderText(int handle, string anchorId, HeaderFooterKind kind, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetHeaderText(anchorId, kind, markdown)); + public static string SetHeaderText(int handle, string anchorId, HeaderFooterKind kind, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.SetHeaderText(anchorId, kind, markdown)); - public static string SetFooterText(int handle, string anchorId, HeaderFooterKind kind, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetFooterText(anchorId, kind, markdown)); + public static string SetFooterText(int handle, string anchorId, HeaderFooterKind kind, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.SetFooterText(anchorId, kind, markdown)); public static string InsertPageNumberField( - int handle, string anchorId, PageNumberField field, NumberFormat? format = null) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertPageNumberField(anchorId, field, format)); + int handle, string anchorId, PageNumberField field, NumberFormat? format = null, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, + s => s.InsertPageNumberField(anchorId, field, format)); - public static string EnsureHeaderFooterVisible(int handle, string anchorId, HeaderFooterKind kind) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).EnsureHeaderFooterVisible(anchorId, kind)); + public static string EnsureHeaderFooterVisible(int handle, string anchorId, HeaderFooterKind kind, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, + s => s.EnsureHeaderFooterVisible(anchorId, kind)); - public static string SetPageNumbering(int handle, string anchorId, PageNumberingOp op) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetPageNumbering(anchorId, op)); + public static string SetPageNumbering(int handle, string anchorId, PageNumberingOp op, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.SetPageNumbering(anchorId, op)); - public static string ClearPageNumbering(int handle, string anchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).ClearPageNumbering(anchorId)); + public static string ClearPageNumbering(int handle, string anchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.ClearPageNumbering(anchorId)); // ─── Footnotes / endnotes ─────────────────────────────────────────── - public static string InsertFootnote(int handle, string anchorId, int characterOffset, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertFootnote(anchorId, characterOffset, markdown)); + public static string InsertFootnote(int handle, string anchorId, int characterOffset, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, + s => s.InsertFootnote(anchorId, characterOffset, markdown)); - public static string InsertEndnote(int handle, string anchorId, int characterOffset, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertEndnote(anchorId, characterOffset, markdown)); + public static string InsertEndnote(int handle, string anchorId, int characterOffset, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, + s => s.InsertEndnote(anchorId, characterOffset, markdown)); // ─── Comments (issue #300) ────────────────────────────────────────── public static string AddComment(int handle, string anchorId, CharSpan? span, string author, - string? initials, string? dateIso, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddComment( + string? initials, string? dateIso, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.AddComment( anchorId, span, author, markdown, initials, DocxSessionJson.ParseCommentDate(dateIso))); public static string AddCommentToRevision(int handle, string revisionId, string author, - string? initials, string? dateIso, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddCommentToRevision( + string? initials, string? dateIso, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, null, s => s.AddCommentToRevision( revisionId, author, markdown, initials, DocxSessionJson.ParseCommentDate(dateIso))); public static string AddCommentReply(int handle, string parentCommentAnchorId, string author, - string? initials, string? dateIso, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddCommentReply( + string? initials, string? dateIso, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, parentCommentAnchorId, s => s.AddCommentReply( parentCommentAnchorId, author, markdown, initials, DocxSessionJson.ParseCommentDate(dateIso))); - public static string UpdateComment(int handle, string commentAnchorId, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).UpdateComment(commentAnchorId, markdown)); + public static string UpdateComment(int handle, string commentAnchorId, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, commentAnchorId, s => s.UpdateComment(commentAnchorId, markdown)); - public static string SetCommentResolved(int handle, string commentAnchorId, bool resolved) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetCommentResolved(commentAnchorId, resolved)); + public static string SetCommentResolved(int handle, string commentAnchorId, bool resolved, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, commentAnchorId, + s => s.SetCommentResolved(commentAnchorId, resolved)); - public static string RemoveComment(int handle, string commentAnchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveComment(commentAnchorId)); + public static string RemoveComment(int handle, string commentAnchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, commentAnchorId, s => s.RemoveComment(commentAnchorId)); public static string ListComments(int handle) => DocxSessionJson.SerializeCommentList(SessionRegistry.Get(handle).ListComments()); // ─── Tier C: formatting ───────────────────────────────────────────── - public static string ApplyFormat(int handle, string anchorId, CharSpan? span, FormatOp op) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).ApplyFormat(anchorId, span, op)); + public static string ApplyFormat(int handle, string anchorId, CharSpan? span, FormatOp op, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.ApplyFormat(anchorId, span, op)); - public static string ApplyFormatBySubstring(int handle, string anchorId, string substring, FormatOp op) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).ApplyFormatToSubstring(anchorId, substring, op)); + public static string ApplyFormatBySubstring(int handle, string anchorId, string substring, FormatOp op, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, + s => s.ApplyFormatToSubstring(anchorId, substring, op)); - public static string SetParagraphStyle(int handle, string anchorId, string styleId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetParagraphStyle(anchorId, styleId)); + public static string SetParagraphStyle(int handle, string anchorId, string styleId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.SetParagraphStyle(anchorId, styleId)); - public static string SetParagraphFormat(int handle, string anchorId, ParagraphFormatOp op) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetParagraphFormat(anchorId, op)); + public static string SetParagraphFormat(int handle, string anchorId, ParagraphFormatOp op, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.SetParagraphFormat(anchorId, op)); - public static string SetListLevel(int handle, string anchorId, int levelDelta) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetListLevel(anchorId, levelDelta)); + public static string SetListLevel(int handle, string anchorId, int levelDelta, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.SetListLevel(anchorId, levelDelta)); - public static string RemoveListMembership(int handle, string anchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveListMembership(anchorId)); + public static string RemoveListMembership(int handle, string anchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.RemoveListMembership(anchorId)); - public static string ApplyListFormat(int handle, string anchorId, ListFormat kind) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).ApplyListFormat(anchorId, kind)); + public static string ApplyListFormat(int handle, string anchorId, ListFormat kind, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.ApplyListFormat(anchorId, kind)); - public static string ApplyListFormatRange(int handle, string firstAnchorId, string lastAnchorId, ListFormat kind) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).ApplyListFormatRange(firstAnchorId, lastAnchorId, kind)); + public static string ApplyListFormatRange(int handle, string firstAnchorId, string lastAnchorId, ListFormat kind, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, firstAnchorId, + s => s.ApplyListFormatRange(firstAnchorId, lastAnchorId, kind)); - public static string SetListStartOverride(int handle, string anchorId, int value) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetListStartOverride(anchorId, value)); + public static string SetListStartOverride(int handle, string anchorId, int value, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.SetListStartOverride(anchorId, value)); - public static string ClearListStartOverride(int handle, string anchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).ClearListStartOverride(anchorId)); + public static string ClearListStartOverride(int handle, string anchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.ClearListStartOverride(anchorId)); // ─── Tier D: tables ───────────────────────────────────────────────── @@ -399,64 +483,78 @@ public static string ResolveTableCellCoordinate( DocxSessionJson.SerializeTableCellResolutionResult( SessionRegistry.Get(handle).ResolveTableCellCoordinate(tableAnchorId, rowIndex, columnIndex)); - public static string ReplaceCellContent(int handle, string cellAnchorId, string markdown) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).ReplaceCellContent(cellAnchorId, markdown)); + public static string ReplaceCellContent(int handle, string cellAnchorId, string markdown, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, + s => s.ReplaceCellContent(cellAnchorId, markdown)); - public static string InsertTable(int handle, string anchorId, Position position, int rows, int cols, string optionsJson) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertTable( + public static string InsertTable(int handle, string anchorId, Position position, int rows, int cols, + string optionsJson, MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.InsertTable( anchorId, position, rows, cols, DocxSessionJson.ParseTableInsertOptions(optionsJson))); - public static string InsertTableRow(int handle, string cellAnchorId, Position position) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertTableRow(cellAnchorId, position)); + public static string InsertTableRow(int handle, string cellAnchorId, Position position, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, s => s.InsertTableRow(cellAnchorId, position)); - public static string InsertTableColumn(int handle, string cellAnchorId, Position position) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertTableColumn(cellAnchorId, position)); + public static string InsertTableColumn(int handle, string cellAnchorId, Position position, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, s => s.InsertTableColumn(cellAnchorId, position)); - public static string DeleteTableRow(int handle, string cellAnchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteTableRow(cellAnchorId)); + public static string DeleteTableRow(int handle, string cellAnchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, s => s.DeleteTableRow(cellAnchorId)); - public static string DeleteTableColumn(int handle, string cellAnchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteTableColumn(cellAnchorId)); + public static string DeleteTableColumn(int handle, string cellAnchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, s => s.DeleteTableColumn(cellAnchorId)); // ─── Cell merge / unmerge (issue #340 Stage B) ────────────────────── /// is "append" (default) | "discard" | "reject" — /// what happens to the content of the cells the merge absorbs. public static string MergeCells(int handle, string cellAnchorId, int rowSpan, int colSpan, - string? content) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).MergeCells(cellAnchorId, rowSpan, colSpan, + string? content, MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, s => s.MergeCells(cellAnchorId, rowSpan, colSpan, new TableMergeOptions { Content = DocxSessionJson.ParseTableMergeContent(content) })); - public static string UnmergeCells(int handle, string cellAnchorId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).UnmergeCells(cellAnchorId)); + public static string UnmergeCells(int handle, string cellAnchorId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, s => s.UnmergeCells(cellAnchorId)); // ─── Table styling (issue #315 Stage A) ───────────────────────────── /// is a JSON array of per-column twip widths /// (one positive value per column, left→right). - public static string SetColumnWidths(int handle, string cellAnchorId, string widthsJson) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetColumnWidths( - cellAnchorId, DocxSessionJson.ParseIntArray(widthsJson))); + public static string SetColumnWidths(int handle, string cellAnchorId, string widthsJson, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, + s => s.SetColumnWidths(cellAnchorId, DocxSessionJson.ParseIntArray(widthsJson))); /// is a TableBorderSpec object /// ({ scope?: "all"|"outside"|"inside", style?, size?, color? }); "" uses the defaults. - public static string SetTableBorders(int handle, string cellAnchorId, string specJson) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetTableBorders( - cellAnchorId, DocxSessionJson.ParseTableBorderSpec(specJson))); + public static string SetTableBorders(int handle, string cellAnchorId, string specJson, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, + s => s.SetTableBorders(cellAnchorId, DocxSessionJson.ParseTableBorderSpec(specJson))); /// is a hex RRGGBB triplet or "auto"; "" clears the shading. /// is "cell" | "row". - public static string SetCellShading(int handle, string cellAnchorId, string fill, string scope) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetCellShading( + public static string SetCellShading(int handle, string cellAnchorId, string fill, string scope, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, s => s.SetCellShading( cellAnchorId, string.IsNullOrEmpty(fill) ? null : fill, DocxSessionJson.ParseTableShadingScope(scope))); - public static string SetRepeatHeaderRow(int handle, string cellAnchorId, bool repeat) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetRepeatHeaderRow(cellAnchorId, repeat)); + public static string SetRepeatHeaderRow(int handle, string cellAnchorId, bool repeat, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, + s => s.SetRepeatHeaderRow(cellAnchorId, repeat)); public static string SetTableRowOptions(int handle, string cellAnchorId, bool? repeatHeader, - bool? allowBreakAcrossPages, int? heightTwips, string? heightRule) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetTableRowOptions(cellAnchorId, + bool? allowBreakAcrossPages, int? heightTwips, string? heightRule, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, cellAnchorId, s => s.SetTableRowOptions(cellAnchorId, new TableRowOptions { RepeatHeader = repeatHeader, @@ -470,30 +568,34 @@ public static string SetTableRowOptions(int handle, string cellAnchorId, bool? r public static string RawGetXml(int handle, string anchorId) => SessionRegistry.Get(handle).Raw.GetXml(anchorId); - public static string RawInsertXml(int handle, string anchorId, Position position, string xml) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).Raw.InsertXml(anchorId, position, xml)); + public static string RawInsertXml(int handle, string anchorId, Position position, string xml, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.Raw.InsertXml(anchorId, position, xml)); - public static string RawReplaceXml(int handle, string anchorId, string xml) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).Raw.ReplaceXml(anchorId, xml)); + public static string RawReplaceXml(int handle, string anchorId, string xml, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.Raw.ReplaceXml(anchorId, xml)); // ─── Tier E: annotations ──────────────────────────────────────────── public static string AddAnnotation(int handle, string anchorId, CharSpan? span, - string annotationJson) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddAnnotation( + string annotationJson, MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, anchorId, s => s.AddAnnotation( anchorId, span, DocxSessionJson.DeserializeAnnotation(annotationJson))); - public static string RemoveAnnotation(int handle, string annotationId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveAnnotation(annotationId)); + public static string RemoveAnnotation(int handle, string annotationId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, null, s => s.RemoveAnnotation(annotationId)); - public static string UpdateAnnotation(int handle, string annotationId, string updateJson) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).UpdateAnnotation( + public static string UpdateAnnotation(int handle, string annotationId, string updateJson, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, null, s => s.UpdateAnnotation( annotationId, DocxSessionJson.DeserializeAnnotationUpdate(updateJson))); public static string MoveAnnotation(int handle, string annotationId, string newAnchorId, - CharSpan? newSpan) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).MoveAnnotation( - annotationId, newAnchorId, newSpan)); + CharSpan? newSpan, MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, newAnchorId, + s => s.MoveAnnotation(annotationId, newAnchorId, newSpan)); // ─── Tracked revisions (issue #318) ───────────────────────────────── @@ -502,11 +604,13 @@ public static string MoveAnnotation(int handle, string annotationId, string newA public static string ListRevisions(int handle) => DocxSessionJson.SerializeRevisionList(SessionRegistry.Get(handle).ListRevisions()); - public static string AcceptRevision(int handle, string revisionId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).AcceptRevision(revisionId)); + public static string AcceptRevision(int handle, string revisionId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, null, s => s.AcceptRevision(revisionId)); - public static string RejectRevision(int handle, string revisionId) => - DocxSessionJson.Serialize(SessionRegistry.Get(handle).RejectRevision(revisionId)); + public static string RejectRevision(int handle, string revisionId, + MutationPreconditions? preconditions = null) => + Mutate(handle, preconditions, null, s => s.RejectRevision(revisionId)); // ─── Undo / Redo ──────────────────────────────────────────────────── @@ -514,6 +618,16 @@ public static string RejectRevision(int handle, string revisionId) => public static bool Redo(int handle) => SessionRegistry.Get(handle).Redo(); + public static string UndoChecked(int handle, MutationPreconditions? preconditions) => + Mutate(handle, preconditions, null, s => s.Undo() + ? new EditResult { Success = true } + : EditResult.Fail(EditErrorCode.NothingToUndo, "nothing to undo")); + + public static string RedoChecked(int handle, MutationPreconditions? preconditions) => + Mutate(handle, preconditions, null, s => s.Redo() + ? new EditResult { Success = true } + : EditResult.Fail(EditErrorCode.NothingToRedo, "nothing to redo")); + // ─── Session configuration (issue #304) ───────────────────────────── public static void SetTrackedChanges(int handle, TrackedChangeMode mode) => diff --git a/Docxodus/Internal/UndoRing.cs b/Docxodus/Internal/UndoRing.cs index c7c56d84..dd4dafb0 100644 --- a/Docxodus/Internal/UndoRing.cs +++ b/Docxodus/Internal/UndoRing.cs @@ -36,6 +36,8 @@ internal sealed class UndoRing private readonly int _capacity; private readonly long _budgetBytes; private readonly Func? _costOf; + private readonly Action? _onRecordPreOp; + private readonly Action? _onPopUndo; private long _undoBytes; private long _redoBytes; @@ -47,11 +49,18 @@ internal sealed class UndoRing /// undo and redo sides together. Values <= 0 disable the budget bound (depth only). /// Approximate retained cost of one snapshot. Null (or a zero budget) /// leaves the ring depth-bounded only, exactly as before. - public UndoRing(int capacity, long budgetBytes = 0, Func? costOf = null) + public UndoRing( + int capacity, + long budgetBytes = 0, + Func? costOf = null, + Action? onRecordPreOp = null, + Action? onPopUndo = null) { _capacity = capacity > 0 ? capacity : 1; _budgetBytes = budgetBytes > 0 ? budgetBytes : 0; _costOf = costOf; + _onRecordPreOp = onRecordPreOp; + _onPopUndo = onPopUndo; } private long CostOf(T snapshot) => @@ -77,6 +86,7 @@ public void RecordPreOp(T preOpSnapshot) _undoBytes += costBytes; ClearRedo(); Trim(); + _onRecordPreOp?.Invoke(preOpSnapshot); } /// Pop the most recent pre-op snapshot (for an undo). @@ -86,6 +96,7 @@ public void RecordPreOp(T preOpSnapshot) var entry = _undo.Last!.Value; _undo.RemoveLast(); _undoBytes -= entry.CostBytes; + _onPopUndo?.Invoke(entry.Snapshot); return (entry.Snapshot, true); } diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md index 95e33b06..e71c0e75 100644 --- a/docs/architecture/docx_agent_server.md +++ b/docs/architecture/docx_agent_server.md @@ -408,6 +408,15 @@ is `ok`/`partial`/`failed`). `mode: preview` runs every step exactly the same wa `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. +The batch itself and each step's `args` may carry `preconditions`, using the same +camel-case guard object as the core API (`expectedVersion`, `anchorId`, +`expectedContentHash`, exact text/range/kind/scope, and `expectedMatchCount`). A +failure is the standard structured `precondition_failed` result and does not +mutate that step. `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. + ### `docxodus_table` — tables `insert`, `insert_row`, `insert_column`, `delete_row`, `delete_column`, `replace_cell_content`, diff --git a/docs/architecture/docx_mutation_api.md b/docs/architecture/docx_mutation_api.md index fd08bb2e..b9f132c5 100644 --- a/docs/architecture/docx_mutation_api.md +++ b/docs/architecture/docx_mutation_api.md @@ -18,6 +18,67 @@ Three design forces, in order of weight: **Errors must be pattern-matchable, not stringly-typed.** Every mutation returns an `EditResult` envelope; failure carries a typed `EditErrorCode` with a remediation message. The same enum is exposed as a snake-case string union in TypeScript, so JS agents pattern-match the same way C# callers do. No method on the session throws across the boundary (the constructor and `Save()` are the only places that can — and only for fatal conditions like an invalid DOCX or IO failure). +## Document version and optimistic preconditions + +Every session exposes a monotonic `long Version`. It is `0` when the document is +opened and advances exactly once for each committed document mutation. A +multi-match `ReplaceTextRange` is one mutation. `Undo()` and `Redo()` also advance +the version—restoring older content never restores an older caller-visible +version. Validation failures, precondition failures, exceptions that roll back, +and successful no-ops do not advance it. Version state is carried in internal +snapshots so rollback and speculative preview restoration cannot leak a version +change. + +Callers that derived an edit from an earlier projection can guard the mutation: + +```csharp +var info = session.GetAnchorInfo(anchor)!; +var result = session.ExecuteMutation( + new MutationPreconditions + { + ExpectedVersion = session.Version, + AnchorId = anchor, + ExpectedContentHash = info.ContentHash, + ExpectedText = info.VisibleText, + ExpectedKind = info.Kind, + ExpectedScope = info.Scope, + }, + s => s.ReplaceText(anchor, replacement)); +``` + +`MutationPreconditions` fields are optional and ANDed: + +| .NET | wire | Meaning | +|---|---|---| +| `ExpectedVersion` | `expectedVersion` | The session version used to derive the plan. | +| `AnchorId` | `anchorId` | Guard target. Mutation façades infer their primary target when omitted. | +| `ExpectedContentHash` | `expectedContentHash` | Hash of the target's current OOXML subtree, also returned by `GetAnchorInfo`. | +| `ExpectedText` | `expectedText` | Exact current visible text, also returned as `AnchorInfo.VisibleText`. | +| `ExpectedTextRange` | `expectedTextRange: {start,length,text}` | Exact ordinal substring guard over visible text. | +| `ExpectedKind` / `ExpectedScope` | `expectedKind` / `expectedScope` | Current canonical anchor metadata. A stale kind prefix still resolves by Unid and reports the new kind. | +| `ExpectedMatchCount` | `expectedMatchCount` | Exact live occurrence count for `ReplaceTextRange`. | + +`EvaluatePreconditions`/transport `checkPreconditions` are read-only probes. +`ExecuteMutation` is the direct .NET gated primitive used by the shared façade. +`ReplaceTextRange` holds the same gate across initial guard evaluation, live match +enumeration/counting, and every replacement, so another mutation cannot slip +between count and commit. + +On mismatch, no document bytes or history entry change and the result has code +`PreconditionFailed` (`precondition_failed` on the wire). Its `precondition` member +contains `condition`, `expected`, `actual`, `currentVersion`, and `currentTarget` +(`exists`, canonical anchor id/kind/scope, content hash, and exact visible text). +That is enough for an agent to decide whether to rebase, retarget, or abandon the +edit without an extra diagnostic round trip. + +The common wire object is available throughout the stack: npm exposes +`getVersion`, `checkPreconditions`, and `runWithPreconditions`; Python exposes +`get_version`, `check_preconditions`, and a `session.preconditioned(...)` context +that attaches the guard to each mutation request; stdio accepts top-level +`preconditions`; MCP mutation tools and individual batch steps accept the same +property. MCP batches may additionally carry a batch-start guard. Preview mode +restores the starting version after it undoes its speculative edits. + ## Architecture ``` @@ -1238,7 +1299,9 @@ session.ReplaceMatch(textMatch, replace) // convenience fo session.ReplaceTextAtSpan(anchor, spanStart, spanLength, repl) // exact-span variant when several identical needles share a block ``` -`ReplaceOptions`: `IgnoreCase` (case-insensitive find) and `MaxReplacements` (cap on how many to apply). +`ReplaceOptions`: `IgnoreCase` (case-insensitive find), `MaxReplacements` (cap on +how many to apply), `ExpectedMatchCount` (require the exact live count before the +cap), and `Preconditions` (the common optimistic guard object). ### Formatting-preservation contract @@ -1254,7 +1317,7 @@ Revision wrappers stay inside a match's hyperlink, run-level SDT, `smartTag`, or ### Ordering and atomicity -Multiple matches in the same paragraph are applied in **reverse document order** so each earlier-offset match's span stays valid after later edits land — the same trick the projector uses for tracked-change accept passes. The whole call records **one** snapshot; `Undo()` rolls every replacement back together. +Multiple matches in the same paragraph are applied in **reverse document order** so each earlier-offset match's span stays valid after later edits land — the same trick the projector uses for tracked-change accept passes. The whole call records **one** snapshot; `Undo()` rolls every replacement back together. Preconditions, exact occurrence counting, and the rewrite execute under one mutation gate; a count mismatch returns one failed result and leaves bytes, version, and undo history unchanged. ### When to reach for the span-addressed variant @@ -1521,6 +1584,7 @@ Errors are grouped by what the agent should do in response, not by where in the | The agent should… | When it sees these codes | |---|---| +| Re-read the current version/target metadata in `error.precondition`, rebase or abandon the stale edit, then retry with fresh guards | `PreconditionFailed` | | Re-project and re-derive the anchor from current text | `AnchorNotFound` | | Re-list revisions (`ListRevisions`) and reissue with a current id | `RevisionNotFound` | | Re-read the anchor's kind via `GetAnchorInfo`, reissue with the right op or coordinates | `AnchorWrongKind`, `TableAnchorMigrationRequired`, `AnchorsNotAdjacent`, `InvalidPosition`, `OffsetOutOfRange`, `EmptyCommentSpan` | diff --git a/npm/src/session.ts b/npm/src/session.ts index 2ee951ed..66ba672a 100644 --- a/npm/src/session.ts +++ b/npm/src/session.ts @@ -38,6 +38,7 @@ import type { ListFormat, GrepOptions, ListMembership, + MutationPreconditions, ReplaceOptions, RevisionListEntry, SectionInfo, @@ -71,6 +72,31 @@ export class DocxSession { return JSON.parse(this.wasm.Project(this.handle)) as DocxSessionProjection; } + /** Monotonic document version (0 at open; +1 per committed mutation/undo/redo). */ + getVersion(): number { + return (JSON.parse(this.wasm.GetVersion(this.handle)) as { version: number }).version; + } + + /** Evaluate optimistic guards without mutating or advancing the version. */ + checkPreconditions(preconditions: MutationPreconditions): EditResult { + return JSON.parse( + this.wasm.CheckPreconditions(this.handle, JSON.stringify(preconditions)), + ) as EditResult; + } + + /** + * Guard any synchronous mutation. WASM calls are synchronous and single-threaded, so the + * check and callback form one uninterrupted client-side operation. Prefer a method's native + * `preconditions` option where it has one (notably replaceTextRange's match-count guard). + */ + runWithPreconditions( + preconditions: MutationPreconditions, + mutation: () => EditResult, + ): EditResult { + const checked = this.checkPreconditions(preconditions); + return checked.success ? mutation() : checked; + } + /** * Project a slice of the document keyed off an anchor — useful for showing * one section to an LLM at a time without paying the cost of projecting the @@ -122,12 +148,26 @@ export class DocxSession { // ─── Tier A: text CRUD ─────────────────────────────────────────────── - replaceText(anchorId: string, markdown: string): EditResult { - return JSON.parse(this.wasm.ReplaceText(this.handle, anchorId, markdown)) as EditResult; + replaceText( + anchorId: string, + markdown: string, + preconditions?: MutationPreconditions, + ): EditResult { + const apply = () => JSON.parse( + this.wasm.ReplaceText(this.handle, anchorId, markdown), + ) as EditResult; + return preconditions + ? this.runWithPreconditions( + { ...preconditions, anchorId: preconditions.anchorId ?? anchorId }, apply) + : apply(); } - deleteBlock(anchorId: string): EditResult { - return JSON.parse(this.wasm.DeleteBlock(this.handle, anchorId)) as EditResult; + deleteBlock(anchorId: string, preconditions?: MutationPreconditions): EditResult { + const apply = () => JSON.parse(this.wasm.DeleteBlock(this.handle, anchorId)) as EditResult; + return preconditions + ? this.runWithPreconditions( + { ...preconditions, anchorId: preconditions.anchorId ?? anchorId }, apply) + : apply(); } /** Reorder one top-level paragraph/heading/list/table block relative to another. */ @@ -1245,5 +1285,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, PlaceholderKind, ReplaceOptions, RunFormatting, RunFragment, TemplatePlaceholder, TextMatch } from "./types.js"; +export type { AnchorInfo, AnchorRef, AnchorTargetRef, BlockSlice, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, DocxSessionProjection, DocxSessionSettings, EditError, EditErrorCode, EditResult, FindOptions, FormatOp, GrepOptions, MarkdownPatch, MutationPreconditions, 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 c2d1c2c2..b772fc06 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1049,6 +1049,8 @@ export interface DocxodusWasmExports { CloseSession: (handle: number) => void; CreateBlankDocx: () => Uint8Array; Project: (handle: number) => string; + GetVersion: (handle: number) => string; + CheckPreconditions: (handle: number, preconditionsJson: string) => string; ProjectAnchor: (handle: number, anchorId: string, depth: number) => string; /** Ordered top-level render units per scope container (JSON {@link RenderPlan}) — * what the editor's incremental reconciler diffs its DOM against. Optional: @@ -1293,6 +1295,7 @@ export type EditErrorCode = | "empty_annotation_span" | "empty_comment_span" | "revision_not_found" + | "precondition_failed" | "internal_error"; export interface AnchorRef { @@ -1306,6 +1309,43 @@ export interface EditError { code: EditErrorCode; message: string; anchorId?: string; + precondition?: PreconditionFailure; +} + +export interface PreconditionTarget { + exists: boolean; + anchorId?: string; + kind?: string; + scope?: string; + contentHash?: string; + visibleText?: string; +} + +export interface PreconditionFailure { + condition: string; + expected: unknown; + actual: unknown; + currentVersion: number; + currentTarget?: PreconditionTarget; +} + +export interface TextRangePrecondition { + start: number; + length: number; + text: string; +} + +/** Optimistic guards checked immediately before a mutation. */ +export interface MutationPreconditions { + expectedVersion?: number; + /** Optional explicit target; target-addressed methods infer their own anchor when omitted. */ + anchorId?: string; + expectedContentHash?: string; + expectedText?: string; + expectedTextRange?: TextRangePrecondition; + expectedKind?: string; + expectedScope?: string; + expectedMatchCount?: number; } export interface MarkdownPatch { @@ -1786,6 +1826,10 @@ export interface ReplaceOptions { ignoreCase?: boolean; /** Cap the number of replacements; omitted = unlimited. */ maxReplacements?: number; + /** Require exactly this many occurrences before applying any replacement. */ + expectedMatchCount?: number; + /** Optional document/anchor guards evaluated before searching. */ + preconditions?: MutationPreconditions; } /** @@ -2022,6 +2066,10 @@ export interface AnchorInfo { kind: string; scope: string; textPreview: string; + /** Exact live subtree hash suitable for expectedContentHash. */ + contentHash: string; + /** Exact (untruncated) reader-visible text suitable for expectedText. */ + visibleText: string; /** Resolved auto-numbering prefix (e.g. "1.", "First") when the element carries * numbering. Absent for un-numbered paragraphs or non-paragraph kinds. */ autoNumberPrefix?: string; diff --git a/python/README.md b/python/README.md index 556fcf5d..e897c90e 100644 --- a/python/README.md +++ b/python/README.md @@ -116,7 +116,7 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a | Tier | Methods | |---|---| -| **Lifecycle** | `save`, `close`, `undo`, `redo`, `to_html` | +| **Lifecycle** | `save`, `close`, `undo`, `redo`, `get_version`, `to_html` | | **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` | @@ -133,6 +133,14 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a Every mutation method returns an `EditResult` envelope — transport-level failures raise `DocxodusTransportError`, but a business outcome (`anchor_not_found`, `malformed_markdown`, etc.) returns `EditResult(success=False, error=EditError(...))`. **Never** an exception across the API boundary. +For optimistic concurrency, build a `MutationPreconditions` object and use +`session.check_preconditions(...)` for a read-only probe or +`with session.preconditioned(guards): ...` to attach it to each mutation request in +the block. The guard can require the document version, anchor hash/exact visible +text or range/kind/scope, and an exact replacement match count. A mismatch returns +`EditErrorCode.PRECONDITION_FAILED` with structured expected/actual/current target +metadata and leaves bytes, version, and undo history unchanged. + ### Stateless functions Alongside the session API, the package exposes stateless one-shot functions at the module root — no session handle, they take DOCX bytes in and return bytes / data out: diff --git a/python/src/docx_scalpel/__init__.py b/python/src/docx_scalpel/__init__.py index 084a5153..1000e62b 100644 --- a/python/src/docx_scalpel/__init__.py +++ b/python/src/docx_scalpel/__init__.py @@ -108,17 +108,21 @@ ListMembership, MarkdownPatch, MarkdownProjection, + MutationPreconditions, NumberFormat, ParagraphBorderEdge, ParagraphFormatOp, ReplaceOptions, RetainedTableAnchor, + PreconditionFailure, + PreconditionTarget, RevisionListEntry, RunFormatting, RunFragment, SectionInfo, TemplatePlaceholder, TextMatch, + TextRangePrecondition, WmlToMarkdownConverterSettings, TableAnchorLocation, TableAnchorMapping, @@ -183,17 +187,21 @@ "ListMembership", "MarkdownPatch", "MarkdownProjection", + "MutationPreconditions", "NumberFormat", "ParagraphBorderEdge", "ParagraphFormatOp", "ReplaceOptions", "RetainedTableAnchor", + "PreconditionFailure", + "PreconditionTarget", "RevisionListEntry", "RunFormatting", "RunFragment", "SectionInfo", "TemplatePlaceholder", "TextMatch", + "TextRangePrecondition", "WmlToMarkdownConverterSettings", "TableAnchorLocation", "TableAnchorMapping", diff --git a/python/src/docx_scalpel/enums.py b/python/src/docx_scalpel/enums.py index c0a2fb76..8785184f 100644 --- a/python/src/docx_scalpel/enums.py +++ b/python/src/docx_scalpel/enums.py @@ -168,6 +168,7 @@ class EditErrorCode(str, Enum): DISALLOWED_NAMESPACE = "disallowed_namespace" INCOMPATIBLE_ELEMENT_TYPE = "incompatible_element_type" VALIDATION_FAILED = "validation_failed" + PRECONDITION_FAILED = "precondition_failed" NOTHING_TO_UNDO = "nothing_to_undo" NOTHING_TO_REDO = "nothing_to_redo" DUPLICATE_ANNOTATION_ID = "duplicate_annotation_id" diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py index 696b6df3..c2ecd735 100644 --- a/python/src/docx_scalpel/session.py +++ b/python/src/docx_scalpel/session.py @@ -21,7 +21,8 @@ from __future__ import annotations import base64 -from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, Sequence +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Mapping, Sequence if TYPE_CHECKING: from types import TracebackType @@ -67,6 +68,7 @@ HtmlOptions, ListMembership, MarkdownProjection, + MutationPreconditions, NumberFormat, ParagraphFormatOp, ReplaceOptions, @@ -358,11 +360,12 @@ class DocxSession: Construct via :func:`open_session`; never instantiate directly. """ - __slots__ = ("_handle", "_closed") + __slots__ = ("_handle", "_closed", "_active_preconditions") def __init__(self, handle: int) -> None: self._handle = handle self._closed = False + self._active_preconditions: MutationPreconditions | None = None # -- lifecycle -------------------------------------------------------- @@ -452,11 +455,43 @@ def set_revision_author(self, author: str | None) -> None: def undo(self) -> bool: """Undo one snapshot. Returns ``True`` if the undo ring had something to pop.""" - return bool(self._call("undo", {})) + result = self._call("undo", {}) + return bool(result.get("success")) if isinstance(result, dict) else bool(result) def redo(self) -> bool: """Redo one snapshot. Returns ``True`` if the redo ring had something to pop.""" - return bool(self._call("redo", {})) + result = self._call("redo", {}) + return bool(result.get("success")) if isinstance(result, dict) else bool(result) + + def get_version(self) -> int: + """Return the monotonic version of the live document state.""" + result = self._call("get_version", {}) + if not isinstance(result, dict) or not isinstance(result.get("version"), int): + raise TypeError(f"get_version: expected {{version: int}}, got {result!r}") + return int(result["version"]) + + def check_preconditions(self, preconditions: MutationPreconditions) -> EditResult: + """Evaluate guards without mutating the document or advancing its version.""" + return EditResult._from_wire( + self._call("check_preconditions", {"preconditions": preconditions.to_wire()}) + ) + + @contextmanager + def preconditioned( + self, preconditions: MutationPreconditions + ) -> Iterator["DocxSession"]: + """Attach guards to each mutation request made inside the context. + + The host evaluates them immediately before that request's mutation. Nested + contexts restore the previous guard on exit. A session should not be shared + across threads while a precondition context is active. + """ + previous = self._active_preconditions + self._active_preconditions = preconditions + try: + yield self + finally: + self._active_preconditions = previous # -- projection ------------------------------------------------------- @@ -1507,6 +1542,8 @@ def raw(self) -> "_RawOps": def _call(self, op: str, args: dict[str, Any]) -> Any: if self._closed: raise ValueError(f"session {self._handle} is closed") + if self._active_preconditions is not None and "preconditions" not in args: + args = {**args, "preconditions": self._active_preconditions.to_wire()} payload = {"handle": self._handle, **args} return _call(op, payload) diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py index afc64ef8..d14d53ad 100644 --- a/python/src/docx_scalpel/types.py +++ b/python/src/docx_scalpel/types.py @@ -53,6 +53,10 @@ "MarkdownPatch", "AnchorTarget", "AnchorInfo", + "PreconditionTarget", + "PreconditionFailure", + "TextRangePrecondition", + "MutationPreconditions", "BlockMetadata", "BulkEditResult", "FillOptions", @@ -315,6 +319,8 @@ class AnchorInfo: kind: str scope: str text_preview: str + content_hash: str | None = None + visible_text: str | None = None @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "AnchorInfo": @@ -323,9 +329,92 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "AnchorInfo": kind=d["kind"], scope=d["scope"], text_preview=d.get("textPreview", ""), + content_hash=d.get("contentHash"), + visible_text=d.get("visibleText"), ) +@dataclass(frozen=True, slots=True) +class PreconditionTarget: + """Current target metadata returned when an optimistic guard fails.""" + + exists: bool + anchor_id: str | None = None + kind: str | None = None + scope: str | None = None + content_hash: str | None = None + visible_text: str | None = None + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "PreconditionTarget": + return cls( + exists=bool(d.get("exists", False)), + anchor_id=d.get("anchorId"), + kind=d.get("kind"), + scope=d.get("scope"), + content_hash=d.get("contentHash"), + visible_text=d.get("visibleText"), + ) + + +@dataclass(frozen=True, slots=True) +class PreconditionFailure: + """Expected/actual detail for ``PRECONDITION_FAILED``.""" + + condition: str + expected: Any + actual: Any + current_version: int + current_target: PreconditionTarget | None = None + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "PreconditionFailure": + target = d.get("currentTarget") + return cls( + condition=str(d.get("condition", "")), + expected=d.get("expected"), + actual=d.get("actual"), + current_version=int(d.get("currentVersion", 0)), + current_target=PreconditionTarget._from_wire(target) if target else None, + ) + + +@dataclass(frozen=True, slots=True) +class TextRangePrecondition: + start: int + length: int + text: str + + def to_wire(self) -> dict[str, Any]: + return {"start": self.start, "length": self.length, "text": self.text} + + +@dataclass(frozen=True, slots=True) +class MutationPreconditions: + """Optional optimistic guards evaluated immediately before a mutation.""" + + expected_version: int | None = None + anchor_id: str | None = None + expected_content_hash: str | None = None + expected_text: str | None = None + expected_text_range: TextRangePrecondition | None = None + expected_kind: str | None = None + expected_scope: str | None = None + expected_match_count: int | None = None + + def to_wire(self) -> dict[str, Any]: + out: dict[str, Any] = {} + if self.expected_version is not None: out["expectedVersion"] = self.expected_version + if self.anchor_id is not None: out["anchorId"] = self.anchor_id + if self.expected_content_hash is not None: out["expectedContentHash"] = self.expected_content_hash + if self.expected_text is not None: out["expectedText"] = self.expected_text + if self.expected_text_range is not None: out["expectedTextRange"] = self.expected_text_range.to_wire() + if self.expected_kind is not None: out["expectedKind"] = self.expected_kind + if self.expected_scope is not None: out["expectedScope"] = self.expected_scope + if self.expected_match_count is not None: out["expectedMatchCount"] = self.expected_match_count + return out + + class NumberFormat(str, Enum): """Six list formats supported by the list write surface and surfaced on ``ListMembership.format``. String-valued so the wire JSON round-trips @@ -756,6 +845,7 @@ class EditError: code: EditErrorCode message: str anchor_id: str | None = None + precondition: PreconditionFailure | None = None @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "EditError": @@ -763,6 +853,8 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "EditError": code=EditErrorCode(d["code"]), message=d.get("message", ""), anchor_id=d.get("anchorId"), + precondition=PreconditionFailure._from_wire(d["precondition"]) + if d.get("precondition") else None, ) @@ -1105,11 +1197,15 @@ class ReplaceOptions: ignore_case: bool = False max_replacements: int | None = None + expected_match_count: int | None = None + preconditions: MutationPreconditions | None = None def to_wire(self) -> dict[str, Any]: out: dict[str, Any] = {} if self.ignore_case: out["ignoreCase"] = True if self.max_replacements is not None: out["maxReplacements"] = self.max_replacements + if self.expected_match_count is not None: out["expectedMatchCount"] = self.expected_match_count + if self.preconditions is not None: out["preconditions"] = self.preconditions.to_wire() return out diff --git a/python/tests/test_preconditions.py b/python/tests/test_preconditions.py new file mode 100644 index 00000000..fa44c2ee --- /dev/null +++ b/python/tests/test_preconditions.py @@ -0,0 +1,79 @@ +"""Version and optimistic-mutation transport coverage (issue #447).""" + +from __future__ import annotations + +from docx_scalpel import ( + EditErrorCode, + MutationPreconditions, + ReplaceOptions, + TextRangePrecondition, + open_session, +) + + +def test_version_and_structured_preconditions(tour_plan_bytes: bytes) -> None: + with open_session(tour_plan_bytes) as session: + target = next( + a for a in session.project().anchor_index.values() + if a.scope == "body" and a.kind in ("p", "h", "li") + ) + info = session.get_anchor_info(target.id) + assert info is not None + assert info.content_hash + assert info.visible_text is not None + assert session.get_version() == 0 + + guards = MutationPreconditions( + expected_version=0, + anchor_id=target.id, + expected_content_hash=info.content_hash, + expected_text=info.visible_text, + expected_text_range=TextRangePrecondition(0, 0, ""), + expected_kind=info.kind, + expected_scope=info.scope, + ) + assert session.check_preconditions(guards).success + with session.preconditioned(guards): + assert session.replace_text(target.id, "cat cat").success + assert session.get_version() == 1 + + with session.preconditioned(MutationPreconditions(expected_version=0)): + stale = session.delete_block(target.id) + assert not stale.success + assert stale.error is not None + assert stale.error.code is EditErrorCode.PRECONDITION_FAILED + assert stale.error.precondition is not None + assert stale.error.precondition.condition == "document_version" + assert stale.error.precondition.expected == 0 + assert stale.error.precondition.actual == 1 + assert stale.error.precondition.current_version == 1 + assert stale.error.precondition.current_target is not None + assert stale.error.precondition.current_target.visible_text == "cat cat" + assert session.get_version() == 1 + + count_failure = session.replace_text_range( + target.id, + "cat", + "dog", + ReplaceOptions(expected_match_count=1), + ) + assert len(count_failure) == 1 + assert not count_failure[0].success + assert count_failure[0].error is not None + assert count_failure[0].error.code is EditErrorCode.PRECONDITION_FAILED + assert count_failure[0].error.precondition is not None + assert count_failure[0].error.precondition.condition == "match_count" + assert session.get_version() == 1 + + replaced = session.replace_text_range( + target.id, + "cat", + "dog", + ReplaceOptions( + expected_match_count=2, + preconditions=MutationPreconditions(expected_version=1), + ), + ) + assert len(replaced) == 2 + assert all(r.success for r in replaced) + assert session.get_version() == 2 diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs index 0b980199..69e03613 100644 --- a/tools/mcp-server/Dispatcher.cs +++ b/tools/mcp-server/Dispatcher.cs @@ -163,9 +163,16 @@ anchorId is null break; } } - return $"{{\"editSummary\":{editSummary},\"sectionInfo\":{sectionInfo}}}"; + return $"{{\"version\":{DocxSessionOps.GetVersion(session.Handle)},\"editSummary\":{editSummary},\"sectionInfo\":{sectionInfo}}}"; } + case "version": + return DocxSessionOps.GetVersionJson(session.Handle); + + case "check_preconditions": + return DocxSessionOps.CheckPreconditions( + session.Handle, ParsePreconditions(args, OptStr(args, "anchorId"))); + default: throw new McpToolException($"unknown format: {format}"); } @@ -270,14 +277,25 @@ private static string Edit(SessionStore store, JsonElement args) /// Shared by and (batched steps route /// through the same per-tool functions so there's exactly one place each action's argument /// parsing lives). - private static string RunEditAction(DocSession session, string action, JsonElement args) => action switch - { + private static string RunEditAction(DocSession session, string action, JsonElement args) + { + var preconditions = ParsePreconditions(args, MutationTarget(args)); + if (action == "undo") + return preconditions is null + ? BoolResult(DocxSessionOps.Undo(session.Handle)) + : DocxSessionOps.UndoChecked(session.Handle, preconditions); + if (action == "redo") + return preconditions is null + ? BoolResult(DocxSessionOps.Redo(session.Handle)) + : DocxSessionOps.RedoChecked(session.Handle, preconditions); + return Guarded(session, preconditions, () => action switch + { "insert_paragraph" => DocxSessionOps.InsertParagraph( session.Handle, Str(args, "anchorId"), ParsePos(args), Str(args, "markdown")), "replace_text" => DocxSessionOps.ReplaceText(session.Handle, Str(args, "anchorId"), Str(args, "markdown")), "replace_text_range" => DocxSessionOps.ReplaceTextRange( session.Handle, Str(args, "anchorId"), Str(args, "find"), Str(args, "replace"), - new ReplaceOptions { IgnoreCase = !BoolOpt(args, "caseSensitive", false) }), + new ReplaceOptions { IgnoreCase = !BoolOpt(args, "caseSensitive", false) }, preconditions), "delete_block" => DocxSessionOps.DeleteBlock(session.Handle, Str(args, "anchorId")), "move_block" => DocxSessionOps.MoveBlock( session.Handle, Str(args, "sourceAnchorId"), Str(args, "targetAnchorId"), ParsePos(args)), @@ -288,10 +306,9 @@ private static string Edit(SessionStore store, JsonElement args) session.Handle, Str(args, "anchorId"), Int(args, "characterOffset")), "merge_paragraphs" => DocxSessionOps.MergeParagraphs( session.Handle, Str(args, "anchorId"), Str(args, "secondAnchorId")), - "undo" => BoolResult(DocxSessionOps.Undo(session.Handle)), - "redo" => BoolResult(DocxSessionOps.Redo(session.Handle)), _ => throw new McpToolException($"unknown docxodus_edit action: {action}"), - }; + }); + } private static bool IsMutatingEditAction(string action) => action is not ("undo" or "redo"); @@ -305,8 +322,9 @@ private static string Format(SessionStore store, JsonElement args) return RunFormatAction(session, Str(args, "action"), args); } - private static string RunFormatAction(DocSession session, string action, JsonElement args) => action switch - { + private static string RunFormatAction(DocSession session, string action, JsonElement args) => + Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch + { "apply_format" => DocxSessionOps.ApplyFormat( session.Handle, Str(args, "anchorId"), ParseSpan(args, "span"), ParseFormatOp(args)), "apply_format_by_substring" => DocxSessionOps.ApplyFormatBySubstring( @@ -321,7 +339,7 @@ private static string Format(SessionStore store, JsonElement args) "apply_list_format" => DocxSessionOps.ApplyListFormat( session.Handle, Str(args, "anchorId"), DocxSessionJson.ParseListFormat(OptStr(args, "listFormat"))), _ => throw new McpToolException($"unknown docxodus_format action: {action}"), - }; + }); private static FormatOp ParseFormatOp(JsonElement args) => args.ValueKind == JsonValueKind.Object && args.TryGetProperty("format", out var f) && f.ValueKind == JsonValueKind.Object @@ -341,8 +359,9 @@ private static string Create(SessionStore store, JsonElement args) return RunCreateAction(session, Str(args, "action"), args); } - private static string RunCreateAction(DocSession session, string action, JsonElement args) => action switch - { + private static string RunCreateAction(DocSession session, string action, JsonElement args) => + Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch + { "insert_paragraph" => DocxSessionOps.InsertParagraph( session.Handle, Str(args, "anchorId"), ParsePos(args), Str(args, "markdown")), "insert_heading" => DocxSessionOps.InsertParagraph( @@ -371,7 +390,7 @@ private static string Create(SessionStore store, JsonElement args) session.Handle, Str(args, "bodyAnchorId"), DocxSessionJson.ParseHeaderFooterKind(Str(args, "kind"))), _ => throw new McpToolException($"unknown docxodus_create action: {action}"), - }; + }); private static string BuildTableInsertOptionsJson(JsonElement args) { @@ -401,8 +420,12 @@ private static string ListTool(SessionStore store, JsonElement args) return RunListAction(session, Str(args, "action"), args); } - private static string RunListAction(DocSession session, string action, JsonElement args) => action switch + private static string RunListAction(DocSession session, string action, JsonElement args) { + if (action == "get_membership") + return DocxSessionOps.GetListMembership(session.Handle, Str(args, "anchorId")); + return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch + { "apply_format" => DocxSessionOps.ApplyListFormat( session.Handle, Str(args, "anchorId"), DocxSessionJson.ParseListFormat(OptStr(args, "listFormat"))), "apply_format_range" => DocxSessionOps.ApplyListFormatRange( @@ -413,9 +436,9 @@ private static string ListTool(SessionStore store, JsonElement args) session.Handle, Str(args, "anchorId"), Int(args, "startValue")), "clear_start" => DocxSessionOps.ClearListStartOverride(session.Handle, Str(args, "anchorId")), "remove" => DocxSessionOps.RemoveListMembership(session.Handle, Str(args, "anchorId")), - "get_membership" => DocxSessionOps.GetListMembership(session.Handle, Str(args, "anchorId")), _ => throw new McpToolException($"unknown docxodus_list action: {action}"), - }; + }); + } private static bool IsMutatingListAction(string action) => action != "get_membership"; @@ -427,8 +450,12 @@ private static string Comment(SessionStore store, JsonElement args) return RunCommentAction(session, Str(args, "action"), args); } - private static string RunCommentAction(DocSession session, string action, JsonElement args) => action switch + private static string RunCommentAction(DocSession session, string action, JsonElement args) { + if (action == "list") + return $"{{\"comments\":{DocxSessionOps.ListComments(session.Handle)}}}"; + return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch + { "add" => AddComment(session, args), "reply" => DocxSessionOps.AddCommentReply( session.Handle, Str(args, "commentAnchorId"), Str(args, "author"), @@ -438,9 +465,9 @@ private static string Comment(SessionStore store, JsonElement args) "resolve" => DocxSessionOps.SetCommentResolved( session.Handle, Str(args, "commentAnchorId"), BoolOpt(args, "resolved", true)), "remove" => DocxSessionOps.RemoveComment(session.Handle, Str(args, "commentAnchorId")), - "list" => $"{{\"comments\":{DocxSessionOps.ListComments(session.Handle)}}}", _ => throw new McpToolException($"unknown docxodus_comment action: {action}"), - }; + }); + } private static bool IsMutatingCommentAction(string action) => action != "list"; @@ -468,39 +495,36 @@ private static string Annotate(SessionStore store, JsonElement args) { var session = Session(store, args); var action = Str(args, "action"); - switch (action) + if (action == "list") + return $"{{\"annotations\":{DocxSessionOps.ListAnnotations(session.Handle)}}}"; + if (action == "find") + return $"{{\"anchors\":{DocxSessionOps.FindByAnnotation(session.Handle, Str(args, "query"))}}}"; + return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch { - case "add": - { - var annotationJson = JsonSerializer.Serialize(new - { - id = OptStr(args, "annotationId") ?? "", - labelId = OptStr(args, "labelId") ?? "", - label = OptStr(args, "label") ?? "", - color = OptStr(args, "color") ?? "#FFEB3B", - author = OptStr(args, "author") ?? "", - }); - return DocxSessionOps.AddAnnotation( - session.Handle, Str(args, "anchorId"), ParseSpan(args, "span"), annotationJson); - } - case "update": - { - var updateEl = args.ValueKind == JsonValueKind.Object && args.TryGetProperty("update", out var u) && u.ValueKind == JsonValueKind.Object - ? u.GetRawText() : "{}"; - return DocxSessionOps.UpdateAnnotation(session.Handle, Str(args, "annotationId"), updateEl); - } - case "remove": - return DocxSessionOps.RemoveAnnotation(session.Handle, Str(args, "annotationId")); - case "move": - return DocxSessionOps.MoveAnnotation( - session.Handle, Str(args, "annotationId"), Str(args, "newAnchorId"), ParseSpan(args, "newSpan")); - case "list": - return $"{{\"annotations\":{DocxSessionOps.ListAnnotations(session.Handle)}}}"; - case "find": - return $"{{\"anchors\":{DocxSessionOps.FindByAnnotation(session.Handle, Str(args, "query"))}}}"; - default: - throw new McpToolException($"unknown docxodus_annotate action: {action}"); - } + "add" => AddAnnotation(session, args), + "update" => DocxSessionOps.UpdateAnnotation( + session.Handle, Str(args, "annotationId"), + args.TryGetProperty("update", out var u) && u.ValueKind == JsonValueKind.Object + ? u.GetRawText() : "{}"), + "remove" => DocxSessionOps.RemoveAnnotation(session.Handle, Str(args, "annotationId")), + "move" => DocxSessionOps.MoveAnnotation( + session.Handle, Str(args, "annotationId"), Str(args, "newAnchorId"), ParseSpan(args, "newSpan")), + _ => throw new McpToolException($"unknown docxodus_annotate action: {action}"), + }); + } + + private static string AddAnnotation(DocSession session, JsonElement args) + { + var annotationJson = JsonSerializer.Serialize(new + { + id = OptStr(args, "annotationId") ?? "", + labelId = OptStr(args, "labelId") ?? "", + label = OptStr(args, "label") ?? "", + color = OptStr(args, "color") ?? "#FFEB3B", + author = OptStr(args, "author") ?? "", + }); + return DocxSessionOps.AddAnnotation( + session.Handle, Str(args, "anchorId"), ParseSpan(args, "span"), annotationJson); } // ─── Track changes ────────────────────────────────────────────────── @@ -521,23 +545,35 @@ private static string TrackChanges(SessionStore store, JsonElement args) return FilterRevisions(revisionsJson, OptStr(args, "author"), OptStr(args, "changeType")); } case "accept": - return DocxSessionOps.AcceptRevision(session.Handle, Str(args, "revisionId")); + return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => + DocxSessionOps.AcceptRevision(session.Handle, Str(args, "revisionId"))); case "reject": - return DocxSessionOps.RejectRevision(session.Handle, Str(args, "revisionId")); + return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => + DocxSessionOps.RejectRevision(session.Handle, Str(args, "revisionId"))); case "accept_all": { + var preconditions = ParsePreconditions(args, MutationTarget(args)); + var check = Check(session, preconditions); + if (check is not null) return check; + var nextVersion = checked(DocxSessionOps.GetVersion(session.Handle) + 1); // SaveWithAnchorIds (not Save) so the transformed bytes still carry the // PtOpenXml:Unid attributes Rebind's reopen needs to keep anchor ids stable. 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); return "{\"success\":true}"; } case "reject_all": { + var preconditions = ParsePreconditions(args, MutationTarget(args)); + var check = Check(session, preconditions); + if (check is not null) return check; + var nextVersion = checked(DocxSessionOps.GetVersion(session.Handle) + 1); 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); return "{\"success\":true}"; } case "set_mode": @@ -599,10 +635,14 @@ 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; + var results = new List(); var errors = new List(); + var startingVersion = DocxSessionOps.GetVersion(session.Handle); int applied = 0; - int mutatingSteps = 0; + int committed = 0; foreach (var step in stepsEl.EnumerateArray()) { @@ -626,6 +666,7 @@ private static string Mutations(SessionStore store, JsonElement args) 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 @@ -657,8 +698,12 @@ private static string Mutations(SessionStore store, JsonElement args) } catch (JsonException) { /* non-EditResult shape (shouldn't happen for batchable tools); assume success */ } - mutatingSteps++; - if (succeeded) applied++; + if (succeeded) + { + applied++; + var versionDelta = DocxSessionOps.GetVersion(session.Handle) - stepStartingVersion; + committed = checked(committed + checked((int)versionDelta)); + } else { using var rdoc = JsonDocument.Parse(resultJson); @@ -668,8 +713,9 @@ private static string Mutations(SessionStore store, JsonElement args) if (mode == "preview") { - for (int i = 0; i < mutatingSteps; i++) + for (int i = 0; i < committed; i++) DocxSessionOps.Undo(session.Handle); + DocxSessionOps.RestorePreviewVersion(session.Handle, startingVersion); } var status = errors.Count == 0 ? "ok" : applied == 0 ? "failed" : "partial"; @@ -694,6 +740,8 @@ private static string Table(SessionStore store, JsonElement args) session.Handle, Str(args, "cellAnchorId")), "resolve_cell_coordinate" => DocxSessionOps.ResolveTableCellCoordinate( session.Handle, Str(args, "tableAnchorId"), Int(args, "rowIndex"), Int(args, "columnIndex")), + _ => Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch + { "insert" => DocxSessionOps.InsertTable( session.Handle, Str(args, "anchorId"), ParsePos(args), Int(args, "rows"), Int(args, "columns"), BuildTableInsertOptionsJson(args)), @@ -721,6 +769,7 @@ private static string Table(SessionStore store, JsonElement args) OptBool(args, "allowBreakAcrossPages"), OptInt(args, "heightTwips"), OptStr(args, "heightRule")), _ => throw new McpToolException($"unknown docxodus_table action: {action}"), + }), }; /// The raw JSON text of a required array argument (passed through to the Ops-layer @@ -747,6 +796,49 @@ private static string BuildTableBorderSpecJson(JsonElement args) // ─── Arg helpers ──────────────────────────────────────────────────── + private static MutationPreconditions? ParsePreconditions(JsonElement args, string? inferredAnchorId) + { + if (args.ValueKind != JsonValueKind.Object + || !args.TryGetProperty("preconditions", out var p) + || p.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + return null; + var parsed = DocxSessionJson.ParseMutationPreconditions(p); + return parsed is not null && parsed.AnchorId is null && inferredAnchorId is not null + ? parsed with { AnchorId = inferredAnchorId } + : parsed; + } + + private static string? MutationTarget(JsonElement args) + { + foreach (var name in new[] + { + "anchorId", "cellAnchorId", "sourceAnchorId", "fromAnchorId", "firstAnchorId", + "headingAnchorId", "bodyAnchorId", "commentAnchorId", "newAnchorId", + }) + { + if (args.ValueKind == JsonValueKind.Object + && args.TryGetProperty(name, out var target) + && target.ValueKind == JsonValueKind.String) + return target.GetString(); + } + return null; + } + + private static string? Check(DocSession session, MutationPreconditions? preconditions) + { + if (preconditions is null) return null; + var result = DocxSessionOps.CheckPreconditions(session.Handle, preconditions); + using var doc = JsonDocument.Parse(result); + return doc.RootElement.GetProperty("success").GetBoolean() ? null : result; + } + + private static string Guarded( + DocSession session, MutationPreconditions? preconditions, Func mutation) + { + var failure = Check(session, preconditions); + return failure ?? mutation(); + } + private static DocSession Session(SessionStore store, JsonElement args) => store.Get(Str(args, "sessionId")); private static string Str(JsonElement args, string name) diff --git a/tools/mcp-server/README.md b/tools/mcp-server/README.md index ba716306..2925991b 100644 --- a/tools/mcp-server/README.md +++ b/tools/mcp-server/README.md @@ -112,7 +112,12 @@ them): 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. + free of history-depth pressure. Its caller-visible document version is restored after + rollback, so merely previewing does not stale a guarded edit plan. +- **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 + `check_preconditions` expose the read side. Batch-level and per-step guards use the same shape. ## License diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 6039f452..915c542c 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -64,8 +64,9 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, - "format": { "type": "string", "enum": ["markdown", "html", "text", "blocks", "info"], "description": "markdown/text: anchor-addressed markdown projection (text strips the markdown syntax). html: fully rendered HTML. blocks: structural metadata for every addressable block. info: section/page-setup facts plus a document edit summary." }, - "anchorId": { "type": "string", "description": "Optional. Scope markdown/html/text output to one block and its descendants instead of the whole document. Anchors in body, header (hdr*), footer (ftr*), note, and comment scopes are accepted." } + "format": { "type": "string", "enum": ["markdown", "html", "text", "blocks", "info", "version", "check_preconditions"], "description": "markdown/text: projection; html: rendered HTML; blocks: metadata; info: version plus page/edit facts; version: monotonic document version; check_preconditions: read-only guard evaluation." }, + "anchorId": { "type": "string", "description": "Optional scope/target anchor." }, + "preconditions": { "type": "object", "description": "check_preconditions: expectedVersion and/or anchorId plus expectedContentHash, expectedText/expectedTextRange, expectedKind, expectedScope, or expectedMatchCount." } }, "required": ["sessionId", "format"] } @@ -109,6 +110,7 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic guards evaluated immediately before the mutation: expectedVersion, anchorId, expectedContentHash, expectedText/expectedTextRange, expectedKind, expectedScope, expectedMatchCount." }, "action": { "type": "string", "enum": ["insert_paragraph", "replace_text", "replace_text_range", "delete_block", "move_block", "delete_range", "delete_section", "split_paragraph", "merge_paragraphs", "undo", "redo"] }, "anchorId": { "type": "string", "description": "Target block. Required for every action except delete_range, delete_section, undo, redo." }, "position": { "type": "string", "enum": ["before", "after"], "description": "insert_paragraph/move_block only." }, @@ -135,6 +137,7 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." }, "action": { "type": "string", "enum": ["apply_format", "apply_format_by_substring", "set_paragraph_style", "set_paragraph_format", "set_list_level", "remove_list_membership", "apply_list_format"] }, "anchorId": { "type": "string" }, "span": { "type": "object", "properties": { "start": { "type": "integer" }, "length": { "type": "integer" } }, "description": "apply_format only. Omit to format the whole block." }, @@ -201,6 +204,7 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." }, "action": { "type": "string", "enum": ["insert_paragraph", "insert_heading", "insert_table", "insert_horizontal_rule", "insert_footnote", "insert_endnote", "insert_page_number_field", "set_header_text", "set_footer_text", "ensure_header_footer_visible"] }, "anchorId": { "type": "string", "description": "Reference block for insert_paragraph/insert_heading/insert_table/insert_horizontal_rule (paired with position), or the citing paragraph for insert_footnote/insert_endnote, or the target paragraph for insert_page_number_field." }, "bodyAnchorId": { "type": "string", "description": "set_header_text/set_footer_text/ensure_header_footer_visible: a body block identifying the section whose running story or visibility flags should change." }, @@ -230,6 +234,7 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." }, "action": { "type": "string", "enum": ["apply_format", "apply_format_range", "set_level", "set_start", "clear_start", "remove", "get_membership"] }, "anchorId": { "type": "string", "description": "Target paragraph for every action except apply_format_range. remove accepts paragraph, heading, or list-item anchors and overrides style-inherited numbering when necessary." }, "startValue": { "type": "integer", "description": "set_start: the number the item restarts at (>= 0), e.g. 5 to make this item render as '5.'" }, @@ -249,6 +254,7 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." }, "action": { "type": "string", "enum": ["add", "reply", "resolve", "update", "remove", "list"] }, "anchorId": { "type": "string", "description": "add: the body paragraph to comment on. Mutually exclusive with revisionId." }, "span": { "type": "object", "properties": { "start": { "type": "integer" }, "length": { "type": "integer" } }, "description": "add: character range within the paragraph. Omit to comment on the whole block." }, @@ -271,6 +277,7 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." }, "action": { "type": "string", "enum": ["add", "update", "remove", "move", "list", "find"] }, "anchorId": { "type": "string", "description": "add/move: block to attach the annotation to." }, "span": { "type": "object", "properties": { "start": { "type": "integer" }, "length": { "type": "integer" } }, "description": "add/move: character range within the block. Omit to annotate the whole block." }, @@ -302,6 +309,7 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic guards for accept/reject/accept_all/reject_all." }, "action": { "type": "string", "enum": ["list", "accept", "reject", "accept_all", "reject_all", "set_mode"], "description": "'list' reads revisions directly off the live markup — each entry carries a stable id, the markup's true author/date, its text, and the containing block's anchorId. 'accept'/'reject' resolve ONE revision by revisionId (undoable via docxodus_undo; other revisions keep their ids). 'accept_all'/'reject_all' resolve the whole document (not undoable — the session is rebound to the transformed bytes). 'set_mode' switches the session's own recording mode mid-workflow (issue #304)." }, "revisionId": { "type": "string", "description": "accept/reject: the id from 'list' (e.g. 'rev101'). Unknown or already-resolved ids fail with revision_not_found — re-list for the current set." }, "author": { "type": "string", "description": "list: only return revisions by this author." }, @@ -320,6 +328,7 @@ internal static class ToolCatalog "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": ["apply", "preview"], "description": "preview applies every step, records the result, then undoes them all before returning — nothing is left changed." }, "steps": { "type": "array", @@ -344,6 +353,7 @@ internal static class ToolCatalog "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." }, "action": { "type": "string", "enum": ["get_metadata", "resolve_cell_anchor", "resolve_cell_coordinate", "insert", "insert_row", "insert_column", "delete_row", "delete_column", "replace_cell_content", "merge_cells", "unmerge_cells", "set_column_widths", "set_borders", "set_shading", "set_repeat_header_row", "set_row_options"] }, "anchorId": { "type": "string", "description": "insert: reference block (paired with position)." }, "tableAnchorId": { "type": "string", "description": "get_metadata/resolve_cell_coordinate: the table's canonical tbl anchor." }, diff --git a/tools/python-host/Dispatcher.cs b/tools/python-host/Dispatcher.cs index f3907d5c..2079e9a4 100644 --- a/tools/python-host/Dispatcher.cs +++ b/tools/python-host/Dispatcher.cs @@ -22,7 +22,25 @@ namespace Docxodus.PyHost; /// internal static class Dispatcher { - public static string Dispatch(string op, JsonElement args) => op switch + public static string Dispatch(string op, JsonElement args) + { + var preconditions = ParsePreconditions(args); + if (preconditions is not null && IsMutation(op) && op != "replace_text_range") + { + if (op == "undo") return DocxSessionOps.UndoChecked(Handle(args), preconditions); + if (op == "redo") return DocxSessionOps.RedoChecked(Handle(args), preconditions); + + // The stdio host dispatches one complete request at a time, so the check and + // mutation below cannot be interleaved by another protocol request. + var check = DocxSessionOps.CheckPreconditions(Handle(args), preconditions); + using var parsed = JsonDocument.Parse(check); + if (!parsed.RootElement.GetProperty("success").GetBoolean()) return check; + } + + return DispatchCore(op, args); + } + + private static string DispatchCore(string op, JsonElement args) => op switch { "ping" => Ping(), "open_session" => OpenSession(args), @@ -44,6 +62,8 @@ internal static class Dispatcher "project_anchor" => DocxSessionOps.ProjectAnchor( Handle(args), Str(args, "anchorId"), (ProjectionDepth)IntOptional(args, "depth", 2)), + "get_version" => DocxSessionOps.GetVersionJson(Handle(args)), + "check_preconditions" => DocxSessionOps.CheckPreconditions(Handle(args), ParsePreconditions(args)), "replace_text" => DocxSessionOps.ReplaceText(Handle(args), Str(args, "anchorId"), Str(args, "markdown")), "delete_block" => DocxSessionOps.DeleteBlock(Handle(args), Str(args, "anchorId")), @@ -559,16 +579,62 @@ private static string[] ParseAnchorIdArray(JsonElement args) private static ReplaceOptions? ParseReplaceOptions(JsonElement args) { - if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty("options", out var o) || o.ValueKind != JsonValueKind.Object) - return null; + if (args.ValueKind != JsonValueKind.Object) return null; + var hasOptions = args.TryGetProperty("options", out var o) && o.ValueKind == JsonValueKind.Object; + var preconditions = ParsePreconditions(args); + if (preconditions is null && hasOptions + && o.TryGetProperty("preconditions", out var nestedPreconditions)) + preconditions = DocxSessionJson.ParseMutationPreconditions(nestedPreconditions); + if (!hasOptions && preconditions is null) return null; return new ReplaceOptions { - IgnoreCase = DocxSessionJson.TryGetBool(o, "ignoreCase", false), - MaxReplacements = o.TryGetProperty("maxReplacements", out var mr) && mr.ValueKind == JsonValueKind.Number + IgnoreCase = hasOptions && DocxSessionJson.TryGetBool(o, "ignoreCase", false), + MaxReplacements = hasOptions && o.TryGetProperty("maxReplacements", out var mr) && mr.ValueKind == JsonValueKind.Number ? mr.GetInt32() : (int?)null, + ExpectedMatchCount = hasOptions && o.TryGetProperty("expectedMatchCount", out var emc) && emc.ValueKind == JsonValueKind.Number + ? emc.GetInt32() : (int?)null, + Preconditions = preconditions, }; } + private static MutationPreconditions? ParsePreconditions(JsonElement args) + { + if (args.ValueKind != JsonValueKind.Object + || !args.TryGetProperty("preconditions", out var p) + || p.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + return null; + var parsed = DocxSessionJson.ParseMutationPreconditions(p); + if (parsed is null || parsed.AnchorId is not null) return parsed; + foreach (var targetName in new[] + { + "anchorId", "cellAnchorId", "sourceAnchorId", "fromAnchorId", + "firstAnchorId", "headingAnchorId", "parentAnchorId", "newAnchorId", + }) + { + if (args.TryGetProperty(targetName, out var target) && target.ValueKind == JsonValueKind.String) + return parsed with { AnchorId = target.GetString() }; + } + return parsed; + } + + private static bool IsMutation(string op) => op is + "replace_text" or "delete_block" or "move_block" or "delete_range" or "delete_section" + or "replace_text_range" or "replace_text_at_span" or "replace_inner" + or "insert_paragraph" or "split_paragraph" or "merge_paragraphs" + or "set_header_text" or "set_footer_text" or "insert_page_number_field" + or "ensure_header_footer_visible" or "set_page_numbering" or "clear_page_numbering" + or "insert_footnote" or "insert_endnote" + or "add_comment" or "add_comment_reply" or "update_comment" + or "set_comment_resolved" or "remove_comment" + or "accept_revision" or "reject_revision" + or "apply_format" or "apply_format_by_substring" or "set_paragraph_style" + or "set_paragraph_format" or "set_list_level" or "remove_list_membership" + or "apply_list_format" or "apply_list_format_range" or "set_list_start_override" + or "clear_list_start_override" or "replace_cell_content" + or "raw_insert_xml" or "raw_replace_xml" + or "add_annotation" or "remove_annotation" or "update_annotation" or "move_annotation" + or "undo" or "redo"; + private static string JsonString(string s) => DocxSessionJson.JsonString(s); private static string JsonObject(JsonElement args, string name) diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs index 1ab06953..0cd37a14 100644 --- a/wasm/DocxodusWasm/DocxSessionBridge.cs +++ b/wasm/DocxodusWasm/DocxSessionBridge.cs @@ -37,6 +37,16 @@ public static int OpenSession(byte[] bytes, string settingsJson) => [JSExport] public static string Project(int handle) => DocxSessionOps.Project(handle); + /// Current monotonic session document version as {"version":N}. + [JSExport] + public static string GetVersion(int handle) => DocxSessionOps.GetVersionJson(handle); + + /// Read-only optimistic guard evaluation. A successful result applies no mutation. + [JSExport] + public static string CheckPreconditions(int handle, string preconditionsJson) => + DocxSessionOps.CheckPreconditions( + handle, DocxSessionJson.ParseMutationPreconditions(preconditionsJson)); + /// /// Ordered top-level render units per scope container (body / footnotes / /// endnotes), as JSON: {"body":[{"id","kind"},…],"footnotes":[…],"endnotes":[…]}. @@ -596,6 +606,9 @@ public static string ReplaceTextRange(int h, string anchor, string find, string IgnoreCase = DocxSessionJson.TryGetBool(root, "ignoreCase", false), MaxReplacements = root.TryGetProperty("maxReplacements", out var mr) && mr.ValueKind == JsonValueKind.Number ? mr.GetInt32() : (int?)null, + ExpectedMatchCount = DocxSessionJson.TryGetIntNullable(root, "expectedMatchCount"), + Preconditions = root.TryGetProperty("preconditions", out var p) + ? DocxSessionJson.ParseMutationPreconditions(p) : null, }; } return DocxSessionOps.ReplaceTextRange(h, anchor, find, replace, opts);