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,792 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # What a turn actually handed its agent, written down at the moment it was
6
+ # handed over.
7
+ #
8
+ # A burst of external events (a PR comment sync, a webhook batch) creates
9
+ # several comments in one topic within milliseconds.
10
+ # Orchestration::TaskCoalescer folds the *un-started* tasks of that burst
11
+ # together, but it cannot help the comment that arrives while a turn is
12
+ # already running: that dispatch parks a waiter, and the agent speaks a
13
+ # second time when the waiter is promoted.
14
+ #
15
+ # For a non-session agent the second turn is usually redundant.
16
+ # AiAgent::MessageBuilder#append_chat_history reads the topic's comments at
17
+ # *execution* time, so a comment committed before the payload was assembled
18
+ # is already inside the running turn's context — the agent has read it.
19
+ #
20
+ # For a session-backed agent it is not redundant at all:
21
+ # AiAgent::SessionContextResolver#incremental_payload keeps only the
22
+ # :trigger message, so the running turn never carried that text. Dropping
23
+ # the dispatch there would be message loss rather than de-duplication, and
24
+ # coalescing (merge into "merged_comment_ids") stays the right answer.
25
+ #
26
+ # So "the agent is busy" is the wrong predicate and "the agent has already
27
+ # been given that comment" is the right one — and the only place that can
28
+ # answer it is the resolved payload itself. This module records the answer
29
+ # off that payload and is the single door every reader goes through.
30
+ module DeliveryRecord
31
+ # Comment ids a turn delivered as chat history although it was not created
32
+ # for them. Sits beside TaskCoalescer::PAYLOAD_KEY and
33
+ # TaskCoalescer::ACQUIRED_ANCHOR_KEY, which record the other two ways a
34
+ # turn ends up carrying a comment it was not dispatched for.
35
+ KEY = "history_delivered_comment_ids"
36
+
37
+ # Comment ids whose dispatch this turn actually refused.
38
+ #
39
+ # A strict subset of KEY, and the distinction matters because the two are
40
+ # read for opposite purposes. KEY answers "has the agent been given this
41
+ # comment?", and chat history is the whole topic — Matcher's exclusive
42
+ # routings do not filter it, so every agent in a topic reads every public
43
+ # comment in it, including ones addressed to somebody else. That is the
44
+ # right predicate for the drop, which is only ever asked downstream of
45
+ # Matcher about a dispatch that already exists.
46
+ #
47
+ # It is the wrong predicate for the restore, whose subject is dispatches
48
+ # and which creates one per id it decides on. Reading KEY there turns a
49
+ # comment routed past this agent into a turn for it, past Matcher
50
+ # entirely; and it reads a dispatch still sitting in the job queue — an
51
+ # :immediate decision's Task row is created only when its job runs — as a
52
+ # discarded one, putting a second turn on a comment that already has one.
53
+ # Neither is visible in the absence of a Task row, so the drop is written
54
+ # down instead of reconstructed.
55
+ DROPPED_KEY = "dropped_dispatch_comment_ids"
56
+
57
+ # This turn asked the provider for an answer and the request never got
58
+ # there.
59
+ #
60
+ # Every other reader here decides off the turn's *status*, which works
61
+ # while the endings line up with what was delivered. This one does not:
62
+ # AiClient#chat catches the provider error, streams "⚠️ AI Error" into
63
+ # the reply and returns nil, and AiAgentJob marks an ordinary task `done`
64
+ # on that — the single status every reader counts as delivery. So a turn
65
+ # can end in the delivered ending having handed over nothing at all, and
66
+ # the comments it discarded were read by nobody.
67
+ #
68
+ # Recorded rather than inferred, because nothing else in the row says it:
69
+ # `done` with an error in the reply text looks exactly like `done`. The
70
+ # only place that knows is the service holding the client that failed.
71
+ HANDOFF_FAILED_KEY = "handoff_failed"
72
+
73
+ # This turn did hand its payload to the provider — the same question,
74
+ # asked positively, for the endings that never reach the line above.
75
+ #
76
+ # HANDOFF_FAILED_KEY is written after #chat returns, so a turn that leaves
77
+ # by exception never records anything: a user pressing Stop mid-answer
78
+ # ends `cancelled`, which every reader counts as undelivered outright,
79
+ # although the provider has the payload by then and AgentLifecycleManager
80
+ # keeps the partial reply. The comments that turn swallowed were read
81
+ # along with it, so restoring them starts fresh work the moment the user
82
+ # asked for none, and answers them twice.
83
+ #
84
+ # Positive rather than a second failure flag, because the absence of the
85
+ # record has to keep meaning "restore": most cancellations and every hard
86
+ # crash write nothing at all, and losing a comment costs more than a
87
+ # redundant turn. Only a turn that got far enough to say so is exempt.
88
+ HANDED_OFF_KEY = "handed_off"
89
+
90
+ # The comment ids an attempt that handed off actually carried.
91
+ #
92
+ # HANDED_OFF_KEY answers for the *turn*, and that is enough while a turn
93
+ # is one attempt. It is not: a tool approval pauses the turn and
94
+ # Comments::ActionExecutor resumes the same Task row, so AiAgentService
95
+ # runs over it again with a fresh client. The first pass reached its tools,
96
+ # which means the provider had that payload; if the resumed request then
97
+ # never lands, the row carries both records, Task#ended_undelivered? reads
98
+ # the failure first, and the restore puts back every dispatch ever dropped
99
+ # against the turn — including the ones the first pass handed over.
100
+ #
101
+ # So the exemption is recorded per comment, which is the subject the
102
+ # restore actually has. KEY at the moment of the handoff is exactly what
103
+ # went over: record! writes it before the request, and a drop taken later
104
+ # against an id already in KEY was in that payload too. Accumulates across
105
+ # attempts for the same reason KEY does — what one pass delivered it still
106
+ # delivered — and a comment only the failed pass read is not in it, so it
107
+ # is still owed back.
108
+ HANDED_OFF_IDS_KEY = "handed_off_comment_ids"
109
+
110
+ # Comments this turn's restore has already put back on their way.
111
+ #
112
+ # AiAgentJob creates the Task row when it *runs*, so between a restored
113
+ # enqueue and its execution the comment has no row and reads as orphaned —
114
+ # a worker outage or a backlog longer than the sweep interval therefore
115
+ # had every pass enqueue another copy, and the copies that lose the
116
+ # admission race become independent waiters that answer the comment again.
117
+ # Eventual Task creation cannot be the idempotency marker for work that
118
+ # has not started.
119
+ #
120
+ # Written before the enqueue and given back if the enqueue raises, so the
121
+ # claim never outlives the dispatch it stands for: a comment claimed by an
122
+ # enqueue that never happened is the previous finding pointing the other
123
+ # way.
124
+ RESTORED_KEY = "restored_dispatch_comment_ids"
125
+
126
+ # StuckDetector failed this `running` row from outside its worker while
127
+ # the worker may still be inside the provider call.
128
+ #
129
+ # The failed status alone cannot say whether the payload reached the
130
+ # provider: AiAgentService writes that evidence only when the call returns
131
+ # or raises. The status callback therefore defers while this marker is
132
+ # present. The live worker clears it in AiAgentJob's ensure; if the worker
133
+ # died outright, restore_missed! waits out the provider timeout and asks
134
+ # again.
135
+ WORKER_SETTLING_KEY = "delivery_worker_settling"
136
+
137
+ # Everything a turn writes onto its own payload while it runs, as opposed
138
+ # to what it was dispatched with.
139
+ #
140
+ # One list rather than a strip enumerated at the point of use: a restored
141
+ # dispatch is the one that was discarded, so it carries what a dispatch
142
+ # carries and nothing the turn wrote onto itself afterwards. Enumerating
143
+ # that at the call site is how HANDOFF_FAILED_KEY came to be left in — it
144
+ # was added here long after restored_context had named the other four —
145
+ # and the drift test on this constant is what will catch the sixth.
146
+ TURN_SCOPED_KEYS = [
147
+ KEY, DROPPED_KEY, HANDOFF_FAILED_KEY, HANDED_OFF_KEY, HANDED_OFF_IDS_KEY,
148
+ RESTORED_KEY, WORKER_SETTLING_KEY, TaskCoalescer::PAYLOAD_KEY,
149
+ TaskCoalescer::ACQUIRED_ANCHOR_KEY
150
+ ].freeze
151
+
152
+ # Statuses in which a turn is still the thing that will answer.
153
+ #
154
+ # Narrower than AgentOrchestrator::DELIVERED_STATUSES, which also counts
155
+ # `done`, and deliberately so: the two readers ask different questions.
156
+ # The promotion door asks "may this *parked* waiter still speak?", and a
157
+ # waiter that may not simply loses its turn — it had no independent claim
158
+ # on the slot. This module's other reader is the dispatch door, where the
159
+ # alternative to running is discarding the dispatch outright. A turn that
160
+ # has already finished is no longer answering anything, so a comment
161
+ # arriving after it gets its own turn rather than being silently dropped.
162
+ #
163
+ # `queued`/`pending` are excluded at both: nothing has been delivered yet.
164
+ IN_FLIGHT_STATUSES = %w[running delegated pending_approval].freeze
165
+
166
+ # Terminal statuses in which the turn delivered nothing after all.
167
+ #
168
+ # The complement of AgentOrchestrator::DELIVERED_STATUSES over the
169
+ # terminal set, and that is the whole point: promotion says a dead turn
170
+ # silences nothing by leaving these statuses out of its list, and a parked
171
+ # waiter survives on that — it is still a row, and the refresh re-reads
172
+ # the covering turn's status before deciding anything.
173
+ #
174
+ # A *dropped* dispatch has no row to re-read anything. So the drop owes
175
+ # the same answer through the only means it has left: putting the
176
+ # discarded dispatch back. Without it the two doors disagree about what a
177
+ # failure means, and the disagreement costs a comment nobody answers.
178
+ #
179
+ # DeliveryRecordTest asserts this is exactly the complement; a status
180
+ # added to one list and not the other is how they drift apart.
181
+ UNDELIVERED_TERMINAL_STATUSES = %w[failed cancelled escalated].freeze
182
+
183
+ # Statuses a row can be in and still owe a restore — the undelivered
184
+ # endings, plus the one that reads as delivered until the payload is
185
+ # consulted. Derived rather than listed so the sweep cannot come to scan
186
+ # for less than Task#ended_undelivered? decides on.
187
+ RESTORABLE_STATUSES = (UNDELIVERED_TERMINAL_STATUSES + %w[done]).freeze
188
+
189
+ # How far back restore_missed! looks.
190
+ #
191
+ # Comfortably more than its schedule, so a restore the callback lost gets
192
+ # several attempts, and finite because it is a backstop: a comment whose
193
+ # turn ended an hour ago has been overtaken by whatever the topic did
194
+ # since, and re-dispatching it then would be the more surprising answer.
195
+ RESTORE_SWEEP_WINDOW = 1.hour
196
+
197
+ # Slack added to the provider timeout when waiting for a worker that
198
+ # another process ended to settle — see .worker_settle_grace.
199
+ WORKER_SETTLE_SLACK = 5.minutes
200
+
201
+ # Record what `resolved` carries as chat history.
202
+ #
203
+ # Measured off the *resolved* payload — the messages the adapter is handed
204
+ # — rather than off MessageBuilder's output, because the session filter
205
+ # sits between the two and is exactly the distinction that decides whether
206
+ # a comment was delivered at all. A future filter added there is then
207
+ # accounted for without a second place having to learn about it.
208
+ #
209
+ # Restricted to ids above the turn's anchor. Chat history normally holds
210
+ # the topic's backlog, which this turn did not swallow and must not
211
+ # silence; a history comment *newer* than the anchor can only have landed
212
+ # after this turn was dispatched, which is the burst case and the only one
213
+ # this record is for. Ids rather than created_at: a burst is written by
214
+ # several processes and the clock is whichever one wrote the row (the same
215
+ # reason AiAgent::MergedTriggerComments orders by id).
216
+ #
217
+ # Accumulates. A resumed turn (pending_approval, or a retry) assembles a
218
+ # second time, and what the first pass delivered it still delivered.
219
+ #
220
+ # Written onto the row's payload under the same lock as claim_drop!, not
221
+ # onto the caller's. A resumed turn's task object is loaded before it
222
+ # assembles, and a dispatch refused in the meantime writes DROPPED_KEY
223
+ # into the same JSON column from another process; a merge onto the stale
224
+ # copy would erase the drop, and a drop no restore can find is the
225
+ # message lost. Both writers of this column now read it under the lock.
226
+ def self.record!(task, resolved)
227
+ return if task.nil?
228
+
229
+ Task.transaction do
230
+ row = Task.lock.find_by(id: task.id)
231
+ payload = row&.trigger_event_payload
232
+ next unless payload.is_a?(Hash)
233
+
234
+ anchor_id = payload.dig("comment", "id").to_i
235
+ next if anchor_id.zero?
236
+
237
+ swallowed = history_comment_ids(resolved).select { |id| id > anchor_id }
238
+ merged = (ids_in(payload) + swallowed).uniq.sort
239
+ next if merged == ids_in(payload)
240
+
241
+ row.update!(trigger_event_payload: payload.merge(KEY => merged))
242
+ task.reload unless task.equal?(row)
243
+ end
244
+ end
245
+
246
+ def self.ids_in(payload)
247
+ return [] unless payload.is_a?(Hash)
248
+
249
+ Array(payload[KEY]).compact.map(&:to_i)
250
+ end
251
+
252
+ # Write down that this turn's request never reached the provider.
253
+ #
254
+ # Under the same row lock as record! and claim_drop!, for the same
255
+ # reason: a dispatch refused in another process writes DROPPED_KEY into
256
+ # this JSON column while the turn runs, and a merge onto the caller's
257
+ # copy would erase it.
258
+ def self.mark_handoff_failed!(task)
259
+ return if task.nil?
260
+
261
+ Task.transaction do
262
+ row = Task.lock.find_by(id: task.id)
263
+ payload = row&.trigger_event_payload
264
+ next unless payload.is_a?(Hash)
265
+ next if handoff_failed?(payload)
266
+
267
+ row.update!(trigger_event_payload: payload.merge(HANDOFF_FAILED_KEY => true))
268
+ task.reload unless task.equal?(row)
269
+ end
270
+ end
271
+
272
+ def self.handoff_failed?(payload)
273
+ return false unless payload.is_a?(Hash)
274
+
275
+ payload[HANDOFF_FAILED_KEY] == true
276
+ end
277
+
278
+ # Write down that this turn's payload did reach the provider.
279
+ #
280
+ # Under the same row lock and for the same reason as its two siblings: a
281
+ # dispatch refused in another process writes DROPPED_KEY into this column
282
+ # while the turn runs, and this is written as the turn is being torn down
283
+ # by the very cancellation that dispatch is racing.
284
+ def self.mark_handed_off!(task)
285
+ return if task.nil?
286
+
287
+ Task.transaction do
288
+ row = Task.lock.find_by(id: task.id)
289
+ payload = row&.trigger_event_payload
290
+ next unless payload.is_a?(Hash)
291
+
292
+ # Not `next if handed_off?`: a resumed attempt records the handoff a
293
+ # second time, and what it carried this time is the point of asking.
294
+ recorded = handed_off_ids_in(payload)
295
+ covered = (recorded + ids_in(payload)).uniq.sort
296
+ next if handed_off?(payload) && covered == recorded
297
+
298
+ row.update!(trigger_event_payload: payload.merge(
299
+ HANDED_OFF_KEY => true, HANDED_OFF_IDS_KEY => covered
300
+ ))
301
+ task.reload unless task.equal?(row)
302
+ end
303
+ end
304
+
305
+ def self.handed_off?(payload)
306
+ return false unless payload.is_a?(Hash)
307
+
308
+ payload[HANDED_OFF_KEY] == true
309
+ end
310
+
311
+ def self.handed_off_ids_in(payload)
312
+ return [] unless payload.is_a?(Hash)
313
+
314
+ Array(payload[HANDED_OFF_IDS_KEY]).compact.map(&:to_i)
315
+ end
316
+
317
+ # Fail a running row from outside its worker without letting the status
318
+ # callback decide from evidence the worker has not written yet.
319
+ #
320
+ # Status and marker are one locked update. A marker written before a
321
+ # separate status update could be observed by the sweep while the row was
322
+ # still running; one written afterwards is too late for the callback.
323
+ def self.fail_while_worker_settles!(task)
324
+ return false if task.nil?
325
+
326
+ failed = false
327
+ Task.transaction do
328
+ row = Task.lock.find_by(id: task.id)
329
+ next unless row&.status == "running"
330
+
331
+ payload = row.trigger_event_payload
332
+ payload = {} unless payload.is_a?(Hash)
333
+ row.update!(
334
+ status: "failed",
335
+ trigger_event_payload: payload.merge(WORKER_SETTLING_KEY => true)
336
+ )
337
+ failed = true
338
+ end
339
+ task.reload if failed
340
+ failed
341
+ end
342
+
343
+ def self.worker_settling?(payload) = payload.is_a?(Hash) && payload[WORKER_SETTLING_KEY] == true
344
+
345
+ # The original worker has left the provider call, so the handoff records
346
+ # now carry the best answer this attempt can give. Clearing under the row
347
+ # lock preserves any drop written by another process in the meantime.
348
+ def self.settle_worker!(task)
349
+ return if task.nil?
350
+
351
+ Task.transaction do
352
+ row = Task.lock.find_by(id: task.id)
353
+ payload = row&.trigger_event_payload
354
+ next unless worker_settling?(payload)
355
+
356
+ row.update!(trigger_event_payload: payload.except(WORKER_SETTLING_KEY))
357
+ end
358
+ task.reload
359
+ end
360
+
361
+ def self.restored_ids_in(payload) = payload.is_a?(Hash) ? Array(payload[RESTORED_KEY]).compact.map(&:to_i) : []
362
+
363
+ # Take responsibility for putting one dispatch back, or refuse it because
364
+ # somebody already has.
365
+ #
366
+ # The same shape as claim_drop!, and for the same reason: the callback and
367
+ # the sweep can be looking at this row at once, and the thing they would
368
+ # both act on leaves no trace until a worker picks it up.
369
+ #
370
+ # @return [Boolean] whether the caller may enqueue the dispatch
371
+ def self.claim_restore!(task, comment_id)
372
+ id = comment_id.to_i
373
+ return false if task.nil? || id.zero?
374
+
375
+ claimed = false
376
+ Task.transaction do
377
+ row = Task.lock.find_by(id: task.id)
378
+ payload = row&.trigger_event_payload
379
+ next unless payload.is_a?(Hash)
380
+ next if restored_ids_in(payload).include?(id)
381
+
382
+ restored = (restored_ids_in(payload) + [ id ]).uniq.sort
383
+ row.update!(trigger_event_payload: payload.merge(RESTORED_KEY => restored))
384
+ claimed = true
385
+ end
386
+ claimed
387
+ end
388
+
389
+ # Give the claim back for a dispatch that was never enqueued after all.
390
+ def self.release_restore_claim!(task, comment_id)
391
+ id = comment_id.to_i
392
+ return if task.nil? || id.zero?
393
+
394
+ Task.transaction do
395
+ row = Task.lock.find_by(id: task.id)
396
+ payload = row&.trigger_event_payload
397
+ next unless payload.is_a?(Hash)
398
+
399
+ restored = restored_ids_in(payload) - [ id ]
400
+ row.update!(trigger_event_payload: payload.merge(RESTORED_KEY => restored))
401
+ end
402
+ end
403
+
404
+ def self.dropped_ids_in(payload) = payload.is_a?(Hash) ? Array(payload[DROPPED_KEY]).compact.map(&:to_i) : []
405
+
406
+ def self.pending_restoration?(task)
407
+ payload = task&.trigger_event_payload
408
+ return false unless task&.ended_undelivered? && task.agent
409
+
410
+ pending = dropped_ids_in(payload) - handed_off_ids_in(payload) - claimed_comment_ids(task)
411
+ restorable_comments(task, pending).exists?
412
+ end
413
+
414
+ # Take responsibility for a dispatch about to be discarded, or refuse it.
415
+ #
416
+ # The drop and its record are one act, and this is where they are made
417
+ # one. `covering_task` answers off the caller's snapshot; between that
418
+ # answer and this write the turn can end, run its restore, and leave —
419
+ # and a record written after that restore is a comment nobody ever comes
420
+ # back for. So the status is re-read from the locked row, and a caller
421
+ # told `false` dispatches normally instead of dropping. Refusing a sound
422
+ # drop costs a redundant turn; taking an unsound one costs the message.
423
+ #
424
+ # @return [Boolean] whether the caller may discard the dispatch
425
+ def self.claim_drop!(covering, comment_id)
426
+ id = comment_id.to_i
427
+ return false if covering.nil? || id.zero?
428
+
429
+ claimed = false
430
+ Task.transaction do
431
+ task = Task.lock.find_by(id: covering.id)
432
+ payload = task&.trigger_event_payload
433
+ next unless task&.status.to_s.in?(IN_FLIGHT_STATUSES) && payload.is_a?(Hash)
434
+
435
+ dropped = (dropped_ids_in(payload) + [ id ]).uniq.sort
436
+ task.update!(trigger_event_payload: payload.merge(DROPPED_KEY => dropped))
437
+ claimed = true
438
+ end
439
+ claimed
440
+ end
441
+
442
+ # The in-flight turn that has already given `comment_id` to `agent`, or nil.
443
+ #
444
+ # Every gate that drops a dispatch goes through this one method: the
445
+ # orchestrator's enqueue door, AiAgentJob's late-admission door, and any
446
+ # later one. Two doors that ask this question separately are how they come
447
+ # to disagree about it.
448
+ #
449
+ # Scoped exactly like AgentOrchestrator.delivered_comment_ids — same agent,
450
+ # topic, creative and trigger event — so a dispatch in another
451
+ # creative, or a different event over the same comment, is a different
452
+ # question and not an answer to this one.
453
+ def self.covering_task(agent, comment_id, context, trigger_event_name)
454
+ return nil if agent.nil? || comment_id.blank?
455
+ return nil unless context.is_a?(Hash) && context.key?("topic")
456
+ return nil unless PolicyResolver.new(context).drop_delivered_dispatches_for?(agent)
457
+ # A review request is bound to the comment it quotes and can only run as
458
+ # its own turn: AiAgentService and ReviewHandler read review behaviour
459
+ # off the anchor, never off the history window. Having been read as
460
+ # context is not having been answered, so a review is never dropped —
461
+ # the same boundary TaskCoalescer and refresh_deferred_context! keep.
462
+ return nil if Comment.review_message_ids([ comment_id ]).any?
463
+
464
+ Task.where(
465
+ agent_id: agent.id,
466
+ topic_id: context.dig("topic", "id"),
467
+ creative_id: context.dig("creative", "id"),
468
+ trigger_event_name: trigger_event_name,
469
+ status: IN_FLIGHT_STATUSES
470
+ ).select(:id, :trigger_event_payload).find do |task|
471
+ payload = task.trigger_event_payload
472
+ # A turn still `running` after its request failed is not going to
473
+ # answer anything, and dropping against it would record a drop after
474
+ # the restore that ends it has already looked.
475
+ next false if handoff_failed?(payload)
476
+
477
+ ids_in(payload).include?(comment_id.to_i)
478
+ end
479
+ end
480
+
481
+ # Put back the dispatches this turn's record caused to be discarded, now
482
+ # that the turn has ended without delivering them.
483
+ #
484
+ # Driven off Task's status callback rather than from AiAgentJob's rescue:
485
+ # the job is not the only door onto a failed turn — StuckDetector fails a
486
+ # task that never reached a rescue at all — and a restore wired to one of
487
+ # them is a restore that does not happen on the other.
488
+ #
489
+ # Its subject is DROPPED_KEY — the dispatches this turn refused — and not
490
+ # KEY, which is merely everything it read. Only a refused dispatch is a
491
+ # thing this turn owes back; see DROPPED_KEY for what taking the wider
492
+ # set costs.
493
+ #
494
+ # Narrowed once more by what nothing else is on the hook for. A comment
495
+ # swept into this turn's history may still have a waiter of its own
496
+ # (parked before the turn assembled, so never droppable), and promotion
497
+ # decides that waiter's fate by re-reading this turn's status. Restoring
498
+ # it as well would put two turns on one comment. Belt to DROPPED_KEY's
499
+ # braces: the record already excludes what was never dropped, and this
500
+ # excludes what has since acquired a row of its own.
501
+ #
502
+ # And once more by HANDED_OFF_IDS_KEY, for the turn that ran more than
503
+ # once: a comment an earlier attempt handed to the provider is not owed
504
+ # back by a later attempt's failure.
505
+ #
506
+ # Each orphan is re-dispatched on its own, exactly as it would have been
507
+ # had it never been dropped, and the ordinary admission path folds them
508
+ # back together — rather than this method inventing a second merge rule
509
+ # beside TaskCoalescer's.
510
+ def self.restore!(task)
511
+ # Re-read the row rather than trust the caller's copy. The drop record
512
+ # is written by whichever dispatch was refused, in another process
513
+ # entirely, onto a row this turn's own object was loaded before — so
514
+ # the in-memory payload that reaches this callback is exactly the one
515
+ # that predates every drop it is being asked about.
516
+ payload = Task.find_by(id: task.id)&.trigger_event_payload
517
+ return unless payload.is_a?(Hash) && payload.key?("topic")
518
+
519
+ orphaned = dropped_ids_in(payload) - handed_off_ids_in(payload) -
520
+ claimed_comment_ids(task) - restored_ids_in(payload)
521
+ return if orphaned.empty?
522
+
523
+ agent = task.agent
524
+ return if agent.nil?
525
+
526
+ # Same eligibility the refresh applies when it moves an anchor: a
527
+ # comment that was deleted, made private, or turned into an approval
528
+ # action while the turn ran has nothing left to answer.
529
+ restorable_comments(task, orphaned).order(:id).each do |comment|
530
+ begin
531
+ enqueue_restored(task, agent, task.trigger_event_name, restored_context(payload, comment))
532
+ rescue StandardError => e
533
+ # Each orphan is a dispatch of its own; one that cannot be enqueued
534
+ # says nothing about the next. What it leaves behind is the absence
535
+ # of a row, which is exactly what restore_missed! reads.
536
+ Rails.logger.error(
537
+ "[DeliveryRecord] Failed to restore dispatch for comment #{comment.id} " \
538
+ "of task #{task.id}: #{e.class}: #{e.message}"
539
+ )
540
+ end
541
+ end
542
+ end
543
+
544
+ # Ask again for every turn whose restore may not have been made.
545
+ #
546
+ # restore! runs from an after_update_commit callback, which is to say
547
+ # after the covering turn's status is already committed: a transient
548
+ # failure there has nothing left to roll back and no later transition that
549
+ # would retry it, and the dropped dispatch is the one thing in this
550
+ # feature with no row of its own to fall back on.
551
+ #
552
+ # Nothing extra is persisted for it, because the orphan set already is:
553
+ # DROPPED_KEY is on the row and claimed_comment_ids subtracts whatever has
554
+ # since acquired a Task, so a comment stays orphaned exactly until its
555
+ # dispatch exists again and restore! is idempotent by construction. What
556
+ # was lost is only the asking.
557
+ #
558
+ # A dispatch the Scheduler refuses leaves no row either, so it is asked
559
+ # again on each pass through the window — which is the right answer for a
560
+ # quota that has since reset, and a bounded number of cheap refusals if it
561
+ # has not.
562
+ # How long after another process ends a worker this sweep waits before
563
+ # deciding it.
564
+ #
565
+ # Task#ended_while_worker_settles? explains why the status callback
566
+ # declines: Stop and StuckDetector both commit from another process while
567
+ # the worker can be inside AiClient#chat, and the worker's account of the
568
+ # handoff is written only on the way out. This door cannot tell "still
569
+ # settling" from "nobody is coming" either. Without a wait it answers in
570
+ # the callback's place and re-dispatches everything the turn swallowed.
571
+ #
572
+ # Derived from the configured provider timeout because that is what bounds
573
+ # the wait in the shape this is for: Stop pressed while the turn is still
574
+ # waiting for its first token, where AgentLifecycleManager#check_cancelled!
575
+ # cannot fire at all because it polls from inside the streaming block.
576
+ #
577
+ # It is not a bound on a conversation that keeps making requests without
578
+ # yielding content — a tool loop can outlast any grace, and for that one
579
+ # this sweep can still be early. What that costs is a duplicate reply;
580
+ # declining these endings outright would cost the comment when the worker
581
+ # died before it could clear the marker.
582
+ def self.worker_settle_grace
583
+ SystemSetting.llm_request_timeout_seconds.seconds + WORKER_SETTLE_SLACK
584
+ end
585
+
586
+ def self.restore_missed!(window: RESTORE_SWEEP_WINDOW)
587
+ grace = worker_settle_grace
588
+
589
+ [
590
+ # The endings whose status is written after the worker is out of the
591
+ # provider call, so the row is as settled as it will ever be.
592
+ Task.where(status: RESTORABLE_STATUSES - %w[cancelled failed])
593
+ .where(updated_at: window.ago..),
594
+ # And the one that is not. The window moves with the wait rather than
595
+ # being spent by it: subtracting the grace from a fixed horizon would
596
+ # leave a cancellation less backstop than every other ending gets, and
597
+ # at the default timeout almost none.
598
+ Task.where(status: "cancelled")
599
+ .where(updated_at: (window + grace).ago..grace.ago)
600
+ ].each do |scope|
601
+ scope.find_each { |task| restore_if_undelivered!(task) }
602
+ end
603
+
604
+ # A normal failed ending is settled and keeps the ordinary window. A
605
+ # StuckDetector failure carries WORKER_SETTLING_KEY and gets the same
606
+ # grace as cancellation. Read the marker in Ruby so this stays portable
607
+ # across the JSON implementations used by SQLite tests and PostgreSQL.
608
+ Task.where(status: "failed")
609
+ .where(updated_at: (window + grace).ago..)
610
+ .find_each do |task|
611
+ settling = worker_settling?(task.trigger_event_payload)
612
+ next if settling && task.updated_at > grace.ago
613
+ next if !settling && task.updated_at < window.ago
614
+
615
+ restore_if_undelivered!(task)
616
+ end
617
+ end
618
+
619
+ # Ask the restore question of a turn that has settled.
620
+ #
621
+ # The one door for every caller that is not Task's status callback: this
622
+ # sweep, and AiAgentJob's cancellation teardown, which is where a turn
623
+ # stopped mid-answer settles — see Task#ended_while_worker_settles? for why
624
+ # the cancel itself cannot decide. Gated on the same predicate the callback
625
+ # is, because a second door deciding "terminal" where the callback decides
626
+ # Task#ended_undelivered? is how one of them comes to re-answer comments the
627
+ # turn had already answered.
628
+ #
629
+ # Best effort, like the callback's: the teardown runs inside the job's last
630
+ # rescue, where an escaping exception would turn a cancelled turn into a
631
+ # failed job, and the sweep must not lose the rest of its pass to one row.
632
+ # Nothing is lost by swallowing — the orphan set is on the row, so the next
633
+ # pass asks again.
634
+ def self.restore_if_undelivered!(task)
635
+ return if task.nil? || !task.ended_undelivered?
636
+
637
+ restore!(task)
638
+ rescue StandardError => e
639
+ Rails.logger.error(
640
+ "[DeliveryRecord] Restore failed for task #{task.id}: #{e.class}: #{e.message} — " \
641
+ "leaving it to the restore sweep"
642
+ )
643
+ end
644
+
645
+ # Put the dispatch back the way the orchestrator would have, by asking the
646
+ # same Scheduler again.
647
+ #
648
+ # The decision the drop discarded was a decision about *then*. A dispatch
649
+ # refused while the agent was at its concurrency limit or rate-limited
650
+ # carried a backoff with it, and enqueuing it now would run it past the
651
+ # check that set that delay — while the dying turn's own ResourceTracker
652
+ # slot is very often still held, since this fires from its status
653
+ # callback. Replaying the delay recorded at drop time would be no better:
654
+ # by now it describes pressure that may be long gone or much worse.
655
+ #
656
+ # Asked again, not merely honoured: a quota exhausted or a loop breaker
657
+ # tripped between the drop and the restore refuses this dispatch exactly
658
+ # as it would refuse a fresh one. AgentOrchestrator's own guard keeps
659
+ # rejected work out of the record; this is the other end of the same
660
+ # question, and only the scheduler can answer it as of now.
661
+ #
662
+ # Enqueued through AiAgentJob rather than AgentOrchestrator#enqueue_jobs
663
+ # even for a :deferred decision, because the job door re-asks
664
+ # Matcher#assignment_permits? against the re-anchored payload before it
665
+ # parks anything — a restored dispatch has moved, and park_waiter would
666
+ # create the row without that question being asked.
667
+ def self.enqueue_restored(task, agent, event_name, context)
668
+ # The one question no door on this path asks. Naming the agent skips
669
+ # Matcher#match, and every routing path in #match is gated on feedback
670
+ # permission for the creative; AiAgentJob re-asks the topic assignment
671
+ # and nothing else. A permission revoked between the drop and the
672
+ # restore would otherwise be seen by nobody, and this dispatch would
673
+ # answer a creative the agent may no longer read.
674
+ unless Matcher.permits_creative_access?(context, agent)
675
+ Rails.logger.info(
676
+ "[DeliveryRecord] Not restoring dispatch for comment #{context.dig("comment", "id")}: " \
677
+ "agent #{agent.id} no longer has feedback permission on creative " \
678
+ "#{context.dig("creative", "id")}"
679
+ )
680
+ return
681
+ end
682
+
683
+ decision = Scheduler.new(context).schedule([ agent ]).first
684
+ return if decision.nil?
685
+
686
+ comment_id = context.dig("comment", "id")
687
+ case decision[:timing]
688
+ when :rejected
689
+ Rails.logger.info(
690
+ "[DeliveryRecord] Not restoring dispatch for comment #{comment_id}: " \
691
+ "scheduler rejected agent #{agent.id} (#{decision[:reason]})"
692
+ )
693
+ when :delayed
694
+ enqueue_claimed(task, comment_id) do
695
+ AiAgentJob.set(wait: decision[:delay]).perform_later(agent.id, event_name, context)
696
+ end
697
+ else
698
+ enqueue_claimed(task, comment_id) { AiAgentJob.perform_later(agent.id, event_name, context) }
699
+ end
700
+ end
701
+ private_class_method :enqueue_restored
702
+
703
+ def self.restorable_comments(task, ids)
704
+ Comment.public_only.without_approval_action
705
+ .where(id: ids, topic_id: task.topic_id, creative_id: task.creative_id)
706
+ .where.not(user_id: [ task.agent_id, nil ])
707
+ end
708
+ private_class_method :restorable_comments
709
+
710
+ # Claim the comment, then enqueue — and only in that order. The job leaves
711
+ # no row until a worker runs it, so between the two the comment reads as
712
+ # orphaned to anyone else looking, and the sweep is looking every ten
713
+ # minutes. A claim released on failure is the whole difference between
714
+ # this and a dispatch nobody comes back for.
715
+ def self.enqueue_claimed(task, comment_id)
716
+ return unless claim_restore!(task, comment_id)
717
+
718
+ begin
719
+ yield
720
+ rescue StandardError
721
+ release_restore_claim!(task, comment_id)
722
+ raise
723
+ end
724
+ end
725
+ private_class_method :enqueue_claimed
726
+
727
+ # The dispatch that was discarded — not a descendant of the turn that
728
+ # discarded it. reanchor_payload is the single door for moving an anchor,
729
+ # and TURN_SCOPED_KEYS is stripped after it because every one of them
730
+ # describes the dead turn: its drop list would make a restored turn that
731
+ # fails again restore the same comments a second time, its history record
732
+ # would licence dropping them, its failed handoff would leave a turn that
733
+ # succeeded unable to cover anything it read, its merged list would
734
+ # re-send comments it was created for, and its acquired anchor would
735
+ # label this turn's own trigger as borrowed.
736
+ def self.restored_context(payload, comment)
737
+ TaskCoalescer.reanchor_payload(payload, comment).except(*TURN_SCOPED_KEYS)
738
+ end
739
+ private_class_method :restored_context
740
+
741
+ # Comment ids some other task in this turn's scope is already on the hook
742
+ # for, as its trigger or as a merged block. Any status counts: what is
743
+ # being asked is whether a dispatch exists at all, since the only thing
744
+ # this restore undoes is a dispatch that was never allowed to become one.
745
+ #
746
+ # A terminal sibling counts too, and deliberately. For a comment to be in
747
+ # DROPPED_KEY *and* have a row, the row came from a different dispatch of
748
+ # the same comment — a redelivered event, which `duplicate_running_for_
749
+ # comment?` lets past a merely queued waiter — so the discarded one is the
750
+ # duplicate and the row is the comment's own turn. Reading `cancelled` or
751
+ # `failed` there as "no longer coverage" would re-dispatch the duplicate
752
+ # of a turn the user stopped, or that reassignment, agent teardown or a
753
+ # workflow stop cancelled on purpose; restore! re-asks the comment's
754
+ # eligibility but has no way to re-ask those decisions. A turn that failed
755
+ # leaves a failed Task the product already shows and offers to retry,
756
+ # which is the ordinary ending for every turn — not this restore's
757
+ # subject, which is the dispatch that left nothing behind at all.
758
+ # DeliveredHistoryDispatchTest reproduces that state and holds both.
759
+ def self.claimed_comment_ids(task)
760
+ Task.where(
761
+ agent_id: task.agent_id, topic_id: task.topic_id,
762
+ creative_id: task.creative_id, trigger_event_name: task.trigger_event_name
763
+ ).where.not(id: task.id).select(:id, :trigger_event_payload).flat_map { |other|
764
+ other_payload = other.trigger_event_payload
765
+ next [] unless other_payload.is_a?(Hash)
766
+
767
+ [ other_payload.dig("comment", "id") ] + Array(other_payload[TaskCoalescer::PAYLOAD_KEY])
768
+ }.compact.map(&:to_i)
769
+ end
770
+ private_class_method :claimed_comment_ids
771
+
772
+ # The comments the history window carried *whole*, read off the tag
773
+ # MessageBuilder#append_chat_history writes. A window entry can render a
774
+ # comment without delivering it — a blob or a linked creative's subtree
775
+ # rides only on the trigger path — and those entries go untagged, so this
776
+ # record never claims them. That judgement lives with the builder, which
777
+ # is the only place that knows what it rendered; see
778
+ # MessageBuilder#delivered_whole?.
779
+ def self.history_comment_ids(resolved)
780
+ messages = resolved.is_a?(Hash) ? Array(resolved[:messages] || resolved["messages"]) : Array(resolved)
781
+ messages.filter_map do |message|
782
+ next unless message.is_a?(Hash)
783
+ next unless (message[:kind] || message["kind"]).to_s == "chat_history"
784
+
785
+ id = message[:comment_id] || message["comment_id"]
786
+ id&.to_i
787
+ end.uniq
788
+ end
789
+ private_class_method :history_comment_ids
790
+ end
791
+ end
792
+ end