collavre 0.20.3 → 0.22.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 +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -46,6 +46,7 @@ module Collavre
|
|
|
46
46
|
include Broadcastable
|
|
47
47
|
include Notifiable
|
|
48
48
|
include Approvable
|
|
49
|
+
include ClaudeChannelPermission
|
|
49
50
|
|
|
50
51
|
attribute :skip_default_user, :boolean, default: false
|
|
51
52
|
attribute :skip_dispatch, :boolean, default: false
|
|
@@ -110,11 +111,35 @@ module Collavre
|
|
|
110
111
|
def cancel_pending_tasks
|
|
111
112
|
# Cancel tasks triggered by this comment (no creative_id scoping —
|
|
112
113
|
# CommentMoveService can change comment.creative_id without updating
|
|
113
|
-
# existing tasks, so scoping would miss moved-comment tasks)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
# existing tasks, so scoping would miss moved-comment tasks).
|
|
115
|
+
# Include "delegated" so a deleted prompt also cancels Claude Channel
|
|
116
|
+
# work that's still waiting on an external MCP reply — otherwise the
|
|
117
|
+
# delegated task keeps holding the topic/agent slot until stuck recovery.
|
|
118
|
+
Task.where(status: %w[pending running queued delegated]).find_each do |task|
|
|
119
|
+
next unless task.trigger_event_payload&.dig("comment", "id") == id
|
|
120
|
+
|
|
121
|
+
was_delegated = task.status == "delegated"
|
|
122
|
+
task.update!(status: "cancelled")
|
|
123
|
+
|
|
124
|
+
# Delegated tasks live past their job: the AiAgentJob already returned,
|
|
125
|
+
# holding the agent slot under task.id and counting against the per-topic
|
|
126
|
+
# serializer. Mirror the cancel path used elsewhere to free both.
|
|
127
|
+
next unless was_delegated
|
|
128
|
+
if task.agent
|
|
129
|
+
Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
|
|
117
130
|
end
|
|
131
|
+
if task.parent_task_id.present?
|
|
132
|
+
begin
|
|
133
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
|
|
134
|
+
task, error_message: "Triggering comment was deleted"
|
|
135
|
+
)
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
Rails.logger.error(
|
|
138
|
+
"[Comment#cancel_pending_tasks] fail_subtask! failed for task #{task.id}: #{e.message}"
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
118
143
|
end
|
|
119
144
|
|
|
120
145
|
# Cancel queued tasks when their waiting notice (system comment) is deleted
|
|
@@ -139,7 +164,32 @@ module Collavre
|
|
|
139
164
|
return unless user_id # nil user = system message
|
|
140
165
|
return if user&.ai_user? # AI replies use A2aDispatcher, not this callback
|
|
141
166
|
return unless creative
|
|
142
|
-
|
|
167
|
+
# Inbox creatives hold the user's notifications/DMs and normally must not
|
|
168
|
+
# trigger AI orchestration. Exception: a Claude Channel agent session
|
|
169
|
+
# registers its topic *inside* the inbox (Creative.inbox_for) and depends
|
|
170
|
+
# on the orchestration pipeline (Matcher → Arbiter → AiAgentService) to
|
|
171
|
+
# deliver comments to the running session. Scope the exception to actual
|
|
172
|
+
# Claude Channel session topics (primary_agent is a claude_channel_agent?).
|
|
173
|
+
# An inbox topic can be given any ai_user as primary_agent via
|
|
174
|
+
# TopicsController#set_primary_agent; gating on mere primary_agent presence
|
|
175
|
+
# would leak ordinary inbox DMs to the live Claude session, which holds
|
|
176
|
+
# inbox-wide :feedback + routing_expression="true" and would be selected by
|
|
177
|
+
# the Matcher for any dispatched inbox comment.
|
|
178
|
+
return if creative.inbox? && !claude_channel_session_topic?
|
|
179
|
+
|
|
180
|
+
# A Claude Channel session suspended on a native tool-permission prompt
|
|
181
|
+
# parks its in-flight dispatch as a `delegated` task carrying a
|
|
182
|
+
# pending_tool_call (stamped by /agent/notify when the prompt is relayed).
|
|
183
|
+
# An intervening human comment posted while the task is parked is dispatched
|
|
184
|
+
# normally — not suppressed. The delegated task holds the topic's only
|
|
185
|
+
# concurrency slot (running_for_topic counts `delegated`,
|
|
186
|
+
# topic_max_concurrent_jobs=1), so the scheduler defers the comment into a
|
|
187
|
+
# `queued` task rather than a competing turn, and
|
|
188
|
+
# AgentOrchestrator.dequeue_next_for_topic promotes it (refreshed to the
|
|
189
|
+
# latest comment) when the parked task is finalized on /reply. Suppressing
|
|
190
|
+
# it here would silently drop the follow-up instead of deferring it — worst
|
|
191
|
+
# when the local Claude TUI answered the prompt, leaving pending_tool_call
|
|
192
|
+
# set on the server for the rest of a locally-approved tool run.
|
|
143
193
|
|
|
144
194
|
SystemEvents::Dispatcher.dispatch("comment_created", dispatch_payload)
|
|
145
195
|
rescue StandardError => e
|
|
@@ -150,6 +200,21 @@ module Collavre
|
|
|
150
200
|
raise # re-raise so calling jobs (e.g. DropTriggerJob) can retry
|
|
151
201
|
end
|
|
152
202
|
|
|
203
|
+
# True only when this comment's topic is an actual Claude Channel session
|
|
204
|
+
# topic — it carries the registration marker (session_id) AND its
|
|
205
|
+
# primary_agent is a claude_channel_agent? (llm_model "claude-code"). Used to
|
|
206
|
+
# scope the inbox dispatch exception so ordinary inbox threads stay local.
|
|
207
|
+
#
|
|
208
|
+
# session_id is required, not just the Claude primary_agent: a Claude
|
|
209
|
+
# channel ai_user can be assigned as primary_agent on an ordinary inbox
|
|
210
|
+
# topic via TopicsController#set_primary_agent without ever registering a
|
|
211
|
+
# session. Gating on the agent alone would dispatch that ordinary thread and
|
|
212
|
+
# leak it to the live Claude session. session_id is exactly what
|
|
213
|
+
# ClaudeChannelAdapter#session_topic? keys on, so the two stay consistent.
|
|
214
|
+
def claude_channel_session_topic?
|
|
215
|
+
topic&.session_id.present? && topic&.primary_agent&.claude_channel_agent?
|
|
216
|
+
end
|
|
217
|
+
|
|
153
218
|
def assign_default_user
|
|
154
219
|
return if skip_default_user
|
|
155
220
|
self.user ||= Collavre.current_user
|
|
@@ -4,11 +4,20 @@ module Collavre
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
included do
|
|
7
|
+
attr_accessor :content_type_input
|
|
8
|
+
attr_reader :markdown_source
|
|
9
|
+
|
|
10
|
+
def markdown_source=(value)
|
|
11
|
+
@markdown_source = value.is_a?(String) ? value.gsub(/\r\n?/, "\n") : value
|
|
12
|
+
end
|
|
13
|
+
|
|
7
14
|
validates :description, presence: true, unless: -> { origin_id.present? }
|
|
8
15
|
validate :description_cannot_change_if_has_origin, on: :update
|
|
9
16
|
validate :description_cannot_change_if_github_source, on: :update
|
|
10
17
|
|
|
18
|
+
before_validation :convert_markdown_to_html
|
|
11
19
|
before_save :sanitize_description_html
|
|
20
|
+
after_save :reconcile_description_attachments
|
|
12
21
|
after_destroy_commit :purge_description_attachments
|
|
13
22
|
end
|
|
14
23
|
|
|
@@ -30,19 +39,208 @@ module Collavre
|
|
|
30
39
|
CGI.unescapeHTML(ActionController::Base.helpers.strip_tags(effective_origin.description || "")).truncate(24, omission: "...")
|
|
31
40
|
end
|
|
32
41
|
|
|
42
|
+
# GitHub-synced creatives reject any description change
|
|
43
|
+
# (description_cannot_change_if_github_source), so embedding would raise
|
|
44
|
+
# and orphan the blob. Callers MUST check this before creating the blob.
|
|
45
|
+
def attachments_embeddable?
|
|
46
|
+
!effective_origin.github_markdown?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Append an attachment node and save; after_save reconcile attaches the
|
|
50
|
+
# blob to creative.files. Linked creatives can't change their own
|
|
51
|
+
# description (it lives on the origin), so embed on effective_origin —
|
|
52
|
+
# otherwise the save raises and orphans the blob.
|
|
53
|
+
def embed_attachment_blob!(blob)
|
|
54
|
+
target = effective_origin
|
|
55
|
+
return target.embed_attachment_blob!(blob) unless target == self
|
|
56
|
+
|
|
57
|
+
node = attachment_node_html(blob)
|
|
58
|
+
new_html = "#{description}#{node}"
|
|
59
|
+
# Markdown-mode creatives derive description from markdown_source; demote
|
|
60
|
+
# to HTML so the embedded node is the persisted source of truth.
|
|
61
|
+
self.content_type_input = "html" if data&.dig("content_type") == "markdown"
|
|
62
|
+
update!(description: new_html)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# HTML for embedding a blob inline, branching on content type. The proxy
|
|
66
|
+
# path MUST match what extract_signed_ids_from_description scans and what
|
|
67
|
+
# the sanitizer allows.
|
|
68
|
+
def attachment_node_html(blob)
|
|
69
|
+
url = "/public-assets/blobs/#{blob.signed_id}/#{blob.filename.sanitized}"
|
|
70
|
+
name = ERB::Util.html_escape(blob.filename.to_s)
|
|
71
|
+
if blob.content_type.to_s.start_with?("image/")
|
|
72
|
+
%(<img src="#{url}" alt="#{name}">)
|
|
73
|
+
elsif blob.content_type.to_s.start_with?("video/")
|
|
74
|
+
%(<video controls src="#{url}"></video>)
|
|
75
|
+
else
|
|
76
|
+
%(<a href="#{url}" download="#{name}" data-filesize="#{blob.byte_size}">#{name}</a>)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Remove the attachment for `signed_id`. HTML is the source of truth, so
|
|
81
|
+
# strip the node and let after_save reconcile detach + safe-purge. A blob
|
|
82
|
+
# that's attached but not embedded (legacy, not yet backfilled) is
|
|
83
|
+
# detached directly. Returns true if an attachment was present.
|
|
84
|
+
def remove_attachment!(signed_id)
|
|
85
|
+
target = effective_origin
|
|
86
|
+
return target.remove_attachment!(signed_id) unless target == self
|
|
87
|
+
|
|
88
|
+
blob = ActiveStorage::Blob.find_signed(signed_id)
|
|
89
|
+
return false unless blob
|
|
90
|
+
|
|
91
|
+
attachment = files.attachments.find_by(blob_id: blob.id)
|
|
92
|
+
return false unless attachment
|
|
93
|
+
|
|
94
|
+
stripped = description_without_attachment_node(blob.signed_id)
|
|
95
|
+
if stripped
|
|
96
|
+
# Demote markdown -> html so the stripped HTML is the persisted source
|
|
97
|
+
# of truth (mirrors embed_attachment_blob!).
|
|
98
|
+
self.content_type_input = "html" if data&.dig("content_type") == "markdown"
|
|
99
|
+
update!(description: stripped)
|
|
100
|
+
else
|
|
101
|
+
detach_and_maybe_purge(attachment)
|
|
102
|
+
end
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
33
106
|
private
|
|
34
107
|
|
|
108
|
+
# description HTML with every node (<img>/<video>/<source>/<a>) whose
|
|
109
|
+
# src/href references signed_id removed, or nil when none is present.
|
|
110
|
+
def description_without_attachment_node(signed_id)
|
|
111
|
+
return nil if description.blank?
|
|
112
|
+
|
|
113
|
+
doc = Loofah.fragment(description.to_s)
|
|
114
|
+
matches = doc.css("img, video, source, a").select do |node|
|
|
115
|
+
ref = node["src"] || node["href"]
|
|
116
|
+
ref&.include?(signed_id)
|
|
117
|
+
end
|
|
118
|
+
return nil if matches.empty?
|
|
119
|
+
|
|
120
|
+
matches.each(&:remove)
|
|
121
|
+
doc.to_html
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def convert_markdown_to_html
|
|
125
|
+
if content_type_input == "markdown"
|
|
126
|
+
self.data ||= {}
|
|
127
|
+
new_source = markdown_source.to_s
|
|
128
|
+
prev_source = data["markdown_source"].to_s
|
|
129
|
+
prev_type = data["content_type"]
|
|
130
|
+
self.data["content_type"] = "markdown"
|
|
131
|
+
if new_source != prev_source || prev_type != "markdown"
|
|
132
|
+
# Rewrite inline data-URI images to freshly-uploaded blob paths
|
|
133
|
+
# FIRST, then persist the rewritten source. Subsequent edits
|
|
134
|
+
# around the same image carry the blob path instead of the
|
|
135
|
+
# data URI, so re-renders no longer create duplicate blobs.
|
|
136
|
+
rewritten_source = Collavre::MarkdownConverter.rewrite_data_uri_images(new_source)
|
|
137
|
+
self.data["markdown_source"] = rewritten_source
|
|
138
|
+
self.description = Collavre::MarkdownConverter.markdown_to_html(rewritten_source)
|
|
139
|
+
else
|
|
140
|
+
self.data["markdown_source"] = new_source
|
|
141
|
+
# Source unchanged: description must stay derived from markdown_source.
|
|
142
|
+
# Restore the persisted value rather than trusting params[:description],
|
|
143
|
+
# which would let a stale/crafted request diverge from markdown_source.
|
|
144
|
+
# Skipping the re-render also avoids re-importing data-URI images as
|
|
145
|
+
# fresh Active Storage blobs on every autosave/progress/move.
|
|
146
|
+
self.description = description_was if description_changed?
|
|
147
|
+
end
|
|
148
|
+
elsif content_type_input == "html"
|
|
149
|
+
self.data ||= {}
|
|
150
|
+
self.data.delete("content_type")
|
|
151
|
+
self.data.delete("markdown_source")
|
|
152
|
+
elsif !new_record? && description_changed? && data&.dig("content_type") == "markdown"
|
|
153
|
+
# Description was rewritten through a non-markdown path (tool/MCP
|
|
154
|
+
# update, direct base.update(description: ...), etc.) on a creative
|
|
155
|
+
# that was previously in markdown mode. The new HTML no longer
|
|
156
|
+
# matches data["markdown_source"], so the next inline-markdown open
|
|
157
|
+
# would load the stale source and silently overwrite this update.
|
|
158
|
+
# Demote to HTML mode so the persisted source matches description.
|
|
159
|
+
self.data.delete("content_type")
|
|
160
|
+
self.data.delete("markdown_source")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
35
164
|
def sanitize_description_html
|
|
36
165
|
table_tags = %w[table thead tbody tfoot tr th td]
|
|
37
166
|
table_attrs = %w[colspan rowspan]
|
|
38
167
|
attachment_attrs = %w[download data-filesize]
|
|
168
|
+
task_list_attrs = %w[type disabled checked]
|
|
169
|
+
media_tags = %w[video source]
|
|
170
|
+
media_attrs = %w[controls src preload width height poster]
|
|
171
|
+
|
|
172
|
+
# GFM task list checkboxes (`- [ ]` / `- [x]`) render as
|
|
173
|
+
# <input type="checkbox" disabled> via Commonmarker's tasklist
|
|
174
|
+
# extension. Strip any other <input> variant before sanitization
|
|
175
|
+
# so allowing the `input` tag in the safelist can't smuggle in
|
|
176
|
+
# text/image/submit inputs.
|
|
177
|
+
scrubbed = Loofah.fragment(description.to_s)
|
|
178
|
+
scrubbed.css("input").each do |node|
|
|
179
|
+
unless node["type"] == "checkbox" && node.has_attribute?("disabled")
|
|
180
|
+
node.remove
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
39
184
|
self.description = ActionController::Base.helpers.sanitize(
|
|
40
|
-
|
|
41
|
-
tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags,
|
|
42
|
-
attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + %w[data-lexical]
|
|
185
|
+
scrubbed.to_html,
|
|
186
|
+
tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags + media_tags + %w[input],
|
|
187
|
+
attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + task_list_attrs + media_attrs + %w[data-lexical]
|
|
43
188
|
)
|
|
44
189
|
end
|
|
45
190
|
|
|
191
|
+
# Sync creative.files to exactly the blobs referenced in the description
|
|
192
|
+
# HTML (attach new, detach removed). Must never raise during save —
|
|
193
|
+
# malformed HTML yields [] and a no-op.
|
|
194
|
+
#
|
|
195
|
+
# Markdown-mode creatives manage their own blobs via MarkdownConverter;
|
|
196
|
+
# the embed paths demote markdown -> html first, so uploads still reconcile.
|
|
197
|
+
def reconcile_description_attachments
|
|
198
|
+
return if data&.dig("content_type") == "markdown"
|
|
199
|
+
# Linked creatives don't own their description (it lives on the origin)
|
|
200
|
+
# and their own column is blank, so reconcile would treat every legacy
|
|
201
|
+
# attachment as an orphan and purge it on the next save (e.g. a
|
|
202
|
+
# reparent). Skip them to preserve pre-existing linked-row attachments.
|
|
203
|
+
return if origin_id.present?
|
|
204
|
+
|
|
205
|
+
referenced = extract_signed_ids_from_description.filter_map do |sid|
|
|
206
|
+
ActiveStorage::Blob.find_signed(sid)
|
|
207
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
referenced_ids = referenced.map(&:id).to_set
|
|
211
|
+
|
|
212
|
+
current = files.includes(:blob).to_a
|
|
213
|
+
current_blob_ids = current.map(&:blob_id).to_set
|
|
214
|
+
|
|
215
|
+
to_attach = referenced.reject { |b| current_blob_ids.include?(b.id) }
|
|
216
|
+
to_detach = current.reject { |a| referenced_ids.include?(a.blob_id) }
|
|
217
|
+
return if to_attach.empty? && to_detach.empty?
|
|
218
|
+
|
|
219
|
+
to_attach.each { |blob| files.attach(blob) }
|
|
220
|
+
to_detach.each { |attachment| detach_and_maybe_purge(attachment) }
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
Rails.logger.error("Creative##{id}: reconcile_description_attachments failed: #{e.message}")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Detach this creative's join, then purge the blob ONLY if nothing else
|
|
226
|
+
# references it — a shared blob (description copied between creatives)
|
|
227
|
+
# would otherwise be deleted out from under the others, 404-ing their
|
|
228
|
+
# descriptions.
|
|
229
|
+
def detach_and_maybe_purge(attachment)
|
|
230
|
+
blob = attachment.blob
|
|
231
|
+
attachment.delete
|
|
232
|
+
return if blob.nil?
|
|
233
|
+
|
|
234
|
+
signed_id = blob.signed_id
|
|
235
|
+
still_referenced = Creative.where.not(id: id)
|
|
236
|
+
.where("description LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%")
|
|
237
|
+
.exists?
|
|
238
|
+
return if still_referenced
|
|
239
|
+
return if ActiveStorage::Attachment.where(blob_id: blob.id).exists?
|
|
240
|
+
|
|
241
|
+
blob.purge_later
|
|
242
|
+
end
|
|
243
|
+
|
|
46
244
|
def purge_description_attachments
|
|
47
245
|
return if description.blank?
|
|
48
246
|
|
|
@@ -74,6 +272,7 @@ module Collavre
|
|
|
74
272
|
|
|
75
273
|
ids = html.scan(%r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/?#]+)}).flatten
|
|
76
274
|
ids += html.scan(%r{/rails/active_storage/blobs/([^/?#]+)}).flatten
|
|
275
|
+
ids += html.scan(%r{/public-assets/blobs/([^/?#]+)}).flatten
|
|
77
276
|
|
|
78
277
|
ids.uniq
|
|
79
278
|
end
|
|
@@ -24,6 +24,8 @@ module Collavre
|
|
|
24
24
|
has_many :comment_read_pointers, class_name: "Collavre::CommentReadPointer", dependent: :delete_all
|
|
25
25
|
has_many :comment_snapshots, class_name: "Collavre::CommentSnapshot", dependent: :destroy
|
|
26
26
|
|
|
27
|
+
has_many_attached :files, dependent: :purge_later
|
|
28
|
+
|
|
27
29
|
has_closure_tree order: :sequence, name_column: :description, hierarchy_table_name: "creative_hierarchies"
|
|
28
30
|
|
|
29
31
|
# --- Archive scopes ---
|
|
@@ -64,6 +64,7 @@ module Collavre
|
|
|
64
64
|
|
|
65
65
|
def notify_recipient
|
|
66
66
|
return unless Current.user && user
|
|
67
|
+
return if user.ai_user?
|
|
67
68
|
inbox_creative = Creative.inbox_for(user)
|
|
68
69
|
short_title = ActionController::Base.helpers.truncate(
|
|
69
70
|
ActionController::Base.helpers.strip_tags(creative.effective_description),
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Stores externally-configurable integration secrets (Slack, Google OAuth,
|
|
5
|
+
# AWS S3/SES, FCM, etc.) on a per-key basis with `value` encrypted at rest.
|
|
6
|
+
#
|
|
7
|
+
# Distinct from {Collavre::SystemSetting}, which stores unencrypted app-behavior
|
|
8
|
+
# toggles (rate limits, themes, etc.). Pair with
|
|
9
|
+
# {Collavre::IntegrationSettings::Registry} (key definitions) and
|
|
10
|
+
# {Collavre::IntegrationSettings::Resolver} (DB > ENV > default precedence).
|
|
11
|
+
class IntegrationSetting < ApplicationRecord
|
|
12
|
+
self.table_name = "integration_settings"
|
|
13
|
+
|
|
14
|
+
encrypts :value, deterministic: false
|
|
15
|
+
|
|
16
|
+
validates :key, presence: true, uniqueness: true
|
|
17
|
+
validates :category, presence: true
|
|
18
|
+
|
|
19
|
+
after_commit :clear_cache
|
|
20
|
+
|
|
21
|
+
def self.cache_key_for(key)
|
|
22
|
+
"collavre/integration_setting/#{key}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def clear_cache
|
|
28
|
+
Rails.cache.delete(self.class.cache_key_for(key))
|
|
29
|
+
if saved_change_to_key?
|
|
30
|
+
old_key = saved_change_to_key.first
|
|
31
|
+
Rails.cache.delete(self.class.cache_key_for(old_key)) if old_key.present?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
# Topic chip for a running development preview server. Unlike
|
|
3
|
+
# GithubPrChannel there is no external webhook source — the chip is
|
|
4
|
+
# populated by AI Agents (or humans) calling preview_attach/preview_detach
|
|
5
|
+
# MCP tools, one for each ./bin/dev they spin up alongside a worktree.
|
|
6
|
+
class PreviewChannel < Collavre::Channel
|
|
7
|
+
self.table_name = "channels"
|
|
8
|
+
|
|
9
|
+
PREVIEW_STATES = %w[running stopped].freeze
|
|
10
|
+
|
|
11
|
+
def preview_url
|
|
12
|
+
config["preview_url"]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def worktree_id
|
|
16
|
+
config["worktree_id"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def custom_label
|
|
20
|
+
config["label"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Chip fallbacks rendered immediately on attach, before the user has
|
|
24
|
+
# interacted with the chip. The label prefers the caller-supplied
|
|
25
|
+
# "Preview #42" style override and falls back to a localized default;
|
|
26
|
+
# the link is always the preview URL.
|
|
27
|
+
def default_label
|
|
28
|
+
custom_label.presence || I18n.t("collavre.channel.preview.label_default")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def default_link
|
|
32
|
+
preview_url
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def badge_state
|
|
36
|
+
preview_state
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def badge_title
|
|
40
|
+
I18n.t("collavre.channel.preview.badge.#{preview_state}", default: preview_state.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def preview_state
|
|
44
|
+
state = config["preview_state"].to_s
|
|
45
|
+
PREVIEW_STATES.include?(state) ? state : "running"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def preview_state=(value)
|
|
49
|
+
value = value.to_s
|
|
50
|
+
raise ArgumentError, "Invalid preview_state: #{value.inspect}" unless PREVIEW_STATES.include?(value)
|
|
51
|
+
self.config = config.merge("preview_state" => value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def attached_message
|
|
55
|
+
Collavre::Channel::InjectedMessage.new(
|
|
56
|
+
speaker: channel_bot_user,
|
|
57
|
+
message: I18n.t("collavre.channel.preview.attached_message",
|
|
58
|
+
label: default_label, url: preview_url),
|
|
59
|
+
label: default_label,
|
|
60
|
+
link: preview_url
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# PreviewChannel has no external event source — preview_attach/detach
|
|
65
|
+
# mutate the channel directly. The base `handle` would raise
|
|
66
|
+
# NotImplementedError, so override with an explicit no-op.
|
|
67
|
+
def handle(event:, payload:)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Same bot user as GithubPrChannel so all channel-injected comments come
|
|
74
|
+
# from a single "Channel" speaker in the topic timeline.
|
|
75
|
+
def channel_bot_user
|
|
76
|
+
@channel_bot_user ||=
|
|
77
|
+
Collavre::User.find_by(email: Collavre::Channel::BOT_EMAIL) ||
|
|
78
|
+
ensure_channel_bot_user!
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_channel_bot_user!
|
|
82
|
+
email = Collavre::Channel::BOT_EMAIL
|
|
83
|
+
user = Collavre::User.find_or_initialize_by(email: email)
|
|
84
|
+
user.name = Collavre::Channel::BOT_NAME
|
|
85
|
+
user.password = SecureRandom.hex(32) if user.new_record?
|
|
86
|
+
user.email_verified_at ||= Time.current
|
|
87
|
+
user.searchable = false if user.respond_to?(:searchable=)
|
|
88
|
+
user.llm_vendor = nil
|
|
89
|
+
user.save!
|
|
90
|
+
user
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -25,9 +25,15 @@ module Collavre
|
|
|
25
25
|
# By default, public access is allowed (false)
|
|
26
26
|
DEFAULT_CREATIVES_LOGIN_REQUIRED = false
|
|
27
27
|
|
|
28
|
-
# Default home page path (nil means use root_path "/")
|
|
28
|
+
# Default home page path for unauthenticated visitors (nil means use root_path "/")
|
|
29
29
|
DEFAULT_HOME_PAGE_PATH = nil
|
|
30
30
|
|
|
31
|
+
# Default home page path for authenticated users.
|
|
32
|
+
# Signed-in visitors hitting "/" are redirected to this path when the
|
|
33
|
+
# admin has not configured a value. Set to "/" via the admin UI to
|
|
34
|
+
# disable the redirect and fall back to the unauthenticated rewrite.
|
|
35
|
+
DEFAULT_HOME_PAGE_PATH_AUTHENTICATED = "/creatives"
|
|
36
|
+
|
|
31
37
|
# Default theme IDs (nil means use built-in light/dark)
|
|
32
38
|
# These reference UserTheme IDs for admin-configured custom themes
|
|
33
39
|
DEFAULT_LIGHT_THEME_ID = nil
|
|
@@ -59,7 +65,7 @@ module Collavre
|
|
|
59
65
|
lockout_duration_minutes session_timeout_minutes password_min_length
|
|
60
66
|
password_reset_rate_limit password_reset_rate_period_minutes
|
|
61
67
|
api_rate_limit api_rate_period_minutes auth_providers_disabled
|
|
62
|
-
creatives_login_required home_page_path default_light_theme_id default_dark_theme_id
|
|
68
|
+
creatives_login_required home_page_path home_page_path_authenticated default_light_theme_id default_dark_theme_id
|
|
63
69
|
display_level completion_mark llm_request_timeout_seconds
|
|
64
70
|
].each { |k| Rails.cache.delete("system_setting:#{k}") }
|
|
65
71
|
end
|
|
@@ -88,6 +94,11 @@ module Collavre
|
|
|
88
94
|
value.presence
|
|
89
95
|
end
|
|
90
96
|
|
|
97
|
+
def self.home_page_path_authenticated
|
|
98
|
+
value = cached_value("home_page_path_authenticated")
|
|
99
|
+
value.presence || DEFAULT_HOME_PAGE_PATH_AUTHENTICATED
|
|
100
|
+
end
|
|
101
|
+
|
|
91
102
|
def self.mcp_tool_approval_required?
|
|
92
103
|
if Current.mcp_tool_approval_required.nil?
|
|
93
104
|
Current.mcp_tool_approval_required = cached_value("mcp_tool_approval_required") == "true"
|
data/app/models/collavre/task.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Collavre
|
|
|
15
15
|
after_update_commit :broadcast_stop_button_removal, if: :became_terminal?
|
|
16
16
|
|
|
17
17
|
scope :running_for_topic, ->(topic_id, creative_id = nil) {
|
|
18
|
-
rel = where(topic_id: topic_id, status:
|
|
18
|
+
rel = where(topic_id: topic_id, status: %w[running delegated])
|
|
19
19
|
rel = rel.where(creative_id: creative_id) if creative_id
|
|
20
20
|
rel
|
|
21
21
|
}
|
|
@@ -25,19 +25,44 @@ module Collavre
|
|
|
25
25
|
rel.order(:created_at)
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
# Check if agent already has
|
|
28
|
+
# Check if agent already has an in-flight task triggered by the same comment.
|
|
29
|
+
# Treats "delegated" as in-flight: a Claude Channel task that is waiting on
|
|
30
|
+
# an external MCP reply is still active work — re-dispatching the same
|
|
31
|
+
# comment would produce duplicate replies.
|
|
29
32
|
def self.duplicate_running_for_comment?(agent_id, comment_id)
|
|
30
|
-
where(agent_id: agent_id, status:
|
|
33
|
+
where(agent_id: agent_id, status: %w[running delegated], trigger_event_name: "comment_created")
|
|
31
34
|
.find_each do |task|
|
|
32
35
|
return true if task.trigger_event_payload&.dig("comment", "id").to_s == comment_id.to_s
|
|
33
36
|
end
|
|
34
37
|
false
|
|
35
38
|
end
|
|
36
39
|
|
|
40
|
+
# Replay the after_update_commit callbacks when the status transition was
|
|
41
|
+
# made via an UPDATE that bypassed callbacks (e.g. update_all in an atomic
|
|
42
|
+
# claim flow). The private callback predicates rely on
|
|
43
|
+
# saved_change_to_attribute? which is false outside a save lifecycle, so
|
|
44
|
+
# the callbacks themselves would no-op when called directly. This method
|
|
45
|
+
# is the supported escape hatch for AgentsController#finalize_claimed_task
|
|
46
|
+
# to drive the same side effects (trigger-loop continuation + stop-button
|
|
47
|
+
# broadcast) once the related reply_comment has been persisted.
|
|
48
|
+
def fire_completion_callbacks_after_external_claim
|
|
49
|
+
check_trigger_loop_completion if trigger_loop_completion_eligible?
|
|
50
|
+
broadcast_stop_button_removal if terminal_status?
|
|
51
|
+
end
|
|
52
|
+
|
|
37
53
|
private
|
|
38
54
|
|
|
39
55
|
def trigger_loop_candidate?
|
|
40
|
-
return false unless saved_change_to_attribute?("status")
|
|
56
|
+
return false unless saved_change_to_attribute?("status")
|
|
57
|
+
|
|
58
|
+
trigger_loop_completion_eligible?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# State-only eligibility check (no save-lifecycle dependency).
|
|
62
|
+
# Reused by trigger_loop_candidate? for the callback path and by
|
|
63
|
+
# fire_completion_callbacks_after_external_claim for explicit replay.
|
|
64
|
+
def trigger_loop_completion_eligible?
|
|
65
|
+
return false unless status == "done"
|
|
41
66
|
return false unless trigger_event_name == "comment_created"
|
|
42
67
|
return false unless creative&.parent&.drop_trigger_enabled?
|
|
43
68
|
|
|
@@ -51,7 +76,11 @@ module Collavre
|
|
|
51
76
|
end
|
|
52
77
|
|
|
53
78
|
def became_terminal?
|
|
54
|
-
saved_change_to_attribute?("status") &&
|
|
79
|
+
saved_change_to_attribute?("status") && terminal_status?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def terminal_status?
|
|
83
|
+
status.in?(%w[done cancelled failed])
|
|
55
84
|
end
|
|
56
85
|
|
|
57
86
|
def broadcast_stop_button_removal
|
|
@@ -5,8 +5,15 @@ module Collavre
|
|
|
5
5
|
belongs_to :creative, class_name: "Collavre::Creative"
|
|
6
6
|
belongs_to :user, class_name: Collavre.configuration.user_class_name
|
|
7
7
|
belongs_to :source_topic, class_name: "Collavre::Topic", optional: true
|
|
8
|
+
belongs_to :primary_agent, class_name: Collavre.configuration.user_class_name, optional: true
|
|
8
9
|
|
|
9
10
|
has_many :comments, class_name: "Collavre::Comment", dependent: :destroy
|
|
11
|
+
has_many :channels, class_name: "Collavre::Channel", dependent: :destroy
|
|
12
|
+
# Compress/merge snapshots capture a topic's comments as a restore point. The
|
|
13
|
+
# comment_snapshots -> topics FK has no DB-level ON DELETE, so without this the
|
|
14
|
+
# row blocks topic deletion (ActiveRecord::InvalidForeignKey -> 500). Once the
|
|
15
|
+
# topic is gone the snapshot can no longer be restored into it, so :destroy.
|
|
16
|
+
has_many :comment_snapshots, class_name: "Collavre::CommentSnapshot", dependent: :destroy
|
|
10
17
|
has_many :branches, class_name: "Collavre::Topic", foreign_key: :source_topic_id, dependent: :nullify
|
|
11
18
|
has_many :user_creative_preferences_as_last_topic, class_name: "Collavre::UserCreativePreference",
|
|
12
19
|
foreign_key: :last_topic_id, dependent: :nullify, inverse_of: :last_topic
|
|
@@ -21,33 +28,9 @@ module Collavre
|
|
|
21
28
|
|
|
22
29
|
default_scope { order(:position) }
|
|
23
30
|
|
|
24
|
-
# Returns the primary agent User for this topic (from orchestration policy)
|
|
25
|
-
def primary_agent
|
|
26
|
-
policy = OrchestratorPolicy.find_by(
|
|
27
|
-
policy_type: "arbitration",
|
|
28
|
-
scope_type: "Topic",
|
|
29
|
-
scope_id: id
|
|
30
|
-
)
|
|
31
|
-
return nil unless policy&.config&.dig("primary_agent_id")
|
|
32
|
-
|
|
33
|
-
User.find_by(id: policy.config["primary_agent_id"])
|
|
34
|
-
end
|
|
35
|
-
|
|
36
31
|
# Sets or replaces the primary agent for this topic
|
|
37
32
|
def set_primary_agent!(agent)
|
|
38
|
-
|
|
39
|
-
policy_type: "arbitration",
|
|
40
|
-
scope_type: "Topic",
|
|
41
|
-
scope_id: id
|
|
42
|
-
)
|
|
43
|
-
policy.update!(
|
|
44
|
-
config: {
|
|
45
|
-
"strategy" => "primary_first",
|
|
46
|
-
"primary_agent_id" => agent.id
|
|
47
|
-
},
|
|
48
|
-
priority: 10,
|
|
49
|
-
enabled: true
|
|
50
|
-
)
|
|
33
|
+
update!(primary_agent: agent)
|
|
51
34
|
end
|
|
52
35
|
|
|
53
36
|
def archived?
|
data/app/models/collavre/user.rb
CHANGED