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
@@ -3,13 +3,30 @@ module Collavre
3
3
  module Permissible
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ # Single declarative registry of which persisted-attribute changes
7
+ # invalidate the permission cache, and how each is rebuilt. A single
8
+ # after_commit dispatcher maps the accumulated changes through this map so
9
+ # a new mutation path can never silently skip a required rebuild.
10
+ #
11
+ # :rebuild -> rebuild_for_creative (self + descendant subtree)
12
+ # :rebuild_owner -> update_owner (move the owner cache entry)
13
+ #
14
+ # origin_id is immutable after create (see Linkable#attr_readonly), so its
15
+ # entry only fires on link creation and is defensive.
16
+ PERMISSION_INVALIDATING_ATTRIBUTES = {
17
+ "parent_id" => :rebuild,
18
+ "user_id" => :rebuild_owner,
19
+ "origin_id" => :rebuild
20
+ }.freeze
21
+
6
22
  included do
7
23
  has_many :creative_shares, class_name: "Collavre::CreativeShare", dependent: :destroy
8
24
  has_many :creative_shares_caches, class_name: "Collavre::CreativeSharesCache", dependent: :delete_all
9
25
 
10
- after_commit :rebuild_permission_cache, if: :saved_change_to_parent_id?, unless: :destroyed?
26
+ after_save :accumulate_permission_cache_changes
27
+ after_commit :dispatch_permission_cache_invalidation, unless: :destroyed?
11
28
  after_commit :cache_owner_permission, on: :create
12
- after_commit :update_owner_cache, if: :saved_change_to_user_id?, unless: :destroyed?
29
+ after_rollback :clear_accumulated_permission_changes
13
30
  end
14
31
 
15
32
  def has_permission?(user, required_permission = :read)
@@ -23,42 +40,21 @@ module Collavre
23
40
  children_ids = children_scope.pluck(:id)
24
41
  return [] if children_ids.empty?
25
42
 
26
- min_rank = CreativeShare.permissions[min_permission.to_s]
27
- accessible_ids = Set.new
28
-
29
- if user
30
- user_entries = CreativeSharesCache
31
- .where(creative_id: children_ids, user_id: user.id)
32
- .pluck(:creative_id, :permission)
33
-
34
- user_has_entry = Set.new
35
- user_entries.each do |cid, perm|
36
- user_has_entry << cid
37
- perm_rank = CreativeSharesCache.permissions[perm]
38
- if perm_rank && perm_rank >= min_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
39
- accessible_ids << cid
40
- end
41
- end
42
-
43
- public_accessible = CreativeSharesCache
44
- .where(creative_id: children_ids, user_id: nil)
45
- .where("permission >= ?", min_rank)
46
- .where.not(permission: :no_access)
47
- .pluck(:creative_id)
48
- accessible_ids.merge(public_accessible - user_has_entry.to_a)
49
-
50
- owned_ids = children_scope.where(user_id: user.id).pluck(:id)
51
- accessible_ids.merge(owned_ids)
52
- else
53
- accessible_ids = CreativeSharesCache
54
- .where(creative_id: children_ids, user_id: nil)
55
- .where("permission >= ?", min_rank)
56
- .where.not(permission: :no_access)
57
- .pluck(:creative_id)
58
- .to_set
59
- end
60
-
61
- children_scope.where(id: accessible_ids.to_a).order(:sequence).to_a
43
+ # The deny-invariant (owner wins → user entry, incl. a no_access deny,
44
+ # beats public → effective-origin resolution) now lives in exactly one
45
+ # place: PermissionFilter. This site used to re-implement it inline.
46
+ accessible_ids = Collavre::Creatives::PermissionFilter.new(user: user)
47
+ .readable_ids(children_ids, min_permission: min_permission)
48
+
49
+ # readable_ids gates a linked shell on its origin being readable AND the
50
+ # viewer being able to see the shell's placement (owns it, or it sits in
51
+ # a subtree shared with the viewer). Listing one's OWN tree keeps the
52
+ # prior policy that a viewer always sees their own children — including a
53
+ # shell they own whose origin is no longer shared with them — so union
54
+ # those owned rows back in. (Owner has admin, so this is rank-independent.)
55
+ accessible_ids |= children_scope.where(user_id: user.id).pluck(:id) if user
56
+
57
+ children_scope.where(id: accessible_ids).order(:sequence).to_a
62
58
  end
