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
@@ -0,0 +1,62 @@
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 inline, agent-generated TEXT content (markdown, html, svg, plain text) to a Creative. The content is stored as an ActiveStorage blob, embedded into the Creative's description, and served via CloudFront-cached /public-assets URLs.\n\nUse this for content the agent produced as text (notes, generated markdown/html/svg).\n\nFor BINARY files already on disk (png, mp4, pdf), use the CLI `collavre attach --creative <id> --file <path>` instead — it uploads the raw bytes over a bearer multipart HTTP endpoint without base64 bloat.\n\nRequires :write permission on the target Creative."
12
+
13
+ tool_param :creative_id, description: "ID of the Creative to attach content to.", required: true
14
+ tool_param :files, description: "Array of objects: { filename, content, content_type? }. `content` is the literal UTF-8 text to store (e.g. markdown/html/svg source). `content_type` is inferred from the filename when omitted.", required: true
15
+
16
+ sig { params(creative_id: Integer, files: T::Array[T::Hash[String, T.untyped]]).returns(T::Hash[Symbol, T.untyped]) }
17
+ def call(creative_id:, files:)
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
+ unless creative.has_permission?(Current.user, :write)
23
+ return { error: "No write permission on Creative", id: creative_id }
24
+ end
25
+ unless creative.attachments_embeddable?
26
+ return { error: "Cannot attach files to GitHub-synced content", id: creative_id }
27
+ end
28
+ return { error: "No files provided" } if files.blank?
29
+ # Validate the entire payload before creating any blob, so a bad entry
30
+ # later in the array never leaves an orphaned blob from an earlier one.
31
+ return { error: "Each file requires a filename" } if files.any? { |f| f["filename"].to_s.blank? }
32
+
33
+ blobs = files.map do |f|
34
+ name = f["filename"].to_s
35
+ content = f["content"].to_s
36
+
37
+ ActiveStorage::Blob.create_and_upload!(
38
+ io: StringIO.new(content),
39
+ filename: name,
40
+ content_type: f["content_type"].presence || Marcel::MimeType.for(name: name) || "text/plain"
41
+ )
42
+ end
43
+
44
+ blobs.each { |blob| creative.embed_attachment_blob!(blob) }
45
+
46
+ {
47
+ success: true,
48
+ creative_id: creative.id,
49
+ attachments: blobs.map { |b|
50
+ {
51
+ signed_id: b.signed_id,
52
+ filename: b.filename.to_s,
53
+ content_type: b.content_type,
54
+ byte_size: b.byte_size,
55
+ url: public_asset_url(b)
56
+ }
57
+ }
58
+ }
59
+ end
60
+ end
61
+ end
62
+ 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,37 @@
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. Strips the attachment's node from the description and purges the underlying blob asynchronously when nothing else references it."
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
+ # HTML is the source of truth: strip the node from the description and let
27
+ # after_save reconcile detach + safe-purge the blob. Removing only the
28
+ # ActiveStorage attachment would leave a dangling node in the description
29
+ # (broken asset, or reconciled back into creative.files on the next save).
30
+ removed = creative.remove_attachment!(signed_id)
31
+ return { error: "Attachment not found on this Creative" } unless removed
32
+
33
+ { success: true, creative_id: creative.id, removed_signed_id: signed_id }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -5,6 +5,7 @@ module Tools
5
5
  class CronListService
6
6
  extend T::Sig
7
7
  extend ToolMeta
8
+ include Collavre::Crons::RecurringTaskArguments
8
9
 
9
10
  tool_name "cron_list"
10
11
  tool_description "List recurring scheduled jobs. Returns all cron jobs for creatives the current user has access to. Filter by creative_id to see jobs for a specific creative."
@@ -46,20 +47,6 @@ module Tools
46
47
 
47
48
  { success: true, cron_jobs: results, count: results.size }
48
49
  end
49
-
50
- private
51
-
52
- def parse_arguments(task)
53
- args = task.arguments
54
- return {} unless args.is_a?(Array) && args.first.is_a?(Hash)
55
-
56
- args.first.stringify_keys
57
- end
58
-
59
- def parse_creative_id_from_key(key)
60
- match = key.match(/\Acron_(\d+)_/)
61
- match[1].to_i if match
62
- end
63
50
  end
64
51
  end
65
52
  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
@@ -4,16 +4,23 @@ module Collavre
4
4
 
5
5
  MAX_BRANCH_COMMENTS = 100
