#include "editor/EditorTransactionConsumer.h" #include #include #include #include #include #include namespace eve::editor { namespace { template eve::Result failure(eve::DiagnosticCode code, std::string message, std::string path = {}) { return eve::Result::failure(eve::Diagnostic::error(code, std::move(message), std::move(path))); } eve::StatusCode commonStatus(EditorStatus status) noexcept { switch (status) { case EditorStatus::Rejected: return eve::StatusCode::Rejected; case EditorStatus::Conflict: return eve::StatusCode::Conflict; case EditorStatus::NotFound: return eve::StatusCode::NotFound; case EditorStatus::Unsupported: return eve::StatusCode::Unsupported; case EditorStatus::Cancelled: return eve::StatusCode::Cancelled; case EditorStatus::Applied: case EditorStatus::Pending: case EditorStatus::NoOp: case EditorStatus::Failed: return eve::StatusCode::Failed; } return eve::StatusCode::Failed; } eve::DiagnosticCode commonDiagnostic(EditorStatus status) noexcept { switch (status) { case EditorStatus::Rejected: return eve::DiagnosticCode::InvalidArgument; case EditorStatus::Conflict: return eve::DiagnosticCode::Conflict; case EditorStatus::NotFound: return eve::DiagnosticCode::NotFound; case EditorStatus::Unsupported: return eve::DiagnosticCode::Unsupported; case EditorStatus::Cancelled: return eve::DiagnosticCode::Cancelled; case EditorStatus::Applied: case EditorStatus::Pending: case EditorStatus::NoOp: case EditorStatus::Failed: return eve::DiagnosticCode::Failed; } return eve::DiagnosticCode::Failed; } template eve::Result convertEditorFailure(const EditorResult& source, std::string_view context) { std::vector diagnostics; const eve::DiagnosticCode fallbackCode = commonDiagnostic(source.status); for (const auto& diagnostic : source.diagnostics) { diagnostics.push_back(eve::Diagnostic::error( fallbackCode, std::string(context) + ": " + diagnostic.message, diagnostic.rule.value())); } if (diagnostics.empty()) diagnostics.push_back(eve::Diagnostic::error(fallbackCode, std::string(context))); return eve::Result::failure(eve::Status(commonStatus(source.status), std::move(diagnostics))); } bool hasCoordinatorCleanupFailure(const eve::Status& status) { for (const auto& diagnostic : status.diagnostics()) { if (diagnostic.path().starts_with("transaction.rollback") || diagnostic.path().starts_with("transaction.compensation")) return true; } return false; } std::optional coordinatorFailedCommitIndex(const eve::Status& status) noexcept { constexpr std::string_view prefix = "transaction.commit["; for (const auto& diagnostic : status.diagnostics()) { const std::string& path = diagnostic.path(); if (!path.starts_with(prefix) || path.back() != ']') continue; std::uint64_t index = 0; const char* first = path.data() + prefix.size(); const char* last = path.data() + path.size() - 1; const auto [end, error] = std::from_chars(first, last, index); if (error == std::errc{} && end == last && index <= static_cast(std::numeric_limits::max())) return static_cast(index); } return std::nullopt; } bool hasPreviouslyCommittedAdditionalParticipant(const eve::Status& status, std::size_t editorParticipantCount) noexcept { const auto failedIndex = coordinatorFailedCommitIndex(status); // Editor-owned participants are rebuilt for every attempt. A borrowed // additional participant that precedes the failing commit is different: // its effect may already have escaped, and retaining only its name is not // enough to prove that a retry is idempotent. Require explicit discard or // reconciliation instead of invoking that participant twice. return failedIndex.has_value() && *failedIndex > editorParticipantCount; } struct CommandBatch { std::vector> commands; bool previewApplied = false; }; class CommandParticipant final : public transaction::ITransactionParticipant { public: enum class InitialPhase { Idle, Committed }; explicit CommandParticipant(CommandBatch& batch, InitialPhase initial = InitialPhase::Idle) : batch_(batch), phase_(initial == InitialPhase::Committed ? Phase::Committed : Phase::Idle) {} std::string_view name() const noexcept override { return "editor.commands"; } [[nodiscard]] eve::Result prepare(const transaction::TransactionContext&) override { if (phase_ != Phase::Idle) return failure(eve::DiagnosticCode::PreconditionViolation, "editor command participant is not idle", "editor.commands.prepare"); if (batch_.commands.empty()) return failure(eve::DiagnosticCode::InvalidArgument, "editor transaction must contain at least one command", "editor.commands"); for (const auto& command : batch_.commands) { if (!command) return failure(eve::DiagnosticCode::InvalidArgument, "editor command must not be null", "editor.commands"); } phase_ = Phase::Prepared; return eve::Result::success(); } [[nodiscard]] eve::Result commit(const transaction::TransactionContext&) override { if (phase_ != Phase::Prepared) return failure(eve::DiagnosticCode::PreconditionViolation, "editor command participant is not prepared", "editor.commands.commit"); if (!batch_.previewApplied) { std::size_t applied = 0; try { for (const auto& command : batch_.commands) { if (!command->apply()) { const bool reverted = revertPrefix(applied); if (!reverted) batch_.previewApplied = true; return failure( reverted ? eve::DiagnosticCode::InvalidArgument : eve::DiagnosticCode::Failed, reverted ? "editor command rejected during commit" : "editor command commit failed and compensation was incomplete", "editor.commands.commit"); } ++applied; } } catch (const std::exception& exception) { const bool reverted = revertPrefix(applied); if (!reverted) batch_.previewApplied = true; return failure(eve::DiagnosticCode::Failed, std::string("editor command threw during commit: ") + exception.what(), "editor.commands.commit"); } catch (...) { const bool reverted = revertPrefix(applied); if (!reverted) batch_.previewApplied = true; return failure(eve::DiagnosticCode::Failed, "editor command threw during commit", "editor.commands.commit"); } batch_.previewApplied = true; } phase_ = Phase::Committed; return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } [[nodiscard]] eve::Result rollback(const transaction::TransactionContext&) override { if (phase_ != Phase::Prepared) return failure(eve::DiagnosticCode::PreconditionViolation, "editor command participant is not prepared", "editor.commands.rollback"); if (batch_.previewApplied && !revertAll()) return failure(eve::DiagnosticCode::Failed, "editor command preview rollback was incomplete", "editor.commands.rollback"); batch_.previewApplied = false; phase_ = Phase::RolledBack; return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } [[nodiscard]] eve::Result compensate(const transaction::TransactionContext&) override { if (phase_ != Phase::Committed) return failure(eve::DiagnosticCode::PreconditionViolation, "editor command participant is not committed", "editor.commands.compensate"); if (!batch_.previewApplied || !revertAll()) return failure(eve::DiagnosticCode::Failed, "editor command compensation was incomplete", "editor.commands.compensate"); batch_.previewApplied = false; phase_ = Phase::Compensated; return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } private: enum class Phase { Idle, Prepared, Committed, RolledBack, Compensated }; bool revertPrefix(std::size_t count) noexcept { try { while (count > 0) batch_.commands[--count]->revert(); return true; } catch (...) { return false; } } bool revertAll() noexcept { return revertPrefix(batch_.commands.size()); } CommandBatch& batch_; Phase phase_ = Phase::Idle; }; class AuthorityParticipant final : public transaction::ITransactionParticipant { public: AuthorityParticipant(IEditAuthority& authority, TransactionSpec specification, std::vector operations) : authority_(&authority), specification_(std::move(specification)), operations_(std::move(operations)) {} AuthorityParticipant(IEditAuthority& authority, TransactionSpec specification, std::vector operations, TransactionReceipt receipt) : authority_(&authority), specification_(std::move(specification)), operations_(std::move(operations)), receipt_(std::move(receipt)), phase_(Phase::Committed) {} std::string_view name() const noexcept override { return "editor.authority"; } [[nodiscard]] eve::Result prepare(const transaction::TransactionContext&) override { if (phase_ != Phase::Idle) return failure(eve::DiagnosticCode::PreconditionViolation, "editor authority participant is not idle", "editor.authority.prepare"); if (!authority_) return failure(eve::DiagnosticCode::Failed, "editor authority participant has no authority", "editor.authority"); auto plan = authority_->preflight(specification_, operations_); if (!plan.accepted() || !plan.value) return convertEditorFailure(plan, "editor authority preflight"); plan_ = std::move(*plan.value); phase_ = Phase::Prepared; return eve::Result::success(); } [[nodiscard]] eve::Result commit(const transaction::TransactionContext&) override { if (phase_ != Phase::Prepared || !plan_) return failure(eve::DiagnosticCode::PreconditionViolation, "editor authority participant is not prepared", "editor.authority.commit"); auto result = authority_->commit(*plan_); if (!result.accepted() || !result.value) return convertEditorFailure(result, "editor authority commit"); receipt_ = std::move(*result.value); phase_ = Phase::Committed; return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } [[nodiscard]] eve::Result rollback(const transaction::TransactionContext&) override { if (phase_ != Phase::Prepared) return failure(eve::DiagnosticCode::PreconditionViolation, "editor authority participant is not prepared", "editor.authority.rollback"); plan_.reset(); phase_ = Phase::RolledBack; return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } [[nodiscard]] eve::Result compensate(const transaction::TransactionContext&) override { if (phase_ != Phase::Committed || !receipt_) return failure(eve::DiagnosticCode::PreconditionViolation, "editor authority participant has no committed receipt", "editor.authority.compensate"); auto result = authority_->compensate(*receipt_); if (!result.accepted() || !result.value) return convertEditorFailure(result, "editor authority compensation"); // History keeps the original commit receipt. This participant's // latest effect is the compensation receipt, whose afterRevision is // also the base revision required by a later redo. receipt_ = std::move(*result.value); phase_ = Phase::Compensated; return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } [[nodiscard]] const std::optional& receipt() const noexcept { return receipt_; } private: enum class Phase { Idle, Prepared, Committed, RolledBack, Compensated }; IEditAuthority* authority_ = nullptr; TransactionSpec specification_; std::vector operations_; std::optional plan_; std::optional receipt_; Phase phase_ = Phase::Idle; }; } // namespace struct EditorTransactionConsumer::Impl { struct Pending { TransactionSpec specification; std::unique_ptr commands; std::vector operations; bool commandModeSet = false; bool previewMode = false; std::vector additionalNames; std::uint64_t attemptSequence = 0; }; struct HistoryEntry { TransactionSpec specification; std::unique_ptr commands; std::vector operations; std::optional authorityReceipt; }; explicit Impl(IEditAuthority* authorityValue) : authority(authorityValue) {} IEditAuthority* authority = nullptr; std::optional pending; std::vector undo; std::vector redo; transaction::Coordinator coordinator; std::uint64_t nextLegacyId = 1; std::uint64_t redoSequence = 0; EditorCommitState state = EditorCommitState::Discarded; std::vector diagnostics; bool retryAllowed = false; bool retryBlockedByCommittedParticipant = false; }; EditorTransactionConsumer::EditorTransactionConsumer(IEditAuthority* authority) : impl_(std::make_unique(authority)) {} EditorTransactionConsumer::~EditorTransactionConsumer() = default; eve::Result EditorTransactionConsumer::setAuthority(IEditAuthority* authority) { if (impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "editor authority cannot change during an active transaction", "editor.authority"); impl_->authority = authority; return eve::Result::success(); } eve::Result EditorTransactionConsumer::begin(TransactionSpec specification) { if (impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "an editor transaction is already active", "editor.transaction"); if (specification.id.empty()) return failure(eve::DiagnosticCode::InvalidArgument, "editor transaction id is required", "editor.transaction.id"); const TransactionId id = specification.id; Impl::Pending pending; pending.specification = std::move(specification); impl_->pending = std::move(pending); impl_->state = EditorCommitState::Pending; impl_->diagnostics.clear(); impl_->retryAllowed = false; impl_->retryBlockedByCommittedParticipant = false; return eve::Result::success(id); } eve::Result EditorTransactionConsumer::beginLegacy(std::string label) { if (impl_->nextLegacyId == std::numeric_limits::max()) return failure(eve::DiagnosticCode::Failed, "legacy editor transaction id allocator is exhausted", "editor.transaction.id"); TransactionSpec specification; specification.id = TransactionId("editor.legacy." + std::to_string(impl_->nextLegacyId++)); specification.label = std::move(label); return begin(std::move(specification)); } eve::Result EditorTransactionConsumer::append(std::unique_ptr command) { if (!impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "no editor transaction is active", "editor.transaction"); if (!command) return failure(eve::DiagnosticCode::InvalidArgument, "editor command must not be null", "editor.command"); if (!impl_->pending->operations.empty()) return failure(eve::DiagnosticCode::Conflict, "commands and authority operations cannot share one editor transaction", "editor.transaction"); if (impl_->pending->commandModeSet && impl_->pending->previewMode) return failure(eve::DiagnosticCode::Conflict, "a strict command cannot follow a preview command", "editor.transaction"); impl_->pending->commandModeSet = true; impl_->pending->previewMode = false; if (!impl_->pending->commands) impl_->pending->commands = std::make_unique(); if (!impl_->pending->commands->commands.empty()) { try { if (impl_->pending->commands->commands.back()->mergeWith(*command)) return eve::Result::success(); } catch (const std::exception& exception) { return failure(eve::DiagnosticCode::Failed, std::string("editor command merge threw: ") + exception.what(), "editor.command.merge"); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command merge threw", "editor.command.merge"); } } impl_->pending->commands->commands.push_back(std::move(command)); return eve::Result::success(); } eve::Result EditorTransactionConsumer::appendPreview(std::unique_ptr command) { if (!impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "no editor transaction is active", "editor.transaction"); if (!command) return failure(eve::DiagnosticCode::InvalidArgument, "editor command must not be null", "editor.command"); if (!impl_->pending->operations.empty()) return failure(eve::DiagnosticCode::Conflict, "commands and authority operations cannot share one editor transaction", "editor.transaction"); if (impl_->pending->commandModeSet && !impl_->pending->previewMode) return failure(eve::DiagnosticCode::Conflict, "a preview command cannot follow a strict command", "editor.transaction"); if (!impl_->pending->commands) impl_->pending->commands = std::make_unique(); // A command is applied only after merge evaluation has completed on an // owning candidate copy. The candidate carries the aggregate history but // never publishes to the real target; the incoming command is the only // object that applies the next preview delta. std::unique_ptr mergedCandidate; if (!impl_->pending->commands->commands.empty()) { try { mergedCandidate = impl_->pending->commands->commands.back()->clone(); } catch (const std::exception& exception) { return failure(eve::DiagnosticCode::Failed, std::string("editor command staging threw: ") + exception.what(), "editor.command.stage"); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command staging threw", "editor.command.stage"); } if (mergedCandidate) { try { if (mergedCandidate->mergeWith(*command)) { // The merge has changed only mergedCandidate. Applying the // incoming delta keeps the live target at the same state // that the command recorded as its `before` value. bool applied = false; try { applied = command->apply(); } catch (const std::exception& exception) { try { command->revert(); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command preview failed and could not be reverted", "editor.command.preview"); } return failure(eve::DiagnosticCode::Failed, std::string("editor command preview threw: ") + exception.what(), "editor.command.preview"); } catch (...) { try { command->revert(); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command preview failed and could not be reverted", "editor.command.preview"); } return failure(eve::DiagnosticCode::Failed, "editor command preview threw", "editor.command.preview"); } if (!applied) { try { command->revert(); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command preview was rejected and could not be reverted", "editor.command.preview"); } return failure(eve::DiagnosticCode::InvalidArgument, "editor command preview was rejected", "editor.command.preview"); } impl_->pending->commands->commands.back() = std::move(mergedCandidate); impl_->pending->commands->previewApplied = true; impl_->pending->commandModeSet = true; impl_->pending->previewMode = true; return eve::Result::success(); } } catch (const std::exception& exception) { return failure(eve::DiagnosticCode::Failed, std::string("editor command merge threw: ") + exception.what(), "editor.command.merge"); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command merge threw", "editor.command.merge"); } } } try { // Reserve before applying so vector growth cannot be the first // observable failure after the target has been changed. impl_->pending->commands->commands.reserve( impl_->pending->commands->commands.size() + 1); } catch (const std::exception& exception) { return failure(eve::DiagnosticCode::Failed, std::string("editor command staging allocation threw: ") + exception.what(), "editor.command.stage"); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command staging allocation threw", "editor.command.stage"); } bool applied = false; try { applied = command->apply(); } catch (const std::exception& exception) { try { command->revert(); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command preview failed and could not be reverted", "editor.command.preview"); } return failure(eve::DiagnosticCode::Failed, std::string("editor command preview threw: ") + exception.what(), "editor.command.preview"); } catch (...) { try { command->revert(); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command preview failed and could not be reverted", "editor.command.preview"); } return failure(eve::DiagnosticCode::Failed, "editor command preview threw", "editor.command.preview"); } if (!applied) { try { command->revert(); } catch (...) { return failure(eve::DiagnosticCode::Failed, "editor command preview was rejected and could not be reverted", "editor.command.preview"); } return failure(eve::DiagnosticCode::InvalidArgument, "editor command preview was rejected", "editor.command.preview"); } impl_->pending->commands->commands.push_back(std::move(command)); impl_->pending->commands->previewApplied = true; impl_->pending->commandModeSet = true; impl_->pending->previewMode = true; return eve::Result::success(); } eve::Result EditorTransactionConsumer::append(DomainOperation operation) { if (!impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "no editor transaction is active", "editor.transaction"); if (operation.type.empty() || operation.target.empty()) return failure(eve::DiagnosticCode::InvalidArgument, "authority operation type and target are required", "editor.operation"); if (impl_->pending->commands) return failure(eve::DiagnosticCode::Conflict, "commands and authority operations cannot share one editor transaction", "editor.transaction"); if (impl_->pending->specification.target.empty()) return failure(eve::DiagnosticCode::InvalidArgument, "authority transaction target is required", "editor.transaction.target"); if (operation.target != impl_->pending->specification.target) return failure(eve::DiagnosticCode::Conflict, "authority operation target does not match the transaction target", "editor.operation.target"); impl_->pending->operations.push_back(std::move(operation)); return eve::Result::success(); } eve::Result EditorTransactionConsumer::dryRun() const { if (!impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "no editor transaction is active", "editor.transaction"); const auto& pending = *impl_->pending; const std::size_t commandCount = pending.commands ? pending.commands->commands.size() : 0; if (commandCount == 0 && pending.operations.empty()) return failure(eve::DiagnosticCode::InvalidArgument, "editor transaction has no work", "editor.transaction"); EditorDryRunReport report; report.specification = pending.specification; report.commandCount = commandCount; report.operationCount = pending.operations.size(); if (!pending.operations.empty()) { if (!impl_->authority) return failure(eve::DiagnosticCode::Unsupported, "authority preflight requires an injected authority", "editor.authority"); auto plan = impl_->authority->preflight(pending.specification, pending.operations); if (!plan.accepted() || !plan.value) return convertEditorFailure(plan, "editor authority preflight"); report.authorityPlan = std::move(*plan.value); } return eve::Result::success(std::move(report)); } eve::Result EditorTransactionConsumer::commit() { return commitAttempt(std::span{}, false); } eve::Result EditorTransactionConsumer::commit( std::span additional) { return commitAttempt(additional, false); } eve::Result EditorTransactionConsumer::retry( std::span additional) { if (!impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "no failed editor transaction is pending", "editor.transaction.retry"); if (impl_->state != EditorCommitState::FailedRetryable) return failure(eve::DiagnosticCode::PreconditionViolation, "editor transaction is not awaiting retry", "editor.transaction.retry"); if (!impl_->retryAllowed) return failure( eve::DiagnosticCode::Conflict, impl_->retryBlockedByCommittedParticipant ? "editor transaction cannot be retried after an additional participant committed; reconcile or discard it" : "editor transaction cannot be retried after incomplete cleanup; discard it", "editor.transaction.retry"); return commitAttempt(additional, true); } eve::Result EditorTransactionConsumer::commitAttempt( std::span additional, bool retryAttempt) { if (!impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "no editor transaction is active", "editor.transaction"); auto retainFailure = [&](const eve::Status& status, bool retryAllowed) { impl_->state = EditorCommitState::FailedRetryable; impl_->retryAllowed = retryAllowed; impl_->retryBlockedByCommittedParticipant = false; impl_->diagnostics = status.diagnostics(); return eve::Result::failure(status); }; auto reject = [&](eve::DiagnosticCode code, std::string message, std::string path) { auto result = failure(code, std::move(message), std::move(path)); const bool preserveRetryBlock = impl_->state == EditorCommitState::FailedRetryable; impl_->state = EditorCommitState::FailedRetryable; if (!preserveRetryBlock) { impl_->retryAllowed = true; impl_->retryBlockedByCommittedParticipant = false; } impl_->diagnostics = result.diagnostics(); return result; }; if (!retryAttempt && impl_->state == EditorCommitState::FailedRetryable) return reject(eve::DiagnosticCode::Conflict, "editor commit failed; call retry() or discard() before another commit attempt", "editor.transaction.commit"); if (retryAttempt && impl_->state != EditorCommitState::FailedRetryable) return reject(eve::DiagnosticCode::PreconditionViolation, "editor transaction is not awaiting retry", "editor.transaction.retry"); Impl::Pending& pending = *impl_->pending; const std::size_t commandCount = pending.commands ? pending.commands->commands.size() : 0; if (commandCount == 0 && pending.operations.empty()) return reject(eve::DiagnosticCode::InvalidArgument, "editor transaction has no work", "editor.transaction"); if (!pending.operations.empty() && !impl_->authority) return reject(eve::DiagnosticCode::Unsupported, "authority commit requires an injected authority", "editor.authority"); for (std::size_t i = 0; i < additional.size(); ++i) { if (!additional[i]) return reject(eve::DiagnosticCode::InvalidArgument, "additional participant must not be null", "editor.transaction.participants[" + std::to_string(i) + "]"); for (std::size_t previous = 0; previous < i; ++previous) { if (additional[previous] == additional[i]) return reject(eve::DiagnosticCode::Conflict, "an additional participant may occur only once in an editor transaction", "editor.transaction.participants[" + std::to_string(i) + "]"); } } if (!retryAttempt) { pending.additionalNames.clear(); pending.additionalNames.reserve(additional.size()); for (auto* participant : additional) pending.additionalNames.emplace_back(participant->name()); } else { if (additional.size() != pending.additionalNames.size()) return reject(eve::DiagnosticCode::Conflict, "retry must provide the same additional participant set", "editor.transaction.retry"); for (std::size_t i = 0; i < additional.size(); ++i) { if (additional[i]->name() != pending.additionalNames[i]) return reject(eve::DiagnosticCode::Conflict, "retry participant order or identity does not match the failed attempt", "editor.transaction.retry"); } } std::unique_ptr authorityParticipant; std::unique_ptr commandParticipant; std::vector participants; if (!pending.operations.empty()) { authorityParticipant = std::make_unique( *impl_->authority, pending.specification, pending.operations); participants.push_back(authorityParticipant.get()); } if (pending.commands) { commandParticipant = std::make_unique(*pending.commands); participants.push_back(commandParticipant.get()); } participants.insert(participants.end(), additional.begin(), additional.end()); if (participants.empty()) return reject(eve::DiagnosticCode::InvalidArgument, "editor transaction has no participant", "editor.transaction"); if (retryAttempt) { if (pending.attemptSequence == std::numeric_limits::max()) return reject(eve::DiagnosticCode::Failed, "editor transaction retry sequence is exhausted", "editor.transaction.retry"); ++pending.attemptSequence; } std::string attemptId = pending.specification.id.value(); if (retryAttempt) attemptId += ".retry." + std::to_string(pending.attemptSequence); transaction::TransactionContext context(std::move(attemptId)); auto coordinated = impl_->coordinator.execute(context, participants); if (!coordinated.ok()) { const eve::Status& status = coordinated.status(); const bool committedAdditional = hasPreviouslyCommittedAdditionalParticipant( status, (pending.operations.empty() ? 0u : 1u) + (pending.commands ? 1u : 0u)); auto result = retainFailure(status, !hasCoordinatorCleanupFailure(status) && !committedAdditional); impl_->retryBlockedByCommittedParticipant = committedAdditional; return result; } transaction::TransactionReceipt coordinatorReceipt = std::move(coordinated).takeValue(); Impl::HistoryEntry history; history.specification = pending.specification; history.commands = std::move(pending.commands); history.operations = std::move(pending.operations); if (authorityParticipant) history.authorityReceipt = authorityParticipant->receipt(); impl_->undo.push_back(std::move(history)); impl_->redo.clear(); impl_->pending.reset(); impl_->state = EditorCommitState::Committed; impl_->retryAllowed = false; impl_->retryBlockedByCommittedParticipant = false; impl_->diagnostics.clear(); EditorTransactionRecord record; record.coordinator = std::move(coordinatorReceipt); record.specification = impl_->undo.back().specification; record.authorityReceipt = impl_->undo.back().authorityReceipt; record.commandCount = commandCount; record.operationCount = impl_->undo.back().operations.size(); return eve::Result::success(std::move(record)); } eve::Result EditorTransactionConsumer::discard() { if (!impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "no editor transaction is active", "editor.transaction"); Impl::Pending& pending = *impl_->pending; if (pending.commands && pending.commands->previewApplied) { try { for (auto it = pending.commands->commands.rbegin(); it != pending.commands->commands.rend(); ++it) (*it)->revert(); } catch (const std::exception& exception) { auto result = failure(eve::DiagnosticCode::Failed, std::string("editor preview rollback threw: ") + exception.what(), "editor.transaction.discard"); impl_->state = EditorCommitState::FailedRetryable; impl_->retryAllowed = false; impl_->retryBlockedByCommittedParticipant = false; impl_->diagnostics = result.diagnostics(); return result; } catch (...) { auto result = failure(eve::DiagnosticCode::Failed, "editor preview rollback threw", "editor.transaction.discard"); impl_->state = EditorCommitState::FailedRetryable; impl_->retryAllowed = false; impl_->retryBlockedByCommittedParticipant = false; impl_->diagnostics = result.diagnostics(); return result; } pending.commands->previewApplied = false; } impl_->pending.reset(); impl_->state = EditorCommitState::Discarded; impl_->retryAllowed = false; impl_->retryBlockedByCommittedParticipant = false; impl_->diagnostics.clear(); return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } eve::Result EditorTransactionConsumer::rollback() { return discard(); } eve::Result EditorTransactionConsumer::undo() { if (impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "cannot undo while an editor transaction is active", "editor.transaction.undo"); if (impl_->undo.empty()) return failure(eve::DiagnosticCode::NotFound, "there is no editor transaction to undo", "editor.history.undo"); Impl::HistoryEntry history = std::move(impl_->undo.back()); impl_->undo.pop_back(); std::unique_ptr authorityParticipant; std::unique_ptr commandParticipant; std::vector participants; if (history.authorityReceipt && impl_->authority) { authorityParticipant = std::make_unique( *impl_->authority, history.specification, history.operations, *history.authorityReceipt); participants.push_back(authorityParticipant.get()); } if (history.commands) { commandParticipant = std::make_unique( *history.commands, CommandParticipant::InitialPhase::Committed); participants.push_back(commandParticipant.get()); } if (participants.empty()) { impl_->undo.push_back(std::move(history)); return failure(eve::DiagnosticCode::Unsupported, "editor history entry has no compensatable participant", "editor.history.undo"); } const std::string compensationId = history.specification.id.value() + ".undo." + std::to_string(++impl_->redoSequence); transaction::TransactionContext context(compensationId); auto compensated = impl_->coordinator.compensate(context, participants); if (!compensated.ok()) { impl_->undo.push_back(std::move(history)); return eve::Result::failure(compensated.status()); } transaction::TransactionReceipt receipt = std::move(compensated).takeValue(); if (authorityParticipant && authorityParticipant->receipt()) history.specification.baseRevision = authorityParticipant->receipt()->afterRevision; impl_->redo.push_back(std::move(history)); const auto& moved = impl_->redo.back(); EditorTransactionRecord record; record.coordinator = std::move(receipt); record.specification = moved.specification; record.authorityReceipt = moved.authorityReceipt; record.commandCount = moved.commands ? moved.commands->commands.size() : 0; record.operationCount = moved.operations.size(); return eve::Result::success(std::move(record)); } eve::Result EditorTransactionConsumer::redo() { if (impl_->pending) return failure(eve::DiagnosticCode::PreconditionViolation, "cannot redo while an editor transaction is active", "editor.transaction.redo"); if (impl_->redo.empty()) return failure(eve::DiagnosticCode::NotFound, "there is no editor transaction to redo", "editor.history.redo"); Impl::HistoryEntry history = std::move(impl_->redo.back()); impl_->redo.pop_back(); if (history.operations.empty() && !history.commands) { impl_->redo.push_back(std::move(history)); return failure(eve::DiagnosticCode::Unsupported, "editor history entry has no replayable participant", "editor.history.redo"); } if (!history.operations.empty() && !impl_->authority) { impl_->redo.push_back(std::move(history)); return failure(eve::DiagnosticCode::Unsupported, "authority redo requires an injected authority", "editor.authority"); } TransactionSpec specification = history.specification; specification.id = TransactionId(specification.id.value() + ".redo." + std::to_string(++impl_->redoSequence)); std::unique_ptr authorityParticipant; std::unique_ptr commandParticipant; std::vector participants; if (!history.operations.empty()) { authorityParticipant = std::make_unique( *impl_->authority, specification, history.operations); participants.push_back(authorityParticipant.get()); } if (history.commands) { commandParticipant = std::make_unique(*history.commands); participants.push_back(commandParticipant.get()); } transaction::TransactionContext context(specification.id.value()); auto replayed = impl_->coordinator.execute(context, participants); if (!replayed.ok()) { impl_->redo.push_back(std::move(history)); return eve::Result::failure(replayed.status()); } transaction::TransactionReceipt receipt = std::move(replayed).takeValue(); history.specification = specification; if (authorityParticipant) history.authorityReceipt = authorityParticipant->receipt(); impl_->undo.push_back(std::move(history)); const auto& moved = impl_->undo.back(); EditorTransactionRecord record; record.coordinator = std::move(receipt); record.specification = moved.specification; record.authorityReceipt = moved.authorityReceipt; record.commandCount = moved.commands ? moved.commands->commands.size() : 0; record.operationCount = moved.operations.size(); return eve::Result::success(std::move(record)); } bool EditorTransactionConsumer::active() const noexcept { return impl_->pending.has_value(); } bool EditorTransactionConsumer::canUndo() const noexcept { return !impl_->undo.empty(); } bool EditorTransactionConsumer::canRedo() const noexcept { return !impl_->redo.empty(); } std::size_t EditorTransactionConsumer::undoCount() const noexcept { return impl_->undo.size(); } std::size_t EditorTransactionConsumer::redoCount() const noexcept { return impl_->redo.size(); } EditorCommitState EditorTransactionConsumer::state() const noexcept { return impl_->state; } const std::vector& EditorTransactionConsumer::diagnostics() const noexcept { return impl_->diagnostics; } void EditorTransactionConsumer::clear() { if (impl_->pending) { auto result = rollback(); result.ignore("EditorTransactionConsumer::clear discards active work"); } impl_->undo.clear(); impl_->redo.clear(); } } // namespace eve::editor