63
59
 
64
60
  def all_shared_users(required_permission = :no_access)
@@ -105,21 +101,73 @@ module Collavre
105
101
 
106
102
  private
107
103
 
108
- def rebuild_permission_cache
109
- PermissionCacheJob.perform_later(:rebuild_for_creative, creative_id: id)
104
+ # Accumulate permission-affecting changes across every save in the
105
+ # transaction. after_commit sees only the *final* save's saved_changes, so
106
+ # if a permission attribute changes in one save and a later same-
107
+ # transaction save of this record touches only untracked columns, that
108
+ # permission change is clobbered out of saved_changes and a saved_changes-
109
+ # gated dispatcher would skip the rebuild, leaving the permission cache
110
+ # stale (fail-open, with no TTL/self-heal). Merging each save's changes
111
+ # keeps the invariant regardless of save ordering. This mirrors the fail-
112
+ # closed intent of the CreativeShare fix (#1393 / 00502d7d); it is
113
+ # centralized here via cross-save accumulation rather than an
114
+ # unconditional refresh so that permission-irrelevant Creative updates
115
+ # (progress rollup, description edits — a hot write path) stay cheap.
116
+ #
117
+ # No production path performs two real saves touching a permission
118
+ # attribute then an untracked column on one Creative in a single
119
+ # transaction today (the reorderer re-saves :sequence via update_column,
120
+ # which does not run changes_applied and so preserves saved_changes), so
121
+ # this is a defensive/latent fix. Note: update_column bypasses this
122
+ # callback too, which is why the reorderer's parent change still refreshes.
123
+ def accumulate_permission_cache_changes
124
+ changes = saved_changes.slice(*PERMISSION_INVALIDATING_ATTRIBUTES.keys)
125
+ return if changes.empty?
126
+
127
+ @accumulated_permission_changes ||= {}
128
+ changes.each do |attribute, (old_value, new_value)|
129
+ existing = @accumulated_permission_changes[attribute]
130
+ # Keep the earliest "old" and the latest "new" across saves.
131
+ @accumulated_permission_changes[attribute] = [ existing ? existing.first : old_value, new_value ]
132
+ end
110
133
  end
111
134
 
112
- def cache_owner_permission
113
- PermissionCacheJob.perform_later(:cache_owner, creative_id: id)
135
+ def clear_accumulated_permission_changes
136
+ @accumulated_permission_changes = nil
114
137
  end
115
138
 
116
- def update_owner_cache
117
- old_user_id, new_user_id = saved_change_to_user_id
118
- PermissionCacheJob.perform_later(:update_owner,
119
- creative_id: id,
120
- old_user_id: old_user_id,
121
- new_user_id: new_user_id
122
- )
139
+ # Single dispatch point for permission-cache invalidation. Maps the
140
+ # accumulated permission-attribute changes through
141
+ # PERMISSION_INVALIDATING_ATTRIBUTES and enqueues each distinct rebuild
142
+ # exactly once.
143
+ def dispatch_permission_cache_invalidation
144
+ accumulated = @accumulated_permission_changes || {}
145
+ clear_accumulated_permission_changes
146
+
147
+ operations = accumulated.keys
148
+ .map { |attr| PERMISSION_INVALIDATING_ATTRIBUTES[attr] }
149
+ .uniq
150
+ return if operations.empty?
151
+
152
+ operations.each { |operation| run_permission_cache_operation(operation, accumulated) }
153
+ end
154
+
155
+ def run_permission_cache_operation(operation, accumulated)
156
+ case operation
157
+ when :rebuild
158
+ PermissionCacheJob.perform_later(:rebuild_for_creative, creative_id: id)
159
+ when :rebuild_owner
160
+ old_user_id, new_user_id = accumulated["user_id"]
161
+ PermissionCacheJob.perform_later(:update_owner,
162
+ creative_id: id,
163
+ old_user_id: old_user_id,
164
+ new_user_id: new_user_id
165
+ )
166
+ end
167
+ end
168
+
169
+ def cache_owner_permission
170
+ PermissionCacheJob.perform_later(:cache_owner, creative_id: id)
123
171
  end
124
172
  end
125
173
  end
@@ -5,6 +5,40 @@ module Collavre
5
5
  class Creative < ApplicationRecord