6
6
 
7
- def initialize(creative:, user:, source_topic:)
7
+ def initialize(creative:, user:, source_topic:, name: nil)
8
8
  @creative = creative
9
9
  @user = user
10
10
  @source_topic = source_topic
11
+ @name = name
11
12
  end
12
13
 
13
14
  # Creates a new topic with copies of the selected comments.
14
15
  # Returns the new Topic.
15
- def call(comment_ids:)
16
- comment_ids = Array(comment_ids).map(&:presence).compact.map(&:to_i).first(MAX_BRANCH_COMMENTS)
16
+ # enforce_limit: false bypasses MAX_BRANCH_COMMENTS for system-initiated
17
+ # full-history copies (e.g. Drop Trigger) where the UI's selection cap
18
+ # does not apply.
19
+ # auto_select: false omits user_id from the topic-created broadcast so
20
+ # background/system branches do not hijack the owner's current selection.
21
+ def call(comment_ids:, enforce_limit: true, auto_select: true)
22
+ comment_ids = Array(comment_ids).map(&:presence).compact.map(&:to_i)
23
+ comment_ids = comment_ids.first(MAX_BRANCH_COMMENTS) if enforce_limit
17
24
  raise BranchError, I18n.t("collavre.comments.branch.no_selection") if comment_ids.empty?
18
25
 
19
26
  validate_permissions!
@@ -24,14 +31,14 @@ module Collavre
24
31
  copy_comments(originals)
25
32
  end
26
33
 
27
- broadcast_topic_created
34
+ broadcast_topic_created(auto_select: auto_select)
28
35
 
29
36
  @new_topic
30
37
  end
31
38
 
32
39
  private
33
40
 
34
- attr_reader :creative, :user, :source_topic
41
+ attr_reader :creative, :user, :source_topic, :name
35
42
 
36
43
  def validate_permissions!
37
44
  unless creative.has_permission?(user, :feedback)
@@ -49,25 +56,28 @@ module Collavre
49
56
  end
50
57
 
51
58
  def create_branch_topic
52
- prefix = I18n.t("collavre.topics.branch_prefix")
53
- source_name = source_topic&.name || I18n.t("collavre.comments.topic_main", default: "All Messages")
54
- name = "#{prefix}:#{source_name}"
55
-
56
- # Ensure uniqueness
57
- existing = creative.topics.where("name LIKE ?", "#{Topic.sanitize_sql_like(name)}%").pluck(:name)
58
- if existing.include?(name)
59
- counter = 2
60
- counter += 1 while existing.include?("#{name} #{counter}")
61
- name = "#{name} #{counter}"
62
- end
59
+ topic_name = name.presence || default_branch_name
63
60
 
64
61
  creative.topics.create!(
65
- name: name,
62
+ name: topic_name,
66
63
  user: user,
67
64
  source_topic_id: source_topic&.id
68
65
  )
69
66
  end
70
67
 
68
+ def default_branch_name
69
+ prefix = I18n.t("collavre.topics.branch_prefix")
70
+ source_name = source_topic&.name || I18n.t("collavre.comments.topic_main", default: "All Messages")
71
+ candidate = "#{prefix}:#{source_name}"
72
+
73
+ existing = creative.topics.where("name LIKE ?", "#{Topic.sanitize_sql_like(candidate)}%").pluck(:name)
74
+ return candidate unless existing.include?(candidate)
75
+
76
+ counter = 2
77
+ counter += 1 while existing.include?("#{candidate} #{counter}")
78
+ "#{candidate} #{counter}"
79
+ end
80
+
71
81
  def copy_comments(originals)
72
82
  id_mapping = {}
73
83
 
@@ -98,15 +108,13 @@ module Collavre
98
108
  end
99
109
  end
100
110
 
101
- def broadcast_topic_created
102
- TopicsChannel.broadcast_to(
103
- creative,
104
- {
105
- action: "created",
106
- topic: { id: @new_topic.id, name: @new_topic.name, source_topic_id: @new_topic.source_topic_id },
107
- user_id: user.id
108
- }
109
- )
111
+ def broadcast_topic_created(auto_select: true)
112
+ payload = {
113
+ action: "created",
114
+ topic: { id: @new_topic.id, name: @new_topic.name, source_topic_id: @new_topic.source_topic_id }
115
+ }
116
+ payload[:user_id] = user.id if auto_select
117
+ TopicsChannel.broadcast_to(creative, payload)
110
118
  end
