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
@@ -1,5 +1,17 @@
1
1
  module Collavre
2
2
  class PushNotificationJob < ApplicationJob
3
+ # Comment bodies are unbounded and go out as the notification body verbatim,
4
+ # so FCM rejects the oversized ones permanently — retrying cannot help, the
5
+ # payload is the defect. Two rejections were observed in production: "Message
6
+ # is too large. The maximum is 4K (4096 bytes)." above ~4.8KB, and a bare
7
+ # "INVALID_ARGUMENT: Invalid argument." starting at 2440 bytes. Budget the
8
+ # whole request at half of FCM's documented ceiling, which also sits below the
9
+ # smallest payload FCM has actually refused for us. A notification body is a
10
+ # preview anyway; no shade renders more than a few lines.
11
+ MAX_REQUEST_BYTES = 2048
12
+ ELLIPSIS = "…".freeze
13
+ TITLE = "새 알림".freeze
14
+
3
15
  queue_as :default
4
16
 
5
17
  def perform(user_id, message:, link: nil)
@@ -27,22 +39,9 @@ class PushNotificationJob < ApplicationJob
27
39
  private
28
40
 
29
41
  def send_v1(service, project_id, token, message, link)
30
- notification = Google::Apis::FcmV1::Notification.new(
31
- title: "새 알림",
32
- body: message
33
- )
34
-
35
- webpush = Google::Apis::FcmV1::WebpushConfig.new(
36
- fcm_options: Google::Apis::FcmV1::WebpushFcmOptions.new(link: link)
37
- )
38
-
39
- msg = Google::Apis::FcmV1::Message.new(
40
- token: token,
41
- notification: notification,
42
- webpush: webpush,
43
- data: { path: link }
44
- )
45
- Rails.logger.info("Sending push to token: #{token} with message: #{message} and link: #{link}")
42
+ body = fit_body(token, message, link)
43
+ msg = build_message(token, body, link)
44
+ Rails.logger.info("Sending push to token: #{token} with #{body.bytesize} body bytes and link: #{link}")
46
45
 
47
46
  request = Google::Apis::FcmV1::SendMessageRequest.new(message: msg)
48
47
 
@@ -51,16 +50,61 @@ class PushNotificationJob < ApplicationJob
51
50
  Rails.logger.info("✅ Push sent successfully: #{token} #{response.inspect}")
52
51
  response
53
52
  rescue Google::Apis::ClientError => e
53
+ # A 4xx is about this one registration, not the notification: the endpoint
54
+ # is gone, or that push service refused the payload. Raising would abandon
55
+ # every device queued behind it, which is how one stale subscription used
56
+ # to silence a user's other browsers. Transient errors (auth, 5xx) still
57
+ # propagate so the job retries.
54
58
  if invalid_registration_token?(e)
55
59
  Rails.logger.warn("⚠️ Removing invalid FCM token: #{token} (#{e.message})")
56
60
  Device.where(fcm_token: token).delete_all
57
- nil
58
61
  else
59
- raise
62
+ Rails.logger.warn("⚠️ FCM rejected the push for token #{token}: #{e.message}")
60
63
  end
64
+ nil
61
65
  end
62
66
  end
63
67
 
68
+ def build_message(token, body, link)
69
+ Google::Apis::FcmV1::Message.new(
70
+ token: token,
71
+ notification: Google::Apis::FcmV1::Notification.new(title: TITLE, body: body),
72
+ webpush: Google::Apis::FcmV1::WebpushConfig.new(
73
+ fcm_options: Google::Apis::FcmV1::WebpushFcmOptions.new(link: link)
74
+ ),
75
+ data: { path: link }
76
+ )
77
+ end
78
+
79
+ # Everything except the body — token, title, link (twice) and the JSON
80
+ # envelope — is fixed, so measure it and spend whatever is left on the body.
81
+ # Deriving the budget instead of hardcoding a character count keeps the cap
82
+ # correct when a long deep link crowds out the text.
83
+ def fit_body(token, message, link)
84
+ text = message.to_s
85
+ return text if request_bytesize(token, text, link) <= MAX_REQUEST_BYTES
86
+
87
+ budget = MAX_REQUEST_BYTES - request_bytesize(token, "", link) - ELLIPSIS.bytesize
88
+ return ELLIPSIS if budget <= 0
89
+
90
+ truncate_bytes(text, budget) + ELLIPSIS
91
+ end
92
+
93
+ def request_bytesize(token, body, link)
94
+ Google::Apis::FcmV1::SendMessageRequest
95
+ .new(message: build_message(token, body, link))
96
+ .to_json
97
+ .bytesize
98
+ end
99
+
100
+ # byteslice can cut a multi-byte character in half; scrub drops the dangling
101
+ # bytes rather than shipping invalid UTF-8 that FCM would reject in turn.
102
+ def truncate_bytes(text, max_bytes)
103
+ return text if text.bytesize <= max_bytes
104
+
105
+ text.byteslice(0, max_bytes).scrub("")
106
+ end
107
+
64
108
  def invalid_registration_token?(error)
