collavre 0.22.0 → 0.24.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -20,6 +20,12 @@ module Collavre
20
20
  end
21
21
  end
22
22
  created = []
23
+ # Per-parent sequence counters. closure_tree orders siblings by the
24
+ # `sequence` column (default 0), but plain Creative.create never assigns
25
+ # it — so without this every imported sibling would share sequence 0 and
26
+ # fall back to a DB-defined tie-break (stable on SQLite, arbitrary on
27
+ # Postgres), corrupting document order. Assign contiguous values here.
28
+ seq = {}
23
29
  root = parent
24
30
  i = 0
25
31
  if create_root
@@ -28,7 +34,7 @@ module Collavre
28
34
  end
29
35
  if i < lines.size && lines[i] !~ /^\s*#/ && lines[i] !~ /^\s*[-*+]/
30
36
  page_title = lines[i].strip
31
- root = Creative.create(user: user, parent: parent, description: page_title)
37
+ root = create_child(user: user, parent: parent, description: page_title, seq: seq)
32
38
  created << root
33
39
  i += 1
34
40
  end
@@ -73,7 +79,7 @@ module Collavre
73
79
  code_html = build_code_block_html(code_content, fence_info)
74
80
 
75
81
  new_parent = stack.any? ? stack.last[1] : root
76
- c = Creative.create(user: user, parent: new_parent, description: code_html)
82
+ c = create_child(user: user, parent: new_parent, description: code_html, seq: seq)
77
83
  created << c
78
84
  next
79
85
  end
@@ -81,7 +87,7 @@ module Collavre
81
87
  if (table_data = parse_markdown_table(lines, i))
82
88
  table_html = build_table_html(table_data, image_refs)
83
89
  new_parent = stack.any? ? stack.last[1] : root
84
- c = Creative.create(user: user, parent: new_parent, description: table_html)
90
+ c = create_child(user: user, parent: new_parent, description: table_html, seq: seq)
85
91
  created << c
86
92
  i = table_data[:next_index]
87
93
  elsif line =~ /^(#+)\s+(.*)$/
@@ -89,7 +95,7 @@ module Collavre
89
95
  desc = ApplicationController.helpers.markdown_links_to_html($2.strip, image_refs)
90
96
  stack.pop while stack.any? && stack.last[0] >= level
91
97
  new_parent = stack.any? ? stack.last[1] : root
92
- c = Creative.create(user: user, parent: new_parent, description: desc)
98
+ c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
93
99
  created << c
94
100
  stack << [ level, c ]
95
101
  i += 1
@@ -99,14 +105,14 @@ module Collavre
99
105
  bullet_level = 10 + indent / 2
100
106
  stack.pop while stack.any? && stack.last[0] >= bullet_level
101
107
  new_parent = stack.any? ? stack.last[1] : root
102
- c = Creative.create(user: user, parent: new_parent, description: desc)
108
+ c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
103
109
  created << c
104
110
  stack << [ bullet_level, c ]
105
111
  i += 1
106
112
  elsif !line.strip.empty?
107
113
  desc = ApplicationController.helpers.markdown_links_to_html(line.strip, image_refs)
108
114
  new_parent = stack.any? ? stack.last[1] : root
109
- c = Creative.create(user: user, parent: new_parent, description: desc)
115
+ c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
110
116
  created << c
111
117
  i += 1
112
118
  else
@@ -121,6 +127,24 @@ module Collavre
121
127
  created
122
128
  end
123
129
 
130
+ # Creates a child under `parent` with an explicit `sequence`, tracking the
131
+ # next value per parent in `seq`. The first time a parent is seen the
132
+ # counter is seeded past any pre-existing children so an import into an
133
+ # already-populated parent appends after them instead of colliding at 0.
134
+ # A nil parent means a root-level import (top-level page with no parent_id);
135
+ # closure_tree orders roots by `sequence` too, so seed from Creative.roots.
136
+ def self.create_child(user:, parent:, description:, seq:)
137
+ key = parent&.id
138
+ unless seq.key?(key)
139
+ siblings = parent ? parent.children : Creative.roots
140
+ max = siblings.maximum(:sequence)
141
+ seq[key] = max ? max + 1 : 0
142
+ end
143
+ creative = Creative.create(user: user, parent: parent, description: description, sequence: seq[key])
144
+ seq[key] += 1
145
+ creative
146
+ end
147
+
124
148
  def self.build_code_block_html(code_content, language = nil)
125
149
  escaped_content = ERB::Util.html_escape(code_content)
126
150
  lang_class = language.present? ? " class=\"language-#{ERB::Util.html_escape(language)}\"" : ""
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Mobile
5
+ # Turns an approval/permission comment or an agent reply into a SHORT,
6
+ # decision-oriented line of speech. Deterministic (no LLM) so the hot path
7
+ # stays fast: it extracts the tool name / human description from the
8
+ # permission action payload and renders a localized, spoken-friendly summary.
9
+ class EventSummarizer
10
+ def initialize(locale:)
11
+ @locale = (locale.presence || I18n.default_locale).to_s
12
+ end
13
+
14
+ def approval_summary(comment:, label:)
15
+ payload = parse_action(comment)
16
+ I18n.with_locale(@locale) do
17
+ detail =
18
+ payload&.dig("description").presence ||
19
+ payload&.dig("tool_name").presence ||
20
+ I18n.t("collavre.mobile.summary.a_tool")
21
+ I18n.t("collavre.mobile.summary.approval", label: label, detail: flatten(detail))
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def flatten(text)
28
+ text.to_s.gsub(/\s+/, " ").strip
29
+ end
30
+
31
+ def parse_action(comment)
32
+ return nil unless comment.approval_action?
33
+
34
+ JSON.parse(comment.action)
35
+ rescue JSON::ParserError
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ end
@@ -126,24 +126,10 @@ module Collavre
126
126
  "id" => @agent.id,
127
127
  "name" => @agent.name,
128
128
  "display_name" => @agent.display_name,
129
- "type" => extract_agent_type
129
+ "type" => AgentTypeClassifier.classify(@agent)
130
130
  }
