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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Reconnect-grace cancellation for delegated tasks owned by a Claude Channel
5
+ # session whose WebSocket dropped without DELETE /api/v1/agent/:id.
6
+ #
7
+ # AgentChannel#unsubscribed makes the agent unroutable, but a task already
8
+ # in "delegated" still holds its ResourceTracker slot and (for workflow
9
+ # subtasks) blocks the parent workflow. The dispatch was broadcast to a
10
+ # now-dead stream, so no client remains to call /reply — without this job
11
+ # the slot stays held until StuckDetectorJob times out (minutes to hours).
12
+ #
13
+ # The grace delay lets transient WS blips self-heal: if the client
14
+ # reconnects before the job fires, AgentChannel#subscribe_to_agent_stream
15
+ # restores routing_expression to "true" and writes a fresh subscription
16
+ # token. The job's online-state recheck then no-ops.
17
+ class CancelOfflineDelegatedTasksJob < ApplicationJob
18
+ queue_as :default
19
+
20
+ GRACE_SECONDS = 30
21
+
22
+ def perform(agent_id, expected_token, session_id = nil)
23
+ agent = User.find_by(id: agent_id)
24
+ return unless agent&.claude_channel_agent?
25
+
26
+ # Session-scoped variant: a live sibling may keep the shared agent
27
+ # routable (so the agent-wide rechecks below would no-op), but the
28
+ # dropped session's OWN session topic is private to it — siblings filter
29
+ # session_topic dispatches to their own topic, so none will /reply. Cancel
30
+ # only that topic's delegated work. Enqueued by AgentChannel#unsubscribed
31
+ # when a sibling remains; mirrors the destroy path's cancel_tasks_for_topic.
32
+ return cancel_dropped_session_tasks(agent, session_id) if session_id.present?
33
+
34
+ # Agent came back online during the grace window — the same MCP
35
+ # session (or a new one) is subscribed and can answer /reply.
36
+ return if agent.routing_expression.present?
37
+
38
+ # A session (reconnect or a still-live sibling sharing this agent) holds
39
+ # a LIVE presence row — the agent is online, its delegated work is still
40
+ # owned. Presence is the authority now that one agent fans out to many
41
+ # concurrent sessions. Reap crash-orphaned rows first so a dead process's
42
+ # leftover row can't masquerade as a live session and strand this work.
43
+ AgentSubscription.reap_stale!(agent.id)
44
+ return if AgentSubscription.live.where(agent_id: agent.id).exists?
45
+
46
+ # A different subscription has taken over (token rotated). The new
47
+ # session's lifecycle owns its own cancellation; don't double-cancel.
48
+ if expected_token.present? &&
49
+ agent.routing_subscription_token.present? &&
50
+ agent.routing_subscription_token != expected_token
51
+ return
52
+ end
53
+
54
+ cancel_delegated_tasks(Task.where(agent_id: agent.id, status: "delegated"), agent)
55
+ end
56
+
57
+ private
58
+
59
+ # Cancel only the dropped session's own session-topic delegated work, unless
60
+ # this same session reconnected within the grace window (a fresh live row
61
+ # under the same session_id). Work on OTHER topics is fan-out — any live
62
+ # sibling can still claim it — so it is deliberately left untouched.
63
+ def cancel_dropped_session_tasks(agent, session_id)
64
+ AgentSubscription.reap_stale!(agent.id)
65
+ return if AgentSubscription.live.where(agent_id: agent.id, session_id: session_id).exists?
66
+
67
+ topic = Topic.find_by(primary_agent_id: agent.id, session_id: session_id)
68
+ return unless topic
69
+
70
+ # Cancel queued/pending/running work FIRST. Otherwise cancelling the
71
+ # delegated task below calls dequeue_next_for_topic, which promotes the next
72
+ # queued task on this session topic into "delegated" and broadcasts it — but
73
+ # the session that owned this private topic is gone and siblings filter
74
+ # session_topic dispatches to their own topic, so nothing would /reply and
75
+ # the promoted task would hold its slot until stuck recovery. Draining the
76
+ # queue first leaves nothing to promote. Mirrors cancel_tasks_for_topic.
77
+ cancel_pending_tasks(
78
+ Task.where(agent_id: agent.id, topic_id: topic.id, status: %w[queued pending running]), agent
79
+ )
80
+ cancel_delegated_tasks(
81
+ Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated"), agent
82
+ )
83
+ end
84
+
85
+ def cancel_pending_tasks(tasks, agent)
86
+ return if tasks.empty?
87
+
88
+ tracker = Orchestration::ResourceTracker.for(agent)
89
+ tasks.find_each do |task|
90
+ was_running = task.status == "running"
91
+ task.update!(status: "cancelled")
92
+ tracker.release!(task.id) if was_running
93
+
94
+ next if task.parent_task_id.blank?
95
+
96
+ begin
97
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
98
+ task, error_message: "Claude Channel session lost connection before dispatch"
99
+ )
100
+ rescue StandardError => e
101
+ Rails.logger.error(
102
+ "[CancelOfflineDelegatedTasksJob] fail_subtask! failed for queued task #{task.id}: #{e.message}"
103
+ )
104
+ end
105
+ end
106
+ end
107
+
108
+ def cancel_delegated_tasks(tasks, agent)
109
+ return if tasks.empty?
110
+
111
+ tracker = Orchestration::ResourceTracker.for(agent)
112
+ tasks.find_each do |task|
113
+ task.update!(status: "cancelled")
114
+ tracker.release!(task.id)
115
+
116
+ if task.parent_task_id.present?
117
+ begin
118
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
119
+ task, error_message: "Claude Channel session lost connection before reply"
120
+ )
121
+ rescue StandardError => e
122
+ Rails.logger.error(
123
+ "[CancelOfflineDelegatedTasksJob] fail_subtask! failed for task #{task.id}: #{e.message}"
124
+ )
125
+ end
126
+ end
127
+
128
+ if task.topic_id.present?
129
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Typing-indicator / "responding" presence for Claude Channel (MCP) agents.
5
+ #
6
+ # RubyLLM agents broadcast agent_status ("thinking" → "streaming" heartbeats →
7
+ # "idle") from inside their long-running AiAgentJob, which is what drives the
8
+ # chat typing indicator and Stop button. The Claude Channel path has no such
9
+ # loop: AiAgentService#delegate_to_claude_channel broadcasts the dispatch and
10
+ # returns immediately, with the reply arriving asynchronously via /reply. So
11
+ # the chat showed nothing while Claude worked.
12
+ #
13
+ # This job restores parity. Enqueued once per Claude dispatch, it:
14
+ # - while the task is "delegated" and a session is LIVE → broadcasts a
15
+ # working agent_status and re-enqueues itself every HEARTBEAT_SECONDS
16
+ # (under the frontend's 10s AGENT_STATUS_TIMEOUT) so the indicator stays up;
17
+ # - when no live session remains (never connected, or dropped mid-turn) →
18
+ # broadcasts "idle" to clear the indicator and posts an authorless system
19
+ # notice so a human sees the channel is disconnected rather than a phantom
20
+ # "thinking"; (CancelOfflineDelegatedTasksJob owns the task-state cleanup);
21
+ # - once the task leaves "delegated" (a /reply completed it, or it was
22
+ # cancelled) → stops without re-enqueuing. The reply path broadcasts the
23
+ # final "idle" itself, so the indicator clears immediately on reply.
24
+ class ClaudeChannelPresenceJob < ApplicationJob
25
+ queue_as :default
26
+
27
+ # Re-broadcast cadence. Must stay below the frontend AGENT_STATUS_TIMEOUT
28
+ # (10s) so the indicator never flickers off between beats, with room for one
29
+ # missed beat (queue jitter, GC pause).
30
+ HEARTBEAT_SECONDS = 5
31
+
32
+ def perform(task_id)
33
+ task = Task.find_by(id: task_id)
34
+ return unless task
35
+
36
+ agent = task.agent
37
+ return unless agent&.claude_channel_agent?
38
+
39
+ # A /reply completed the task (or it was cancelled). The reply path already
40
+ # broadcast the final "idle"; nothing more to do, and crucially do NOT post
41
+ # a disconnect notice for a normally-finished turn.
42
+ return unless task.reload.status == "delegated"
43
+
44
+ creative = resolve_creative(task)
45
+ return unless creative
46
+
47
+ AgentSubscription.reap_stale!(agent.id)
48
+
49
+ # Reuse the canonical status broadcaster that drives the typing indicator
50
+ # for every other agent (AiAgentService#execute_llm_conversation), so the
51
+ # "thinking"/"idle" payload stays identical across both paths.
52
+ lifecycle = AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
53
+
54
+ if AgentSubscription.live.where(agent_id: agent.id).exists?
55
+ lifecycle.broadcast_status("thinking")
56
+ self.class.set(wait: HEARTBEAT_SECONDS.seconds).perform_later(task_id)
57
+ else
58
+ lifecycle.broadcast_status("idle")
59
+ post_disconnect_notice(creative, task)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def resolve_creative(task)
66
+ creative_id = task.creative_id || task.trigger_event_payload&.dig("creative", "id")
67
+ Creative.find_by(id: creative_id)&.effective_origin
68
+ end
69
+
70
+ def resolve_topic_id(task)
71
+ task.topic_id || task.trigger_event_payload&.dig("topic", "id")
72
+ end
73
+
74
+ # Authorless, non-dispatching system message so the human sees the channel is
75
+ # offline. skip_dispatch is essential: an authorless comment that matched the
76
+ # agent's routing would otherwise spawn a fresh delegated task — an infinite
77
+ # disconnect-notice loop.
78
+ def post_disconnect_notice(creative, task)
79
+ topic_id = resolve_topic_id(task)
80
+ return unless topic_id
81
+
82
+ creative.comments.create!(
83
+ content: I18n.t("collavre.claude_channel.disconnected"),
84
+ topic_id: topic_id,
85
+ private: false,
86
+ skip_default_user: true,
87
+ skip_dispatch: true
88
+ )
89
+ end
90
+ end
91
+ end
@@ -8,6 +8,8 @@ module Collavre
8
8
  # This triggers retry_on instead of silently succeeding.