111
119
  end
112
120
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ # When a Topic is deleted, find every non-static recurring cron task that
6
+ # targets that topic (topic_id stored inside the task's arguments JSON) and
7
+ # post a system message into the affected creative's Main topic so the user
8
+ # knows the cron is now orphaned.
9
+ #
10
+ # Decision: notify only. The recurring task is intentionally left in place
11
+ # so the user can decide whether to re-point or cancel it.
12
+ class OrphanedCronNotifier
13
+ include Collavre::Crons::RecurringTaskArguments
14
+
15
+ def initialize(topic_id:, topic_name:)
16
+ @topic_id = topic_id
17
+ @topic_name = topic_name
18
+ end
19
+
20
+ def call
21
+ return if @topic_id.blank?
22
+
23
+ matching_tasks.each do |task, args|
24
+ notify_for(task, args)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def matching_tasks
31
+ SolidQueue::RecurringTask.where(static: false).filter_map do |task|
32
+ args = parse_arguments(task)
33
+ target = args["topic_id"]
34
+ next if target.blank?
35
+ next unless target.to_i == @topic_id.to_i
36
+
37
+ [ task, args ]
38
+ end
39
+ end
40
+
41
+ def notify_for(task, args)
42
+ creative_id = args["creative_id"] || parse_creative_id_from_key(task.key)
43
+ creative = Creative.find_by(id: creative_id)
44
+ return unless creative
45
+
46
+ # The recurring task stores the original (possibly linked/child) creative
47
+ # id, but Comment#use_origin_creative rewrites the comment to the origin.
48
+ # Post to the origin's Main topic so the notice stays on the origin and
49
+ # is reachable from normal topic navigation (matches CronCreateService).
50
+ main_topic = creative.effective_origin.main_topic
51
+ return unless main_topic
52
+
53
+ Comment.create!(
54
+ creative: creative,
55
+ topic_id: main_topic.id,
56
+ user: nil, # System message
57
+ skip_default_user: true, # keep user nil even when a deleter is Current.user; don't trigger AI orchestration
58
+ content: I18n.t(
59
+ "collavre.topics.orphaned_cron_notice",
60
+ topic_name: @topic_name,
61
+ cron_key: task.key,
62
+ message: args["message"]
63
+ )
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
@@ -3,4 +3,5 @@
3
3
  <%= link_to t('admin.tabs.uiux'), collavre.admin_uiux_path, class: "tab-button #{'active' if controller_name == 'settings' && action_name == 'uiux'}" %>
4
4
  <%= link_to t('admin.tabs.users'), collavre.users_path, class: "tab-button #{'active' if controller_name == 'users'}" %>
5
5
  <%= link_to t('admin.tabs.orchestration'), collavre.admin_orchestration_path, class: "tab-button #{'active' if controller_name == 'orchestration'}" %>
6
+ <%= link_to t('collavre.admin.integrations.tab_label'), collavre.admin_integrations_path, class: "tab-button #{'active' if controller_name == 'integrations'}" %>
6
7
  </div>
@@ -0,0 +1,22 @@
1
+ <div style="margin-top: 2em; border-top: 1px solid var(--color-border); padding-top: 1.5em;">
2
+ <h3 style="font-size: 1.1em; margin-bottom: 1em;">
3
+ <%= t("collavre.admin.integrations.category.#{category}", default: category.to_s.humanize) %>
4
+ </h3>
5
+
6
+ <div class="table-scroll">
7
+ <table class="settings-table" style="width: 100%; min-width: 640px; border-collapse: collapse; table-layout: fixed;">
8
+ <thead>
9
+ <tr>
10
+ <th style="text-align: left; padding: 0.5em; width: 30%;"><%= t("collavre.admin.integrations.headers.key") %></th>
11
+ <th style="text-align: left; padding: 0.5em; width: 50%;"><%= t("collavre.admin.integrations.headers.value") %></th>
12
+ <th style="text-align: left; padding: 0.5em; width: 20%;"><%= t("collavre.admin.integrations.headers.actions") %></th>
13
+ </tr>
14
+ </thead>
15
+ <tbody>
16
+ <% rows.each do |row| %>
17
+ <%= render partial: "setting_row", locals: { row: row, bulk_form_id: bulk_form_id } %>
18
+ <% end %>
19
+ </tbody>
20
+ </table>
21
+ </div>
22
+ </div>
@@ -0,0 +1,70 @@
1
+ <% definition = row[:definition] %>
2
+ <% key_str = definition.key.to_s %>
3
+ <%
4
+ source_bg, source_fg = case row[:source]
5
+ when :db then ["var(--color-success)", "var(--text-on-badge)"]
6
+ when :env then ["var(--color-warning)", "var(--text-on-badge)"]
7
+ else ["var(--surface-btn)", "var(--text-on-btn)"]
8
+ end
9
+ current_display = row[:present] ? row[:display_value].to_s : "—"
10
+ %>
11
+ <% description = t("collavre.admin.integrations.descriptions.#{key_str}", default: "") %>
12
+ <tr style="border-top: 1px solid var(--color-border);">
13
+ <td style="padding: 0.5em; vertical-align: top; word-break: break-word; overflow-wrap: anywhere;">
14
+ <code style="word-break: break-all; overflow-wrap: anywhere;"><%= key_str %></code>
15
+ <% if definition.requires_restart %>
16
+ <span class="badge badge-warning" style="display: inline-block; margin-left: 0.5em; padding: 0.1em 0.5em; font-size: 0.75em; background: var(--color-warning); color: var(--text-on-badge); border-radius: 3px;">
17
+ <%= t("collavre.admin.integrations.restart_required") %>
18
+ </span>
19
+ <% end %>
20
+ <% if description.present? %>
21
+ <div style="font-size: 0.85em; color: var(--color-text); margin-top: 0.4em; line-height: 1.4;">
22
+ <%= simple_format(description) %>
23
+ </div>
24
+ <% end %>
25
+ <div style="font-size: 0.8em; color: var(--color-muted); margin-top: 0.25em;">
26
+ <%= t("collavre.admin.integrations.env_var_label") %>: <code style="word-break: break-all; overflow-wrap: anywhere;"><%= definition.env_var %></code>
27
+ </div>
28
+ </td>
29
+ <td style="padding: 0.5em; vertical-align: top;">
30
+ <%# Input references the bulk form via HTML5 `form` attribute so the table is not nested inside <form>. %>
31
+ <%# Placeholder displays the current value (masked for sensitive); empty input keeps the existing value. %>
32
+ <% if definition.input_type == :textarea %>
33
+ <%= text_area_tag "integration_setting[#{key_str}]",
34
+ nil,
35
+ form: bulk_form_id,
36
+ placeholder: current_display,
37
+ autocomplete: "off",
38
+ rows: 8,
39
+ style: "width: 100%; font-family: var(--font-mono, monospace); font-size: 0.85em; min-height: 8em;" %>
40
+ <% else %>
41
+ <% input_tag = definition.sensitive ? :password_field_tag : :text_field_tag %>
42
+ <%= send(input_tag,
43
+ "integration_setting[#{key_str}]",
44
+ nil,
45
+ form: bulk_form_id,
46
+ placeholder: current_display,
47
+ autocomplete: "off",
48
+ style: "width: 100%;") %>
49
+ <% end %>
50
+ <div style="font-size: 0.8em; margin-top: 0.25em;">
51
+ <span class="badge badge-source-<%= row[:source] %>" style="display: inline-block; padding: 0.1em 0.5em; font-size: 0.85em; border-radius: 3px; background: <%= source_bg %>; color: <%= source_fg %>;">
52
+ <%= t("collavre.admin.integrations.source.#{row[:source]}", default: row[:source].to_s.upcase) %>
53
+ </span>
54
+ <% if definition.sensitive %>
55
+ <span style="margin-left: 0.5em; color: var(--color-muted);"><%= t("collavre.admin.integrations.sensitive") %></span>
56
+ <% end %>
57
+ <span style="margin-left: 0.5em; color: var(--color-muted);"><%= t("collavre.admin.integrations.placeholder_leave_blank") %></span>
58
+ </div>
59
+ </td>
60
+ <td style="padding: 0.5em; vertical-align: top; white-space: nowrap;">
61
+ <%= button_to t("collavre.admin.integrations.actions.reset"),
62
+ collavre.reset_admin_integration_path(key: key_str),
63
+ method: :delete,
64
+ form: {
65
+ style: "display: inline-block;",
66
+ data: { turbo_confirm: t("collavre.admin.integrations.confirm_reset") }
67
+ },
68
+ class: "btn btn-small btn-secondary" %>
69
+ </td>
70
+ </tr>