collavre 0.20.2 → 0.21.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 (109) 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 +83 -0
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  7. data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
  8. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  9. data/app/controllers/collavre/application_controller.rb +27 -0
  10. data/app/controllers/collavre/channels_controller.rb +23 -0
  11. data/app/controllers/collavre/creatives_controller.rb +50 -6
  12. data/app/controllers/collavre/landing_controller.rb +8 -0
  13. data/app/controllers/collavre/passwords_controller.rb +1 -0
  14. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  15. data/app/controllers/collavre/topics_controller.rb +21 -30
  16. data/app/helpers/collavre/comments_helper.rb +7 -0
  17. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  18. data/app/javascript/controllers/comment_controller.js +9 -0
  19. data/app/javascript/controllers/comments/form_controller.js +4 -0
  20. data/app/javascript/controllers/comments/list_controller.js +10 -7
  21. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  22. data/app/javascript/controllers/comments/presence_controller.js +83 -1
  23. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  24. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  25. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  26. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  27. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  28. data/app/javascript/controllers/index.js +4 -1
  29. data/app/javascript/controllers/landing_video_controller.js +53 -0
  30. data/app/javascript/creatives/tree_renderer.js +6 -0
  31. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  32. data/app/javascript/lib/api/queue_manager.js +17 -5
  33. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  34. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  35. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  36. data/app/javascript/modules/command_args_form.js +22 -4
  37. data/app/javascript/modules/command_menu.js +27 -0
  38. data/app/javascript/modules/creative_row_editor.js +227 -17
  39. data/app/javascript/modules/html_content_empty.js +12 -0
  40. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  41. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  42. data/app/mailers/collavre/application_mailer.rb +1 -1
  43. data/app/models/collavre/channel/injected_message.rb +5 -0
  44. data/app/models/collavre/channel.rb +87 -0
  45. data/app/models/collavre/creative/describable.rb +65 -3
  46. data/app/models/collavre/creative.rb +2 -0
  47. data/app/models/collavre/integration_setting.rb +35 -0
  48. data/app/models/collavre/preview_channel.rb +93 -0
  49. data/app/models/collavre/system_setting.rb +13 -2
  50. data/app/models/collavre/topic.rb +3 -25
  51. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  52. data/app/services/collavre/ai_client.rb +3 -3
  53. data/app/services/collavre/channel_attacher.rb +58 -0
  54. data/app/services/collavre/comments/mcp_command.rb +31 -1
  55. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  56. data/app/services/collavre/google_calendar_service.rb +4 -2
  57. data/app/services/collavre/markdown_converter.rb +130 -15
  58. data/app/services/collavre/markdown_importer.rb +7 -2
  59. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  60. data/app/services/collavre/tools/creative_attach_files_service.rb +96 -0
  61. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  62. data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
  63. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  64. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  65. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  66. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  67. data/app/services/collavre/topic_branch_service.rb +34 -26
  68. data/app/views/admin/shared/_tabs.html.erb +1 -0
  69. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  70. data/app/views/collavre/admin/integrations/_setting_row.html.erb +54 -0
  71. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  72. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  73. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  74. data/app/views/collavre/comments/_comment.html.erb +6 -1
  75. data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
  76. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  77. data/app/views/collavre/creatives/index.html.erb +10 -2
  78. data/app/views/collavre/landing/show.html.erb +130 -0
  79. data/app/views/layouts/collavre/landing.html.erb +33 -0
  80. data/config/locales/admin.en.yml +4 -2
  81. data/config/locales/admin.ko.yml +4 -2
  82. data/config/locales/channels.en.yml +11 -0
  83. data/config/locales/channels.ko.yml +11 -0
  84. data/config/locales/comments.en.yml +2 -0
  85. data/config/locales/comments.ko.yml +2 -0
  86. data/config/locales/creatives.en.yml +9 -0
  87. data/config/locales/creatives.ko.yml +8 -0
  88. data/config/locales/integrations.en.yml +44 -0
  89. data/config/locales/integrations.ko.yml +44 -0
  90. data/config/locales/landing.en.yml +51 -0
  91. data/config/locales/landing.ko.yml +51 -0
  92. data/config/routes.rb +18 -0
  93. data/db/migrate/20260526000000_create_channels.rb +42 -0
  94. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  95. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  96. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  97. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  98. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  99. data/db/seeds.rb +19 -0
  100. data/lib/collavre/aws_credentials.rb +75 -0
  101. data/lib/collavre/engine.rb +51 -0
  102. data/lib/collavre/integration_settings/key_definition.rb +29 -0
  103. data/lib/collavre/integration_settings/registry.rb +55 -0
  104. data/lib/collavre/integration_settings/resolver.rb +71 -0
  105. data/lib/collavre/integration_settings.rb +46 -0
  106. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  107. data/lib/collavre/version.rb +1 -1
  108. data/lib/collavre.rb +3 -0
  109. metadata +52 -1
