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
@@ -4,8 +4,6 @@ module Collavre
4
4
 
5
5
  belongs_to :agent, class_name: "Collavre::User"
6
6
  has_many :task_actions, class_name: "Collavre::TaskAction", dependent: :destroy
7
- belongs_to :parent_task, class_name: "Collavre::Task", optional: true
8
- has_many :sub_tasks, class_name: "Collavre::Task", foreign_key: :parent_task_id, dependent: :destroy
9
7
  belongs_to :creative, class_name: "Collavre::Creative", optional: true
10
8
  has_one :reply_comment, class_name: "Collavre::Comment", foreign_key: :task_id, dependent: :nullify
11
9
 
@@ -25,6 +23,7 @@ module Collavre
25
23
 
26
24
  after_update_commit :check_trigger_loop_completion, if: :trigger_loop_candidate?
27
25
  after_update_commit :broadcast_stop_button_removal, if: :became_terminal?
26
+ after_update_commit :restore_undelivered_dispatches, if: :ended_without_delivering?
28
27
 
29
28
  scope :running_for_topic, ->(topic_id, creative_id = nil) {
30
29
  rel = where(topic_id: topic_id, status: %w[running delegated])
@@ -52,6 +51,36 @@ module Collavre
52
51
  rel
53
52
  }
54
53
 
54
+ # Of the slot-occupying statuses, these have no live worker to run
55
+ # AiAgentJob's ensure-block drain, so whoever cancels one must release the
56
+ # slot itself.
57
+ HELD_SLOT_WITHOUT_WORKER = %w[pending delegated pending_approval].freeze
58
+
59
+ # Work still worth a typing indicator — and the Stop button that only exists
60
+ # inside it: everything occupying_topic_slot counts, plus queued, a waiter
61
+ # not yet promoted. delegated and pending_approval look idle but are not, the
62
+ # job returned holding the slot while it awaits an MCP /reply or a tool
63
+ # approval, and #cancel still accepts both. Kept here rather than in the
64
+ # client so one list decides it.
65
+ ACTIVE_STATUSES = %w[running delegated pending pending_approval queued].freeze
66
+
67
+ def active?
68
+ ACTIVE_STATUSES.include?(status)
69
+ end
70
+
71
+ # Cancellation callers often select an active row before waiting on another
72
+ # request's task lock. Reload under that lock so a completed reply cannot be
73
+ # overwritten by a stale running/delegated instance.
74
+ def cancel_if_active!(statuses: ACTIVE_STATUSES, **attributes)
75
+ with_lock do
76
+ next unless status.in?(statuses)
77
+
78
+ previous_status = status
79
+ update!(attributes.merge(status: "cancelled"))
80
+ previous_status
81
+ end
82
+ end
83
+
55
84
  # Check if agent already has an in-flight task triggered by the same comment.
56
85
  # Treats "delegated" as in-flight: a Claude Channel task that is waiting on
57
86
  # an external MCP reply is still active work — re-dispatching the same
@@ -77,6 +106,33 @@ module Collavre
77
106
  broadcast_stop_button_removal if terminal_status?
78
107
  end
79
108
 
109
+ # What a persisted row says about whether this turn ever handed anything
110
+ # over — ended_without_delivering? without the transition.
111
+ #
112
+ # Asked twice: by the callback of a status change, and by
113
+ # RestoreDroppedDispatchesJob of a row it finds afterwards. Written once,
114
+ # because a sweep deciding "terminal" where the callback decides this is how
115
+ # a backstop comes to re-answer comments the turn had already answered.
116
+ def ended_undelivered?
117
+ payload = trigger_event_payload
118
+ # Asked first, so that a row carrying both records is read as undelivered.
119
+ # A resumed turn can legitimately carry both: an earlier attempt handed
120
+ # off, while a later one failed before doing so. DeliveryRecord restores
121
+ # that row but subtracts the comment ids the successful attempts carried,
122
+ # leaving only the later attempt's undelivered comments.
123
+ return true if status == "done" && Orchestration::DeliveryRecord.handoff_failed?(payload)
124
+
125
+ # An ending is undelivered because the payload never reached the provider,
126
+ # not because of the name of the ending. A turn stopped — or broken — after
127
+ # the handoff has been read by the agent along with everything it
128
+ # swallowed, and the product already shows that turn with its partial
129
+ # reply. Only a turn that got far enough to record the handoff is exempt;
130
+ # everything quieter than that still owes its dispatches back.
131
+ return false if Orchestration::DeliveryRecord.handed_off?(payload)
132
+
133
+ status.in?(Orchestration::DeliveryRecord::UNDELIVERED_TERMINAL_STATUSES)
134
+ end
135
+
80
136
  private