6
6
  self.table_name = "creatives"
7
7
 
8
+ # ---------------------------------------------------------------------------
9
+ # Reserved metadata key registry — engines register their own namespaces so
10
+ # `update_metadata` preserves them without core naming vendor-specific keys.
11
+ # ---------------------------------------------------------------------------
12
+ BUILTIN_RESERVED_METADATA_KEYS = %w[markdown_source content_type editor].freeze
13
+
14
+ class << self
15
+ def registered_reserved_metadata_keys
16
+ @registered_reserved_metadata_keys ||= []
17
+ end
18
+
19
+ def register_reserved_metadata_key(key)
20
+ registered_reserved_metadata_keys << key.to_s unless registered_reserved_metadata_keys.include?(key.to_s)
21
+ end
22
+
23
+ def reserved_metadata_keys
24
+ (BUILTIN_RESERVED_METADATA_KEYS + registered_reserved_metadata_keys).freeze
25
+ end
26
+
27
+ # ------------------------------------------------------------------------
28
+ # Read-only source registry — vendor engines register the `data.source.type`
29
+ # values whose content is owned by an external system (e.g. a synced GitHub
30
+ # repository) and therefore must not be edited in-app. Core enforces the
31
+ # read-only behavior via `read_only_source?` without naming any vendor.
32
+ # ------------------------------------------------------------------------
33
+ def read_only_source_types
34
+ @read_only_source_types ||= Set.new
35
+ end
36
+
37
+ def register_read_only_source(type)
38
+ read_only_source_types << type.to_s
39
+ end
40
+ end
41
+
8
42
  # Use non-namespaced partial path for backward compatibility
9
43
  def to_partial_path
10
44
  "creatives/creative"
@@ -43,10 +77,28 @@ module Collavre
43
77
  data&.dig("kind") == "inbox"
44
78
  end
45
79
 
46
- attr_accessor :skip_github_validation
80
+ # Bypass the read-only-source guard for a single save (used by the vendor
81
+ # sync services that legitimately write the synced content into core).
82
+ attr_accessor :skip_read_only_source_validation
83
+
84
+ # The registered source identifier for this creative, or nil when the
85
+ # description is authored in-app.
86
+ def source_type
87
+ data.is_a?(Hash) ? data.dig("source", "type") : nil
88
+ end
89
+
90
+ # Whether this creative's description is owned by an external, registered
91
+ # source and therefore read-only in-app.
92
+ def read_only_source?
93
+ type = source_type
94
+ type.present? && self.class.read_only_source_types.include?(type)
95
+ end
47
96
 
97
+ # GitHub-sourced content still needs a vendor-specific predicate for the
98
+ # comment view's inline-image rendering (a GitHub-only concern, distinct
99
+ # from the neutral read-only behavior above).
48
100
  def github_markdown?
49
- data.is_a?(Hash) && data.dig("source", "type") == "github_markdown"
101
+ source_type == "github_markdown"
50
102
  end
51
103
 
52
104
  # Find or create the "System" topic for this inbox creative.
@@ -133,7 +185,14 @@ module Collavre
133
185
 
134
186
  after_save :update_parent_progress
135
187
  after_destroy :update_parent_progress
136
- after_save :update_mcp_tools
188
+ # Re-derive MCP tools only when the description (the tool source of truth)
189
+ # actually changed, and defer the HTML parsing to a background job so it
190
+ # never runs inline on progress/move/autosave writes. The dirty flag is
191
+ # captured in after_save (where saved_change_to_description? is reliable);
192
+ # a later same-transaction save can clobber saved_changes before the
193
+ # after_commit hook runs.
194
+ after_save :mark_mcp_tools_sync_pending, if: :saved_change_to_description?
195
+ after_commit :enqueue_mcp_tools_sync, if: :mcp_tools_sync_pending?
137
196
 
138
197
  # --- Drop Trigger ---
139
198
  def drop_trigger_enabled?
@@ -292,8 +351,17 @@ module Collavre
292
351
  @progress_service ||= Collavre::Creatives::ProgressService.new(self)
293
352
  end
294
353
 