@@ -14,25 +14,38 @@ module Collavre
14
14
  # Falls back to lightweight regex conversion for short inline fragments.
15
15
  def markdown_to_html(text, image_refs = {})
16
16
  return "" if text.nil?
17
- input = text.dup
18
17
 
19
- # Collect reference-style data-URI images: [alt]: <data:...>
20
- input.gsub!(/^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/) do
21
- image_refs[$1] = $2.strip
22
- ""
23
- end
18
+ # Protect fenced code blocks and inline code spans from the regex
19
+ # rewrites below — a Markdown sample like ```` ```md\n![x](data:...)\n``` ````
20
+ # must not silently upload its data URI as an Active Storage blob.
21
+ input = with_code_protected(text.dup) do |source|
22
+ # Collect reference-style data-URI images. CommonMark allows both the
23
+ # angle-bracket form `[alt]: <data:...>` and the bare form
24
+ # `[alt]: data:image/...`, optionally followed by a title.
25
+ source.gsub!(/^\s*\[([^\]]+)\]:\s*(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*$/) do
26
+ image_refs[$1] = ($2 || $3).strip
27
+ ""
28
+ end
24
29
 
25
- # Convert data-URI images to Active Storage before rendering
26
- input.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
27
- if (data_url = image_refs[$2])
28
- data_image_to_attachment(data_url, $1)
29
- else
30
- "![#{$1}][#{$2}]"
30
+ # Convert data-URI images to Active Storage before rendering
31
+ source.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
32
+ if (data_url = image_refs[$2])
33
+ data_image_to_attachment(data_url, $1)
34
+ else
35
+ "![#{$1}][#{$2}]"
36
+ end
37
+ end
38
+
39
+ # Inline data-URI images. base64 contains no whitespace or `)`, so bound
40
+ # the URL on those and capture an optional CommonMark title separately;
41
+ # otherwise `data:...;base64,XYZ "caption"` would be slurped as one URL
42
+ # and fail the strict parser in `data_image_to_attachment`. Titles may be
43
+ # double-quoted, single-quoted, or parenthesized per CommonMark.
44
+ source.gsub!(/(?<!\\)!\[([^\]]*)\]\(\s*(data:image\/[^\s)]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/) do
45
+ data_image_to_attachment($2, $1)
31
46
  end
32
- end
33
47
 
34
- input.gsub!(/(?<!\\)!\[([^\]]*)\]\((data:image\/[^)]+)\)/) do
35
- data_image_to_attachment($2, $1)
48
+ source
36
49
  end
37
50
 
38
51
  # Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter)
@@ -194,8 +207,110 @@ module Collavre
194
207
  end
195
208
  end
196
209
 