81
137
 
82
138
  def trigger_loop_candidate?
@@ -106,6 +162,69 @@ module Collavre
106
162
  saved_change_to_attribute?("status") && terminal_status?
107
163
  end
108
164
 
165
+ # This turn refused other dispatches on the strength of having read their
166
+ # comments, and then died without answering anything. Those dispatches have
167
+ # to come back — see Orchestration::DeliveryRecord.restore!.
168
+ #
169
+ # Only the status transition is decided here. Whether anything was actually
170
+ # refused is asked inside restore!, off the persisted row: the drop record
171
+ # is written by the refused dispatch in another process, so this object's
172
+ # payload predates it and a check here would read `none` on every turn that
173
+ # dropped something.
174
+ #
175
+ # A status callback rather than a call site: AiAgentJob's rescue and
176
+ # StuckDetector both end a turn this way, and only one of them runs for a
177
+ # process that was killed outright.
178
+ #
179
+ # `done` is an undelivered ending too when the turn recorded that its
180
+ # request never reached the provider: AiClient#chat catches the error and
181
+ # AiAgentJob finishes the task normally, so the status alone cannot say it.
182
+ # Read off this object rather than the row because the flag is written by
183
+ # this turn's own service, in this process, immediately before the status
184
+ # update that fires this callback — unlike DROPPED_KEY, which the refused
185
+ # dispatch writes from another process and restore! therefore re-reads.
186
+ def ended_without_delivering?
187
+ return false unless saved_change_to_attribute?("status")
188
+ return false if ended_while_worker_settles?
189
+
190
+ ended_undelivered?
191
+ end
192
+
193
+ # A turn ended from another process while its worker is still inside the
194
+ # provider call, whose own account of the handoff does not exist yet.
195
+ #
196
+ # Stop is committed from a web request, and StuckDetector can fail a running
197
+ # row from its recurring job. Both can beat AiAgentService's ensure, which
198
+ # writes whether the payload reached the provider only after the call exits.
199
+ # This callback fires before that evidence exists.
200
+ #
201
+ # So it declines, and the question is asked where the turn settles —
202
+ # AiAgentJob's teardown, once the record is written. Behind that,
203
+ # DeliveryRecord.restore_missed! covers an ending with no live worker to
204
+ # settle anything.
205
+ def ended_while_worker_settles?
206
+ return false unless status_before_last_save == "running"
207
+ return true if status == "cancelled"
208
+
209
+ status == "failed" &&
210
+ Orchestration::DeliveryRecord.worker_settling?(trigger_event_payload)
211
+ end
212
+
213
+ # Best effort, deliberately: this runs after the turn's own status is
214
+ # committed, so an exception here propagates back into the update! that
215
+ # ended the turn — and AiAgentJob's rescue answers that by writing `failed`
216
+ # over a turn that had in fact finished and answered. The restore is
217
+ # reconstructible from the row it was given, and
218
+ # DeliveryRecord.restore_missed! is what asks again.
219
+ def restore_undelivered_dispatches
220
+ Orchestration::DeliveryRecord.restore!(self)
221
+ rescue StandardError => e
222
+ Rails.logger.error(
223
+ "[Task] Restore failed for task #{id}: #{e.class}: #{e.message} — " \
224
+ "leaving it to the restore sweep"
225
+ )
226
+ end
227
+
109
228
  def terminal_status?
110
229
  status.in?(%w[done cancelled failed])
111
230
  end
@@ -18,6 +18,11 @@ module Collavre
18
18
  has_many :user_creative_preferences_as_last_topic, class_name: "Collavre::UserCreativePreference",
19
19
  foreign_key: :last_topic_id, dependent: :nullify, inverse_of: :last_topic
20
20
 
