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,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module CliProxy
5
+ class Client
6
+ class Error < StandardError
7
+ attr_reader :status, :code, :details
8
+
9
+ def initialize(message, status: nil, code: nil, details: nil)
10
+ super(message)
11
+ @status = status
12
+ @code = code
13
+ @details = details
14
+ end
15
+ end
16
+
17
+ def initialize(gateway:, workspace: nil, user_key: nil, http_client: nil)
18
+ @gateway = gateway
19
+ @workspace = workspace
20
+ @user_key = user_key
21
+ @http_client = http_client || default_http_client
22
+ end
23
+
24
+ def engines
25
+ request(:get, "/v1/auth/engines")
26
+ end
27
+
28
+ def engine_status(engine)
29
+ request(:get, "/v1/auth/#{segment(engine)}/status")
30
+ end
31
+
32
+ def create_auth_session(engine, flow:, provisioning_url:)
33
+ request(
34
+ :post,
35
+ "/v1/auth/#{segment(engine)}/sessions",
36
+ body: { flow: flow, provisioning_url: provisioning_url }.compact
37
+ )
38
+ end
39
+
40
+ def auth_session(engine, session_id)
41
+ request(:get, "/v1/auth/#{segment(engine)}/sessions/#{segment(session_id)}")
42
+ end
43
+
44
+ def submit_auth_session(engine, session_id, value, base_url: nil)
45
+ request(
46
+ :post,
47
+ "/v1/auth/#{segment(engine)}/sessions/#{segment(session_id)}",
48
+ body: { value: value, base_url: base_url }.compact
49
+ )
50
+ end
51
+
52
+ def cancel_auth_session(engine, session_id)
53
+ request(:delete, "/v1/auth/#{segment(engine)}/sessions/#{segment(session_id)}")
54
+ end
55
+
56
+ def provision_status
57
+ request(:get, "/v1/provision")
58
+ end
59
+
60
+ # Registers the workspace manifest URL and applies it, without a login.
61
+ # The login-carried provisioning_url does the same thing as a side effect
62
+ # of authenticating; this route is how an already-authenticated workspace
63
+ # gets (re)provisioned.
64
+ def provision_register_manifest(url)
65
+ request(:post, "/v1/provision/manifest", body: { url: url })
66
+ end
67
+
68
+ def provision_sync
69
+ request(:post, "/v1/provision/sync")
70
+ end
71
+
72
+ def provision_approve(type, name)
73
+ request(:post, "/v1/provision/items/#{segment(type)}/#{segment(name)}/approve")
74
+ end
75
+
76
+ def provision_delete(type, name)
77
+ request(:delete, "/v1/provision/items/#{segment(type)}/#{segment(name)}")
78
+ end
79
+
80
+ private
81
+
82
+ def default_http_client
83
+ requires_endpoint_policy = !@gateway.owner.system_admin? &&
84
+ !@gateway.desktop_loopback?
85
+ policy = EndpointPolicy.new if requires_endpoint_policy
86
+ Collavre::HttpClient.new(open_timeout: 5, read_timeout: 35, endpoint_policy: policy)
87
+ end
88
+
89
+ def request(method, path, body: nil)
90
+ headers = {
91
+ "Authorization" => "Bearer #{@gateway.admin_key}",
92
+ "Accept" => "application/json"
93
+ }
94
+ headers["X-CLI-Proxy-User-Key"] = @user_key if @user_key.present?
95
+ headers.merge!(Identity.headers(gateway: @gateway, workspace: @workspace, method: method, path: path)) if @workspace
96
+ headers["Content-Type"] = "application/json" if body
97
+
98
+ response = if method == :delete
99
+ @http_client.delete(@gateway.proxy_path(path), headers: headers)
100
+ elsif method == :get
101
+ @http_client.get(@gateway.proxy_path(path), headers: headers)
102
+ else
103
+ @http_client.public_send(
104
+ method,
105
+ @gateway.proxy_path(path),
106
+ body: body&.to_json,
107
+ headers: headers
108
+ )
109
+ end
110
+ parsed = parse_body(response)
111
+ return parsed || {} if response.success?
112
+
113
+ error = parsed.is_a?(Hash) ? (parsed["error"] || parsed) : {}
114
+ raise Error.new(
115
+ error["message"].presence || response.message,
116
+ status: response.code,
117
+ code: error["code"],
118
+ details: parsed
119
+ )
120
+ rescue Collavre::HttpClient::ConnectionError => e
121
+ raise Error.new(e.message, code: "proxy_unreachable")
122
+ rescue EndpointPolicy::UnsafeEndpoint
123
+ raise Error.new(I18n.t("collavre.agent_gateways.unsafe_endpoint"), code: "unsafe_proxy_endpoint")
124
+ end
125
+
126
+ # A failure may answer with a non-JSON body: the proxy itself answers JSON
127
+ # throughout, but anything in front of it (a reverse proxy, a gateway
128
+ # error page) does not. Keep the HTTP status in that case so a caller can
129
+ # branch on it — a 404 is how an endpoint this proxy version lacks reports
130
+ # itself — instead of collapsing it into a status-less parse error.
131
+ def parse_body(response)
132
+ response.json
133
+ rescue JSON::ParserError => e
134
+ raise Error.new("Invalid JSON from CLI proxy", details: e.message) if response.success?
135
+
136
+ nil
137
+ end
138
+
139
+ def segment(value)
140
+ value = value.to_s
141
+ raise ArgumentError, "Invalid path segment" unless value.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
142
+
143
+ value
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "resolv"
5
+ require "uri"
6
+
7
+ module Collavre
8
+ module CliProxy
9
+ class EndpointPolicy
10
+ class UnsafeEndpoint < StandardError; end
11
+
12
+ DISALLOWED_RANGES = [
13
+ IPAddr.new("0.0.0.0/8"),
14
+ IPAddr.new("10.0.0.0/8"),
15
+ IPAddr.new("100.64.0.0/10"),
16
+ IPAddr.new("127.0.0.0/8"),
17
+ IPAddr.new("169.254.0.0/16"),
18
+ IPAddr.new("172.16.0.0/12"),
19
+ IPAddr.new("192.0.0.0/24"),
20
+ IPAddr.new("192.0.2.0/24"),
21
+ IPAddr.new("192.168.0.0/16"),
22
+ IPAddr.new("198.18.0.0/15"),
23
+ IPAddr.new("198.51.100.0/24"),
24
+ IPAddr.new("203.0.113.0/24"),
25
+ IPAddr.new("224.0.0.0/4"),
26
+ IPAddr.new("240.0.0.0/4"),
27
+ IPAddr.new("::/128"),
28
+ IPAddr.new("::1/128"),
29
+ IPAddr.new("100::/64"),
30
+ IPAddr.new("2001:db8::/32"),
31
+ IPAddr.new("fc00::/7"),
32
+ IPAddr.new("fe80::/10"),
33
+ IPAddr.new("ff00::/8")
34
+ ].freeze
35
+
36
+ def initialize(resolver: Resolv)
37
+ @resolver = resolver
38
+ end
39
+
40
+ def resolve!(url)
41
+ uri = parsed_https_url!(url)
42
+
43
+ addresses = @resolver.getaddresses(uri.hostname).uniq
44
+ raise UnsafeEndpoint if addresses.empty? || addresses.any? { |address| unsafe_ip?(address) }
45
+
46
+ addresses
47
+ rescue URI::InvalidURIError, Resolv::ResolvError, SocketError, ArgumentError
48
+ raise UnsafeEndpoint
49
+ end
50
+
51
+ def safe_literal?(url)
52
+ uri = parsed_https_url!(url)
53
+
54
+ literal = IPAddr.new(uri.hostname)
55
+ !unsafe_ip?(literal)
56
+ rescue IPAddr::InvalidAddressError
57
+ !uri.hostname.casecmp("localhost").zero? && !uri.hostname.downcase.end_with?(".localhost")
58
+ rescue URI::InvalidURIError, UnsafeEndpoint
59
+ false
60
+ end
61
+
62
+ private
63
+
64
+ def parsed_https_url!(url)
65
+ uri = url.is_a?(URI) ? url : URI.parse(url.to_s)
66
+ raise UnsafeEndpoint unless uri.is_a?(URI::HTTPS) && uri.hostname.present?
67
+ raise UnsafeEndpoint if uri.userinfo.present? || uri.query.present? || uri.fragment.present?
68
+
69
+ uri
70
+ end
71
+
72
+ def unsafe_ip?(address)
73
+ ip = address.is_a?(IPAddr) ? address : IPAddr.new(address)
74
+ ip = ip.native if ip.ipv4_mapped?
75
+ DISALLOWED_RANGES.any? { |range| range.include?(ip) }
76
+ rescue IPAddr::InvalidAddressError
77
+ true
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Collavre
6
+ module CliProxy
7
+ class Identity
8
+ HEADER_NAMES = {
9
+ tenant: "X-CLI-Proxy-Tenant-ID",
10
+ user: "X-CLI-Proxy-User-ID",
11
+ workspace: "X-CLI-Proxy-Workspace-ID",
12
+ timestamp: "X-CLI-Proxy-Identity-Timestamp",
13
+ signature: "X-CLI-Proxy-Identity-Signature"
14
+ }.freeze
15
+
16
+ # The workspace header is part of the signed payload. Signing it outside
17
+ # the HMAC would let anyone holding a completion key retarget the request
18
+ # at another workspace of the same user.
19
+ PAYLOAD_VERSION = "v2"
20
+
21
+ def self.headers(gateway:, workspace:, method:, path:, at: Time.current)
22
+ secret = gateway.identity_secret.to_s
23
+ return {} if secret.blank?
24
+
25
+ timestamp = at.to_i.to_s
26
+ payload = [
27
+ PAYLOAD_VERSION,
28
+ method.to_s.upcase,
29
+ path,
30
+ timestamp,
31
+ gateway.tenant_id,
32
+ workspace.proxy_credential_id,
33
+ workspace.proxy_workspace_id
34
+ ].join("\n")
35
+ signature = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
36
+
37
+ {
38
+ HEADER_NAMES[:tenant] => gateway.tenant_id,
39
+ HEADER_NAMES[:user] => workspace.proxy_credential_id,
40
+ HEADER_NAMES[:workspace] => workspace.proxy_workspace_id,
41
+ HEADER_NAMES[:timestamp] => timestamp,
42
+ HEADER_NAMES[:signature] => signature
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday/net_http"
4
+
5
+ module Collavre
6
+ module CliProxy
7
+ class SafeNetHttpAdapter < Faraday::Adapter::NetHttp
8
+ def net_http_connection(env)
9
+ uri = env[:url]
10
+ pinned_ip = EndpointPolicy.new.resolve!(uri).first
11
+ port = uri.port || 443
12
+
13
+ Net::HTTP.new(uri.hostname, port, nil).tap do |http|
14
+ http.ipaddr = pinned_ip
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -36,16 +36,6 @@ module Collavre
36
36
  { name: "agent_name", type: "string", format: "mention", required: false, description: I18n.t("collavre.comments.command_menu.topic_agent_hint") }
37
37
  ]
