coplan-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/app/assets/stylesheets/coplan/application.css +956 -0
- data/app/controllers/coplan/api/v1/base_controller.rb +75 -0
- data/app/controllers/coplan/api/v1/comments_controller.rb +135 -0
- data/app/controllers/coplan/api/v1/leases_controller.rb +62 -0
- data/app/controllers/coplan/api/v1/operations_controller.rb +298 -0
- data/app/controllers/coplan/api/v1/plans_controller.rb +129 -0
- data/app/controllers/coplan/api/v1/sessions_controller.rb +92 -0
- data/app/controllers/coplan/application_controller.rb +78 -0
- data/app/controllers/coplan/automated_reviews_controller.rb +29 -0
- data/app/controllers/coplan/comment_threads_controller.rb +148 -0
- data/app/controllers/coplan/comments_controller.rb +40 -0
- data/app/controllers/coplan/dashboard_controller.rb +6 -0
- data/app/controllers/coplan/plan_versions_controller.rb +29 -0
- data/app/controllers/coplan/plans_controller.rb +53 -0
- data/app/controllers/coplan/settings/tokens_controller.rb +37 -0
- data/app/helpers/coplan/application_helper.rb +4 -0
- data/app/helpers/coplan/comments_helper.rb +20 -0
- data/app/helpers/coplan/markdown_helper.rb +36 -0
- data/app/javascript/controllers/coplan/dropdown_controller.js +25 -0
- data/app/javascript/controllers/coplan/line_selection_controller.js +112 -0
- data/app/javascript/controllers/coplan/tabs_controller.js +18 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +376 -0
- data/app/jobs/coplan/application_job.rb +4 -0
- data/app/jobs/coplan/automated_review_job.rb +71 -0
- data/app/jobs/coplan/commit_expired_session_job.rb +29 -0
- data/app/jobs/coplan/notification_job.rb +10 -0
- data/app/models/coplan/api_token.rb +50 -0
- data/app/models/coplan/application_record.rb +14 -0
- data/app/models/coplan/automated_plan_reviewer.rb +62 -0
- data/app/models/coplan/comment.rb +24 -0
- data/app/models/coplan/comment_thread.rb +151 -0
- data/app/models/coplan/current.rb +5 -0
- data/app/models/coplan/edit_lease.rb +57 -0
- data/app/models/coplan/edit_session.rb +61 -0
- data/app/models/coplan/plan.rb +28 -0
- data/app/models/coplan/plan_collaborator.rb +12 -0
- data/app/models/coplan/plan_version.rb +23 -0
- data/app/models/coplan/user.rb +20 -0
- data/app/policies/coplan/application_policy.rb +14 -0
- data/app/policies/coplan/comment_thread_policy.rb +23 -0
- data/app/policies/coplan/plan_policy.rb +15 -0
- data/app/services/coplan/ai_providers/anthropic.rb +21 -0
- data/app/services/coplan/ai_providers/open_ai.rb +44 -0
- data/app/services/coplan/broadcaster.rb +32 -0
- data/app/services/coplan/plans/apply_operations.rb +128 -0
- data/app/services/coplan/plans/commit_session.rb +186 -0
- data/app/services/coplan/plans/create.rb +30 -0
- data/app/services/coplan/plans/operation_error.rb +5 -0
- data/app/services/coplan/plans/position_resolver.rb +191 -0
- data/app/services/coplan/plans/review_prompt_formatter.rb +25 -0
- data/app/services/coplan/plans/review_response_parser.rb +65 -0
- data/app/services/coplan/plans/transform_range.rb +99 -0
- data/app/services/coplan/plans/trigger_automated_reviews.rb +33 -0
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +17 -0
- data/app/views/coplan/comment_threads/_reply_form.html.erb +8 -0
- data/app/views/coplan/comment_threads/_thread.html.erb +47 -0
- data/app/views/coplan/comments/_comment.html.erb +9 -0
- data/app/views/coplan/dashboard/show.html.erb +8 -0
- data/app/views/coplan/plan_versions/index.html.erb +21 -0
- data/app/views/coplan/plan_versions/show.html.erb +29 -0
- data/app/views/coplan/plans/_header.html.erb +26 -0
- data/app/views/coplan/plans/edit.html.erb +15 -0
- data/app/views/coplan/plans/index.html.erb +35 -0
- data/app/views/coplan/plans/show.html.erb +56 -0
- data/app/views/coplan/settings/tokens/_form.html.erb +8 -0
- data/app/views/coplan/settings/tokens/_token_reveal.html.erb +7 -0
- data/app/views/coplan/settings/tokens/_token_row.html.erb +24 -0
- data/app/views/coplan/settings/tokens/create.turbo_stream.erb +11 -0
- data/app/views/coplan/settings/tokens/destroy.turbo_stream.erb +5 -0
- data/app/views/coplan/settings/tokens/index.html.erb +32 -0
- data/app/views/layouts/coplan/application.html.erb +42 -0
- data/config/importmap.rb +1 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20260226200000_create_coplan_schema.rb +173 -0
- data/lib/coplan/configuration.rb +17 -0
- data/lib/coplan/engine.rb +34 -0
- data/lib/coplan/version.rb +3 -0
- data/lib/coplan.rb +15 -0
- data/lib/tasks/coplan.rake +4 -0
- data/prompts/reviewers/routing.md +14 -0
- data/prompts/reviewers/scalability.md +14 -0
- data/prompts/reviewers/security.md +14 -0
- metadata +245 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class AutomatedPlanReviewer < ApplicationRecord
|
|
3
|
+
ACTOR_TYPE = "cloud_persona"
|
|
4
|
+
AI_PROVIDERS = %w[openai anthropic].freeze
|
|
5
|
+
|
|
6
|
+
DEFAULT_REVIEWERS = [
|
|
7
|
+
{ key: "security-reviewer", name: "Security Reviewer", prompt_file: "prompts/reviewers/security.md",
|
|
8
|
+
trigger_statuses: [ "considering" ], ai_model: "gpt-4o" },
|
|
9
|
+
{ key: "scalability-reviewer", name: "Scalability Reviewer", prompt_file: "prompts/reviewers/scalability.md",
|
|
10
|
+
trigger_statuses: [ "considering", "developing" ], ai_model: "gpt-4o" },
|
|
11
|
+
{ key: "routing-reviewer", name: "Routing Reviewer", prompt_file: "prompts/reviewers/routing.md",
|
|
12
|
+
trigger_statuses: [], ai_model: "gpt-4o" }
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
after_initialize { self.trigger_statuses ||= [] }
|
|
16
|
+
|
|
17
|
+
validates :key, presence: true,
|
|
18
|
+
format: { with: /\A[a-z0-9-]+\z/, message: "only allows lowercase letters, numbers, and hyphens" }
|
|
19
|
+
validates :key, uniqueness: true
|
|
20
|
+
validates :name, presence: true
|
|
21
|
+
validates :prompt_text, presence: true
|
|
22
|
+
validates :ai_provider, presence: true, inclusion: { in: AI_PROVIDERS }
|
|
23
|
+
validates :ai_model, presence: true
|
|
24
|
+
validate :validate_trigger_statuses
|
|
25
|
+
|
|
26
|
+
scope :enabled, -> { where(enabled: true) }
|
|
27
|
+
|
|
28
|
+
def self.create_defaults
|
|
29
|
+
DEFAULT_REVIEWERS.each do |template|
|
|
30
|
+
find_or_create_by!(key: template[:key]) do |r|
|
|
31
|
+
r.name = template[:name]
|
|
32
|
+
r.prompt_text = File.read(CoPlan::Engine.root.join(template[:prompt_file]))
|
|
33
|
+
r.trigger_statuses = template[:trigger_statuses]
|
|
34
|
+
r.ai_model = template[:ai_model]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
40
|
+
%w[id key name enabled ai_provider ai_model created_at updated_at]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.ransackable_associations(auth_object = nil)
|
|
44
|
+
%w[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def triggers_on_status?(status)
|
|
48
|
+
trigger_statuses.include?(status.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_trigger_statuses
|
|
54
|
+
return if trigger_statuses.blank?
|
|
55
|
+
|
|
56
|
+
invalid = trigger_statuses - Plan::STATUSES
|
|
57
|
+
if invalid.any?
|
|
58
|
+
errors.add(:trigger_statuses, "contains invalid status: #{invalid.join(', ')}. Valid statuses are: #{Plan::STATUSES.join(', ')}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class Comment < ApplicationRecord
|
|
3
|
+
AUTHOR_TYPES = %w[human local_agent cloud_persona system].freeze
|
|
4
|
+
|
|
5
|
+
belongs_to :comment_thread
|
|
6
|
+
|
|
7
|
+
validates :body_markdown, presence: true
|
|
8
|
+
validates :author_type, presence: true, inclusion: { in: AUTHOR_TYPES }
|
|
9
|
+
validates :agent_name, presence: { message: "is required for agent comments" }, if: -> { author_type == "local_agent" }
|
|
10
|
+
validates :agent_name, length: { maximum: 20 }, allow_nil: true
|
|
11
|
+
|
|
12
|
+
after_create_commit :notify_plan_author, if: :first_comment_in_thread?
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def first_comment_in_thread?
|
|
17
|
+
self == comment_thread.comments.order(:created_at).first
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def notify_plan_author
|
|
21
|
+
CoPlan::NotificationJob.perform_later("comment_created", { comment_thread_id: comment_thread_id })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class CommentThread < ApplicationRecord
|
|
3
|
+
STATUSES = %w[open resolved accepted dismissed].freeze
|
|
4
|
+
|
|
5
|
+
attr_accessor :anchor_occurrence
|
|
6
|
+
|
|
7
|
+
belongs_to :plan
|
|
8
|
+
belongs_to :plan_version
|
|
9
|
+
belongs_to :created_by_user, class_name: "CoPlan::User"
|
|
10
|
+
belongs_to :resolved_by_user, class_name: "CoPlan::User", optional: true
|
|
11
|
+
belongs_to :out_of_date_since_version, class_name: "PlanVersion", optional: true
|
|
12
|
+
belongs_to :addressed_in_plan_version, class_name: "PlanVersion", optional: true
|
|
13
|
+
has_many :comments, dependent: :destroy
|
|
14
|
+
|
|
15
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
16
|
+
|
|
17
|
+
before_create :resolve_anchor_position
|
|
18
|
+
|
|
19
|
+
scope :open_threads, -> { where(status: "open") }
|
|
20
|
+
scope :current, -> { where(out_of_date: false) }
|
|
21
|
+
scope :active, -> { where(status: "open", out_of_date: false) }
|
|
22
|
+
scope :archived, -> { where("status != 'open' OR out_of_date = ?", true) }
|
|
23
|
+
|
|
24
|
+
# Transforms anchor positions through intervening version edits using OT.
|
|
25
|
+
# Threads without positional data (anchor_start/anchor_end/anchor_revision)
|
|
26
|
+
# are marked out-of-date unconditionally — all new threads resolve positions
|
|
27
|
+
# on creation via resolve_anchor_position.
|
|
28
|
+
def self.mark_out_of_date_for_new_version!(new_version)
|
|
29
|
+
threads = where(out_of_date: false).where.not(plan_version_id: new_version.id)
|
|
30
|
+
anchored_threads = threads.select(&:anchored?)
|
|
31
|
+
|
|
32
|
+
# Pre-fetch all versions that any thread might need (from the oldest
|
|
33
|
+
# anchor_revision to the new version) in a single query.
|
|
34
|
+
min_anchor_rev = anchored_threads
|
|
35
|
+
.filter_map { |t| t.anchor_revision if t.anchor_start.present? && t.anchor_end.present? && t.anchor_revision.present? }
|
|
36
|
+
.min
|
|
37
|
+
|
|
38
|
+
all_versions = if min_anchor_rev
|
|
39
|
+
new_version.plan.plan_versions
|
|
40
|
+
.where("revision > ? AND revision <= ?", min_anchor_rev, new_version.revision)
|
|
41
|
+
.order(revision: :asc)
|
|
42
|
+
.to_a
|
|
43
|
+
else
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
anchored_threads.each do |thread|
|
|
48
|
+
unless thread.anchor_start.present? && thread.anchor_end.present? && thread.anchor_revision.present?
|
|
49
|
+
thread.update_columns(out_of_date: true, out_of_date_since_version_id: new_version.id)
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
intervening = all_versions.select { |v| v.revision > thread.anchor_revision && v.revision <= new_version.revision }
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
new_range = Plans::TransformRange.transform_through_versions(
|
|
57
|
+
[thread.anchor_start, thread.anchor_end],
|
|
58
|
+
intervening
|
|
59
|
+
)
|
|
60
|
+
thread.update_columns(
|
|
61
|
+
anchor_start: new_range[0],
|
|
62
|
+
anchor_end: new_range[1],
|
|
63
|
+
anchor_revision: new_version.revision
|
|
64
|
+
)
|
|
65
|
+
rescue Plans::TransformRange::Conflict
|
|
66
|
+
thread.update_columns(
|
|
67
|
+
out_of_date: true,
|
|
68
|
+
out_of_date_since_version_id: new_version.id
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def anchored?
|
|
75
|
+
anchor_text.present?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def line_specific?
|
|
79
|
+
start_line.present?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def line_range_text
|
|
83
|
+
return nil unless line_specific?
|
|
84
|
+
start_line == end_line ? "Line #{start_line}" : "Lines #{start_line}–#{end_line}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def anchor_preview(max_length: 80)
|
|
88
|
+
return nil unless anchored?
|
|
89
|
+
anchor_text.length > max_length ? "#{anchor_text[0...max_length]}…" : anchor_text
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resolve!(user)
|
|
93
|
+
update!(status: "resolved", resolved_by_user: user)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def accept!(user)
|
|
97
|
+
update!(status: "accepted", resolved_by_user: user)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def dismiss!(user)
|
|
101
|
+
update!(status: "dismissed", resolved_by_user: user)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def anchor_valid?
|
|
105
|
+
return true unless anchored?
|
|
106
|
+
!out_of_date
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def anchor_context_with_highlight(chars: 100)
|
|
110
|
+
return nil unless anchored? && anchor_start.present?
|
|
111
|
+
|
|
112
|
+
content = plan.current_content
|
|
113
|
+
return nil unless content.present?
|
|
114
|
+
|
|
115
|
+
context_start = [anchor_start - chars, 0].max
|
|
116
|
+
context_end = [anchor_end + chars, content.length].min
|
|
117
|
+
|
|
118
|
+
before = content[context_start...anchor_start]
|
|
119
|
+
anchor = content[anchor_start...anchor_end]
|
|
120
|
+
after = content[anchor_end...context_end]
|
|
121
|
+
|
|
122
|
+
"#{before}**#{anchor}**#{after}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def resolve_anchor_position
|
|
128
|
+
return unless anchor_text.present?
|
|
129
|
+
|
|
130
|
+
content = plan.current_content
|
|
131
|
+
return unless content.present?
|
|
132
|
+
|
|
133
|
+
occurrence = self.anchor_occurrence || 1
|
|
134
|
+
return if occurrence < 1
|
|
135
|
+
|
|
136
|
+
ranges = []
|
|
137
|
+
start_pos = 0
|
|
138
|
+
while (idx = content.index(anchor_text, start_pos))
|
|
139
|
+
ranges << [idx, idx + anchor_text.length]
|
|
140
|
+
start_pos = idx + anchor_text.length
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
if ranges.length >= occurrence
|
|
144
|
+
range = ranges[occurrence - 1]
|
|
145
|
+
self.anchor_start = range[0]
|
|
146
|
+
self.anchor_end = range[1]
|
|
147
|
+
self.anchor_revision = plan.current_revision
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class EditLease < ApplicationRecord
|
|
3
|
+
HOLDER_TYPES = %w[local_agent cloud_persona system].freeze
|
|
4
|
+
LEASE_DURATION = 5.minutes
|
|
5
|
+
|
|
6
|
+
class Conflict < StandardError; end
|
|
7
|
+
|
|
8
|
+
belongs_to :plan
|
|
9
|
+
|
|
10
|
+
validates :holder_type, presence: true, inclusion: { in: HOLDER_TYPES }
|
|
11
|
+
validates :lease_token_digest, presence: true
|
|
12
|
+
validates :expires_at, presence: true
|
|
13
|
+
validates :last_heartbeat_at, presence: true
|
|
14
|
+
|
|
15
|
+
def self.acquire!(plan:, holder_type:, holder_id:, lease_token:)
|
|
16
|
+
digest = Digest::SHA256.hexdigest(lease_token)
|
|
17
|
+
|
|
18
|
+
ActiveRecord::Base.transaction do
|
|
19
|
+
lease = EditLease.lock.find_by(plan_id: plan.id)
|
|
20
|
+
if lease && lease.expires_at > Time.current && lease.lease_token_digest != digest
|
|
21
|
+
raise Conflict, "Plan is currently being edited by another agent"
|
|
22
|
+
end
|
|
23
|
+
lease ||= EditLease.new(plan_id: plan.id)
|
|
24
|
+
lease.update!(
|
|
25
|
+
holder_type: holder_type,
|
|
26
|
+
holder_id: holder_id,
|
|
27
|
+
lease_token_digest: digest,
|
|
28
|
+
expires_at: LEASE_DURATION.from_now,
|
|
29
|
+
last_heartbeat_at: Time.current
|
|
30
|
+
)
|
|
31
|
+
lease
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def renew!(lease_token:)
|
|
36
|
+
digest = Digest::SHA256.hexdigest(lease_token)
|
|
37
|
+
raise Conflict, "Lease token mismatch" unless lease_token_digest == digest
|
|
38
|
+
update!(expires_at: LEASE_DURATION.from_now, last_heartbeat_at: Time.current)
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def release!(lease_token:)
|
|
43
|
+
digest = Digest::SHA256.hexdigest(lease_token)
|
|
44
|
+
raise Conflict, "Lease token mismatch" unless lease_token_digest == digest
|
|
45
|
+
destroy!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def held?
|
|
49
|
+
expires_at > Time.current
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def held_by?(lease_token:)
|
|
53
|
+
digest = Digest::SHA256.hexdigest(lease_token)
|
|
54
|
+
lease_token_digest == digest && held?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class EditSession < ApplicationRecord
|
|
3
|
+
STATUSES = %w[open committed expired cancelled failed].freeze
|
|
4
|
+
ACTOR_TYPES = %w[human local_agent cloud_persona].freeze
|
|
5
|
+
LOCAL_AGENT_TTL = 10.minutes
|
|
6
|
+
CLOUD_PERSONA_TTL = 30.minutes
|
|
7
|
+
|
|
8
|
+
belongs_to :plan
|
|
9
|
+
belongs_to :plan_version, optional: true
|
|
10
|
+
|
|
11
|
+
after_initialize { self.operations_json ||= [] }
|
|
12
|
+
after_create :enqueue_expiry_job
|
|
13
|
+
|
|
14
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
15
|
+
validates :actor_type, presence: true, inclusion: { in: ACTOR_TYPES }
|
|
16
|
+
validates :base_revision, presence: true
|
|
17
|
+
validates :expires_at, presence: true
|
|
18
|
+
|
|
19
|
+
scope :open_sessions, -> { where(status: "open") }
|
|
20
|
+
scope :expired_pending, -> { where(status: "open").where("expires_at < ?", Time.current) }
|
|
21
|
+
|
|
22
|
+
def open?
|
|
23
|
+
status == "open"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def active?
|
|
27
|
+
open? && (expires_at.nil? || expires_at > Time.current)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def committed?
|
|
31
|
+
status == "committed"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def expired?
|
|
35
|
+
open? && expires_at < Time.current
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def has_operations?
|
|
39
|
+
operations_json.present? && operations_json.any?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_operation(op)
|
|
43
|
+
self.operations_json = operations_json + [op]
|
|
44
|
+
save!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
48
|
+
%w[id status actor_type actor_id plan_id base_revision created_at]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.ransackable_associations(auth_object = nil)
|
|
52
|
+
%w[plan plan_version]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def enqueue_expiry_job
|
|
58
|
+
CommitExpiredSessionJob.set(wait_until: expires_at).perform_later(session_id: id)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class Plan < ApplicationRecord
|
|
3
|
+
STATUSES = %w[brainstorm considering developing live abandoned].freeze
|
|
4
|
+
|
|
5
|
+
belongs_to :created_by_user, class_name: "CoPlan::User"
|
|
6
|
+
belongs_to :current_plan_version, class_name: "PlanVersion", optional: true
|
|
7
|
+
has_many :plan_versions, -> { order(revision: :asc) }, dependent: :destroy
|
|
8
|
+
has_many :plan_collaborators, dependent: :destroy
|
|
9
|
+
has_many :collaborators, through: :plan_collaborators, source: :user
|
|
10
|
+
has_many :comment_threads, dependent: :destroy
|
|
11
|
+
has_many :edit_sessions, dependent: :destroy
|
|
12
|
+
has_one :edit_lease, dependent: :destroy
|
|
13
|
+
|
|
14
|
+
after_initialize { self.tags ||= [] }
|
|
15
|
+
after_initialize { self.metadata ||= {} }
|
|
16
|
+
|
|
17
|
+
validates :title, presence: true
|
|
18
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
19
|
+
|
|
20
|
+
def to_param
|
|
21
|
+
id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def current_content
|
|
25
|
+
current_plan_version&.content_markdown
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class PlanCollaborator < ApplicationRecord
|
|
3
|
+
ROLES = %w[author reviewer viewer].freeze
|
|
4
|
+
|
|
5
|
+
belongs_to :plan
|
|
6
|
+
belongs_to :user, class_name: "CoPlan::User"
|
|
7
|
+
belongs_to :added_by_user, class_name: "CoPlan::User", optional: true
|
|
8
|
+
|
|
9
|
+
validates :role, presence: true, inclusion: { in: ROLES }
|
|
10
|
+
validates :user_id, uniqueness: { scope: :plan_id }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class PlanVersion < ApplicationRecord
|
|
3
|
+
ACTOR_TYPES = %w[human local_agent cloud_persona system].freeze
|
|
4
|
+
|
|
5
|
+
belongs_to :plan
|
|
6
|
+
has_many :comment_threads, dependent: :nullify
|
|
7
|
+
|
|
8
|
+
after_initialize { self.operations_json ||= [] }
|
|
9
|
+
|
|
10
|
+
validates :revision, presence: true, uniqueness: { scope: :plan_id }
|
|
11
|
+
validates :content_markdown, presence: true
|
|
12
|
+
validates :content_sha256, presence: true
|
|
13
|
+
validates :actor_type, presence: true, inclusion: { in: ACTOR_TYPES }
|
|
14
|
+
|
|
15
|
+
before_validation :compute_sha256, if: -> { content_markdown.present? && content_sha256.blank? }
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def compute_sha256
|
|
20
|
+
self.content_sha256 = Digest::SHA256.hexdigest(content_markdown)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class User < ApplicationRecord
|
|
3
|
+
has_many :api_tokens, dependent: :destroy
|
|
4
|
+
has_many :plan_collaborators, dependent: :destroy
|
|
5
|
+
|
|
6
|
+
validates :external_id, presence: true, uniqueness: true
|
|
7
|
+
validates :name, presence: true
|
|
8
|
+
validates :email, uniqueness: true, allow_nil: true
|
|
9
|
+
|
|
10
|
+
after_initialize { self.metadata ||= {} }
|
|
11
|
+
|
|
12
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
13
|
+
%w[id external_id name email admin created_at updated_at]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.ransackable_associations(auth_object = nil)
|
|
17
|
+
%w[api_tokens plan_collaborators]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class CommentThreadPolicy < ApplicationPolicy
|
|
3
|
+
def create?
|
|
4
|
+
true
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def resolve?
|
|
8
|
+
record.created_by_user_id == user.id || record.plan.created_by_user_id == user.id
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def accept?
|
|
12
|
+
record.plan.created_by_user_id == user.id
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dismiss?
|
|
16
|
+
record.plan.created_by_user_id == user.id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reopen?
|
|
20
|
+
record.created_by_user_id == user.id || record.plan.created_by_user_id == user.id
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module AiProviders
|
|
3
|
+
class Anthropic
|
|
4
|
+
def self.call(system_prompt:, user_content:, model: "claude-sonnet-4-20250514")
|
|
5
|
+
new(system_prompt:, user_content:, model:).call
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(system_prompt:, user_content:, model:)
|
|
9
|
+
@system_prompt = system_prompt
|
|
10
|
+
@user_content = user_content
|
|
11
|
+
@model = model
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
raise Error, "Anthropic provider not yet implemented. Use OpenAI for now."
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module AiProviders
|
|
3
|
+
class OpenAi
|
|
4
|
+
def self.call(system_prompt:, user_content:, model: "gpt-4o")
|
|
5
|
+
new(system_prompt:, user_content:, model:).call
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(system_prompt:, user_content:, model:)
|
|
9
|
+
@system_prompt = system_prompt
|
|
10
|
+
@user_content = user_content
|
|
11
|
+
@model = model
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
client = OpenAI::Client.new(access_token: api_key)
|
|
16
|
+
|
|
17
|
+
response = client.chat(
|
|
18
|
+
parameters: {
|
|
19
|
+
model: @model,
|
|
20
|
+
messages: [
|
|
21
|
+
{ role: "system", content: @system_prompt },
|
|
22
|
+
{ role: "user", content: @user_content }
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
content = response.dig("choices", 0, "message", "content")
|
|
28
|
+
raise Error, "No response content from OpenAI" if content.blank?
|
|
29
|
+
|
|
30
|
+
content
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def api_key
|
|
36
|
+
key = CoPlan.configuration.ai_api_key || Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"]
|
|
37
|
+
raise Error, "OpenAI API key not configured" if key.blank?
|
|
38
|
+
key
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class Error < StandardError; end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Broadcaster
|
|
3
|
+
class << self
|
|
4
|
+
def prepend_to(streamable, target:, partial:, locals: {})
|
|
5
|
+
Turbo::StreamsChannel.broadcast_prepend_to(streamable, target: target, html: render(partial:, locals:))
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def append_to(streamable, target:, partial:, locals: {})
|
|
9
|
+
Turbo::StreamsChannel.broadcast_append_to(streamable, target: target, html: render(partial:, locals:))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def replace_to(streamable, target:, html: nil, partial: nil, locals: {})
|
|
13
|
+
html ||= render(partial:, locals:)
|
|
14
|
+
Turbo::StreamsChannel.broadcast_replace_to(streamable, target: target, html: html)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update_to(streamable, target:, html:)
|
|
18
|
+
Turbo::StreamsChannel.broadcast_update_to(streamable, target: target, html: html)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def remove_to(streamable, target:)
|
|
22
|
+
Turbo::StreamsChannel.broadcast_remove_to(streamable, target: target)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def render(partial:, locals:)
|
|
28
|
+
CoPlan::ApplicationController.render(partial: partial, locals: locals)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|