Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions engine/app/controllers/coplan/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ module CoPlan
module Api
module V1
class BaseController < ActionController::API
# Disable Rails' wrap_parameters middleware: it auto-wraps the
# JSON body under the controller's resource name, which collides
# with body params that share that name (e.g. PUT /content with
# `{ "content": "..." }` would silently nest the body under
# params[:content]).
wrap_parameters false

before_action :authenticate_api!
after_action :set_agent_instructions_header

Expand Down
75 changes: 75 additions & 0 deletions engine/app/controllers/coplan/api/v1/content_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
module CoPlan
module Api
module V1
# PUT /api/v1/plans/:plan_id/content — replace plan content wholesale.
#
# The agent-friendly edit path: read the plan, edit the markdown
# locally, then PUT the updated content back. The server diffs
# against the current revision, decomposes into operations (so
# comment anchors in unchanged regions survive via OT), and creates
# a new immutable PlanVersion.
#
# Optimistic concurrency: caller MUST supply base_revision matching
# the plan's current_revision, or the request fails with 409.
class ContentController < BaseController
before_action :set_plan
before_action :authorize_plan_access!

def update
if params[:content].nil?
return render json: { error: "content is required" }, status: :unprocessable_content
end

raw_base_revision = params[:base_revision]
if raw_base_revision.blank?
return render json: { error: "base_revision is required" }, status: :unprocessable_content
end

begin
base_revision = Integer(raw_base_revision)
rescue ArgumentError, TypeError
return render json: { error: "base_revision must be a positive integer" }, status: :unprocessable_content
end

if base_revision <= 0
return render json: { error: "base_revision must be a positive integer" }, status: :unprocessable_content
end

result = Plans::ReplaceContent.call(
plan: @plan,
new_content: params[:content].to_s,
base_revision: base_revision,
actor_type: api_author_type,
actor_id: api_actor_id,
change_summary: params[:change_summary],
reason: params[:reason]
)

if result[:no_op]
render json: {
revision: @plan.current_revision,
applied: 0,
no_op: true
}, status: :ok
return
end

version = result[:version]
render json: {
revision: version.revision,
content_sha256: version.content_sha256,
applied: result[:applied],
version_id: version.id
}, status: :created
rescue Plans::ReplaceContent::StaleRevisionError => e
render json: {
error: e.message,
current_revision: e.current_revision
}, status: :conflict
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_content
end
end
end
end
end
25 changes: 22 additions & 3 deletions engine/app/services/coplan/plans/apply_operations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,18 @@ def apply_replace_exact(op, index)
old_text = op["old_text"]
new_text = op["new_text"]

raise OperationError, "Operation #{index}: replace_exact requires 'old_text'" if old_text.blank?
# old_text may be empty ONLY when _pre_resolved_ranges is supplied
# (e.g. pure insertions emitted by Plans::DiffToOperations). Without
# pre-resolved ranges, PositionResolver has nothing to search for.
if old_text.blank? && !op.key?("_pre_resolved_ranges")
raise OperationError, "Operation #{index}: replace_exact requires 'old_text'"
end
raise OperationError, "Operation #{index}: replace_exact requires 'new_text'" if new_text.nil?

# Coerce to string so length/delta arithmetic below is always safe
# — clients supplying _pre_resolved_ranges may omit old_text entirely.
old_text = old_text.to_s

ranges = if op.key?("_pre_resolved_ranges")
op["_pre_resolved_ranges"]
else
Expand All @@ -59,7 +68,14 @@ def apply_replace_exact(op, index)

@content = @content[0...adjusted_start] + new_text + @content[adjusted_end..]