131
131
  end
132
132
 
133
- def extract_agent_type
134
- # Extract agent type from system_prompt or default
135
- prompt = @agent.system_prompt.to_s.downcase
136
- case prompt
137
- when /developer|개발/ then "developer"
138
- when /pm|project.?manager|프로젝트/ then "pm"
139
- when /qa|test|quality|테스트|품질/ then "qa"
140
- when /research|조사|연구/ then "researcher"
141
- when /market|마케팅/ then "marketer"
142
- when /plan|기획/ then "planner"
143
- else "agent"
144
- end
145
- end
146
-
147
133
  def build_collaborators
148
134
  return [] unless @creative
149
135
 
@@ -45,7 +45,12 @@ module Collavre
45
45
 
46
46
  Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil)
47
47
  .where("content LIKE ?", "⏳%")
48
- .destroy_all
48
+ .find_each do |notice|
49
+ # System promotion, not user abandonment: do not let the destroy
50
+ # callback cancel other still-queued waiters in this topic.
51
+ notice.suppress_waiter_cancellation = true
52
+ notice.destroy
53
+ end
49
54
  end
50
55
  private_class_method :cleanup_waiting_notices!
51
56
 
@@ -59,7 +64,7 @@ module Collavre
59
64
  return unless creative_id && context&.key?("topic")
60
65
 
61
66
  topic_id = context.dig("topic", "id")
62
- scope = Comment.public_only
67
+ scope = Comment.public_only.without_approval_action
63
68
  .where(creative_id: creative_id, topic_id: topic_id)
64
69
  .where.not(user_id: [ task.agent_id, nil ])
65
70
  .order(created_at: :desc)
@@ -184,17 +189,38 @@ module Collavre
184
189
  creative = Creative.find_by(id: creative_id)
185
190
  return unless creative
186
191
 
187
- reason_key = decision[:reason] || :unknown
188
- reason_text = I18n.t(
189
- "collavre.orchestration.waiting_reasons.#{reason_key}",
190
- default: reason_key.to_s.humanize
191
- )
192
+ reason_text = waiting_reason_text(decision[:reason] || :unknown, topic_id, creative_id)
192
193
 