65
109
  status_code = error.respond_to?(:status_code) ? error.status_code : nil
66
110
  return false unless status_code == 404
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Backstop for Orchestration::DeliveryRecord.restore!, which runs from a
5
+ # status callback and therefore after the point where its own failure could
6
+ # still be undone. See DeliveryRecord.restore_missed! for why nothing has to
7
+ # be written down for this to work.
8
+ #
9
+ # Scheduled in config/recurring.yml.
10
+ class RestoreDroppedDispatchesJob < ApplicationJob
11
+ queue_as :default
12
+
13
+ def perform
14
+ Orchestration::DeliveryRecord.restore_missed!
15
+ end
16
+ end
17
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Collavre
4
- # StuckDetectorJob runs periodically to detect stuck tasks and creatives.
4
+ # StuckDetectorJob runs periodically to recover and escalate stuck tasks.
5
5
  #
6
6
  # This job should be scheduled to run every 5-10 minutes via cron or similar.
7
7
  #
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class TouchCreativeSubtreeJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(creative_id)
8
+ creative = Creative.find_by(id: creative_id)
9
+ return unless creative
10
+
11
+ creative.descendants.update_all(updated_at: Time.current)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class AgentGateway < ApplicationRecord
5
+ self.table_name = "agent_gateways"
6
+
7
+ # Must match the proxy's USER_IDENTITY_HMAC_SECRET.
8
+ MIN_IDENTITY_SECRET_BYTES = 32
9
+ DESKTOP_NATIVE_CREDENTIAL_ATTRIBUTES = %i[admin_key completion_key identity_secret].freeze
10
+
11
+ belongs_to :owner, class_name: "Collavre::User"
12
+ has_many :agents, class_name: "Collavre::User", dependent: :restrict_with_error
13
+ has_many :agent_workspaces, class_name: "Collavre::AgentWorkspace", dependent: :destroy
14
+
15
+ enum :workspace_mode, { shared: 0, per_user: 1 }, default: :shared
16
+
17
+ encrypts :admin_key, deterministic: false
18
+ encrypts :completion_key, deterministic: false
19
+ encrypts :identity_secret, deterministic: false
20
+
21
+ normalizes :name, with: ->(value) { value.to_s.strip }
22
+ normalizes :base_url, with: ->(value) { value.to_s.strip.sub(%r{/+\z}, "") }
23
+ normalizes :tenant_id, with: ->(value) { value.to_s.strip }
24
+
25
+ validates :name, :base_url, :admin_key, :tenant_id, presence: true
26
+ validates :name, uniqueness: { scope: :owner_id }
27
+ validates :tenant_id, format: { with: /\A[A-Za-z0-9][A-Za-z0-9._:@\/-]{0,199}\z/ }
28
+ validate :base_url_is_http
29
+ validate :base_url_is_safe_for_owner
30
+ validate :desktop_managed_base_url_is_immutable
31
+ validate :desktop_managed_credentials_are_immutable
32
+ validate :desktop_managed_gateway_uses_shared_workspace
33
+ validate :identity_secret_is_usable
34
+ validate :completion_key_is_present_when_assigned_to_agents
35
+ around_update :serialize_completion_key_removal
36
+ before_update :validate_completion_key_removal_under_lock
37
+ after_update :reconcile_workspaces_after_gateway_change, if: :workspace_credentials_changed?
38
+
39
+ scope :active, -> { where(active: true) }
40
+
41
+ def chat_capable?
42
+ completion_key.present?
43
+ end
44
+
45
+ def completion_base_url
46
+ "#{proxy_base_url}/v1"
47
+ end
48
+
49
+ def proxy_path(path)
50
+ suffix = path.start_with?("/") ? path : "/#{path}"
51
+ "#{proxy_base_url}#{suffix}"
52
+ end
53
+
54
+ # The signed native setup handoff is the only flow permitted to retarget a
55
+ # desktop-managed gateway. Its loopback exception must not be reusable by
56
+ # an owner editing the gateway through the regular settings UI.
57
+ def update_from_desktop_registration!(attributes)
58
+ @desktop_registration_update = true
59
+ update!(attributes)
60
+ ensure
61
+ @desktop_registration_update = false
62
+ end
63
+
64
+ # Desktop-managed gateways are created only by the signed native setup
65
+ # handoff and always target the local proxy. They remain usable when their
66
+ # original owner later loses the system administrator role.
67
+ def desktop_loopback?
68
+ return false unless desktop_managed?
69
+
70
+ uri = URI.parse(base_url.to_s)
71
+ uri.scheme == "http" &&
72
+ uri.host == "127.0.0.1" &&
73
+ uri.port.present? &&
74
+ uri.userinfo.blank? &&
75
+ uri.query.blank? &&
76
+ uri.fragment.blank?
77
+ rescue URI::InvalidURIError
78
+ false
79
+ end
80
+
81
+ private
82
+
83
+ def proxy_base_url
84
+ base_url.delete_suffix("/v1")
85
+ end
86
+
87
+ def base_url_is_http
88
+ uri = URI.parse(base_url.to_s)
89
+ return if uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.blank? && uri.query.blank? && uri.fragment.blank?
90
+
91
+ errors.add(:base_url, :invalid)
92
+ rescue URI::InvalidURIError
93
+ errors.add(:base_url, :invalid)
94
+ end
95
+
96
+ def base_url_is_safe_for_owner
97
+ return if owner&.system_admin?
98
+ return if desktop_loopback?
99
+ return if CliProxy::EndpointPolicy.new.safe_literal?(base_url)
100
+
101
+ errors.add(:base_url, :unsafe)
102
+ end
103
+
104
+ def desktop_managed_base_url_is_immutable
105
+ return unless persisted? && desktop_managed? && will_save_change_to_base_url?
106
+ return if @desktop_registration_update
107
+
108
+ errors.add(:base_url, :immutable)
109
+ end
110
+
111
+ # Keychain is the source of truth for the local proxy credentials. The
112
+ # regular Rails settings form cannot synchronize it, so only the signed
113
+ # native registration handoff may rotate these values.
114
+ def desktop_managed_credentials_are_immutable
115
+ return unless persisted? && desktop_managed?
116
+ return if @desktop_registration_update
117
+
118
+ DESKTOP_NATIVE_CREDENTIAL_ATTRIBUTES.each do |attribute|
119
+ next unless public_send("will_save_change_to_#{attribute}?")
120
+
121
+ errors.add(attribute, :immutable)
122
+ end
123
+ end
124
+
125
+ # The macOS bundle deliberately runs one shared local proxy. Per-user
126
+ # routing requires a worker and identity secret that desktop never starts.
127
+ def desktop_managed_gateway_uses_shared_workspace
128
+ return unless desktop_managed? && !shared?
129
+
130
+ errors.add(:workspace_mode, :desktop_shared_only)
131
+ end
132
+
133
+ def identity_secret_is_usable
134
+ if identity_secret.blank?
135
+ # A single-agent shared gateway may fall back to the proxy's unscoped workspace.
136
+ errors.add(:identity_secret, :blank) if per_user? || agents.many?
137
+ return
138
+ end
139
+
140
+ return if identity_secret.to_s.bytesize >= MIN_IDENTITY_SECRET_BYTES
141
+
142
+ errors.add(:identity_secret, :too_short, count: MIN_IDENTITY_SECRET_BYTES)
143
+ end
144
+
145
+ def completion_key_is_present_when_assigned_to_agents
146
+ return if completion_key.present? || !agents.exists?
147
+
148
+ errors.add(:completion_key, :required_for_agents)
149
+ end
150
+
151
+ # An agent assignment and key removal both depend on the same invariant.
152
+ # Re-check under the gateway row lock so two requests that validated a
153
+ # previously key-bearing, unassigned gateway cannot both commit.
154
+ def serialize_completion_key_removal
155
+ return yield unless will_save_change_to_completion_key? && completion_key.blank?
156
+
157
+ locked_gateway = self.class.find(id)
158
+ locked_gateway.with_lock do
159
+ begin
160
+ @completion_key_removal_lock = locked_gateway
161
+ yield
162
+ ensure
163
+ @completion_key_removal_lock = nil
164
+ end
165
+ end
166
+ end
167
+
168
+ def validate_completion_key_removal_under_lock
169
+ return unless @completion_key_removal_lock&.agents&.exists?
170
+
171
+ errors.add(:completion_key, :required_for_agents)
172
+ throw :abort
173
+ end
174
+
175
+ def workspace_credentials_changed?
176
+ saved_change_to_base_url? || saved_change_to_tenant_id? || saved_change_to_workspace_mode? || deactivated?
177
+ end
178
+
179
+ def deactivated?
180
+ saved_change_to_active? && !active?
181
+ end
182
+
183
+ def reconcile_workspaces_after_gateway_change
184
+ if saved_change_to_base_url? || saved_change_to_tenant_id? || deactivated?
185
+ agent_workspaces.destroy_all
186
+ return
187
+ end
188
+
189
+ agents.find_each do |agent|
190
+ workspaces = agent_workspaces.where(agent: agent)
191
+ next unless workspaces.exists?
192
+
193
+ workspaces.destroy_all
194
+ next unless active?
195
+
196
+ AgentWorkspace.resolve!(agent: agent, user: per_user? ? agent.creator : nil)
197
+ end
198
+ end
199
+ end
200
+ end
@@ -3,15 +3,15 @@
3
3
  module Collavre