295
- def update_mcp_tools
296
- McpService.new.update_from_creative(self)
354
+ def mark_mcp_tools_sync_pending
355
+ @mcp_tools_sync_pending = true
356
+ end
357
+
358
+ def mcp_tools_sync_pending?
359
+ @mcp_tools_sync_pending == true
360
+ end
361
+
362
+ def enqueue_mcp_tools_sync
363
+ @mcp_tools_sync_pending = false
364
+ UpdateMcpToolsJob.perform_later(id)
297
365
  end
298
366
 
299
367
  def progress_cannot_change_if_has_origin
@@ -20,11 +20,32 @@ module Collavre
20
20
  validates :permission, presence: true
21
21
  validates :user_id, uniqueness: { scope: :creative_id }, allow_nil: true
22
22
 
23
+ # Single declarative registry mirroring Creative::Permissible: which
24
+ # persisted CreativeShare attributes affect the permission cache, and how.
25
+ # Every create and update re-propagates the share unconditionally (see
26
+ # dispatch_share_cache_invalidation) — a fail-closed refresh that matches
27
+ # the prior propagate_cache and survives a multi-save transaction clobbering
28
+ # an earlier permission change out of the final saved_changes. This map
29
+ # governs the *extra* cleanup a move or reassignment needs on top of that
30
+ # base refresh, so a new share-mutation path still can't silently skip it:
31
+ #
32
+ # :relocate -> creative_id moved: purge this share's rows and rebuild
33
+ # the vacated (old creative, old user) subtree
34
+ # :reassign -> user_id changed: purge this share's rows and rebuild the
35
+ # old user's subtree at the current creative
36
+ # :repropagate -> permission changed: covered by the unconditional
37
+ # re-propagate above (listed to document the invariant)
38
+ PERMISSION_INVALIDATING_ATTRIBUTES = {
39
+ "creative_id" => :relocate,
40
+ "user_id" => :reassign,
41
+ "permission" => :repropagate
42
+ }.freeze
43
+
23
44
  after_create_commit :notify_recipient, unless: :no_access?
24
45
  after_save :touch_creative_subtree
25
46
  after_destroy :touch_creative_subtree
26
47
 
27
- after_commit :propagate_cache, on: [ :create, :update ]
48
+ after_commit :dispatch_share_cache_invalidation, on: [ :create, :update ]
28
49
  after_commit :broadcast_share_change, on: [ :create, :update ]
29
50
  after_destroy_commit :remove_cache
30
51
  after_destroy_commit :broadcast_share_destroy
@@ -92,34 +113,65 @@ module Collavre
92
113
  Creative.exists?(origin_id: creative.id, user_id: user.id)
93
114
  end
94
115
 
95
- def propagate_cache
96
- # If creative_id or user_id changed, handle old cache entries properly
97
- if saved_change_to_creative_id? || saved_change_to_user_id?
98
- # Delete only caches created by THIS share (fast operation, keep synchronous)
99
- CreativeSharesCache.where(source_share_id: id).delete_all
100
-
101
- # Rebuild caches for old user in old subtree (background job)
102
- if saved_change_to_creative_id?
103
- old_creative_id = creative_id_before_last_save
104
- old_user_id = user_id_before_last_save || user_id
105
- if old_creative_id
106
- PermissionCacheJob.perform_later(:rebuild_user_cache_for_subtree,
107
- creative_id: old_creative_id,
108
- user_id: old_user_id
109
- )
110
- end
111
- elsif saved_change_to_user_id?
112
- old_user_id = user_id_before_last_save
113
- if old_user_id
114
- PermissionCacheJob.perform_later(:rebuild_user_cache_for_subtree,
115
- creative_id: creative_id,
116
- user_id: old_user_id
117
- )
118
- end
116
+ # Single dispatch point for permission-cache invalidation on share writes.
117
+ # Every create and update re-propagates the share unconditionally a
118
+ # fail-closed refresh matching the prior propagate_cache, correct even when
119
+ # a multi-save transaction clobbers an earlier permission change out of the
120
+ # final saved_changes. On an update, a move (creative_id) or reassignment
121
+ # (user_id) additionally purges the stale rows this share left behind — the
122
+ # purge is carried on the same propagate_share job (purge_stale:) rather than
123
+ # deleted synchronously, so timing is uniform and it no longer blocks commit.
124
+ def dispatch_share_cache_invalidation
125
+ propagate_args = { creative_share_id: id }
126
+
127
+ unless previously_new_record?
128
+ operations = (saved_changes.keys & PERMISSION_INVALIDATING_ATTRIBUTES.keys)
129
+ .map { |attr| PERMISSION_INVALIDATING_ATTRIBUTES[attr] }
130
+ .uniq
131
+
132
+ # A move (creative_id) or reassignment (user_id) leaves stale rows keyed
133
+ # to this share. The purge rides ON the propagate_share job (purge_stale:
134
+ # true), which deletes those rows immediately before re-propagating — in
135
+ # one job, never as a standalone enqueue. The authz queue runs two
136
+ # threads, so a separate purge could otherwise run AFTER propagate and
137
+ # delete the freshly written rows (both key on source_share_id), dropping
138
+ # the share's access until an unrelated rebuild. Folding it in keeps the
139
+ # work async (off the commit path) so the revoke of the vacated access is
140
+ # merely deferred by the queue latency (~1-2s) — it only PROLONGS an
141
+ # already-granted permission by that window, never grants a new one,
142
+ # which the CTO judged acceptable for the perf win. Then rebuild the
143
+ # vacated subtree. creative_id takes precedence when both change,
144
+ # preserving the prior branch order.
145
+ if operations.include?(:relocate) || operations.include?(:reassign)
146
+ propagate_args[:purge_stale] = true
147
+ rebuild_vacated_subtree(relocated: operations.include?(:relocate))
119
148
  end