193
194
  creative.comments.create!(
194
195
  content: I18n.t("collavre.orchestration.waiting_notice", reason: reason_text),
195
196
  topic_id: topic_id,
196
197
  private: false,
197
- skip_default_user: true
198
+ skip_default_user: true,
199
+ # Only :deferred queues a topic waiter; mark it so its stop button can
200
+ # target the blocker. :delayed (busy / rate_limited) notices stay false.
201
+ topic_concurrency_defer: decision[:timing] == :deferred
202
+ )
203
+ end
204
+
205
+ # Human-readable reason for the "⏳" waiting notice. For topic-concurrency
206
+ # deferrals, name the agent(s) actually holding the topic's running slot so
207
+ # a waiting user can see *who* is blocking them (and reach that task's stop
208
+ # button) rather than an anonymous "another task is running" dead end.
209
+ def waiting_reason_text(reason_key, topic_id, creative_id)
210
+ if reason_key == :topic_concurrency && topic_id
211
+ names = Task.running_for_topic(topic_id, creative_id)
212
+ .includes(:agent).filter_map { |t| t.agent&.name }.uniq
213
+ if names.any?
214
+ return I18n.t(
215
+ "collavre.orchestration.waiting_reasons.topic_concurrency_with_agent",
216
+ agent: names.join(", ")
217
+ )
218
+ end
219
+ end
220
+
221
+ I18n.t(
222
+ "collavre.orchestration.waiting_reasons.#{reason_key}",
223
+ default: reason_key.to_s.humanize
198
224
  )
199
225
  end
200
226
  end
@@ -25,6 +25,13 @@ module Collavre
25
25
  def select(candidates)
26
26
  return [] if candidates.empty?
27
27
 
28
+ # Review feedback is forced routing: the Matcher already restricts candidates
29
+ # to the quoted comment's author, the sole agent ReviewHandler will accept.
30
+ # Floor-control arbitration (bid scoring, round_robin) among a forced single
31
+ # recipient is meaningless, and a low bid score must not drop it (e.g. bid
32
+ # strategy with bid_fallback_enabled: false) or the Review button no-ops.
33
+ return candidates if review_message?
34
+
28
35
  strategy = @policy_resolver.arbitration_strategy
29
36
  selected = apply_strategy(strategy, candidates)
30
37
 
@@ -37,6 +44,15 @@ module Collavre
37
44
 
38
45
  private
39
46
 
47
+ # True when the triggering comment is review feedback (mirrors Matcher's
48
+ # review-author routing), so arbitration can be bypassed for forced routing.
49
+ def review_message?
50
+ comment_id = @context.dig("comment", "id") || @context.dig(:comment, :id)
51
+ return false unless comment_id
52
+
53
+ Comment.find_by(id: comment_id)&.review_message? || false
54
+ end
55
+
40
56
  def apply_strategy(strategy, candidates)
41
57
  case strategy
42
58
  when "all"
@@ -21,6 +21,10 @@ module Collavre
21
21
 
22
22
  # Returns Array of User (AI agents) that are qualified to respond
23
23
  def match
24
+ # Priority 0: Review routing (exclusive)
25
+ review_result = match_by_review_author
26
+ return review_result unless review_result.nil?
27
+
24
28
  # Priority 1: Mention-based routing (exclusive)
25
29
  mentioned_result = match_by_mention
26
30
  return mentioned_result unless mentioned_result.nil?
@@ -31,6 +35,39 @@ module Collavre
31
35
 
32
36
  private
33
37
 
38
+ # Returns [author] for a routable review message, [] for an unroutable one,
39
+ # nil when this is not a review message.
40
+ #
41
+ # A review message can ONLY be handled by the author of the quoted comment:
42
+ # ReviewHandler#eligible? requires quoted_comment.user_id == agent.id, so any
43
+ # other agent would post a stray reply instead of the in-place review update.
44
+ # Route exclusively to that author — independent of mention or
45
+ # routing_expression — so the Review button is reliable even when the author
46
+ # has none (e.g. /compress summaries authored by a primary agent resolved via
47
+ # primary_agent_id). Without this the button renders but the feedback no-ops.
48
+ #
49
+ # Once this IS a review message, the only safe outcomes are exclusive-route or
50
+ # block ([]) — never nil. ResponseFinalizer keys on review_message? regardless
51
+ # of which agent the matcher picks, so a fall-through to mention/expression
52
+ # routing would schedule an agent that ReviewHandler#handle then bails on,
53
+ # producing the very stray reply this routing exists to prevent.
54
+ def match_by_review_author
55
+ return nil unless matched_comment&.review_message?
56
+
57
+ author = matched_comment.quoted_comment&.user
58
+ return [] unless author&.ai_user?
59
+
60
+ # Mirror ReviewHandler#eligible?: if the handler would reject this quote
61
+ # (private, or from another topic/creative), no agent can handle the review.
62
+ # Block rather than fall through, so it can't become a stray normal reply.
63
+ return [] unless Collavre::AiAgent::ReviewHandler.eligible?(matched_comment, author)
64
+
65
+ return [] unless has_creative_permission?(author)
66
+ return [] unless eligible_in_inbox?(author)
67
+
68
+ [ author ]
69
+ end
70
+
34
71
  # Returns Array of agents if mention found, nil if no mention