21
+ # The dependent nullify below clears a user's selected topic without going
22
+ # through the preference controller. Advance the ordering token first so a
23
+ # client can distinguish that deletion from an older empty preference.
24
+ before_destroy :advance_last_topic_preference_revisions, prepend: true
25
+
21
26
  # --- Archive scopes ---
22
27
  scope :active, -> { where(archived_at: nil) }
23
28
  scope :archived, -> { where.not(archived_at: nil) }
@@ -33,6 +38,48 @@ module Collavre
33
38
  update!(primary_agent: agent)
34
39
  end
35
40
 
41
+ # A primary agent is an exclusive assignment: Matcher#match_by_primary_agent
42
+ # routes ambient events to it alone and returns [] — silence, not a fallback
43
+ # — when it cannot respond. Routing also requires :feedback on the creative,
44
+ # so pinning an agent that lacks it would disable the topic outright: the
45
+ # pinned agent is not permitted to answer, and every other agent is excluded
46
+ # by the assignment. User.mentionable_for and the agent palette both surface
47
+ # searchable agents regardless of creative access, so this is reachable from
48
+ # plain drag-and-drop or `/topic "name" @agent:`, not just a crafted request.
49
+ #
50
+ # This deliberately mirrors BOTH predicates Matcher applies before it will
51
+ # route to a primary agent — #has_creative_permission? and
52
+ # #eligible_in_inbox? — so "assignable" and "can respond" cannot drift
53
+ # apart. Checking only the permission is not enough: registration grants a
54
+ # Claude Channel agent inbox-wide :feedback, so it passes that check on
55
+ # every inbox topic while Matcher confines it to its own session topic.
56
+ # Pinning one on an ordinary inbox topic would therefore silence the topic
57
+ # for everyone, and it is reachable by plain drag-and-drop (the agent is
58
+ # created_by the user, so the palette lists it) or by `/topic "Main" @agent:`
59
+ # (the inbox share puts it in User.mentionable_for regardless of searchable).
60
+ #
61
+ # Returns nil when assignable, otherwise a symbol naming the reason, so
62
+ # callers can render a message that says which rule was hit.
63
+ #
64
+ # Deliberately NOT enforced inside #set_primary_agent!: SessionProvisioner
65
+ # and Api::V1::AgentsController#register write the pin as session identity
66
+ # before the inbox share exists, and must not be gated on it.
67
+ def self.primary_agent_rejection(creative, agent, topic: nil)
68
+ return :not_an_agent unless agent&.ai_user?
69
+ return :no_creative_access unless creative&.has_permission?(agent, :feedback)
70
+
71
+ # Mirror Matcher#eligible_in_inbox?
72
+ return nil unless creative.inbox?
73
+ return nil unless agent.claude_channel_agent?
74
+ return nil if topic&.session_id.present? && topic.primary_agent_id == agent.id
75
+
76
+ :session_agent_outside_session_topic
77
+ end
78
+
79
+ def self.primary_agent_assignable?(creative, agent, topic: nil)
80
+ primary_agent_rejection(creative, agent, topic: topic).nil?
81
+ end
82
+
36
83
  def archived?
37
84
  archived_at.present?
38
85
  end
@@ -47,6 +94,10 @@ module Collavre
47
94
 
48
95
  private
49
96
 
97
+ def advance_last_topic_preference_revisions
98
+ user_creative_preferences_as_last_topic.update_all("last_topic_revision = last_topic_revision + 1")
99
+ end
100
+
50
101
  def set_default_position
51
102
  return if position_changed? && position != 0
52
103
 
@@ -29,7 +29,6 @@ module Collavre
29
29
  has_many :creative_shares_caches, class_name: "Collavre::CreativeSharesCache", dependent: :delete_all
30
30
  has_many :shared_creative_shares, class_name: "Collavre::CreativeShare", foreign_key: :shared_by_id,
31
31
  dependent: :nullify, inverse_of: :shared_by
32
- has_many :inbox_items, class_name: "Collavre::InboxItem", foreign_key: :owner_id, dependent: :destroy, inverse_of: :owner
33
32
  has_many :invitations, class_name: "Collavre::Invitation", foreign_key: :inviter_id, dependent: :destroy, inverse_of: :inviter
34
33
  has_many :activity_logs, class_name: "Collavre::ActivityLog", dependent: :destroy