120
149
  end
121
150
 
122
- PermissionCacheJob.perform_later(:propagate_share, creative_share_id: id)
151
+ PermissionCacheJob.perform_later(:propagate_share, **propagate_args)
152
+ end
153
+
154
+ # Rebuild the cache the moved/reassigned share vacated, for the old user in
155
+ # the old location, so an ancestor share (or its absence) is re-applied.
156
+ def rebuild_vacated_subtree(relocated:)
157
+ if relocated
158
+ old_creative_id = creative_id_before_last_save
159
+ old_user_id = user_id_before_last_save || user_id
160
+ return unless old_creative_id
161
+
162
+ PermissionCacheJob.perform_later(:rebuild_user_cache_for_subtree,
163
+ creative_id: old_creative_id,
164
+ user_id: old_user_id
165
+ )
166
+ else
167
+ old_user_id = user_id_before_last_save
168
+ return unless old_user_id
169
+
170
+ PermissionCacheJob.perform_later(:rebuild_user_cache_for_subtree,
171
+ creative_id: creative_id,
172
+ user_id: old_user_id
173
+ )
174
+ end
123
175
  end
124
176
 
125
177
  def remove_cache
@@ -16,10 +16,10 @@ module Collavre
16
16
  after_commit :broadcast_badge_update, on: %i[create update destroy]
17
17
  after_create_commit :enqueue_push_notification
18
18
 
19
- attribute :state, :string, default: "new"
20
- validates :state, inclusion: { in: %w[new read archived] }
19
+ enum :state, { new: "new", read: "read", archived: "archived" }, default: :new, scopes: false
21
20
  validates :message_key, presence: true
22
21
 
22
+ # Kept explicit (not enum's generated `.new` class-scope, which collides with the constructor).
23
23
  scope :new_items, -> { where(state: "new") }
24
24
 
25
25
  def localized_message(locale: I18n.locale)
@@ -11,6 +11,18 @@ module Collavre
11
11
 
12
12
  validates :name, presence: true
13
13
 
14
+ enum :status, {
15
+ pending: "pending",
16
+ queued: "queued",
17
+ running: "running",
18
+ delegated: "delegated",
19
+ pending_approval: "pending_approval",
20
+ done: "done",
21
+ failed: "failed",
22
+ cancelled: "cancelled",
23
+ escalated: "escalated"
24
+ }, default: :pending
25
+
14
26
  after_update_commit :check_trigger_loop_completion, if: :trigger_loop_candidate?
15
27
  after_update_commit :broadcast_stop_button_removal, if: :became_terminal?
16
28
 
@@ -24,6 +36,21 @@ module Collavre
24
36
  rel = rel.where(creative_id: creative_id) if creative_id
25
37
  rel.order(:created_at)
26
38
  }