35
72
  # When mention IS found, this is exclusive routing
36
73
  def match_by_mention
@@ -47,6 +84,11 @@ module Collavre
47
84
  # Permission check for mentioned AI agent
48
85
  return [] unless has_creative_permission?(mentioned_user)
49
86
 
87
+ # Inbox confinement applies to mentions too: a live Claude Channel
88
+ # session agent must not be pulled into an ordinary inbox topic, even by
89
+ # an explicit @mention (see #eligible_in_inbox?).
90
+ return [] unless eligible_in_inbox?(mentioned_user)
91
+
50
92
  [ mentioned_user ]
51
93
  end
52
94
 
@@ -57,11 +99,26 @@ module Collavre
57
99
 
58
100
  agents.select do |agent|
59
101
  next false unless has_creative_permission?(agent)
102
+ next false unless eligible_in_inbox?(agent)
60
103
 
61
104
  evaluate_routing_expression(agent)
62
105
  end
63
106
  end
64
107
 
108
+ # A Claude Channel session agent holds inbox-wide :feedback +
109
+ # routing_expression="true", so within the user's Inbox it would otherwise
110
+ # match EVERY topic. Confine it to its own registered session topic (the
111
+ # topic it is primary_agent on, carrying a session_id) so ordinary inbox
112
+ # topics — Main, Content, user threads — stay identical to a normal topic
113
+ # and are never absorbed by a live session. Only the inbox is affected: on
114
+ # work/project creatives the agent still matches via routing_expression.
115
+ def eligible_in_inbox?(agent)
116
+ return true unless matched_creative&.inbox?
117
+ return true unless agent.claude_channel_agent?
118
+
119
+ matched_topic&.session_id.present? && matched_topic.primary_agent_id == agent.id
120
+ end
121
+
65
122
  def evaluate_routing_expression(agent)
66
123
  expression = agent.routing_expression.strip
67
124
 
@@ -85,14 +142,32 @@ module Collavre
85
142
  def has_creative_permission?(agent)
86
143
  # All agents need feedback permission on the creative to respond
87
144
  # searchable only affects discoverability, not response permission
88
- creative_id = @context.dig("creative", "id") || @context.dig(:creative, :id)
89
- return false unless creative_id
90
-
91
- creative = Creative.find_by(id: creative_id)
145
+ creative = matched_creative
92
146
  return false unless creative
93
147
 
94
148
  creative.has_permission?(agent, :feedback)
95
149
  end
150
+
151
+ def matched_creative
152
+ return @matched_creative if defined?(@matched_creative)
153
+
154
+ creative_id = @context.dig("creative", "id") || @context.dig(:creative, :id)
155
+ @matched_creative = creative_id && Creative.find_by(id: creative_id)
156
+ end
157
+
158
+ def matched_topic
159
+ return @matched_topic if defined?(@matched_topic)
160
+
161
+ topic_id = @context.dig("topic", "id") || @context.dig(:topic, :id)
162
+ @matched_topic = topic_id && Topic.find_by(id: topic_id)
163
+ end
164
+
165
+ def matched_comment
166
+ return @matched_comment if defined?(@matched_comment)
167
+
168
+ comment_id = @context.dig("comment", "id") || @context.dig(:comment, :id)
169
+ @matched_comment = comment_id && Comment.find_by(id: comment_id)
170
+ end
96
171
  end
97
172
  end
98
173
  end
@@ -39,9 +39,10 @@ module Collavre
39
39
  },
40
40
  "stuck_detection" => {
41
41
  "enabled" => false,
42
- "task_stuck_threshold_minutes" => 30, # Task running for > N minutes
43
- "creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
44
- "create_system_comment" => true # Create system comment on escalation
42
+ "task_stuck_threshold_minutes" => 30, # Task running for > N minutes
43
+ "creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
44
+ "queued_orphan_threshold_minutes" => 5, # Queued waiter with no live blocker for > N minutes
45
+ "create_system_comment" => true # Create system comment on escalation
45
46
  },