9
9
  class DispatchFailedError < StandardError; end
10
10
 
11
+ DROP_TRIGGER_TOPIC_NAME = "Drop Trigger"
12
+
11
13
  retry_on DispatchFailedError, wait: 5.seconds, attempts: 3
12
14
 
13
15
  # End-to-end idempotent: safe to retry at any point.
@@ -57,9 +59,8 @@ module Collavre
57
59
  private
58
60
 
59
61
  def post_trigger_failure_notice(child, parent)
60
- topic = child.topics.find_or_create_by!(name: "Drop Trigger") do |t|
61
- t.user = child.user
62
- end
62
+ topic = child.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME) ||
63
+ create_drop_trigger_topic(child)
63
64
  post_system_notice(child, topic, I18n.t(
64
65
  "collavre.drop_trigger.no_agent",
65
66
  parent_description: parent.creative_snippet
@@ -82,17 +83,45 @@ module Collavre
82
83
  end
83
84
 
84
85
  def find_or_create_trigger_topic(creative, agent)
85
- topic = creative.topics.find_by(name: "Drop Trigger")
86
+ topic = creative.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME)
86
87
  return topic if topic
87
88
 
88
- topic = creative.topics.create!(
89
- name: "Drop Trigger",
90
- user: creative.user
91
- )
89
+ topic = create_drop_trigger_topic(creative)
92
90
  topic.set_primary_agent!(agent)
93
91
  topic
94
92
  end
95
93
 
94
+ # Creates the Drop Trigger topic, branching from the creative's Main topic
95
+ # when Main has messages. Equivalent to the user manually selecting every
96
+ # Main message and pressing "branch" — only the resulting topic name is
97
+ # fixed to "Drop Trigger".
98
+ def create_drop_trigger_topic(creative)
99
+ main = creative.main_topic(fallback_user: creative.user)
100
+ # Mirror the manual "select all → branch" flow: only comments the owner
101
+ # can see are copied. enforce_limit: false bypasses the UI's 100-comment
102
+ # selection cap so full Main history transfers regardless of length.
103
+ main_comment_ids = main.comments
104
+ .visible_to(creative.user)
105
+ .order(:created_at)
106
+ .pluck(:id)
107
+
108
+ if main_comment_ids.any?
109
+ # auto_select: false prevents the broadcast from hijacking the owner's
110
+ # current topic selection while this background job runs.
111
+ TopicBranchService.new(
112
+ creative: creative,
113
+ user: creative.user,
114
+ source_topic: main,
115
+ name: DROP_TRIGGER_TOPIC_NAME
116
+ ).call(comment_ids: main_comment_ids, enforce_limit: false, auto_select: false)
117
+ else
118
+ creative.topics.create!(
119
+ name: DROP_TRIGGER_TOPIC_NAME,
120
+ user: creative.user
121
+ )
122
+ end
123
+ end
124
+
96
125
  def trigger_content_key(child, parent)
97
126
  I18n.t(
98
127
  "collavre.drop_trigger.child_entered",
@@ -1,6 +1,6 @@
1
1
  module Collavre
2
2
  class ApplicationMailer < ActionMailer::Base
3
- default from: ENV.fetch("DEFAULT_MAILER_FROM", "no-reply@example.com")
3
+ default from: -> { Collavre::IntegrationSettings.fetch(:default_mailer_from, default: "no-reply@example.com") }
4
4
  layout "mailer"
5
5
 
6
6
  private
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Presence record for one live Claude Channel session subscription on the
5
+ # per-agent ActionCable stream. The set of LIVE rows for an agent_id is the
6
+ # authority for whether routing_expression should be active: a human's
7
+ # concurrent sessions share one agent, so routing stays on while ANY session
8
+ # holds a live row, and only turns off when the last one is gone.
9
+ #
10
+ # Liveness is leased, not assumed. Rows are deleted only by
11
+ # AgentChannel#unsubscribed, so a Puma/ActionCable crash or deploy orphans a
12
+ # row. last_seen_at is refreshed by the channel's periodic heartbeat; a row
13
+ # whose last_seen_at falls outside STALE_AFTER is dead — ignored by the live
14
+ # scope so it cannot pin routing on, and reaped opportunistically on the next
15
+ # subscribe/unsubscribe for the agent.
16
+ class AgentSubscription < ApplicationRecord
17
+ self.table_name = "agent_subscriptions"
18
+
19
+ belongs_to :agent, class_name: Collavre.configuration.user_class_name
20
+
21
+ # Heartbeat cadence (see AgentChannel#subscribe_to_agent_stream). STALE_AFTER
22
+ # is a multiple of it so a single missed beat (GC pause, brief stall) does
23
+ # not flap a live session to "dead".
24
+ HEARTBEAT_SECONDS = 15
25
+ STALE_AFTER = 45.seconds
26
+
27
+ before_validation :ensure_last_seen_at, on: :create
28
+
29
+ scope :live, -> { where(arel_table[:last_seen_at].gt(STALE_AFTER.ago)) }
30
+ scope :stale, -> { where(arel_table[:last_seen_at].lteq(STALE_AFTER.ago)) }
31
+
32
+ # Refresh the heartbeat for one session's row. No-op if the row is already
33
+ # gone (a newer subscribe rotated it, or it was reaped) — the periodic
34
+ # callback must not resurrect a removed presence row.
35
+ def self.touch!(agent_id, token)
36
+ where(agent_id: agent_id, token: token).update_all(last_seen_at: Time.current)
37
+ end
38
+
39
+ # Drop crash-orphaned rows for an agent so a plain presence check is
40
+ # accurate. Scoped to one agent_id: cheap, and called on the agent's own
41
+ # subscribe/unsubscribe path.
42
+ def self.reap_stale!(agent_id)
43
+ stale.where(agent_id: agent_id).delete_all
44
+ end
45
+
46
+ private
47
+
48
+ def ensure_last_seen_at
49
+ self.last_seen_at ||= Time.current
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ module Collavre
2
+ class Channel
3
+ InjectedMessage = Data.define(:speaker, :message, :label, :link)
4
+ end
5
+ end
@@ -0,0 +1,87 @@
1
+ module Collavre
2
+ class Channel < ApplicationRecord
3
+ BOT_EMAIL = "channel@collavre.local"
4
+ BOT_NAME = "Channel"
5
+
6
+ self.table_name = "channels"
7
+
8
+ belongs_to :topic, class_name: "Collavre::Topic"
9
+
10
+ enum :state, { active: 0, detached: 1 }, default: :active
11
+
12
+ scope :not_dismissed, -> { where(dismissed_at: nil) }
13
+
14
+ def handle(event:, payload:)
15
+ raise NotImplementedError, "#{self.class} must implement #handle"
16
+ end
17
+
18
+ # Chip fallbacks rendered before any webhook event populates latest_label /
19
+ # latest_link. Base class returns nil; subclasses override when they can
20
+ # derive a stable label/link from their own config (e.g. PR #N + URL).
21
+ def default_label
22
+ nil
23
+ end
24
+
25
+ def default_link
26
+ nil
27
+ end
28
+
29
+ # Badge interface for the typing-indicator chip. Subclasses override
30
+ # `badge_state` to drive the chip badge color (the value becomes a CSS
31
+ # modifier: channel-chip-badge--<state>), and `badge_title` to provide a
32
+ # localized title / aria-label string. Returning nil from `badge_state`
33
+ # hides the badge entirely.
34
+ def badge_state
35
+ nil
36
+ end
37
+
38
+ def badge_title
39
+ nil
40
+ end
41
+
42
+ def detach!
43
+ update!(state: :detached)
44
+ end
45
+
46
+ def dismissed?
47
+ dismissed_at.present?
48
+ end
49
+
50
+ # Hide the chip from the typing-indicator row. Performs detach! as a
51
+ # side-effect when the channel is still active so dismissal is a single
52
+ # user-facing action — clicking the X always removes the chip regardless
53
+ # of prior state.
54
+ def dismiss!
55
+ transaction do
56
+ detach! if active?
57
+ update!(dismissed_at: Time.current) if dismissed_at.nil?
58
+ end
59
+ end
60
+
61
+ def record_event!(label:, link:)
62
+ update!(latest_label: label, latest_link: link, last_event_at: Time.current)
63
+ end
64
+
65
+ def inject_into_topic!(injected_message)
66
+ transaction do
67
+ comment = topic.creative.comments.create!(
68
+ user: injected_message.speaker,
69
+ topic_id: topic.id,
70
+ content: injected_message.message,
71
+ private: false
72
+ )
73
+ record_event!(label: injected_message.label, link: injected_message.link)
74
+ comment
75
+ end
76
+ end
77
+
78
+ after_create_commit :broadcast_chips_changed
79
+ after_update_commit :broadcast_chips_changed
80
+
81
+ private
82
+
83
+ def broadcast_chips_changed
84
+ Collavre::CommentsPresenceChannel.broadcast_channel_chips_changed(topic.creative_id, topic_id: topic_id)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class Comment < ApplicationRecord
5
+ # A native Claude Code tool-permission prompt surfaced into a topic as a
6
+ # structured approval comment (built by Api::V1::AgentsController#notify when
7
+ # the relayed notify carries a permission_request_id). It reuses the native
8
+ # approval comment UI — approver gate, approve button, ✅/🚫 decided label —
9
+ # but a decision does NOT execute a tool server-side (the tool runs inside
10
+ # the remote Claude Code process). Instead approve/deny relays the decision
11
+ # over the agent stream so the MCP plugin resolves the paused tool call.
12
+ module ClaudeChannelPermission
13
+ extend ActiveSupport::Concern
14
+
15
+ ACTION_TYPE = "claude_channel_permission"
16
+
17
+ # Raised when a decision was already recorded, so a double-click (or a
18
+ # concurrent approve+deny) resolves to exactly one decision.
19
+ class AlreadyDecided < StandardError; end
20
+
21
+ class_methods do
22
+ # Re-broadcast the recorded decision for each given request_id that belongs
23
+ # to this agent and has been decided. Called when a Claude Channel session
24
+ # sends the request_ids it still holds pending after a (re)subscribe
25
+ # (pull-on-resubscribe): broadcast_claude_channel_permission_decision fires
26
+ # once into the transient agent:user:<id> stream, so a decision clicked
27
+ # while the plugin's WebSocket was reconnecting lands in a subscriber-less
28
+ # stream and is lost — leaving the suspended tool hung with no retry path.
29
+ #
30
+ # The plugin's pending set is the SOLE bound: there is no wall-clock window,
31
+ # so a decision made during an outage of any length is still redelivered,
32
+ # and a decision the plugin already consumed is simply never requested. The
33
+ # coarse action LIKE filter (one per requested id) is narrowed by an exact
34
+ # request_id match per row before re-broadcasting; replay is idempotent
35
+ # (PermissionCoordinator.claim ignores a request_id it no longer holds), so
36
+ # an over-broad LIKE match is a harmless no-op on the plugin side.
37
+ def replay_claude_channel_permission_decisions_for(agent_id, request_ids)
38
+ ids = Array(request_ids).filter_map { |r| r.to_s.presence }.uniq
39
+ return if ids.empty?
40
+
41
+ # Build the coarse "action LIKE '%<rid>%'" prefilter with Arel matchers
42
+ # rather than a raw SQL fragment so the wildcards stay bound parameters
43
+ # (the surrounding %…% is ours; sanitize_sql_like escapes any %/_ inside
44
+ # the id). No interpolation into SQL — Brakeman-clean and identical
45
+ # semantics to the old OR-joined LIKE.
46
+ matcher = ids
47
+ .map { |rid| arel_table[:action].matches("%#{sanitize_sql_like(rid)}%") }
48
+ .reduce(:or)
49
+ where(user_id: agent_id)
50
+ .where.not(action_executed_at: nil)
51
+ .where(matcher)
52
+ .find_each do |comment|
53
+ next unless comment.claude_channel_permission?
54
+ next unless ids.include?(comment.claude_channel_permission_request_id)
55
+
56
+ comment.rebroadcast_claude_channel_permission_decision
57
+ end
58
+ end
59
+ end
60
+
61
+ # True when this comment's action payload is a Claude Channel permission
62
+ # prompt (vs. a native execute_tool/approve_tool action).
63
+ def claude_channel_permission?
64
+ claude_channel_permission_action.present?
65
+ end
66
+
67
+ def claude_channel_permission_request_id
68
+ claude_channel_permission_action&.dig("request_id")
69
+ end
70
+
71
+ # The decision, once made, is persisted into the action payload so the
72
+ # rendered comment can distinguish ✅ approved from 🚫 denied.
73
+ def claude_channel_permission_denied?
74
+ claude_channel_permission_action&.dig("decision") == "deny"
75
+ end
76
+
77
+ # Atomically record the human's allow/deny: stamp action_executed_at/by
78
+ # (which hides the buttons and marks the comment decided) and persist the
79
+ # decision into the action payload. Raises AlreadyDecided if a decision was
80
+ # already recorded.
81
+ def decide_claude_channel_permission!(behavior, by:)
82
+ behavior = behavior.to_s
83
+ raise ArgumentError, "behavior must be allow or deny" unless %w[allow deny].include?(behavior)
84
+
85
+ with_lock do
86
+ reload
87
+ raise AlreadyDecided if action_executed_at.present?
88
+
89
+ payload = claude_channel_permission_action
90
+ raise ArgumentError, "not a Claude Channel permission comment" unless payload
91
+
92
+ payload["decision"] = behavior
93
+ update!(
94
+ action: JSON.pretty_generate(payload),
95
+ action_executed_at: Time.current,
96
+ action_executed_by: by
97
+ )
98
+ end
99
+ end
100
+
101
+ # Relay the decision to the suspended session over the agent stream. The
102
+ # MCP plugin matches request_id against the prompts it surfaced, so sibling
103
+ # sessions sharing this (shared) agent ignore a request_id they never
104
+ # raised. task_id is intentionally absent: the decision only unblocks the
105
+ # paused tool call; the in-flight delegated task completes later via /reply.
106
+ def broadcast_claude_channel_permission_decision(behavior)
107
+ request_id = claude_channel_permission_request_id
108
+ return false if request_id.blank? || user_id.blank?
109
+
110
+ AgentChannel.broadcast_to_agent(user_id, {
111
+ type: "permission_decision",
112
+ request_id: request_id,
113
+ behavior: behavior.to_s,
114
+ agent_id: user_id
115
+ })
116
+ true
117
+ end
118
+
119
+ # Replay this comment's already-recorded decision (used by the resubscribe
120
+ # path). Unlike broadcast_claude_channel_permission_decision the behavior is
121
+ # read from the persisted payload rather than passed in, and only a comment
122
+ # that is both a permission prompt and decided re-broadcasts — a still-
123
+ # pending prompt has no decision to deliver.
124
+ def rebroadcast_claude_channel_permission_decision
125
+ return false unless claude_channel_permission?
126
+
127
+ decision = claude_channel_permission_action&.dig("decision")
128
+ return false if decision.blank?
129
+
130
+ broadcast_claude_channel_permission_decision(decision)
131
+ end
132
+
133
+ private
134
+
135
+ def claude_channel_permission_action
136
+ return nil if action.blank?
137
+
138
+ parsed = JSON.parse(action)
139
+ parsed.is_a?(Hash) && parsed["action"] == ACTION_TYPE ? parsed : nil
140
+ rescue JSON::ParserError
141
+ nil
142
+ end
143
+ end
144
+ end
145
+ end