delta = new_text.length - old_text.length
# Delta is computed from the actual range slice, NOT from
# old_text.length. Otherwise a client supplying mismatched
# `_pre_resolved_ranges` and `old_text` (e.g. ranges=[[0,100]],
# old_text="") would corrupt cumulative_delta and persist
# broken positional metadata into the new PlanVersion's
# operations_json — silently breaking all future OT transforms
# through this version.
delta = new_text.length - (range[1] - range[0])
replacements << {
"resolved_range" => range,
"new_range" => [range[0], range[0] + new_text.length],
Expand All @@ -74,7 +90,10 @@ def apply_replace_exact(op, index)
range = ranges[0]
@content = @content[0...range[0]] + new_text + @content[range[1]..]

delta = new_text.length - old_text.length
# See comment above — delta MUST be computed from the actual
# range being replaced, not from the (potentially mismatched)
# `old_text` supplied by the caller.
delta = new_text.length - (range[1] - range[0])
applied_data["resolved_range"] = range
applied_data["new_range"] = [range[0], range[0] + new_text.length]
applied_data["delta"] = delta
Expand Down
147 changes: 147 additions & 0 deletions engine/app/services/coplan/plans/diff_to_operations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
require "diff/lcs"

module CoPlan
module Plans
# Converts (old_content, new_content) into an ordered array of replace_exact
# operations whose application reproduces new_content exactly. Each op is
# emitted with `_pre_resolved_ranges` already set (in the coordinate system
# of the content state immediately BEFORE the op is applied), so it can be
# fed straight into Plans::ApplyOperations without re-resolving positions.
#
# Granularity is line-level: consecutive non-equal lines in the LCS diff
# are grouped into a single hunk, which becomes one replace_exact op. This
# is the right granularity for two reasons:
#
# 1. Anchors in unchanged regions survive intact via the existing OT
# engine (Plans::TransformRange) — only anchors that overlap a
# changed hunk get marked out-of-date.
# 2. The resulting operations_json on the new PlanVersion stays compact
# (one entry per hunk, not one per character), which keeps OT
# transforms cheap when later edits rebase through this version.
#
# Operations are emitted in left-to-right order over the OLD content, and
# each `_pre_resolved_ranges` accounts for cumulative deltas from prior
# ops in the sequence — so applying them via ApplyOperations one after
# another produces correct positional metadata on every op.
class DiffToOperations
def self.call(old_content:, new_content:)
new(old_content: old_content, new_content: new_content).call
end

def initialize(old_content:, new_content:)
@old_content = old_content || ""
@new_content = new_content || ""
end

def call
return [] if @old_content == @new_content

old_lines = @old_content.lines
new_lines = @new_content.lines

# offsets[i] = character offset of the start of line i (offsets[len] = total chars).
# Positions throughout the codebase (anchor_start/anchor_end, resolved_range, etc.)
# are character offsets, NOT byte offsets — so unicode is handled correctly.
old_offsets = build_line_offsets(old_lines)
new_offsets = build_line_offsets(new_lines)

sdiff = Diff::LCS.sdiff(old_lines, new_lines)
hunks = group_hunks(sdiff)

cumulative_delta = 0

hunks.map do |hunk|
old_start, old_end = char_range(hunk[:old_lines], hunk[:old_anchor], old_offsets)
new_start, new_end = char_range(hunk[:new_lines], hunk[:new_anchor], new_offsets)

old_text = @old_content[old_start...old_end] || ""
new_text = @new_content[new_start...new_end] || ""

# Shift positions to account for prior ops' deltas. Because hunks
# are emitted left-to-right and don't overlap, all prior ops are
# strictly before this one — a simple cumulative shift is exact.
adjusted_start = old_start + cumulative_delta
adjusted_end = old_end + cumulative_delta

op = {
"op" => "replace_exact",
"old_text" => old_text,
"new_text" => new_text,
"_pre_resolved_ranges" => [[adjusted_start, adjusted_end]]
}

cumulative_delta += new_text.length - old_text.length
op
end
end

private

def build_line_offsets(lines)
offsets = [0]
running = 0
lines.each do |line|
running += line.length
offsets << running
end
offsets
end

# Groups consecutive non-"=" sdiff entries into hunks. Each hunk
# records the line indexes it touches on each side. Pure insertions
# have an empty :old_lines list; pure deletions have an empty
# :new_lines list — those use the recorded anchor as a zero-width
# insertion point in that side.
def group_hunks(sdiff)
hunks = []
current_old = []
current_new = []
current_old_anchor = nil
current_new_anchor = nil
in_hunk = false

flush = lambda do
if in_hunk
hunks << {
old_lines: current_old.any? ? (current_old.min..current_old.max).to_a : [],
new_lines: current_new.any? ? (current_new.min..current_new.max).to_a : [],
old_anchor: current_old_anchor,
new_anchor: current_new_anchor
}
current_old = []
current_new = []
current_old_anchor = nil
current_new_anchor = nil
in_hunk = false
end
end

sdiff.each do |ctx|
if ctx.action == "="
flush.call
next
end

in_hunk = true
current_old_anchor ||= ctx.old_position
current_new_anchor ||= ctx.new_position
current_old << ctx.old_position if %w[- !].include?(ctx.action)
current_new << ctx.new_position if %w[+ !].include?(ctx.action)
end

flush.call
hunks
end

# Returns [start, end] character offsets for a hunk's line list. For a
# pure insertion/deletion (empty line list), uses the anchor as a
# zero-width range at offsets[anchor].
def char_range(line_indexes, anchor, offsets)
return [offsets[anchor], offsets[anchor]] if line_indexes.empty?
first = line_indexes.first
last = line_indexes.last
[offsets[first], offsets[last + 1]]
end
end
end
end
Loading
Loading