38
38
  },
39
- {
40
- name: "work",
41
- label: "/work",
42
- description: I18n.t("collavre.comments.command_menu.work_description"),
43
- args: I18n.t("collavre.comments.command_menu.work_args"),
44
- input_schema: [
45
- { name: "agent_name", type: "string", format: "mention", required: true, description: I18n.t("collavre.comments.command_menu.work_agent_hint") },
46
- { name: "context", type: "string", required: false, description: I18n.t("collavre.comments.command_menu.work_context_hint") }
47
- ]
48
- },
49
39
  {
50
40
  name: "compress",
51
41
  label: "/compress",
@@ -20,8 +20,8 @@ module Collavre
20
20
 
21
21
  moved_count = perform_move(comments, target_origin, new_topic_id)
22
22
 
23
- Comment.broadcast_badges(@creative)
24
- Comment.broadcast_badges(target_origin) unless target_origin == @creative
23
+ Comment.broadcast_badges_later(@creative)
24
+ Comment.broadcast_badges_later(target_origin) unless target_origin == @creative
25
25
 
26
26
  { success: true, moved_count: moved_count }
27
27
  end
@@ -34,13 +34,14 @@ module Collavre
34
34
  if target_creative_id.present?
35
35
  target_creative = Creative.find_by(id: target_creative_id)
36
36
  raise MoveError, I18n.t("collavre.comments.move_invalid_target") unless target_creative
37
- [ target_creative.effective_origin, nil ]
37
+ target_origin = target_creative.effective_origin
38
+ [ target_origin, target_origin.main_topic.id ]
38
39
  elsif !target_topic_id.nil?
39
- new_topic_id = target_topic_id.presence
40
+ new_topic_id = target_topic_id.presence&.to_i
40
41
  if new_topic_id.present? && !creative.topics.exists?(id: new_topic_id)
41
42
  raise MoveError, I18n.t("collavre.comments.move_invalid_topic", default: "Invalid topic")
42
43
  end
43
- [ creative, new_topic_id ]
44
+ [ creative, new_topic_id || creative.main_topic.id ]
44
45
  else
45
46
  raise MoveError, I18n.t("collavre.comments.move_invalid_target")
46
47
  end
@@ -62,14 +63,24 @@ module Collavre
62
63
  def perform_move(comments, target_origin, new_topic_id)
63
64
  moved_count = 0
64
65
  ActiveRecord::Base.transaction do
66
+ locked_topics = lock_move_topics(comments, new_topic_id)
67
+ locked_source_creative = lock_legacy_source_creative(comments)
68
+ validate_destination_topic!(locked_topics, new_topic_id, target_origin)
69
+
65
70
  comments.each do |comment|
71
+ validate_source_comment!(comment, locked_topics, locked_source_creative)
66
72
  same_creative = comment.creative_id == target_origin.id
67
- same_topic = comment.topic_id.to_s == new_topic_id.to_s
68
- next if same_creative && same_topic
73
+ next if same_creative && comment.topic_id.to_s == new_topic_id.to_s
69
74
 
70
75
  if same_creative
76
+ preserve_unread_state_for_topic_move(comment, new_topic_id)
71
77
  comment.update!(topic_id: new_topic_id)
72
78
  else
79
+ preserve_unread_state_for_topic_move(
80
+ comment,
81
+ new_topic_id || target_origin.main_topic.id,
82
+ destination_creative: target_origin
83
+ )
73
84
  comment.update!(creative: target_origin, topic_id: new_topic_id)
74
85
  broadcast_move_removal(comment, comment.creative)
75
86
  end
@@ -79,6 +90,84 @@ module Collavre
79
90
  moved_count
80
91
  end
81
92
 
93
+ def lock_legacy_source_creative(comments)
94
+ return creative unless comments.any? { |comment| comment.topic_id.nil? }
95
+
96
+ Creative.lock.find(creative.id)
97
+ end
98
+
99
+ # TopicMove takes the source topic lock before sweeping its comments. Lock
100
+ # every source plus the destination in one deterministic order, so an
101
+ # existing-comment move cannot write a stale topic_id after that sweep.
102
+ def lock_move_topics(comments, destination_topic_id)
103
+ topic_ids = (comments.map(&:topic_id) + [ destination_topic_id ]).compact.uniq.sort
104
+ Topic.where(id: topic_ids).order(:id).lock.index_by(&:id)
105
+ end
106
+
107
+ def validate_destination_topic!(locked_topics, topic_id, target_origin)
108
+ return if locked_topics[topic_id]&.creative_id == target_origin.id
109
+
110
+ raise MoveError, I18n.t("collavre.comments.move_invalid_topic", default: "Invalid topic")
111
+ end
112
+
113
+ def validate_source_comment!(comment, locked_topics, locked_source_creative)
114
+ original_topic_id = comment.topic_id
115
+ comment.reload
116
+ source_topic = locked_topics[original_topic_id]
117
+ source_membership_valid = original_topic_id.nil? || source_topic&.creative_id == locked_source_creative.id
118
+ return if comment.creative_id == creative.id && comment.topic_id == original_topic_id &&
119
+ source_membership_valid
120
+
121
+ raise MoveError, I18n.t("collavre.comments.move_not_allowed")
122
+ end
123
+
124
+ # A topic watermark is an ordered cursor, so a comment moved into a topic
125
+ # whose cursor is already beyond its id would otherwise become read without
126
+ # the recipient seeing it. Lower only affected recipients' destination
127
+ # cursor to just before the moved comment; this is deliberately
128
+ # conservative and may re-show later destination comments as unread.
129
+ def preserve_unread_state_for_topic_move(comment, destination_topic_id, destination_creative: creative)
130
+ source_topic_id = comment.topic_id
131
+ readable_user_ids = CreativeShare.readable_user_ids_from_shares(
132
+ destination_creative,
133
+ readers_affected_by(comment, destination_creative)
134
+ )
135
+ readable_user_ids.each do |user_id|
136
+ source_pointers = CommentReadPointer.where(user_id: user_id, creative: creative).index_by(&:topic_id)
137
+ destination_pointers = if destination_creative == creative
138
+ source_pointers
139
+ else
140
+ CommentReadPointer.where(user_id: user_id, creative: destination_creative).index_by(&:topic_id)
141
+ end
142
+ source_watermark = watermark_for(source_pointers, source_topic_id)
143
+ destination_watermark = watermark_for(destination_pointers, destination_topic_id)
144
+ next unless source_watermark < comment.id && destination_watermark >= comment.id
145
+
146
+ destination_pointer = destination_pointers[destination_topic_id] || CommentReadPointer.find_or_create_by!(
147
+ user_id: user_id, creative: destination_creative, topic_id: destination_topic_id
148
+ )
149
+ destination_pointer.update!(last_read_comment_id: comment.id - 1)
150
+ end
151
+ end
152
+
153
+ # A named pointer with a nil cursor is an explicit initial watermark. It
154
+ # must win over the retained legacy fallback, otherwise a topic preserved
155
+ # when the legacy lane advanced is incorrectly treated as already read.
156
+ def watermark_for(pointers, topic_id)
157
+ return pointers.fetch(topic_id).last_read_comment_id.to_i if pointers.key?(topic_id)
158
+
159
+ pointers[nil]&.last_read_comment_id.to_i || 0
160
+ end
161
+
162
+ # Private comments are visible only to their author and approver. Rewinding
163
+ # another user's cursor would make unrelated destination comments unread.
164
+ def readers_affected_by(comment, destination_creative)
165
+ reader_ids = CommentReadPointer.where(creative: [ creative, destination_creative ]).distinct.pluck(:user_id)
166
+ return reader_ids unless comment.private?
167
+
168
+ reader_ids & [ comment.user_id, comment.approver_id ].compact
169
+ end
170
+
82
171
  def broadcast_move_removal(comment, original_creative)
83
172
  return if comment.private?
84
173
 
@@ -11,14 +11,13 @@ module Collavre
11
11
 
12
12
  def call
13
13
  ActiveRecord::Base.transaction do
14
- # Lock the snapshot row to prevent concurrent restore (P2 fix)
15
- locked_snapshot = CommentSnapshot.lock.find(@snapshot.id)
14
+ locked_snapshot = lock_snapshot
16
15
 
17
16
  if locked_snapshot.restored_at.present?
18
17
  raise RestoreError, I18n.t("collavre.comment_snapshots.already_restored")
19
18
  end
20
19
 
21
- validate_permission!
20
+ validate_permission!(locked_snapshot)
22
21
 
23
22
  # Build a mapping from old comment IDs to new comments for quoted_comment_id restoration
24
23
  old_id_to_new = {}
@@ -71,8 +70,15 @@ module Collavre
71
70
 
72
71
  private
73
72
 
74
- def validate_permission!
75
- creative = @snapshot.creative
73
+ # TopicMove locks topic -> snapshots. Match that order so restoration
74
+ # cannot deadlock relocation while recreating comments.
75
+ def lock_snapshot
76
+ Topic.lock.find(@snapshot.topic_id) if @snapshot.topic_id
77
+ CommentSnapshot.lock.find(@snapshot.id)
78
+ end
79
+
80
+ def validate_permission!(snapshot)
81
+ creative = snapshot.creative
76
82
  unless creative.has_permission?(@user, :write)
77
83
  raise RestoreError, I18n.t("collavre.comment_snapshots.not_authorized")
78
84
  end
@@ -179,7 +179,19 @@ module Collavre
179
179
 
180
180
  raise InvalidActionError, "Tool name is required" if tool_name.blank?
181
181
 
182
- # Execute the tool via MetaToolService
182
+ # Claim the paused turn before the tool runs. Stop stays reachable while a
183
+ # turn waits on approval, and stopping it leaves the approval comment on
184
+ # screen — approving afterwards would fire the side effect for a turn that
185
+ # is already gone, since the resumed job just exits on a cancelled task.
186
+ task = task_id.present? ? claim_pending_task!(task_id, tool_call_id) : nil
187
+
188
+ # Already claimed — hand back what that run recorded. A duplicate entry in
189
+ # one actions array shares the row lock, so the approval marker is the only
190
+ # thing standing between it and a second side effect plus a second resume.
191
+ if task&.pending_tool_call&.dig("approved")
192
+ return task.pending_tool_call["result"]
193
+ end
194
+
183
195
  result = begin
184
196
  ::Tools::MetaToolService.new.call(
185
197
  action: "call",
@@ -191,30 +203,46 @@ module Collavre
191
203
  { error: e.message }
192
204
  end
193
205
 
194
- # If there's a task to resume, update it and re-queue the job
195
- if task_id.present?
196
- task = Task.find_by(id: task_id)
197
- if task
198
- # Mark the tool call as approved with its result
199
- task.update!(
200
- pending_tool_call: {
201
- tool_name: tool_name,
202
- tool_call_id: tool_call_id,
203
- arguments: arguments,
204
- approved: true,
205
- result: result,
206
- approved_at: Time.current.iso8601
207
- }
208
- )
209
-
210
- # Resume the AI agent job
211
- AiAgentJob.perform_later(task)
212
- end
206
+ if task
207
+ task.update!(
208
+ pending_tool_call: {
209
+ tool_name: tool_name,
210
+ tool_call_id: tool_call_id,
211
+ arguments: arguments,
212
+ approved: true,
213
+ result: result,
214
+ approved_at: Time.current.iso8601
215
+ }
216
+ )
217
+
218
+ AiAgentJob.perform_later(task)
213
219
  end
214
220
 
215
221
  result
216
222
  end
217
223
 
224
+ # The row lock is held until the outer transaction commits, so a Stop racing
225
+ # this approval either blocks and lands after it or wins and makes the claim
226
+ # fail. A missing task keeps the pre-existing "run, nothing to resume" path.
227
+ # The status stays pending_approval on purpose: only AiAgentJob may promote
228
+ # it to running, since a Stop before the job starts relies on a
229
+ # HELD_SLOT_WITHOUT_WORKER status to release the slot it still holds.
230
+ def claim_pending_task!(task_id, tool_call_id)
231
+ task = Task.lock.find_by(id: task_id)
232
+ return nil unless task
233
+
234
+ unless task.status == "pending_approval"
235
+ raise InvalidActionError, I18n.t("collavre.comments.approve_task_not_pending")
236
+ end
237
+
238
+ # A re-paused turn leaves its earlier approval comment behind.
239
+ unless task.pending_tool_call&.dig("tool_call_id") == tool_call_id
240
+ raise InvalidActionError, I18n.t("collavre.comments.approve_task_superseded")
241
+ end
242
+
243
+ task
244
+ end
245
+
218
246
  def process_action(payload)
219
247
  unless payload.is_a?(Hash)
220
248
  raise InvalidActionError, I18n.t("collavre.comments.approve_invalid_format")
@@ -29,7 +29,6 @@ module Collavre
29
29
  [
30
30
  Collavre::Comments::CalendarCommand.new(comment: comment, user: user),
31
31
  Collavre::Comments::TopicCommand.new(comment: comment, user: user),
32
- Collavre::Comments::WorkCommand.new(comment: comment, user: user),
33
32
  Collavre::Comments::CompressCommand.new(comment: comment, user: user)
34
33
  ]
35
34
  end