39
+ # Tasks that hold a topic concurrency slot: running/delegated (executing)
40
+ # plus pending — a task that has been claimed (dequeue_next_for_topic moves a
41
+ # waiter queued -> pending, a retry re-queues to pending, initial dispatch
42
+ # creates pending) but whose AiAgentJob has not started yet — plus
43
+ # pending_approval — a task paused awaiting tool approval that intentionally
44
+ # keeps its resource (AiAgentJob sets should_release = false) and does NOT
45
+ # drain the topic queue (dequeue_next_for_topic only fires on terminal
46
+ # statuses done/failed/cancelled/escalated). Orphan detection must count all
47
+ # of these, otherwise a claimed-but-not-started or approval-paused slot looks
48
+ # free and a second waiter gets promoted into the same slot.
49
+ scope :occupying_topic_slot, ->(topic_id, creative_id = nil) {
50
+ rel = where(topic_id: topic_id, status: %w[running delegated pending pending_approval])
51
+ rel = rel.where(creative_id: creative_id) if creative_id
52
+ rel
53
+ }
27
54
 
28
55
  # Check if agent already has an in-flight task triggered by the same comment.
29
56
  # Treats "delegated" as in-flight: a Claude Channel task that is waiting on
@@ -44,6 +44,15 @@ module Collavre
44
44
  has_many :creatives, class_name: "Collavre::Creative", dependent: nil
45
45
  before_destroy :destroy_creatives_leaf_first
46
46
 
47
+ # /compress and /merge summaries are durable recovery artifacts: they replace
48
+ # the deleted original conversation and anchor the restore control (rendered
49
+ # from the surviving result comment via CommentSnapshot). Detach them (nullify
50
+ # author) BEFORE the comments dependent: :destroy cascade — prepend ensures we
51
+ # run first — so deleting the authoring agent/user doesn't destroy the summary
52
+ # and orphan the snapshot, which would erase the only path to restore the
53
+ # compressed conversation.
54
+ before_destroy :preserve_durable_summary_comments, prepend: true
55
+
47
56
  belongs_to :creator, class_name: "Collavre::User", foreign_key: "created_by_id", optional: true
48
57
  has_many :created_ai_users, class_name: "Collavre::User", foreign_key: "created_by_id", dependent: :destroy
49
58
 
@@ -58,6 +67,15 @@ module Collavre
58
67
  attribute :system_admin, :boolean, default: false
59
68
  attribute :searchable, :boolean, default: false
60
69
 
70
+ # Typo correction (2D gating: typing-device AND input-location must both be on).
71
+ attribute :typo_correction_enabled, :boolean, default: true
72
+ attribute :typo_correction_threshold, :integer, default: 80
73
+ attribute :typo_correction_on_soft_keyboard, :boolean, default: true
74
+ attribute :typo_correction_on_voice, :boolean, default: true
75
+ attribute :typo_correction_on_physical_keyboard, :boolean, default: false
76
+ attribute :typo_correction_in_chat, :boolean, default: true
77
+ attribute :typo_correction_in_editor, :boolean, default: false
78
+
61
79
  attribute :google_uid, :string
62
80
  attribute :google_access_token, :string
63
81
  attribute :google_refresh_token, :string
@@ -114,12 +132,14 @@ module Collavre
114
132
  end
115
133
 
116
134
  # Whether this agent uses stateful sessions (incremental messaging).
117
- # nil in agent_conf = auto-detect by vendor (openclaw true).
135
+ # nil in agent_conf = auto-detect via the AiClient adapter layer, which
136
+ # vendor engines register into (core never string-matches a vendor name).
118
137
  def supports_session?
119
138
  explicit = parsed_agent_conf.dig("session", "enabled")
120
139
  return ActiveModel::Type::Boolean.new.cast(explicit) unless explicit.nil?
140
+ return false if llm_vendor.blank?
121
141
 
122
- llm_vendor&.downcase == "openclaw"
142
+ Collavre::AiClient.vendor_supports_session?(llm_vendor)
123
143
  end
124
144
 
125
145
  encrypts :llm_api_key, deterministic: false
@@ -140,15 +160,45 @@ module Collavre
140
160
  end
141
161
  end
142
162
 
