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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. 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
- Task.where(status: %w[pending running queued]).find_each do |task|
115
- if task.trigger_event_payload&.dig("comment", "id") == id
116
- task.update!(status: "cancelled")
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
- return if creative.inbox?
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
- description,
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"
@@ -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: "running")
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 a running task triggered by the same comment
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: "running", trigger_event_name: "comment_created")
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") && status == "done"
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") && status.in?(%w[done cancelled failed])
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
- policy = OrchestratorPolicy.find_or_initialize_by(
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?
@@ -157,6 +157,10 @@ module Collavre
157
157
  llm_vendor.present?
158
158
  end
159
159
 
160
+ def claude_channel_agent?
161
+ llm_model == "claude-code"
162
+ end
163
+
160
164
  scope :ai_agents, -> { where.not(llm_vendor: [ nil, "" ]) }
161
165
 
162
166
  def self.accessible_ai_agents_for(user)