4
4
  # Presence record for one live Claude Channel session subscription on the
5
5
  # per-agent ActionCable stream. The set of LIVE rows for an agent_id is the
6
- # authority for whether routing_expression should be active: a human's
7
- # concurrent sessions share one agent, so routing stays on while ANY session
8
- # holds a live row, and only turns off when the last one is gone.
6
+ # authority for whether Claude Channel dispatch is active: a human's
7
+ # concurrent sessions share one agent, so dispatch stays on while ANY session
8
+ # holds a live row, and turns off when the last one is gone.
9
9
  #
10
10
  # Liveness is leased, not assumed. Rows are deleted only by
11
11
  # AgentChannel#unsubscribed, so a Puma/ActionCable crash or deploy orphans a
12
12
  # row. last_seen_at is refreshed by the channel's periodic heartbeat; a row
13
13
  # whose last_seen_at falls outside STALE_AFTER is dead — ignored by the live
14
- # scope so it cannot pin routing on, and reaped opportunistically on the next
14
+ # scope so it cannot keep dispatch active, and reaped opportunistically on the next
15
15
  # subscribe/unsubscribe for the agent.
16
16
  class AgentSubscription < ApplicationRecord
17
17
  self.table_name = "agent_subscriptions"
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Collavre
6
+ class AgentWorkspace < ApplicationRecord
7
+ self.table_name = "agent_workspaces"
8
+
9
+ belongs_to :agent, class_name: "Collavre::User"
10
+ belongs_to :user, class_name: "Collavre::User", optional: true
11
+ belongs_to :agent_gateway, class_name: "Collavre::AgentGateway"
12
+
13
+ encrypts :manifest_token, deterministic: false
14
+ encrypts :callback_token, deterministic: false
15
+
16
+ # Mirrors the proxy's STABLE_ID_RE: the credential axis selects the OS worker.
17
+ CREDENTIAL_ID_FORMAT = /\A[A-Za-z0-9][A-Za-z0-9._:@\/-]{0,199}\z/
18
+ # Mirrors the proxy's WORKSPACE_ID_RE. Narrower than the credential format
19
+ # because this value becomes one path segment below the worker's HOME, so
20
+ # "/" and "." must stay out of it.
21
+ WORKSPACE_ID_FORMAT = /\A[A-Za-z0-9][A-Za-z0-9_:@-]{0,199}\z/
22
+
23
+ validates :proxy_credential_id, :proxy_workspace_id, :manifest_token,
24
+ :manifest_token_digest, :callback_token, presence: true
25
+ validates :proxy_credential_id, format: { with: CREDENTIAL_ID_FORMAT }
26
+ validates :proxy_workspace_id, format: { with: WORKSPACE_ID_FORMAT }
27
+ validates :manifest_token_digest, uniqueness: true
28
+
29
+ before_validation :derive_manifest_token_digest, if: :will_save_change_to_manifest_token?
30
+ before_destroy :revoke_callback_token
31
+
32
+ class << self
33
+ def resolve!(agent:, user:)
34
+ loop do
35
+ gateway_id = agent.class.where(id: agent.id).pick(:agent_gateway_id)
36
+ raise ArgumentError, "Agent has no gateway" unless gateway_id
37
+
38
+ gateway = Collavre::AgentGateway.find(gateway_id)
39
+ workspace = gateway.with_lock do
40
+ agent.with_lock do
41
+ # User validation takes the gateway lock before updating the agent.
42
+ # Use the same lock order, then verify the association under both
43
+ # locks. A concurrent reassignment makes this attempt retry against
44
+ # the newly persisted gateway instead of creating stale credentials.
45
+ next unless agent.agent_gateway_id == gateway.id
46
+
47
+ raise ArgumentError, "Agent gateway is inactive" unless gateway.active?
48
+
49
+ gateway.shared? ? resolve_shared!(agent, gateway) : resolve_per_user!(agent, user, gateway)
50
+ end
51
+ end
52
+
53
+ return workspace if workspace
54
+ end
55
+ end
56
+
57
+ def find_by_manifest_token!(agent_id:, token:)
58
+ supplied = token.to_s
59
+ workspace = find_by!(agent_id: agent_id, manifest_token_digest: manifest_digest(supplied))
60
+ return workspace if ActiveSupport::SecurityUtils.secure_compare(supplied, workspace.manifest_token.to_s)
61
+
62
+ raise ActiveRecord::RecordNotFound
63
+ end
64
+
65
+ def manifest_digest(token)
66
+ Digest::SHA256.hexdigest(token)
67
+ end
68
+
69
+ # The workspace axis is always the agent: skills, workspace config, and the
70
+ # provisioning lockfile are per agent regardless of the gateway mode.
71
+ def proxy_workspace_id_for(agent)
72
+ "agent-#{agent.id}"
73
+ end
74
+
75
+ # The credential axis selects the proxy worker that holds the engine
76
+ # logins. A per-user gateway keys it by Collavre user so one login covers
77
+ # every agent that user reaches; a shared gateway keys it by agent so all
78
+ # of its users keep sharing the one login.
79
+ def proxy_credential_id_for(agent, user)
80
+ user ? "user-#{user.id}" : "agent-#{agent.id}"
81
+ end
82
+
83
+ # Nothing here reads proxy_user_id; it is written so a process still
84
+ # running the pre-split code can resolve rows this release creates —
85
+ # the previous container during a Kamal rollout, or the whole app after
86
+ # a rollback. Drop this together with the column in the contract
87
+ # migration that follows this release.
88
+ def legacy_proxy_user_id_for(agent, user)
89
+ return "agent-#{agent.id}" unless user
90
+
91
+ "agent-#{agent.id}--user-#{user.id}"
92
+ end
93
+
94
+ private
95
+
96
+ def resolve_shared!(agent, gateway)
97
+ existing = find_by(agent: agent, agent_gateway: gateway, user_id: nil)
98
+ return existing if identity_current?(existing, agent, nil)
99
+
100
+ existing&.destroy!
101
+ create_workspace!(agent: agent, user: nil, gateway: gateway)
102
+ rescue ActiveRecord::RecordNotUnique
103
+ find_by!(agent: agent, agent_gateway: gateway, user_id: nil)
104
+ end
105
+
106
+ def resolve_per_user!(agent, user, gateway)
107
+ raise ArgumentError, "A user is required for a per-user workspace" unless user
108
+
109
+ existing = find_by(agent: agent, user: user, agent_gateway: gateway)
110
+ return existing if identity_current?(existing, agent, user)
111
+
112
+ existing&.destroy!
113
+
114
+ if user.id == agent.created_by_id
115
+ find_by(agent: agent, agent_gateway: gateway, user_id: nil)&.destroy!
116
+ end
117
+
118
+ create_workspace!(agent: agent, user: user, gateway: gateway)
119
+ rescue ActiveRecord::RecordNotUnique
120
+ find_by!(agent: agent, user: user, agent_gateway: gateway)
121
+ end
122
+
123
+ # A row minted under an older identity scheme addresses a proxy workspace
124
+ # that no longer matches its agent/user pair, so it is replaced rather than
125
+ # reused — its credentials would resolve to the wrong worker or directory.
126
+ def identity_current?(workspace, agent, user)
127
+ return false unless workspace
128
+
129
+ workspace.proxy_workspace_id == proxy_workspace_id_for(agent) &&
130
+ workspace.proxy_credential_id == proxy_credential_id_for(agent, user)
131
+ end
132
+
133
+ def create_workspace!(agent:, user:, gateway:)
134
+ transaction do
135
+ callback_token = issue_callback_token!(gateway: gateway, owner: user || agent)
136
+
137
+ create!(
138
+ agent: agent,
139
+ user: user,
140
+ agent_gateway: gateway,
141
+ proxy_workspace_id: proxy_workspace_id_for(agent),
142
+ proxy_credential_id: proxy_credential_id_for(agent, user),
143
+ proxy_user_id: legacy_proxy_user_id_for(agent, user),
144
+ manifest_token: SecureRandom.urlsafe_base64(32),
145
+ callback_token: callback_token
146
+ )
147
+ end
148
+ end
149
+
150
+ def issue_callback_token!(gateway:, owner:)
151
+ application = Doorkeeper::Application.find_or_create_by!(
152
+ owner: gateway.owner,
153
+ name: "Collavre Agent Gateway",
154
+ redirect_uri: "urn:ietf:wg:oauth:2.0:oob"
155
+ ) do |app|
156
+ app.scopes = "public"
157
+ app.confidential = true
158
+ end
159
+
160
+ access_token = Doorkeeper::AccessToken.create!(
161
+ application: application,
162
+ resource_owner_id: owner.id,
163
+ scopes: "public",
164
+ expires_in: nil,
165
+ use_refresh_token: false
166
+ )
167
+ plaintext = access_token.token
168
+ access_token.update_column(:token, Collavre::HashedAccessTokenLookup.encode(plaintext))
169
+
170
+ plaintext
171
+ end
172
+ end
173
+
174
+ def config_payload(base_url:)
175
+ { url: base_url.sub(%r{/+\z}, ""), token: callback_token }
176
+ end
177
+
178
+ def rotate_tokens!
179
+ gateway = agent_gateway
180
+ gateway.with_lock do
181
+ workspace_agent = agent
182
+ workspace_agent.with_lock do
183
+ raise ActiveRecord::RecordNotFound unless workspace_agent.agent_gateway_id == gateway.id
184
+ raise ArgumentError, "Agent gateway is inactive" unless gateway.active?
185
+
186
+ with_lock do
187
+ raise ActiveRecord::RecordNotFound unless agent_gateway_id == gateway.id
188
+
189
+ old_access_token = Doorkeeper::AccessToken.by_token(callback_token)
190
+ new_callback_token = self.class.send(:issue_callback_token!, gateway: gateway, owner: user || workspace_agent)
191
+ update!(callback_token: new_callback_token)
192
+ old_access_token&.revoke
193
+ end
194
+ end
195
+ end
196
+
197
+ self
198
+ end
199
+
200
+ private
201
+
202
+ def derive_manifest_token_digest
203
+ self.manifest_token_digest = self.class.manifest_digest(manifest_token.to_s)
204
+ end
205
+
206
+ def revoke_callback_token
207
+ Doorkeeper::AccessToken.by_token(callback_token)&.revoke
208
+ end
209
+ end
210
+ end