46
47
  "collaboration" => {
47
48
  "a2a_focus_instruction" => nil, # nil = locale default
@@ -32,6 +32,7 @@ module Collavre
32
32
 
33
33
  stuck_items = []
34
34
  stuck_items.concat(detect_stuck_tasks(config))
35
+ stuck_items.concat(detect_orphaned_queued_tasks(config))
35
36
  stuck_items.concat(detect_stalled_creatives(config))
36
37
 
37
38
  auto_recover_stuck_tasks(stuck_items)
@@ -47,54 +48,124 @@ module Collavre
47
48
 
48
49
  stuck_items = []
49
50
  stuck_items.concat(detect_stuck_tasks(config))
51
+ stuck_items.concat(detect_orphaned_queued_tasks(config))
50
52
  stuck_items.concat(detect_stalled_creatives(config))
51
53
  stuck_items
52
54
  end
53
55
 
54
56
  private
55
57
 
56
- # Auto-recover stuck tasks by marking them as failed and draining the queue.
58
+ # Auto-recover stuck items: fail-and-drain for live tasks, self-heal for
59
+ # orphaned queued waiters.
57
60
  def auto_recover_stuck_tasks(stuck_items)
58
61
  stuck_items.each do |stuck_item|
59
- next unless stuck_item.type == :task
62
+ case stuck_item.type
63
+ when :task then recover_stuck_task(stuck_item)
64
+ when :queued_orphan then recover_orphaned_queued_task(stuck_item)
65
+ end
66
+ end
67
+ end
60
68
 
61
- task = stuck_item.item
62
- next unless %w[running delegated].include?(task.status)
69
+ # Self-heal an orphaned queued waiter: its blocker is gone but it was never
70
+ # drained (missed dequeue / enqueue-vs-terminate TOCTOU race / lost
71
+ # cross-process broadcast). Re-check liveness atomically, then drain.
72
+ def recover_orphaned_queued_task(stuck_item)
73
+ task = stuck_item.item.reload
74
+ return unless task.status == "queued"
75
+
76
+ # If the topic is back at capacity since detection, the normal terminal
77
+ # callback will drain the queue when a slot frees — leave it alone. This
78
+ # check also bounds promotions across one detection cycle: dequeue moves
79
+ # a waiter queued -> pending synchronously, which occupying_topic_slot
80
+ # counts, so consecutive orphans each fill exactly one free slot until the
81
+ # topic is full. With topic_max_concurrent_jobs > 1 and several free slots
82
+ # all are filled this cycle; a per-topic dedupe would instead leave every
83
+ # waiter past the first orphaned until the next run.
84
+ return if topic_at_capacity?(task)
85
+
86
+ Rails.logger.info(
87
+ "[StuckDetector] Self-healing orphaned queued task #{task.id} " \
88
+ "(topic=#{task.topic_id}, creative=#{task.creative_id}): no live blocker, draining queue"
89
+ )
90
+ AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
91
+ rescue StandardError => e
92
+ Rails.logger.error("[StuckDetector] Self-heal failed for queued task #{stuck_item.item.id}: #{e.message}")
93
+ end
63
94
 
64
- task.update!(status: "failed")
65
- Rails.logger.info(
66
- "[StuckDetector] Auto-recovered task #{task.id} (agent=#{task.agent_id}): " \
67
- "marked as failed after #{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
68
- )
95
+ # Whether a topic holds no free concurrency slot for a queued waiter.
96
+ # Compares occupied slots against topic_max (the scheduler's admission rule)
97
+ # rather than treating any single live blocker as full capacity — otherwise,
98
+ # with topic_max_concurrent_jobs > 1, a missed dequeue would leave the waiter
99
+ # suppressed until the *last* blocker terminates instead of the moment a slot
100
+ # frees up. Occupancy counts pending as well as running/delegated: a waiter
101
+ # that a prior dequeue already claimed sits in "pending" until its AiAgentJob
102
+ # starts, and the detector fires precisely on the backed-up condition where
103
+ # that window is wide — counting only running/delegated would see a free slot
104
+ # and promote a second waiter into a slot that is already claimed (double
105
+ # dequeue). This is intentionally stricter than the scheduler's reactive
106
+ # check; being stricter can only suppress recovery, never over-admit. When no
107
+ # topic limit is configured the scheduler never defers, so fall back to the
108
+ # conservative "any occupied slot" check.
109
+ def topic_at_capacity?(task)
110
+ topic_max = scheduling_resolver_for(task).topic_max_concurrent_jobs
111
+ occupied_count = Task.occupying_topic_slot(task.topic_id, task.creative_id).count
112
+ return occupied_count.positive? unless topic_max
113
+
114
+ occupied_count >= topic_max
115
+ end
69
116
 
70
- # Release resources held by the stuck task
71
- if task.agent
72
- tracker = ResourceTracker.for(task.agent)
73
- tracker.release!(task.id)
74
- end
117
+ # Resolve scheduling policy against the queued task's own topic/creative
118
+ # context — the same context the scheduler used to admit it. The detector's
119
+ # default resolver is built with an empty context (it only needs the global
120
+ # stuck_detection policy), so reading topic_max_concurrent_jobs off it would
121
+ # see only the global default and ignore any topic-/creative-scoped override.
122
+ # That mismatch would violate a topic's serialization when its scoped limit
123
+ # is below the global value, or wrongly suppress recovery when it is above.
124
+ def scheduling_resolver_for(task)
125
+ context = {}
126
+ context["creative"] = { "id" => task.creative_id } if task.creative_id
127
+ context["topic"] = { "id" => task.topic_id } if task.topic_id
128
+ PolicyResolver.new(context)
129
+ end
75
130
 
76
- # If this was a workflow subtask, fail the parent so the workflow
77
- # advances instead of staying running with pending_creative_ids
78
- # pointing at a child that's been failed underneath it.
79
- if task.parent_task_id.present?
80
- begin
81
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
82
- task,
83
- error_message: "Auto-recovered: stuck for " \
84
- "#{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
85
- )
86
- rescue StandardError => e
87
- Rails.logger.error(
88
- "[StuckDetector] fail_subtask! failed for task #{task.id}: #{e.message}"
89
- )
90
- end
91
- end
131
+ # Fail a stuck running/delegated task and drain the topic queue.
132
+ def recover_stuck_task(stuck_item)
133
+ task = stuck_item.item
134
+ return unless %w[running delegated].include?(task.status)
135
+
136
+ task.update!(status: "failed")
137
+ Rails.logger.info(
138
+ "[StuckDetector] Auto-recovered task #{task.id} (agent=#{task.agent_id}): " \
139
+ "marked as failed after #{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
140
+ )
92
141
 