35
34
  has_many :labels, class_name: "Collavre::Label", foreign_key: :owner_id, dependent: :destroy
@@ -42,6 +41,7 @@ module Collavre
42
41
 
43
42
  # Leaf-first destroy to avoid closure_tree find(parent_id) errors
44
43
  has_many :creatives, class_name: "Collavre::Creative", dependent: nil
44
+ belongs_to :last_visited_creative, class_name: "Collavre::Creative", optional: true
45
45
  before_destroy :destroy_creatives_leaf_first
46
46
 
47
47
  # /compress and /merge summaries are durable recovery artifacts: they replace
@@ -55,6 +55,16 @@ module Collavre
55
55
 
56
56
  belongs_to :creator, class_name: "Collavre::User", foreign_key: "created_by_id", optional: true
57
57
  has_many :created_ai_users, class_name: "Collavre::User", foreign_key: "created_by_id", dependent: :destroy
58
+ has_many :created_llm_models,
59
+ class_name: "Collavre::LlmModel",
60
+ foreign_key: :created_by_id,
61
+ dependent: :nullify,
62
+ inverse_of: :creator
63
+ belongs_to :agent_gateway, class_name: "Collavre::AgentGateway", optional: true
64
+ has_many :owned_agent_gateways, class_name: "Collavre::AgentGateway", foreign_key: :owner_id, dependent: :destroy
65
+ has_many :agent_workspaces, class_name: "Collavre::AgentWorkspace", foreign_key: :agent_id, dependent: :destroy
66
+ has_many :personal_agent_workspaces, class_name: "Collavre::AgentWorkspace", foreign_key: :user_id, dependent: :destroy
67
+ after_update :revoke_old_gateway_workspaces, if: :saved_change_to_agent_gateway_id?
58
68
 
59
69
  has_one_attached :avatar
60
70
 
@@ -66,15 +76,7 @@ module Collavre
66
76
  attribute :locale, :string
67
77
  attribute :system_admin, :boolean, default: false
68
78
  attribute :searchable, :boolean, default: false
69
-
70
- # Typo correction (2D gating: typing-device AND input-location must both be on).
71
- attribute :typo_correction_enabled, :boolean, default: true
72
- attribute :typo_correction_threshold, :integer, default: 80
73
- attribute :typo_correction_on_soft_keyboard, :boolean, default: true
74
- attribute :typo_correction_on_voice, :boolean, default: true
75
- attribute :typo_correction_on_physical_keyboard, :boolean, default: false
76
- attribute :typo_correction_in_chat, :boolean, default: true
77
- attribute :typo_correction_in_editor, :boolean, default: false
79
+ attribute :creative_workspace_enabled, :boolean, default: true
78
80
 
79
81
  attribute :google_uid, :string
80
82
  attribute :google_access_token, :string
@@ -88,6 +90,7 @@ module Collavre
88
90
  attribute :gateway_url, :string
89
91
  attribute :tools, :json, default: -> { [] }
90
92
  attribute :agent_conf, :string
93
+ attribute :dismissed_notices, :json, default: -> { [] }
91
94
 
92
95
  # Default context settings for AI agents