210
+ # Rewrite data-URI image references in Markdown source to point at
211
+ # newly-created Active Storage blobs. Returns rewritten Markdown.
212
+ #
213
+ # Used by the inline Markdown editor to persist blob URLs in the stored
214
+ # `markdown_source`, so subsequent text edits around the image do not
215
+ # re-import the same data URI into a fresh blob on every autosave.
216
+ def rewrite_data_uri_images(text)
217
+ return text if text.nil?
218
+ image_refs = {}
219
+
220
+ # Protect fenced code blocks and inline code spans before rewriting —
221
+ # a Markdown code sample like ```` ```md\n![x](data:...)\n``` ```` must
222
+ # not be silently uploaded as a blob and have its source rewritten,
223
+ # which would corrupt the user's code snippet on every autosave.
224
+ with_code_protected(text.dup) do |result|
225
+ # Collect reference-style data-URI image definitions. CommonMark accepts
226
+ # both the angle-bracket form `[alt]: <data:...>` and the bare form
227
+ # `[alt]: data:image/...`, optionally followed by a title. Rewrite each
228
+ # definition to point at a freshly-uploaded blob, normalizing to the
229
+ # bare form (titles are dropped since blob paths cannot collide with
230
+ # surrounding text).
231
+ result.gsub!(/^(\s*\[)([^\]]+)(\]:\s*)(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))(\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*)$/) do
232
+ lead, label, mid, angle_url, bare_url, tail = $1, $2, $3, $4, $5, $6
233
+ data_url = (angle_url || bare_url).strip
234
+ blob_path = data_uri_to_blob_path(data_url)
235
+ image_refs[label] = blob_path
236
+ "#{lead}#{label}#{mid}#{blob_path}#{tail}"
237
+ end
238
+
239
+ # Inline data-URI images: ![alt](data:...) or ![alt](data:... "title")
240
+ # → ![alt](blob_path) / ![alt](blob_path "title"). Parse the title
241
+ # separately so it doesn't get captured into the data URL and break
242
+ # strict matching in `data_uri_to_blob_path`. Titles may be
243
+ # double-quoted, single-quoted, or parenthesized per CommonMark.
244
+ result.gsub!(/(?<!\\)!\[([^\]]*)\]\(\s*(data:image\/[^\s)]+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/) do
245
+ alt, data_url, title = $1, $2, $3
246
+ "![#{alt}](#{data_uri_to_blob_path(data_url)}#{title})"
247
+ end
248
+
249
+ result
250
+ end
251
+ end
252
+
253
+ # Convert a data-URI to a freshly-uploaded Active Storage blob path.
254
+ # Returns the original URL unchanged on parse failure.
255
+ def data_uri_to_blob_path(data_url)
256
+ return data_url unless data_url =~ %r{\Adata:(image/[\w.+-]+);base64,(.+)\z}
257
+
258
+ content_type = Regexp.last_match(1)
259
+ data = Base64.decode64(Regexp.last_match(2))
260
+ ext = Mime::Type.lookup(content_type).symbol.to_s
261
+ filename = "import-#{SecureRandom.hex}.#{ext}"
262
+ blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename: filename, content_type: content_type)
263
+ Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)
264
+ end
265
+
197
266
  private
198
267
 