93
- # Drain the queue for the topic so waiting tasks can execute
94
- AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
95
- rescue StandardError => e
96
- Rails.logger.error("[StuckDetector] Auto-recovery failed for task #{task.id}: #{e.message}")
142
+ # Release resources held by the stuck task
143
+ if task.agent
144
+ tracker = ResourceTracker.for(task.agent)
145
+ tracker.release!(task.id)
97
146
  end
147
+
148
+ # If this was a workflow subtask, fail the parent so the workflow
149
+ # advances instead of staying running with pending_creative_ids
150
+ # pointing at a child that's been failed underneath it.
151
+ if task.parent_task_id.present?
152
+ begin
153
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
154
+ task,
155
+ error_message: "Auto-recovered: stuck for " \
156
+ "#{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
157
+ )
158
+ rescue StandardError => e
159
+ Rails.logger.error(
160
+ "[StuckDetector] fail_subtask! failed for task #{task.id}: #{e.message}"
161
+ )
162
+ end
163
+ end
164
+
165
+ # Drain the queue for the topic so waiting tasks can execute
166
+ AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
167
+ rescue StandardError => e
168
+ Rails.logger.error("[StuckDetector] Auto-recovery failed for task #{stuck_item.item.id}: #{e.message}")
98
169
  end
99
170
 
100
171
  def stuck_detection_config
@@ -129,6 +200,38 @@ module Collavre
129
200
  end
130
201
  end
131
202
 