93
96
  AGENT_CONF_DEFAULTS = {
@@ -160,31 +163,6 @@ module Collavre
160
163
  end
161
164
  end
162
165
 
163
- TYPO_CORRECTION_DEVICES = %w[voice soft_keyboard physical_keyboard].freeze
164
- TYPO_CORRECTION_LOCATIONS = %w[chat editor].freeze
165
-
166
- # 2D gating: typo correction runs only when the master switch is on AND the
167
- # originating typing device AND the input location are both enabled. Unknown
168
- # device/location values are treated as disabled (fail closed).
169
- def typo_correction_active_for?(device:, location:)
170
- return false unless typo_correction_enabled
171
-
172
- device_on = case device.to_s
173
- when "voice" then typo_correction_on_voice
174
- when "soft_keyboard" then typo_correction_on_soft_keyboard
175
- when "physical_keyboard" then typo_correction_on_physical_keyboard
176
- else false
177
- end
178
-
179
- location_on = case location.to_s
180
- when "chat" then typo_correction_in_chat
181
- when "editor" then typo_correction_in_editor
182
- else false
183
- end
184
-
185
- device_on && location_on
186
- end
187
-
188
166
  # LLM_VENDOR_OPTIONS is resolved dynamically from the AiClient vendor-option
189
167
  # registry so core lists only its built-in providers while vendor engines
190
168
  # (e.g. OpenClaw) contribute their own. Resolved lazily via const_missing so
@@ -200,17 +178,50 @@ module Collavre
200
178
  SUPPORTED_LLM_MODELS = [
201
179
  "gemini-3.1-flash-lite",
202
180
  "gemini-1.5-flash",
203
- "gemini-1.5-pro"
181
+ "gemini-1.5-pro",
182
+ "paperclip/claude_local",
183
+ "paperclip/codex_local"
204
184
  ].freeze
205
185
 
206
186
  def ai_user?
207
187
  llm_vendor.present?
208
188
  end
209
189
 
190
+ def cli_proxy_agent?
191
+ llm_vendor == "cli_proxy" && agent_gateway.present?
192
+ end
193
+
194
+ def gateway_accessible_to?(user)
195
+ return false unless user
196
+ return true if user.system_admin? || created_by_id == user.id
197
+ return true if user.contact_users.where(id: id).exists?
198
+
199
+ agent_creative_ids = Collavre::Creative.where(user_id: id).pluck(:id)
200
+ agent_creative_ids.concat(
201
+ Collavre::CreativeSharesCache.where(user_id: [ id, nil ]).distinct.pluck(:creative_id)
202
+ )
203
+ agent_permissions = Collavre::Creatives::PermissionFilter.new(user: self)
204
+ agent_creative_ids = agent_permissions.readable_ids(agent_creative_ids, min_permission: :feedback)
205
+ Collavre::Creatives::PermissionFilter.new(user: user)
206
+ .readable_ids(agent_creative_ids)
207
+ .any?
208
+ end
209
+
210
+ def revoke_old_gateway_workspaces
211
+ old_gateway_id = agent_gateway_id_before_last_save
212
+ return unless old_gateway_id
213
+
214
+ agent_workspaces.where(agent_gateway_id: old_gateway_id).destroy_all
215
+ end
216
+
210
217
  def claude_channel_agent?
211
218
  llm_model == "claude-code"
212
219
  end
213
220
 
221
+ def claude_channel_online?
222
+ claude_channel_agent? && AgentSubscription.live.where(agent_id: id).exists?
223
+ end
224
+
214
225
  scope :ai_agents, -> { where.not(llm_vendor: [ nil, "" ]) }
215
226
 
216
227
  def self.accessible_ai_agents_for(user)
@@ -239,16 +250,65 @@ module Collavre
239
250
  validates :email, presence: true, uniqueness: true,
240
251
  format: { with: URI::MailTo::EMAIL_REGEXP }
241
252
  validates :name, presence: true
253
+ validates :llm_vendor,
254
+ length: { maximum: Collavre::LlmModel::MAX_VENDOR_LENGTH },
255
+ if: :will_save_change_to_llm_vendor?
256
+ validates :llm_model,
257
+ length: { maximum: Collavre::LlmModel::MAX_NAME_LENGTH },
258
+ if: :will_save_change_to_llm_model?
259
+ validate :cli_proxy_gateway_belongs_to_creator
260
+ around_save :serialize_cli_proxy_gateway_assignment
261
+ before_save :validate_cli_proxy_gateway_assignment_under_lock
242
262
  validate :theme_accessibility
243
263
  validate :password_meets_minimum_length
244
264
  validates :timezone,
245
265
  inclusion: { in: ActiveSupport::TimeZone.all.map { |z| z.tzinfo.identifier } },
246
266
  allow_nil: true
247
- # Column is NOT NULL; clearing the profile field (or a crafted PATCH) casts to
248
- # nil and would raise a DB error on save. Validate so the form re-renders.
249
- validates :typo_correction_threshold,
250
- presence: true,
251
- numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
267
+ def cli_proxy_gateway_belongs_to_creator
268
+ return unless llm_vendor == "cli_proxy"
269
+
270
+ validate_cli_proxy_gateway(agent_gateway)
271
+ end
272
+
273
+ # See AgentGateway#serialize_completion_key_removal. This repeats the
274
+ # gateway checks immediately before writing the agent while holding the
275
+ # same row lock used by a completion-key removal.
276
+ def serialize_cli_proxy_gateway_assignment
277
+ return yield unless llm_vendor == "cli_proxy" && agent_gateway_id.present?
278
+
279
+ gateway = AgentGateway.find_by(id: agent_gateway_id)
280
+ return yield unless gateway
281
+
282
+ gateway.with_lock do
283
+ begin
284
+ @cli_proxy_gateway_assignment_lock = gateway
285
+ yield
286
+ ensure
287
+ @cli_proxy_gateway_assignment_lock = nil
288
+ end
289
+ end
290
+ end
291
+
292
+ def validate_cli_proxy_gateway_assignment_under_lock
293
+ gateway = @cli_proxy_gateway_assignment_lock
294
+ return unless gateway
295
+
296
+ error_count = errors.count
297
+ validate_cli_proxy_gateway(gateway)
298
+ throw :abort if errors.count > error_count
299
+ end
300
+
301
+ def validate_cli_proxy_gateway(gateway)
302
+ if gateway.nil?
303
+ errors.add(:agent_gateway, :blank)
304
+ elsif gateway.owner_id != created_by_id
305
+ errors.add(:agent_gateway, :invalid)
306
+ elsif !gateway.chat_capable?
307
+ errors.add(:agent_gateway, :completion_key_required)
308
+ elsif gateway.identity_secret.blank? && (gateway.per_user? || gateway.agents.where.not(id: id).exists?)
309
+ errors.add(:agent_gateway, :identity_required)
310
+ end
311
+ end
252
312
 
253
313
  generates_token_for :email_verification, expires_in: 1.day do
254
314
  email
@@ -6,7 +6,9 @@ module Collavre
6
6
  belongs_to :user, class_name: Collavre.configuration.user_class_name
7
7
  belongs_to :last_topic, class_name: "Collavre::Topic", optional: true
8
8
 
9
- validates :expanded_status, presence: true, unless: :last_topic_id?
9
+ validates :expanded_status, presence: true, unless: -> {
10
+ last_topic_id? || last_topic_revision.to_i.positive? || last_topic_save_fence_issued.to_i.positive?
11
+ }
10
12
  validates :creative_id, uniqueness: { scope: :user_id }, allow_nil: true
11
13
  end
12
14
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "stringio"
6
+ require "zlib"
7
+
8
+ module Collavre
9
+ module AgentProvisioning
10
+ class Archive
11
+ BLOCK_SIZE = 512
12
+ MAX_FILE_SIZE = 1.megabyte
13
+ MAX_ARCHIVE_SIZE = 10.megabytes
14
+
15
+ class << self
16
+ def collavre_skill
17
+ @collavre_skill ||= build_from_directory(Collavre::Engine.root.join("skills/collavre"))
18
+ end
19
+
20
+ def workspace_config(workspace, base_url:)
21
+ json = JSON.pretty_generate(workspace.config_payload(base_url: base_url)) << "\n"
22
+ build({ "config.json" => { content: json, mode: 0o600 } })
23
+ end
24
+
25
+ def sha256(bytes)
26
+ Digest::SHA256.hexdigest(bytes)
27
+ end
28
+
29
+ private
30
+
31
+ def build_from_directory(root)
32
+ raise ArgumentError, "Provisioning source directory does not exist: #{root}" unless root.directory?
33
+
34
+ files = Dir.glob(root.join("**/*"), File::FNM_DOTMATCH).filter_map do |path|
35
+ next unless File.file?(path)
36
+
37
+ relative = Pathname(path).relative_path_from(root).to_s
38
+ mode = File.executable?(path) ? 0o755 : 0o644
39
+ [ relative, { content: File.binread(path), mode: mode } ]
40
+ end.to_h
41
+ raise ArgumentError, "Provisioning source directory is empty: #{root}" if files.empty?
42
+
43
+ build(files)
44
+ end
45
+
46
+ def build(files)
47
+ tar = +"".b
48
+ total_size = 0
49
+ files.sort.each do |name, entry|
50
+ content = entry.fetch(:content).to_s.b
51
+ raise ArgumentError, "Provisioning file is too large: #{name}" if content.bytesize > MAX_FILE_SIZE
52
+ raise ArgumentError, "Provisioning file is binary: #{name}" if content.include?("\0")
53
+
54
+ total_size += content.bytesize
55
+ raise ArgumentError, "Provisioning archive expands beyond the limit" if total_size > MAX_ARCHIVE_SIZE
56
+
57
+ tar << tar_header(name, mode: entry.fetch(:mode), size: content.bytesize)
58
+ tar << content
59
+ tar << "\0" * ((BLOCK_SIZE - (content.bytesize % BLOCK_SIZE)) % BLOCK_SIZE)
60
+ end
61
+ tar << "\0" * (BLOCK_SIZE * 2)
62
+
63
+ io = StringIO.new(+"".b)
64
+ gzip = Zlib::GzipWriter.new(io, Zlib::BEST_COMPRESSION)
65
+ gzip.mtime = 0
66
+ gzip.write(tar)
67
+ gzip.close
68
+ bytes = io.string
69
+ raise ArgumentError, "Provisioning archive is too large" if bytes.bytesize > MAX_ARCHIVE_SIZE
70
+
71
+ bytes.freeze
72
+ end
73
+
74
+ def tar_header(name, mode:, size:)
75
+ raise ArgumentError, "Provisioning path is too long: #{name}" if name.bytesize > 100
76
+
77
+ header = +"\0" * BLOCK_SIZE
78
+ write_field(header, 0, 100, name)
79
+ write_octal(header, 100, 8, mode)
80
+ write_octal(header, 108, 8, 0)
81
+ write_octal(header, 116, 8, 0)
82
+ write_octal(header, 124, 12, size)
83
+ write_octal(header, 136, 12, 0)
84
+ write_field(header, 148, 8, " " * 8)
85
+ write_field(header, 156, 1, "0")
86
+ write_field(header, 257, 6, "ustar\0")
87
+ write_field(header, 263, 2, "00")
88
+ write_field(header, 265, 32, "root")
89
+ write_field(header, 297, 32, "root")
90
+ checksum = header.bytes.sum
91
+ write_field(header, 148, 8, format("%06o\0 ", checksum))
92
+ header
93
+ end
94
+
95
+ def write_octal(buffer, offset, length, value)
96
+ write_field(buffer, offset, length, format("%0#{length - 1}o\0", value))
97
+ end
98
+
99
+ def write_field(buffer, offset, length, value)
100
+ value = value.to_s.b
101
+ raise ArgumentError, "Tar field overflow" if value.bytesize > length
102
+
103
+ buffer[offset, length] = value.ljust(length, "\0")
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -6,10 +6,11 @@ module Collavre
6
6
  # Detects mentioned agents, records interactions for loop prevention,
7
7
  # and dispatches system events for downstream processing.
8
8
  class A2aDispatcher
9
- def initialize(agent:, reply_comment:, context:)
9
+ def initialize(agent:, reply_comment:, context:, workspace_user: nil)
10
10
  @agent = agent
11
11
  @reply_comment = reply_comment
12
12
  @context = context
13
+ @workspace_user = workspace_user
13
14
  end
14
15
 
15
16
  # Dispatch A2A events if the response mentions any AI agents
@@ -53,7 +54,7 @@ module Collavre
53
54
  end
54
55
 
55
56
  def dispatch_event(creative)
56
- SystemEvents::Dispatcher.dispatch("comment_created", {
57
+ payload = {
57
58
  comment: {
58
59
  id: @reply_comment.id,
59
60
  content: @reply_comment.content,
@@ -65,7 +66,19 @@ module Collavre
65
66
  },
66
67
  topic: { id: @reply_comment.topic_id },
67
68
  chat: { content: @reply_comment.content }
68
- })
69
+ }
70
+ # Always carry the resolved principal, including an explicit nil. Nil
71
+ # means the current anchor could not prove a human identity; omitting the
72
+ # key would let a downstream per-user CLI Proxy agent fall back to its
73
+ # creator and execute with that person's workspace credentials.
74
+ payload[:workspace_user_id] = @workspace_user&.id
75
+
76
+ SystemEvents::Dispatcher.dispatch(
77
+ "comment_created",
78
+ payload,
79
+ source: "a2a",
80
+ parent: SystemEvents::Envelope.in(@context)
81
+ )
69
82
  end
70
83
  end
71
84
  end