Class: CoPlan::Plans::CommitSession

Inherits:
Object
  • Object
show all
Defined in:
app/services/coplan/plans/commit_session.rb

Overview

Commits an EditSession’s accumulated operations as a single PlanVersion.

If the session’s base_revision is behind current_revision, each operation’s resolved ranges are transformed through intervening versions via TransformRange (OT). All versions must have positional metadata —TransformRange raises Conflict if any operation lacks it.

Defined Under Namespace

Classes: SessionConflictError, SessionNotOpenError, StaleSessionError

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(session:, change_summary: nil) ⇒ CommitSession

Returns a new instance of CommitSession.



18
19
20
21
# File 'app/services/coplan/plans/commit_session.rb', line 18

def initialize(session:, change_summary: nil)
  @session = session
  @change_summary = change_summary || session.change_summary
end

Class Method Details

.call(session:, change_summary: nil) ⇒ Object



14
15
16
# File 'app/services/coplan/plans/commit_session.rb', line 14

def self.call(session:, change_summary: nil)
  new(session:, change_summary:).call
end

Instance Method Details

#callObject

Raises:



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'app/services/coplan/plans/commit_session.rb', line 23

def call
  raise SessionNotOpenError, "Session is not open" unless @session.open?

  plan = @session.plan

  ActiveRecord::Base.transaction do
    @session.lock!
    raise SessionNotOpenError, "Session is not open" unless @session.open?

    # No operations: just mark committed, no version created
    unless @session.has_operations?
      @session.update!(status: "committed", committed_at: Time.current)
      return { session: @session, version: nil }
    end

    plan.lock!

    base_revision = @session.base_revision
    current_revision = plan.current_revision
    current_content = plan.current_content || ""

    # Determine the content to use as the result
    if base_revision == current_revision
      # No intervening edits — use draft_content directly
      new_content = @session.draft_content || current_content
      final_ops = @session.operations_json
    else
      # Stale! Need to rebase through intervening versions
      stale_gap = current_revision - base_revision
      if stale_gap > 20
        raise StaleSessionError, "Session is too stale (#{stale_gap} revisions behind, max 20)"
      end

      # Get intervening versions
      intervening_versions = plan.plan_versions
        .where("revision > ? AND revision <= ?", base_revision, current_revision)
        .order(revision: :asc)
        .to_a

      # Transform each operation's resolved positions through intervening edits,
      # then re-apply using the transformed positions (not re-resolving from scratch)
      verification_content = current_content.dup
      rebased_ops = []
      @session.operations_json.each do |op_data|
        op_data = op_data.transform_keys(&:to_s)
        semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count]
        semantic_op = op_data.slice(*semantic_keys)

        if op_data["resolved_range"]
          begin
            transformed_range = Plans::TransformRange.transform_through_versions(
              op_data["resolved_range"], intervening_versions
            )
          rescue Plans::TransformRange::Conflict => e
            raise SessionConflictError, "Conflict during rebase: #{e.message}"
          end

          verify_text_at_range!(verification_content, transformed_range, op_data)
          semantic_op["_pre_resolved_ranges"] = [transformed_range]
        elsif op_data.key?("replacements")
          transformed_ranges = op_data["replacements"].map do |rep|
            begin
              Plans::TransformRange.transform_through_versions(
                rep["resolved_range"], intervening_versions
              )
            rescue Plans::TransformRange::Conflict => e
              raise SessionConflictError, "Conflict during rebase: #{e.message}"
            end
          end

          transformed_ranges.each { |tr| verify_text_at_range!(verification_content, tr, op_data) }
          semantic_op["_pre_resolved_ranges"] = transformed_ranges
        end

        rebased_ops << semantic_op

        # Advance verification content so subsequent ops verify against
        # the incrementally updated snapshot (not the original current_content).
        step = Plans::ApplyOperations.call(content: verification_content, operations: [semantic_op])
        verification_content = step[:content]
      end

      result = Plans::ApplyOperations.call(content: current_content, operations: rebased_ops)
      new_content = result[:content]
      final_ops = result[:applied]
    end

    # Create the version
    new_revision = plan.current_revision + 1
    diff = Diffy::Diff.new(current_content, new_content).to_s

    version = PlanVersion.create!(
      plan: plan,
      revision: new_revision,
      content_markdown: new_content,
      actor_type: @session.actor_type,
      actor_id: @session.actor_id,
      change_summary: @change_summary,
      diff_unified: diff.presence,
      operations_json: final_ops,
      base_revision: @session.base_revision
    )

    plan.update!(
      current_plan_version: version,
      current_revision: new_revision
    )

    plan.comment_threads.mark_out_of_date_for_new_version!(version)

    @session.update!(
      status: "committed",
      committed_at: Time.current,
      plan_version_id: version.id,
      change_summary: @change_summary
    )

    # Broadcast update
    Broadcaster.replace_to(
      plan,
      target: "plan-header",
      partial: "coplan/plans/header",
      locals: { plan: plan }
    )

    { session: @session, version: version }
  end
end