203
+ # Detect orphaned queued waiters: tasks left in "queued" for a topic that
204
+ # holds no occupied slot (no running/delegated blocker and no pending claim).
205
+ # A queued task's only path to
206
+ # execution is dequeue_next_for_topic, which fires when the blocker reaches
207
+ # a terminal status. If that single hand-off is missed — an
208
+ # enqueue-vs-terminate TOCTOU race, or a lost cross-process broadcast — the
209
+ # blocker is already gone and nothing will ever wake the waiter: it shows
210
+ # "⏳" waiting notice forever. These are invisible to detect_stuck_tasks, which only
211
+ # scans running/delegated. There is no stop button to press here because the
212
+ # blocker no longer exists; the fix is to drain the queue (see
213
+ # recover_orphaned_queued_task), not to escalate.
214
+ def detect_orphaned_queued_tasks(config)
215
+ threshold_minutes = config["queued_orphan_threshold_minutes"] || 5
216
+ threshold_time = threshold_minutes.minutes.ago
217
+
218
+ Task.where(status: "queued")
219
+ .where("updated_at < ?", threshold_time)
220
+ .filter_map do |task|
221
+ # Only orphaned if the topic has a free slot. A waiter is legitimately
222
+ # queued while the topic is at capacity.
223
+ next if topic_at_capacity?(task)
224
+
225
+ StuckItem.new(
226
+ type: :queued_orphan,
227
+ item: task,
228
+ reason: :orphaned_waiter,
229
+ stuck_since: task.updated_at,
230
+ escalation_targets: []
231
+ )
232
+ end
233
+ end
234
+
132
235
  # Detect creatives that have stalled (no activity for extended period)
133
236
  def detect_stalled_creatives(config)
134
237
  threshold_minutes = config["creative_stall_threshold_minutes"] || 120
@@ -216,6 +319,10 @@ module Collavre
216
319
  escalated_count = 0
217
320
 
218
321
  stuck_items.each do |stuck_item|
322
+ # Orphaned queued waiters are silently self-healed (queue drained),
323
+ # not escalated to admins — there is no human action to take.
324
+ next if stuck_item.type == :queued_orphan
325
+
219
326
  escalated = escalate_item(stuck_item, config)
220
327
  if escalated
221
328
  mark_escalated(stuck_item)
@@ -34,23 +34,10 @@ module Collavre
34
34
  "name" => user.name,
35
35
  "display_name" => user.respond_to?(:display_name) ? user.display_name : user.name,
36
36
  "is_ai" => user.ai_user?,
37
- "type" => user.ai_user? ? extract_agent_type(user) : "human"
37
+ "type" => user.ai_user? ? AgentTypeClassifier.classify(user) : "human"
38
38
  }
39
39
  end
40
40
 
41
- def extract_agent_type(user)
42
- prompt = user.system_prompt.to_s.downcase
43
- case prompt
44
- when /developer|개발/ then "developer"
45
- when /pm|project.?manager|프로젝트/ then "pm"
46
- when /qa|test|quality|테스트|품질/ then "qa"
47
- when /research|조사|연구/ then "researcher"
48
- when /market|마케팅/ then "marketer"
49
- when /plan|기획/ then "planner"
50
- else "agent"
51
- end
52
- end
53
-
54
41
  def mentioned_user(chat_context)
55
42
  content = chat_context["content"]
56
43
  return nil unless content
@@ -18,9 +18,10 @@ module Collavre
18
18
  end
19
19
 
20
20
  tool_param :operations, description: "Array of operation objects. Each object must have an 'action' key.\n\n" \
21
- "For 'create': { action: 'create', parent_id: <int>, description: <string>, progress: <float>, after_id: <int>, before_id: <int> }\n" \
22
- "For 'update': { action: 'update', id: <int>, description: <string>, progress: 1.0, parent_id: <int> } — progress only accepts 1.0 (complete) and only on leaf Creatives\n" \
21
+ "For 'create': { action: 'create', parent_id: <int>, description: <markdown string>, progress: <float>, after_id: <int>, before_id: <int> }\n" \
22
+ "For 'update': { action: 'update', id: <int>, description: <markdown string>, progress: 1.0, parent_id: <int> } — progress only accepts 1.0 (complete) and only on leaf Creatives\n" \
23
23
  "For 'delete': { action: 'delete', id: <int> }\n\n" \
24
+ "The 'description' field is written as Markdown (GitHub-Flavored).\n" \
24
25
  "Fields other than 'action' and 'id'/'parent_id' are optional.", required: true
25
26
 
26
27
  class BatchRollbackError < StandardError