143
- LLM_VENDOR_OPTIONS = [
144
- [ "Google (Gemini)", "google" ],
145
- [ "OpenAI", "openai" ],
146
- [ "Anthropic", "anthropic" ],
147
- [ "OpenClaw", "openclaw" ]
148
- ].freeze
163
+ TYPO_CORRECTION_DEVICES = %w[voice soft_keyboard physical_keyboard].freeze
164
+ TYPO_CORRECTION_LOCATIONS = %w[chat editor].freeze
165
+
166
+ # 2D gating: typo correction runs only when the master switch is on AND the
167
+ # originating typing device AND the input location are both enabled. Unknown
168
+ # device/location values are treated as disabled (fail closed).
169
+ def typo_correction_active_for?(device:, location:)
170
+ return false unless typo_correction_enabled
171
+
172
+ device_on = case device.to_s
173
+ when "voice" then typo_correction_on_voice
174
+ when "soft_keyboard" then typo_correction_on_soft_keyboard
175
+ when "physical_keyboard" then typo_correction_on_physical_keyboard
176
+ else false
177
+ end
178
+
179
+ location_on = case location.to_s
180
+ when "chat" then typo_correction_in_chat
181
+ when "editor" then typo_correction_in_editor
182
+ else false
183
+ end
184
+
185
+ device_on && location_on
186
+ end
187
+
188
+ # LLM_VENDOR_OPTIONS is resolved dynamically from the AiClient vendor-option
189
+ # registry so core lists only its built-in providers while vendor engines
190
+ # (e.g. OpenClaw) contribute their own. Resolved lazily via const_missing so
191
+ # the value always reflects registrations made after this class first loads
192
+ # (they run in a to_prepare hook), and existing views keep referring to the
193
+ # constant unchanged.
194
+ def self.const_missing(name)
195
+ return Collavre::AiClient.vendor_options if name == :LLM_VENDOR_OPTIONS
196
+
197
+ super
198
+ end
149
199
 
150
200
  SUPPORTED_LLM_MODELS = [
151
- "gemini-3-flash-preview",
201
+ "gemini-3.1-flash-lite",
152
202
  "gemini-1.5-flash",
153
203
  "gemini-1.5-pro"
154
204
  ].freeze
@@ -194,6 +244,11 @@ module Collavre
194
244
  validates :timezone,
195
245
  inclusion: { in: ActiveSupport::TimeZone.all.map { |z| z.tzinfo.identifier } },
196
246
  allow_nil: true
247
+ # Column is NOT NULL; clearing the profile field (or a crafted PATCH) casts to
248
+ # nil and would raise a DB error on save. Validate so the form re-renders.
249
+ validates :typo_correction_threshold,
250
+ presence: true,
251
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
197
252
 
198
253
  generates_token_for :email_verification, expires_in: 1.day do
199
254
  email
@@ -255,10 +310,35 @@ module Collavre
255
310
  end
256
311
  end
257
312
 
313
+ # Keep durable compress/merge summaries (snapshot result comments) alive when
314
+ # their author is deleted: nullify authorship instead of cascading destroy.
315
+ def preserve_durable_summary_comments
316
+ Collavre::Comment
317
+ .where(id: Collavre::CommentSnapshot.where(result_comment_id: comments.select(:id)).select(:result_comment_id))
318
+ .update_all(user_id: nil)
319
+ end
320
+
258
321
  # Destroy creatives deepest-first so closure_tree always finds its parent
259
322
  def destroy_creatives_leaf_first
260
323
  all_creatives = creatives.flat_map { |c| c.self_and_descendants.to_a }.uniq
261
- all_creatives.sort_by { |c| -c.self_and_ancestors.count }.each do |c|
324
+
325
+ # Order by depth in memory. The subtree is contiguous within all_creatives
326
+ # (every node between an owned root and its descendant is itself a
327
+ # descendant), so following parent_id links yields the same leaf-first
328
+ # ordering as self_and_ancestors.count without firing a COUNT query per
329
+ # creative (the previous N+1).
330
+ by_id = all_creatives.index_by(&:id)
331
+ depth_of = lambda do |creative|
332
+ depth = 0
333
+ node = creative
334
+ while node&.parent_id && (parent = by_id[node.parent_id])
335
+ depth += 1
336
+ node = parent
337
+ end
338
+ depth
339
+ end
340
+
341
+ all_creatives.sort_by { |c| -depth_of.call(c) }.each do |c|
262
342
  c.reload.destroy! if Creative.exists?(c.id)
263
343
  end
264
344
  end