268
+ # Run a regex-rewriting block on `text` with fenced code blocks,
269
+ # indented code blocks, and inline code spans swapped for unique tokens,
270
+ # then restore the original segments in the block's return value.
271
+ # Prevents data-URI rewrites from touching code samples that happen to
272
+ # contain image syntax.
273
+ def with_code_protected(text)
274
+ segments = {}
275
+ index = 0
276
+ protected_text = text
277
+
278
+ # Fenced code blocks (``` or ~~~, indented up to 3 spaces per CommonMark).
279
+ # The closing fence is optional: per CommonMark, an unclosed fence runs to
280
+ # end-of-document, so we still need to protect its contents from rewrites.
281
+ protected_text.gsub!(/^([ \t]{0,3})(`{3,}|~{3,})[^\n]*(?:\n(?:[\s\S]*?\n\1\2[ \t]*(?=\n|\z)|[\s\S]*\z)|\z)/) do |match|
282
+ token = "\x00MDPROTECT#{index}\x00"
283
+ segments[token] = match
284
+ index += 1
285
+ token
286
+ end
287
+
288
+ # Indented code blocks: contiguous lines each starting with 4+ spaces
289
+ # or a tab, preceded by start-of-document or a blank line (CommonRule
290
+ # requirement so we don't mistake list-item continuations for code).
291
+ protected_text.gsub!(/(\A|\n\n+)((?:(?:[ ]{4,}|\t)[^\n]*(?:\n|\z))+)/) do
292
+ prefix = Regexp.last_match(1)
293
+ block = Regexp.last_match(2)
294
+ token = "\x00MDPROTECT#{index}\x00"
295
+ segments[token] = block
296
+ index += 1
297
+ "#{prefix}#{token}"
298
+ end
299
+
300
+ # Inline single-backtick code spans. Multi-backtick spans are rare in
301
+ # practice; the protection above already covers fenced samples.
302
+ protected_text.gsub!(/`[^`\n]+?`/) do |match|
303
+ token = "\x00MDPROTECT#{index}\x00"
304
+ segments[token] = match
305
+ index += 1
306
+ token
307
+ end
308
+
309
+ rewritten = yield(protected_text)
310
+ segments.each { |token, original| rewritten.sub!(token, original) }
311
+ rewritten
312
+ end
313
+
199
314
  def escape_table_cell(text)
200
315
  text.to_s.gsub(/(?<!\\)\|/, '\\|')
201
316
  end
@@ -6,9 +6,14 @@ module Collavre
6
6
  def self.import(content, parent:, user:, create_root: false)
7
7
  lines = content.to_s.lines
8
8
  image_refs = {}
9
+ # CommonMark allows both the angle-bracket form `[alt]: <data:...>` and
10
+ # the bare form `[alt]: data:image/...`, optionally followed by a title
11
+ # (`"..."`, `'...'`, or `(...)`). Match both so reference-style data-URI
12
+ # images survive the import path and resolve to attachments downstream
13
+ # (mirrors MarkdownConverter#markdown_to_html).
9
14
  lines.reject! do |ln|
10
- if ln =~ /^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/
11
- image_refs[$1] = $2.strip
15
+ if ln =~ /^\s*\[([^\]]+)\]:\s*(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*$/
16
+ image_refs[$1] = ($2 || $3).strip
12
17
  true
13
18
  else
14
19
  false
@@ -88,6 +88,8 @@ module Collavre
88
88
 
89
89
  # Convenience methods
90
90
  def arbitration_strategy
91
+ return "primary_first" if topic_primary_agent_id.present?
92
+
91
93
  arbitration_config["strategy"]
92
94
  end
93
95
 
@@ -96,7 +98,7 @@ module Collavre
96
98
  end
97
99
 
98
100
  def primary_agent_id
99
- arbitration_config["primary_agent_id"]
101
+ topic_primary_agent_id || arbitration_config["primary_agent_id"]
100
102
  end
101
103
 
102
104
  # Bid strategy specific
@@ -160,6 +162,14 @@ module Collavre
160
162
 
161
163
  config
162
164
  end
165
+
166
+ # Read primary_agent_id directly from the topics table
167
+ def topic_primary_agent_id
168
+ topic_id = @context.dig("topic", "id")
169
+ return nil unless topic_id
170
+
171
+ Topic.where(id: topic_id).pick(:primary_agent_id)
172
+ end
163
173
  end
164
174
  end
165
175
  end
@@ -0,0 +1,96 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ class CreativeAttachFilesService
6
+ extend T::Sig
7
+ extend ToolMeta
8
+ include Collavre::PublicAssetsHelper
9
+
10
+ tool_name "creative_attach_files_service"
11
+ tool_description "Attach one or more local files (images, video, documents) to a Creative. Files are uploaded to ActiveStorage (S3) and served publicly via CloudFront-cached /public-assets URLs.\n\nUse this to:\n- Upload landing page assets (hero images, demo videos)\n- Attach reference documents to a task\n\nFile paths must live under the configured upload root (MCP_UPLOAD_ROOT, default tmp/mcp_uploads under Rails.root). Paths outside that root are rejected.\n\nRequires :write permission on the target Creative."
12
+
13
+ tool_param :creative_id, description: "ID of the Creative to attach files to.", required: true
14
+ tool_param :file_paths, description: "Absolute local file paths to upload. Each path must resolve to a regular file under the upload root (MCP_UPLOAD_ROOT). If any path is missing or escapes the upload root, the entire call fails atomically.", required: true
15
+
16
+ sig { params(creative_id: Integer, file_paths: T::Array[String]).returns(T::Hash[Symbol, T.untyped]) }
17
+ def call(creative_id:, file_paths:)
18
+ raise "Current.user is required" unless Current.user
19
+
20
+ creative = Creative.find_by(id: creative_id)
21
+ return { error: "Creative not found", id: creative_id } unless creative
22
+
23
+ unless creative.has_permission?(Current.user, :write)
24
+ return { error: "No write permission on Creative", id: creative_id }
25
+ end
26
+
27
+ root = self.class.upload_root
28
+ resolved = []
29
+ missing = []
30
+ outside = []
31
+ file_paths.each do |raw|
32
+ begin
33
+ real = File.realpath(raw)
34
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
35
+ missing << raw
36
+ next
37
+ end
38
+ unless File.file?(real)
39
+ missing << raw
40
+ next
41
+ end
42
+ if path_under?(real, root)
43
+ resolved << real
44
+ else
45
+ outside << raw
46
+ end
47
+ end
48
+
49
+ return { error: "Missing files", missing: missing } if missing.any?
50
+ return { error: "Files outside upload root", upload_root: root.to_s, outside: outside } if outside.any?
51
+
52
+ attached = []
53
+ ActiveRecord::Base.transaction do
54
+ resolved.each do |path|
55
+ File.open(path, "rb") do |io|
56
+ creative.files.attach(
57
+ io: io,
58
+ filename: File.basename(path),
59
+ content_type: Marcel::MimeType.for(Pathname.new(path)) || "application/octet-stream"
60
+ )
61
+ end
62
+ end
63
+ creative.save!
64
+ attached = creative.files.last(resolved.length)
65
+ end
66
+
67
+ {
68
+ success: true,
69
+ creative_id: creative.id,
70
+ attachments: attached.map { |a|
71
+ {
72
+ signed_id: a.blob.signed_id,
73
+ filename: a.filename.to_s,
74
+ content_type: a.content_type,
75
+ byte_size: a.byte_size,
76
+ url: public_asset_url(a.blob)
77
+ }
78
+ }
79
+ }
80
+ end
81
+
82
+ def self.upload_root
83
+ raw = Collavre::IntegrationSettings.fetch(:mcp_upload_root, default: Rails.root.join("tmp/mcp_uploads").to_s)
84
+ Pathname.new(File.expand_path(raw)).tap do |p|
85
+ FileUtils.mkdir_p(p) unless p.exist?
86
+ end.realpath
87
+ end
88
+
89
+ private
90
+
91
+ def path_under?(path, root)
92
+ Pathname.new(path).ascend.any? { |p| p == root }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,42 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ class CreativeListAttachmentsService
6
+ extend T::Sig
7
+ extend ToolMeta
8
+ include Collavre::PublicAssetsHelper
9
+
10
+ tool_name "creative_list_attachments_service"
11
+ tool_description "List all attachments on a Creative with public URLs, filenames, content types, and sizes. Requires :read permission."
12
+
13
+ tool_param :creative_id, description: "ID of the Creative.", required: true
14
+
15
+ sig { params(creative_id: Integer).returns(T::Hash[Symbol, T.untyped]) }
16
+ def call(creative_id:)
17
+ raise "Current.user is required" unless Current.user
18
+
19
+ creative = Creative.find_by(id: creative_id)
20
+ return { error: "Creative not found", id: creative_id } unless creative
21
+
22
+ unless creative.has_permission?(Current.user, :read)
23
+ return { error: "No read permission on Creative", id: creative_id }
24
+ end
25
+
26
+ {
27
+ success: true,
28
+ creative_id: creative.id,
29
+ attachments: creative.files.with_all_variant_records.map { |a|
30
+ {
31
+ signed_id: a.blob.signed_id,
32
+ filename: a.filename.to_s,
33
+ content_type: a.content_type,
34
+ byte_size: a.byte_size,
35
+ url: public_asset_url(a.blob)
36
+ }
37
+ }
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ class CreativeRemoveAttachmentService
6
+ extend T::Sig
7
+ extend ToolMeta
8
+
9
+ tool_name "creative_remove_attachment_service"
10
+ tool_description "Remove a single attachment from a Creative by its signed_id. Requires :write permission. The underlying blob is purged asynchronously."
11
+
12
+ tool_param :creative_id, description: "ID of the Creative.", required: true
13
+ tool_param :signed_id, description: "signed_id of the blob (from creative_list_attachments_service or creative_attach_files_service response).", required: true
14
+
15
+ sig { params(creative_id: Integer, signed_id: String).returns(T::Hash[Symbol, T.untyped]) }
16
+ def call(creative_id:, signed_id:)
17
+ raise "Current.user is required" unless Current.user
18
+
19
+ creative = Creative.find_by(id: creative_id)
20
+ return { error: "Creative not found", id: creative_id } unless creative
21
+
22
+ unless creative.has_permission?(Current.user, :write)
23
+ return { error: "No write permission on Creative", id: creative_id }
24
+ end
25
+
26
+ blob = ActiveStorage::Blob.find_signed(signed_id)
27
+ attachment = blob && creative.files.attachments.find_by(blob_id: blob.id)
28
+ return { error: "Attachment not found on this Creative" } unless attachment
29
+
30
+ attachment.purge_later
31
+ { success: true, creative_id: creative.id, removed_signed_id: signed_id }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Tools
5
+ # Raised by tool services when the current user lacks the required
6
+ # creative-level permission to perform a mutating action.
7
+ class PermissionDeniedError < StandardError; end
8
+ end
9
+ end
@@ -0,0 +1,128 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ # Attach a development-preview chip to a topic. AI Agents call this after
6
+ # spinning up `./bin/dev` against a worktree so the preview URL and run
7
+ # state surface as a chip in the typing-indicator row. Idempotent by
8
+ # (topic_id, worktree_id).
9
+ class PreviewAttachService
10
+ extend T::Sig
11
+ extend ToolMeta
12
+
13
+ tool_name "preview_attach"
14
+ tool_description <<~DESC.strip
15
+ Attach a development preview server to a Collavre topic. Renders a
16
+ chip in the topic's typing-indicator row with a clickable link to the
17
+ preview URL and a state badge (running/stopped). Call this after
18
+ starting a preview server (e.g. ./bin/dev on a worktree). Idempotent
19
+ per (topic_id, worktree_id) — calling twice does not duplicate the
20
+ chip or re-announce.
21
+ DESC
22
+
23
+ tool_param :topic_id, description: "The Collavre topic id to attach the preview chip to."
24
+ tool_param :preview_url, description: "Full URL of the preview server, e.g. http://localhost:4001"
25
+ tool_param :worktree_id, description: "Stable identifier for the worktree (or environment) running this preview. Used as the dedup key — re-calling with the same worktree_id reactivates the existing chip instead of creating a duplicate."
26
+ tool_param :label, description: "Optional display label shown on the chip. Defaults to a localized 'Preview' string.", required: false
27
+
28
+ sig do
29
+ params(
30
+ topic_id: Integer,
31
+ preview_url: String,
32
+ worktree_id: String,
33
+ label: T.nilable(String)
34
+ ).returns(T::Hash[Symbol, T.untyped])
35
+ end
36
+ def call(topic_id:, preview_url:, worktree_id:, label: nil)
37
+ validate_preview_url!(preview_url)
38
+
39
+ topic = Collavre::Topic.find(topic_id)
40
+ Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
41
+
42
+ refresh_config = ->(c) {
43
+ # Re-attach against the same worktree may carry a fresh URL/label
44
+ # (e.g. port reassignment after a restart). Refresh the persisted
45
+ # config so the chip link goes to the live server, not the dead one
46
+ # — applies both when reactivating a stopped chip and when the chip
47
+ # is still active (:noop), which is the common dev-restart case.
48
+ new_config = c.config.merge(
49
+ "preview_url" => preview_url,
50
+ "preview_state" => "running"
51
+ )
52
+ new_config["label"] = label if label
53
+ c.config = new_config
54
+ # Also refresh the cached chip fields that record_event! seeded on the
55
+ # first attach. The chip partial renders latest_link.presence ||
56
+ # default_link, so without this update a :noop reattach with a new port
57
+ # leaves the chip pointing at the dead URL even though config is fresh.
58
+ # (On :reactivate the subsequent inject_into_topic! would overwrite
59
+ # these via record_event! anyway, so updating here is harmless.)
60
+ c.latest_link = preview_url
61
+ c.latest_label = c.default_label
62
+ }
63
+
64
+ channel, status = Collavre::ChannelAttacher.call(
65
+ channel_class: Collavre::PreviewChannel,
66
+ lookup: -> { lookup_channel(topic, worktree_id) },
67
+ create_attrs: {
68
+ topic_id: topic.id,
69
+ config: {
70
+ "worktree_id" => worktree_id,
71
+ "preview_url" => preview_url,
72
+ "preview_state" => "running",
73
+ "label" => label
74
+ }.compact
75
+ },
76
+ on_reactivate: refresh_config,
77
+ on_noop: refresh_config
78
+ )
79
+
80
+ # Mirror PR-channel UX: only the first attach (and a true reactivation
81
+ # from stopped/dismissed) announces in the topic. Subsequent idempotent
82
+ # attaches stay silent so frequent restarts do not spam the timeline.
83
+ if status == :created || status == :reactivated
84
+ channel.inject_into_topic!(channel.attached_message)
85
+ end
86
+
87
+ {
88
+ ok: true,
89
+ channel_id: channel.id,
90
+ worktree_id: worktree_id,
91
+ preview_url: preview_url,
92
+ status: status
93
+ }
94
+ end
95
+
96
+ private
97
+
98
+ ALLOWED_PREVIEW_URL_SCHEMES = %w[http https].freeze
99
+ private_constant :ALLOWED_PREVIEW_URL_SCHEMES
100
+
101
+ # The preview_url is rendered directly as the chip's <a href> via ERB,
102
+ # which escapes quotes but does NOT reject dangerous URL schemes. Without
103
+ # this gate a write-capable MCP caller could persist `javascript:...` or
104
+ # `data:...` and turn the chip into a one-click XSS for any topic viewer.
105
+ sig { params(preview_url: String).void }
106
+ def validate_preview_url!(preview_url)
107
+ uri = URI.parse(preview_url)
108
+ unless ALLOWED_PREVIEW_URL_SCHEMES.include?(uri.scheme&.downcase)
109
+ raise ArgumentError, "preview_url must be http(s); got: #{preview_url.inspect}"
110
+ end
111
+ raise ArgumentError, "preview_url must include a host" if uri.host.to_s.empty?
112
+ rescue URI::InvalidURIError
113
+ raise ArgumentError, "preview_url is not a valid URI: #{preview_url.inspect}"
114
+ end
115
+
116
+ # config is JSON, so worktree_id matching is a Ruby-side scan over the
117
+ # topic's preview channels. With only a handful of previews per topic in
118
+ # practice this is fine and avoids JSON operator divergence between
119
+ # Postgres (config->>'worktree_id') and SQLite (json_extract).
120
+ sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
121
+ def lookup_channel(topic, worktree_id)
122
+ Collavre::PreviewChannel.where(topic_id: topic.id).find do |c|
123
+ c.worktree_id.to_s == worktree_id.to_s
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,61 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ # Mark a previously-attached preview as stopped. AI Agents call this
6
+ # immediately before killing the preview server (per the worktree cleanup
7
+ # workflow) so the chip flips to the "stopped" badge with reduced opacity.
8
+ # The chip stays visible until the user dismisses it with the X button —
9
+ # mirrors the PR-channel post-close UX where the closed badge persists for
10
+ # context.
11
+ class PreviewDetachService
12
+ extend T::Sig
13
+ extend ToolMeta
14
+
15
+ tool_name "preview_detach"
16
+ tool_description <<~DESC.strip
17
+ Mark a development preview as stopped. The chip stays visible with a
18
+ stopped badge until the user dismisses it. Idempotent — calling on an
19
+ already-stopped or missing channel returns ok with status :noop.
20
+ DESC
21
+
22
+ tool_param :topic_id, description: "The Collavre topic id the preview was attached to."
23
+ tool_param :worktree_id, description: "The worktree_id used when the preview was attached."
24
+
25
+ sig do
26
+ params(
27
+ topic_id: Integer,
28
+ worktree_id: String
29
+ ).returns(T::Hash[Symbol, T.untyped])
30
+ end
31
+ def call(topic_id:, worktree_id:)
32
+ topic = Collavre::Topic.find(topic_id)
33
+ Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
34
+
35
+ channel = lookup_channel(topic, worktree_id)
36
+ return { ok: true, status: :noop, worktree_id: worktree_id } if channel.nil?
37
+
38
+ status =
39
+ if channel.preview_state == "stopped" && channel.detached?
40
+ :noop
41
+ else
42
+ channel.preview_state = "stopped"
43
+ channel.state = :detached unless channel.detached?
44
+ channel.save!
45
+ :stopped
46
+ end
47
+
48
+ { ok: true, channel_id: channel.id, worktree_id: worktree_id, status: status }
49
+ end
50
+
51
+ private
52
+
53
+ sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
54
+ def lookup_channel(topic, worktree_id)
55
+ Collavre::PreviewChannel.where(topic_id: topic.id).find do |c|
56
+ c.worktree_id.to_s == worktree_id.to_s
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Tools
5
+ # Topic write authorization for MCP tool services. Mirrors
6
+ # CreativePermissionGuard#require_creative_write! but is callable outside a
7
+ # controller request. Attaching a channel injects external messages into a
8
+ # topic, so it is a write-equivalent mutation — restrict to users with
9
+ # write permission on the topic's effective_origin creative.
10
+ module TopicAuthorizer
11
+ module_function
12
+
13
+ def authorize_write!(topic, user: Collavre::Current.user)
14
+ creative = topic.creative&.effective_origin
15
+ raise ArgumentError, "Topic has no creative" unless creative
16
+ return if creative.user == user
17
+ return if user && creative.has_permission?(user, :write)
18
+
19
+ raise Collavre::Tools::PermissionDeniedError,
20
+ "No write permission on topic #{topic.id}"
21
+ end
22
+ end
23
+ end
24
+ end