collavre 0.24.0 → 0.25.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 (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # Folds several un-started agent tasks for one conversation turn into one.
6
+ #
7
+ # A burst of external events (a PR comment sync, a webhook batch) creates
8
+ # several comments in the same topic within milliseconds, and each one
9
+ # dispatches its own task. With topic_max_concurrent_jobs = 1 the extras
10
+ # pile up as `queued` waiters, and AgentOrchestrator.refresh_deferred_context!
11
+ # points every one of them at the same latest comment — so the agent answers
12
+ # the same message once per waiter.
13
+ #
14
+ # Cancelling the extras is not sufficient: session-backed agents receive
15
+ # only the :trigger message (SessionContextResolver#incremental_payload
16
+ # drops chat history), so the intermediate comments would be lost entirely.
17
+ # Their ids are therefore merged into the survivor's payload under
18
+ # "merged_comment_ids", and MessageBuilder folds them into the trigger.
19
+ class TaskCoalescer
20
+ PAYLOAD_KEY = "merged_comment_ids"
21
+
22
+ # Set when a payload's anchor is moved onto a comment the task was not
23
+ # created for. AgentOrchestrator.delivered_comment_ids has to tell that
24
+ # apart from a task answering its own trigger, and the only evidence
25
+ # otherwise was comment.created_at against task.created_at — two rows
26
+ # stamped by whichever process wrote them, which is why nothing else in
27
+ # this turn's machinery orders by time either. A tie or a skew reads an
28
+ # acquired anchor as an original one and the comment is answered twice,
29
+ # or an original one as acquired and a promoted waiter is left with no
30
+ # candidate and cancelled.
31
+ ACQUIRED_ANCHOR_KEY = "acquired_comment_id"
32
+
33
+ # The statuses a survivor may still be in when a fold runs: both enqueue
34
+ # doors hand over a `queued` row and both start-of-turn doors a `pending`
35
+ # one. Anything else means it lost its turn between the caller reading it
36
+ # and this transaction.
37
+ UNSTARTED_STATUSES = %w[queued pending].freeze
38
+
39
+ # Cancel un-started siblings of `keep_task` and absorb their trigger
40
+ # comments into it.
41
+ #
42
+ # @param keep_task [Collavre::Task] the survivor
43
+ # @param scope [:older, :all] which queued siblings to supersede.
44
+ # :older (default) is for the enqueue path, where `keep_task` is itself
45
+ # a fresh `queued` row and two concurrent dispatches must not cancel
46
+ # each other. :all is for the promotion path, where `keep_task` has
47
+ # already left `queued` — nothing can cancel it, and the waiters left
48
+ # behind are *newer*, so the id guard would absorb nothing.
49
+ # @return [Array<Integer>] ids of the tasks that were absorbed
50
+ def self.coalesce!(keep_task, scope: :older)
51
+ new(keep_task, scope: scope).coalesce!
52
+ end
53
+
54
+ # Point a payload at a new trigger comment, recording the move.
55
+ #
56
+ # Both doors that move an anchor go through this — the refresh carrying a
57
+ # waiter forward, and the re-anchor rescuing a task whose anchor was
58
+ # deleted. They differ only in how they rebuild the merged list, and
59
+ # keeping the anchor write itself in one place is what makes "this turn
60
+ # acquired that comment" a fact the payload carries rather than one a
61
+ # later reader has to reconstruct.
62
+ #
63
+ # A refresh that lands back on the anchor it started from is a documented
64
+ # no-op, so the mark is written only when the id actually changes, and
65
+ # never cleared: an anchor moved twice is still not the turn's own.
66
+ #
67
+ # A payload with no anchor at all is left unmarked. There is no move to
68
+ # record, and this whole record is read to decide whether a *waiter* may
69
+ # keep its turn — so where the payload cannot say, the side that cannot
70
+ # discard someone's work is the one to take.
71
+ #
72
+ # @return [Hash] the updated payload (not saved)
73
+ def self.reanchor_payload(payload, comment)
74
+ previous_id = payload.dig("comment", "id")
75
+ # The anchor block is rebuilt from Comment#dispatch_payload rather than
76
+ # enumerated here. That method is the declared single source of truth
77
+ # for what a comment_created dispatch carries, and everything in it
78
+ # describes the anchor: "from_ai" is what AiAgentJob#record_loop_breaker_
79
+ # turn reads to decide whether a turn was agent-started, so a move that
80
+ # drops it exempts agent-to-agent work from the creative-retry breaker,
81
+ # and "quoted_comment_id" is what binds a review to the comment it
82
+ # quotes. Enumerating the keys here means the list falls behind the
83
+ # payload the ordinary door builds, silently, one key at a time.
84
+ #
85
+ # Rebuilt, never merged onto: a key absent for this comment must be
86
+ # absent afterwards. Carrying the previous anchor's "from_ai" or its
87
+ # quoted comment forward is the same defect pointing the other way.
88
+ moved = payload.merge(
89
+ comment.dispatch_payload.slice(:comment).deep_stringify_keys,
90
+ "chat" => SystemEvents::ContextBuilder.reanchor_chat(comment.content)
91
+ )
92
+ if previous_id && previous_id.to_i != comment.id
93
+ moved[ACQUIRED_ANCHOR_KEY] = comment.id
94
+
95
+ # The per-user CLI Proxy workspace is part of the anchor's security
96
+ # context. A waiter initially carried for one person can be promoted
97
+ # onto a later comment from another, so retaining the old principal
98
+ # would run the new prompt with the first person's callback token.
99
+ # Human anchors identify their own principal. An AI/system anchor
100
+ # cannot reconstruct its initiating person from the Comment row, so
101
+ # retain an explicit nil sentinel. Downstream resolution distinguishes
102
+ # this security decision from an ordinary payload that never carried a
103
+ # principal and must not fall through to the agent creator.
104
+ if comment.user && !comment.user.ai_user?
105
+ if moved.key?("workspace_user_id")
106
+ moved["workspace_user_id"] = comment.user_id
107
+ end
108
+ else
109
+ moved["workspace_user_id"] = nil
110
+ end
111
+ end
112
+ # Both keys ContextBuilder *derives* from the anchor are rebuilt here
113
+ # too — dispatch_payload does not carry them, because ContextBuilder
114
+ # fills each one in with `||=` and does not run again on either of
115
+ # these paths. "chat"/"mentioned_user" moves with the anchor above
116
+ # (ContextBuilder.reanchor_chat); "sender" below.
117
+ #
118
+ # The payload's "sender" labels the trigger and is what
119
+ # ClaudeChannelAdapter sends as author_id/author_name, and
120
+ # SystemEvents::ContextBuilder only ever fills it in with `||=` — it does
121
+ # not run again on either of these paths. A burst spanning two people
122
+ # would otherwise put the first speaker's name on the second's words.
123
+ SystemEvents::ContextBuilder.reanchor_sender(moved, comment)
124
+ end
125
+
126
+ # Merge extra comment ids into a task payload without touching siblings.
127
+ # Used when a refresh moves the anchor to a newer comment: the previous
128
+ # anchor still has to reach the agent.
129
+ #
130
+ # @return [Hash] the updated payload (not saved)
131
+ def self.absorb_into_payload(payload, comment_ids)
132
+ merged = Array(payload[PAYLOAD_KEY]) + Array(comment_ids)
133
+ anchor_id = payload.dig("comment", "id")
134
+ merged = merged.compact.map(&:to_i).uniq
135
+ merged -= [ anchor_id.to_i ] if anchor_id
136
+ payload.merge(PAYLOAD_KEY => merged.sort)
137
+ end
138
+
139
+ # Restrict a re-anchor query to comments that keep the task on the same
140
+ # potential workspace principal. The current anchor is always retained as
141
+ # the no-op candidate: an A2A payload may carry a human principal even
142
+ # though its anchor was authored by an agent, while moving onto another
143
+ # agent comment would deliberately turn that principal into explicit nil.
144
+ # A verified human boundary applies before a per-user gateway is selected
145
+ # because configuration may change while the task is queued. A fallback
146
+ # creator is not evidence of who initiated an ordinary turn, so it keeps
147
+ # the legacy refresh behavior until a per-user gateway requires fail-closed
148
+ # handling.
149
+ def self.reanchor_scope_for_workspace_principal(scope, task)
150
+ principal = workspace_principal_for(task)
151
+ return scope if principal.first == :fallback && !workspace_principal_isolated?(task.agent)
152
+
153
+ anchor_id = (task.trigger_event_payload || {}).dig("comment", "id")
154
+ compatible =
155
+ case principal.first
156
+ when :human
157
+ scope.where(user_id: principal.second)
158
+ when :unprovable
159
+ scope.where(user_id: nil).or(scope.where(user_id: User.ai_agents.select(:id)))
160
+ else
161
+ scope.none
162
+ end
163
+
164
+ anchor_id ? compatible.or(scope.where(id: anchor_id)) : compatible
165
+ end
166
+
167
+ def self.workspace_principal_for(task)
168
+ workspace_principals_for([ task ]).fetch(task.id)
169
+ end
170
+
171
+ def self.workspace_principal_isolated?(agent)
172
+ agent&.cli_proxy_agent? && agent.agent_gateway.per_user?
173
+ end
174
+
175
+ def initialize(keep_task, scope: :older)
176
+ @keep = keep_task
177
+ @scope = scope
178
+ end
179
+
180
+ def coalesce!
181
+ return [] if review_trigger?(@keep)
182
+
183
+ absorbed_ids = []
184
+
185
+ Task.transaction do
186
+ # Lock the survivor together with its siblings, and re-read its status
187
+ # from the locked row. The survivor is the caller's snapshot: deleting
188
+ # its anchor cancels it through Comment#cancel_pending_tasks, which
189
+ # holds no lock this method waits on. Folding on a stale object would
190
+ # cancel every still-valid sibling and merge the whole burst onto a
191
+ # task AiAgentJob abandons on sight — and those siblings have no task
192
+ # of their own left to answer them.
193
+ #
194
+ # One id-ordered statement rather than locking @keep first: in the
195
+ # :older scope the survivor's id is above every sibling's, so taking it
196
+ # first would descend where a concurrent fold ascends, which is the
197
+ # shape a deadlock needs.
198
+ locked = Task.where(id: superseded_scope.pluck(:id) + [ @keep.id ])
199
+ .order(:id).lock.index_by(&:id)
200
+ keep = locked[@keep.id]
201
+ next unless keep && UNSTARTED_STATUSES.include?(keep.status)
202
+
203
+ # Status is re-checked against the locked rows too: a sibling promoted
204
+ # or cancelled since the id read above is no longer ours to supersede.
205
+ siblings = reject_review_triggers(
206
+ locked.values.select { |t| t.id != keep.id && t.status == "queued" }
207
+ )
208
+ siblings = reject_other_workspace_principals(keep, siblings)
209
+ comment_ids = siblings.flat_map { |t| trigger_comment_ids(t) }
210
+
211
+ siblings.each do |task|
212
+ task.task_actions.create!(
213
+ action_type: "superseded",
214
+ status: "done",
215
+ payload: { "superseded_by_task_id" => @keep.id }
216
+ )
217
+ task.update!(status: "cancelled")
218
+ absorbed_ids << task.id
219
+ end
220
+
221
+ # A waiter absorbed here may have parked with a per-deferral notice of
222
+ # its own — the policy only has to have been off when it deferred and
223
+ # on by the time this fold runs. That notice speaks for this one
224
+ # waiter, so with the waiter cancelled it is a stop button that
225
+ # selects a task no longer in the queue and cancels nothing at all.
226
+ #
227
+ # Nothing else would collect it either: the cancelled task will never
228
+ # be promoted through cleanup_waiter_notice!, and the drained sweep
229
+ # needs the topic queue to empty — which the survivor of this very
230
+ # fold prevents. Same transaction as the cancellation, so the two
231
+ # cannot come apart.
232
+ Comment.remove_waiter_notices!(
233
+ creative_id: @keep.creative_id, topic_id: @keep.topic_id, task_ids: absorbed_ids
234
+ )
235
+
236
+ if siblings.any?
237
+ # Merge onto the locked row's payload, but write through the
238
+ # caller's own object: dequeue_next_for_topic hands that same
239
+ # instance to refresh_deferred_context! immediately after this, and
240
+ # it has to see the ids merged here rather than its pre-fold copy.
241
+ payload = self.class.absorb_into_payload(keep.trigger_event_payload || {}, comment_ids)
242
+ @keep.update!(trigger_event_payload: payload)
243
+ end
244
+ end
245
+
246
+ if absorbed_ids.any?
247
+ Rails.logger.info(
248
+ "[TaskCoalescer] Task #{@keep.id} absorbed #{absorbed_ids.size} queued sibling(s): " \
249
+ "#{absorbed_ids.join(', ')}"
250
+ )
251
+ end
252
+ absorbed_ids
253
+ end
254
+
255
+ private
256
+
257
+ # Only `queued` tasks are safe to supersede. A `pending` task may already
258
+ # be riding an enqueued AiAgentJob; cancelling it makes that job return
259
+ # early *without* draining the topic queue, stalling the topic.
260
+ #
261
+ # `id < keep.id` rather than "every other sibling": two dispatches
262
+ # coalescing concurrently would otherwise cancel each other and leave no
263
+ # survivor. Each run only ever supersedes strictly older rows.
264
+ def superseded_scope
265
+ rel = Task.where(
266
+ agent_id: @keep.agent_id,
267
+ topic_id: @keep.topic_id,
268
+ creative_id: @keep.creative_id,
269
+ trigger_event_name: @keep.trigger_event_name,
270
+ status: "queued"
271
+ ).where.not(id: @keep.id)
272
+ rel = rel.where("id < ?", @keep.id) if @scope == :older
273
+ rel.order(:id)
274
+ end
275
+
276
+ # A Review request is an action bound to one comment, not one more message
277
+ # in the burst. AiAgentService and ResponseFinalizer read review behaviour
278
+ # off the surviving anchor alone (Comment#review_message? plus its
279
+ # quoted_comment), so folding across that boundary breaks both ways: an
280
+ # absorbed review silently degrades into a normal reply, and a surviving
281
+ # review overwrites the quoted comment with text that also answers an
282
+ # unrelated message. Reviews therefore neither absorb nor get absorbed —
283
+ # they keep their own turn, which is the only shape ReviewHandler can run.
284
+ def review_trigger?(task)
285
+ anchor_id = (task.trigger_event_payload || {}).dig("comment", "id")
286
+ anchor_id.present? && Comment.review_message_ids([ anchor_id ]).any?
287
+ end
288
+
289
+ # One query for the whole burst rather than review_trigger? per sibling.
290
+ def reject_review_triggers(siblings)
291
+ anchors = siblings.filter_map { |t| (t.trigger_event_payload || {}).dig("comment", "id") }
292
+ review_ids = Comment.review_message_ids(anchors).to_set
293
+ return siblings if review_ids.empty?
294
+
295
+ siblings.reject do |task|
296
+ review_ids.include?((task.trigger_event_payload || {}).dig("comment", "id").to_i)
297
+ end
298
+ end
299
+
300
+ # Coalesced comments are rendered into one trigger and execute under the
301
+ # survivor's single workspace principal. Folding across people would let
302
+ # one person's prompt run with another person's provider login and
303
+ # callback permissions, so only siblings whose effective principal is
304
+ # identical may be absorbed.
305
+ def reject_other_workspace_principals(keep, siblings)
306
+ return siblings if siblings.empty?
307
+
308
+ principals = self.class.send(:workspace_principals_for, [ keep ] + siblings)
309
+ keep_principal = principals.fetch(keep.id)
310
+ siblings.select { |task| principals.fetch(task.id) == keep_principal }
311
+ end
312
+
313
+ def self.workspace_principals_for(tasks)
314
+ payloads = tasks.to_h { |task| [ task.id, task.trigger_event_payload || {} ] }
315
+ explicit_ids = payloads.values.filter_map do |payload|
316
+ payload["workspace_user_id"] if payload.key?("workspace_user_id")
317
+ end
318
+ anchor_ids = payloads.values.filter_map { |payload| payload.dig("comment", "id") }
319
+ agent_creators = User.where(id: tasks.map(&:agent_id).compact.uniq).pluck(:id, :created_by_id).to_h
320
+ creator_ids = agent_creators.values.compact
321
+ users = User.where(id: explicit_ids + creator_ids).index_by(&:id)
322
+ comments = Comment.where(id: anchor_ids).includes(:user).index_by(&:id)
323
+
324
+ tasks.to_h do |task|
325
+ payload = payloads.fetch(task.id)
326
+ principal = effective_workspace_principal(task, payload, users, comments, agent_creators)
327
+ [ task.id, principal ]
328
+ end
329
+ end
330
+ private_class_method :workspace_principals_for
331
+
332
+ def self.effective_workspace_principal(task, payload, users, comments, agent_creators)
333
+ if payload.key?("workspace_user_id")
334
+ carried = users[payload["workspace_user_id"].to_i]
335
+ return carried && !carried.ai_user? ? [ :human, carried.id ] : [ :unprovable ]
336
+ end
337
+
338
+ comment = comments[payload.dig("comment", "id").to_i]
339
+ return [ :human, comment.user_id ] if comment&.user && !comment.user.ai_user?
340
+
341
+ creator = users[agent_creators[task.agent_id]]
342
+ [ :fallback, creator&.id ]
343
+ end
344
+ private_class_method :effective_workspace_principal
345
+
346
+ # The comment a task was going to answer, plus anything it had already
347
+ # absorbed itself — coalescing has to be transitive or a second round
348
+ # drops the first round's merges.
349
+ def trigger_comment_ids(task)
350
+ payload = task.trigger_event_payload || {}
351
+ ([ payload.dig("comment", "id") ] + Array(payload[PAYLOAD_KEY])).compact
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # The single rule for who may hold a topic's concurrency slot, shared by the
6
+ # two paths that hand one out: AiAgentJob's admission of a fresh dispatch and
7
+ # AgentOrchestrator.dequeue_next_for_topic's promotion of a parked waiter.
8
+ #
9
+ # Keeping the rule in one place is the point. When only admission enforced
10
+ # it, promotion could still hand a second slot to an agent that already held
11
+ # one, or overfill a topic outright — and TaskCoalescer folds only `queued`
12
+ # rows, so two concurrent turns for one agent can never be merged afterwards.
13
+ module TopicSlot
14
+ # Serialize every admission and promotion for one topic scope on one
15
+ # stable row. SELECT ... FOR UPDATE on Postgres; the SQLite visitor drops
16
+ # the lock clause, where writes are serialized anyway.
17
+ #
18
+ # A creative's Main topic carries topic_id == nil, which is a real scope
19
+ # (occupancy is counted as topic_id: nil within the creative) but has no
20
+ # topic row to lock — fall back to the creative, which is just as stable
21
+ # and is shared by exactly the dispatches that compete for that slot.
22
+ # Tasks carry topic_id with no foreign key, so a deleted topic leaves rows
23
+ # pointing at a row that is gone — and a delayed job can carry that id long
24
+ # after the fact. Returning the missing topic's nil without trying the
25
+ # creative would run admission, promotion and notice dedup with no
26
+ # serialization at all: silently the one state this lock exists to prevent.
27
+ def self.lock!(topic_id, creative_id = nil)
28
+ if topic_id
29
+ topic = Topic.lock.find_by(id: topic_id)
30
+ return topic if topic
31
+ end
32
+ return Creative.lock.find_by(id: creative_id) if creative_id
33
+
34
+ nil
35
+ end
36
+
37
+ def self.matches_context?(locked_scope, topic_id, creative_id)
38
+ expected_class = topic_id ? Topic : Creative
39
+ expected_id = topic_id || creative_id
40
+ locked_scope.is_a?(expected_class) && locked_scope.id.to_s == expected_id.to_s &&
41
+ (topic_id.nil? || locked_scope.creative_id.to_s == creative_id.to_s)
42
+ end
43
+
44
+ def self.lock_matches_context?(topic_id, creative_id)
45
+ matches_context?(lock!(topic_id, creative_id), topic_id, creative_id)
46
+ end
47
+
48
+ # May `agent_id` take a slot in this topic scope right now?
49
+ #
50
+ # Deliberately NOT gated on coalesce_pending_tasks?: that switch governs
51
+ # whether waiters are folded together, not whether topic_max_concurrent_jobs
52
+ # is enforced. Skipping the check when it is off would let a burst start
53
+ # one concurrent turn per comment in a topic limited to one.
54
+ #
55
+ # Occupancy counts running/delegated (executing) plus pending (claimed,
56
+ # job not started) and pending_approval (paused, keeps its resource and
57
+ # does not drain the queue) — treating any of those as free is exactly how
58
+ # a second turn slips in.
59
+ def self.available_for?(agent_id, topic_id, creative_id, context)
60
+ topic_max = PolicyResolver.new(context || {}).topic_max_concurrent_jobs
61
+ return true unless topic_max
62
+
63
+ occupying = Task.occupying_topic_slot(topic_id, creative_id)
64
+ return false if occupying.count >= topic_max
65
+
66
+ # A free slot is not automatically THIS agent's to take. topic_max > 1
67
+ # exists to run *different* agents in parallel, so an agent that already
68
+ # has a turn in flight here must wait rather than answer itself twice.
69
+ !occupying.where(agent_id: agent_id).exists?
70
+ end
71
+ end
72
+ end
73
+ end
@@ -20,13 +20,15 @@ module Collavre
20
20
  ctx
21
21
  end
22
22
 
23
- private
24
-
25
- def build_sender_context(ctx)
26
- user_id = ctx.dig("comment", "user_id")
27
- return nil unless user_id
28
-
29
- user = User.find_by(id: user_id)
23
+ # The sender block for a user, or nil when there is nobody to attribute to.
24
+ #
25
+ # Public because `build` only ever fills the key in (`||=`) and never runs
26
+ # again on a promotion path: re-anchoring a coalesced task moves the trigger
27
+ # to a different comment, so whoever moves it has to rebuild the sender from
28
+ # the new author or the turn is labelled with the wrong speaker. Both callers
29
+ # must produce the same shape — a stub missing "is_ai" silently flips the
30
+ # A2A gate in AgentContextBuilder.
31
+ def self.sender_context_for(user)
30
32
  return nil unless user
31
33
 
32
34
  {
@@ -38,12 +40,59 @@ module Collavre
38
40
  }
39
41
  end
40
42
 
41
- def mentioned_user(chat_context)
42
- content = chat_context["content"]
43
+ # The "chat" block for a comment the trigger has just been moved onto.
44
+ #
45
+ # Public for the same reason `sender_context_for` is: `build` only ever
46
+ # fills "mentioned_user" in (`||=`) and does not run again on a
47
+ # re-anchoring path, and AiAgentJob asks Matcher#assignment_permits?
48
+ # against the raw payload. A mention left behind by the move therefore
49
+ # reads as "no mention at all" — and an explicit mention is precisely what
50
+ # outranks a topic's primary-agent assignment, so a restored or promoted
51
+ # dispatch that *was* addressed to this agent gets refused for a topic
52
+ # that belongs to someone else.
53
+ #
54
+ # The block is rebuilt rather than merged onto: a mention that finds
55
+ # nobody must leave the key absent, since Matcher reads its presence as
56
+ # the mention that outranks the assignment.
57
+ def self.reanchor_chat(content)
58
+ chat = { "content" => content }
59
+ mention = mentioned_user_for(content)
60
+ mention ? chat.merge("mentioned_user" => mention) : chat
61
+ end
62
+
63
+ def self.mentioned_user_for(content)
43
64
  return nil unless content
44
65
 
45
- user = MentionParser.resolve_user(content)
46
- user&.as_json(only: [ :id, :name, :email ])
66
+ MentionParser.resolve_user(content)&.as_json(only: [ :id, :name, :email ])
67
+ end
68
+
69
+ # Point a payload's sender at the comment the trigger has just been moved
70
+ # onto. Only when the author actually changed: a payload may carry a sender
71
+ # its producer shaped deliberately, and re-anchoring inside one user's own
72
+ # burst is no reason to touch it.
73
+ #
74
+ # A rebuild that finds no user drops the key rather than leaving a wrong
75
+ # one: ClaudeChannelAdapter then falls back to the comment's own user_id and
76
+ # MessageBuilder omits the speaker label instead of naming the wrong person.
77
+ def self.reanchor_sender(payload, comment)
78
+ return payload unless payload.key?("sender")
79
+ return payload if payload.dig("sender", "id") == comment.user_id
80
+
81
+ sender = sender_context_for(comment.user)
82
+ sender ? payload.merge("sender" => sender) : payload.except("sender")
83
+ end
84
+
85
+ private
86
+
87
+ def build_sender_context(ctx)
88
+ user_id = ctx.dig("comment", "user_id")
89
+ return nil unless user_id
90
+
91
+ self.class.sender_context_for(User.find_by(id: user_id))
92
+ end
93
+
94
+ def mentioned_user(chat_context)
95
+ self.class.mentioned_user_for(chat_context["content"])
47
96
  end
48
97
  end
49
98
  end
@@ -1,22 +1,68 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Collavre
2
4
  module SystemEvents
5
+ # The only entry point to orchestration. It validates the event type and
6
+ # stamps its envelope before the payload is persisted or scheduled.
3
7
  class Dispatcher
4
- def self.dispatch(event_name, context)
5
- new.dispatch(event_name, context)
8
+ def self.dispatch(event_name, context, source: nil, parent: nil, selected_agents: nil, selection: nil,
9
+ context_for: nil, scheduling_hooks: nil)
10
+ new.dispatch(
11
+ event_name, context, source: source, parent: parent,
12
+ selected_agents: selected_agents, selection: selection, context_for: context_for,
13
+ scheduling_hooks: scheduling_hooks
14
+ )
15
+ end
16
+
17
+ def dispatch(event_name, context, source: nil, parent: nil, selected_agents: nil, selection: nil,
18
+ context_for: nil, scheduling_hooks: nil)
19
+ definition = Vocabulary.fetch(event_name)
20
+ ctx = (context || {}).deep_stringify_keys
21
+ envelope = resolve_envelope(definition.name, ctx, source, parent)
22
+ ctx[Envelope::KEY] = envelope.to_h
23
+
24
+ warn_missing_keys(definition, ctx, envelope)
25
+ log_dispatch(definition, ctx, envelope)
26
+
27
+ Orchestration::AgentOrchestrator.dispatch(
28
+ definition.name, ctx, selected_agents: selected_agents, selection: selection,
29
+ context_for: context_for, scheduling_hooks: scheduling_hooks
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_envelope(event_name, context, source, parent)
36
+ return Envelope.child(event_name, parent: parent, source: source) if parent
37
+
38
+ existing = Envelope.in(context)
39
+ return Envelope.root(event_name, source: source) if existing.nil?
40
+ return existing if existing.name == event_name
41
+
42
+ Envelope.child(event_name, parent: existing, source: source)
43
+ end
44
+
45
+ def warn_missing_keys(definition, context, envelope)
46
+ missing = Vocabulary.missing_keys(definition.name, context)
47
+ return if missing.empty?
48
+
49
+ Rails.logger.warn(
50
+ "[SystemEvents::Dispatcher] event=#{definition.name} " \
51
+ "event_id=#{envelope.id} missing_keys=#{missing.join(',')}"
52
+ )
6
53
  end
7
54
 
8
- def dispatch(event_name, context)
9
- comment_id = context.dig("comment", "id")
10
- comment_user_id = context.dig("comment", "user_id")
11
- creative_id = context.dig("creative", "id")
55
+ def log_dispatch(definition, context, envelope)
12
56
  Rails.logger.info(
13
- "[SystemEvents::Dispatcher] event=#{event_name} " \
14
- "comment_id=#{comment_id} comment_user_id=#{comment_user_id} " \
15
- "creative_id=#{creative_id} " \
16
- "caller=#{caller_locations(1, 5)&.map { |l| "#{File.basename(l.path)}:#{l.lineno}" }&.join(' <- ')}"
57
+ "[SystemEvents::Dispatcher] event=#{definition.name} " \
58
+ "event_id=#{envelope.id} correlation_id=#{envelope.correlation_id} " \
59
+ "causation_id=#{envelope.causation_id || '-'} depth=#{envelope.depth} " \
60
+ "source=#{envelope.source} " \
61
+ "comment_id=#{context.dig('comment', 'id')} " \
62
+ "comment_user_id=#{context.dig('comment', 'user_id')} " \
63
+ "creative_id=#{context.dig('creative', 'id')} " \
64
+ "caller=#{caller_locations(1, 5)&.map { |location| "#{File.basename(location.path)}:#{location.lineno}" }&.join(' <- ')}"
17
65
  )
18
- # Delegate to AgentOrchestrator for unified routing/scheduling
19
- Orchestration::AgentOrchestrator.dispatch(event_name, context)
20
66
  end
21
67
  end
22
68
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module SystemEvents
5
+ # JSON-safe metadata that identifies an orchestration dispatch and its
6
+ # causal chain. It travels inside tasks.trigger_event_payload under KEY.
7
+ class Envelope < Data.define(
8
+ :id, :name, :correlation_id, :causation_id, :depth, :source, :occurred_at
9
+ )
10
+ KEY = "event"
11
+ UNKNOWN_SOURCE = "unknown"
12
+
13
+ def self.root(name, source: nil)
14
+ id = SecureRandom.uuid
15
+ new(
16
+ id: id,
17
+ name: name.to_s,
18
+ correlation_id: id,
19
+ causation_id: nil,
20
+ depth: 0,
21
+ source: (source.presence || UNKNOWN_SOURCE).to_s,
22
+ occurred_at: Time.current.utc.iso8601
23
+ )
24
+ end
25
+
26
+ def self.child(name, parent:, source: nil)
27
+ parent = from(parent) unless parent.is_a?(Envelope)
28
+ return root(name, source: source) if parent.nil?
29
+
30
+ root(name, source: source).with(
31
+ correlation_id: parent.correlation_id,
32
+ causation_id: parent.id,
33
+ depth: parent.depth.to_i + 1
34
+ )
35
+ end
36
+
37
+ def self.from(hash)
38
+ return hash if hash.is_a?(Envelope)
39
+ return nil unless hash.respond_to?(:to_h)
40
+
41
+ hash = hash.to_h.deep_stringify_keys
42
+ id = hash["id"]
43
+ return nil if id.blank?
44
+
45
+ new(
46
+ id: id,
47
+ name: hash["name"].to_s,
48
+ correlation_id: hash["correlation_id"].presence || id,
49
+ causation_id: hash["causation_id"].presence,
50
+ depth: hash["depth"].to_i,
51
+ source: hash["source"].presence || UNKNOWN_SOURCE,
52
+ occurred_at: hash["occurred_at"].presence
53
+ )
54
+ rescue TypeError
55
+ nil
56
+ end
57
+
58
+ def self.in(context)
59
+ return nil unless context.is_a?(Hash)
60
+
61
+ from(context[KEY] || context[KEY.to_sym])
62
+ end
63
+
64
+ def root?
65
+ causation_id.nil?
66
+ end
67
+
68
+ def to_h
69
+ super.deep_stringify_keys
70
+ end
71
+ end
72
+ end
73
+ end