collavre 0.23.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. metadata +42 -1
@@ -18,7 +18,7 @@ module Collavre
18
18
 
19
19
  validates :description, presence: true, unless: -> { origin_id.present? }
20
20
  validate :description_cannot_change_if_has_origin, on: :update
21
- validate :description_cannot_change_if_github_source, on: :update
21
+ validate :description_cannot_change_if_read_only_source, on: :update
22
22
 
23
23
  before_validation :convert_markdown_to_html
24
24
  before_save :sanitize_description_html
@@ -29,7 +29,13 @@ module Collavre
29
29
  # Linked Creative의 description을 안전하게 반환
30
30
  def effective_description(variation_id = nil, html = true)
31
31
  if variation_id.present?
32
- variation_tag = tags.find_by(label_id: variation_id)
32
+ # `find_by` on a loaded association still round-trips; the browse tree
33
+ # preloads tags per level and reaches here once per node.
34
+ variation_tag = if tags.loaded?
35
+ tags.find { |tag| tag.label_id.to_s == variation_id.to_s }
36
+ else
37
+ tags.find_by(label_id: variation_id)
38
+ end
33
39
  return variation_tag.value if variation_tag&.value.present?
34
40
  end
35
41
  description_val = origin_id.nil? ? description : origin.description
@@ -44,11 +50,11 @@ module Collavre
44
50
  CGI.unescapeHTML(ActionController::Base.helpers.strip_tags(effective_origin.description || "")).truncate(24, omission: "...")
45
51
  end
46
52
 
47
- # GitHub-synced creatives reject any description change
48
- # (description_cannot_change_if_github_source), so embedding would raise
53
+ # Read-only-source creatives reject any description change
54
+ # (description_cannot_change_if_read_only_source), so embedding would raise
49
55
  # and orphan the blob. Callers MUST check this before creating the blob.
50
56
  def attachments_embeddable?
51
- !effective_origin.github_markdown?
57
+ !effective_origin.read_only_source?
52
58
  end
53
59
 
54
60
  # Append an attachment node and save; after_save reconcile attaches the
@@ -362,12 +368,12 @@ module Collavre
362
368
  end
363
369
  end
364
370
 
365
- def description_cannot_change_if_github_source
371
+ def description_cannot_change_if_read_only_source
366
372
  return unless will_save_change_to_description?
367
- return unless github_markdown?
368
- return if @skip_github_validation
373
+ return unless read_only_source?
374
+ return if skip_read_only_source_validation
369
375
 
370
- errors.add(:description, "cannot be changed directly for GitHub synced content")
376
+ errors.add(:description, I18n.t("collavre.creatives.errors.description_read_only_source"))
371
377
  end
372
378
  end
373
379
  end
@@ -7,6 +7,15 @@ module Collavre
7
7
  belongs_to :origin, class_name: "Collavre::Creative", optional: true
8
8
  has_many :linked_creatives, class_name: "Collavre::Creative", foreign_key: :origin_id, dependent: :destroy
9
9
 
10
+ # origin_id is immutable once a record is persisted. Permission
11
+ # resolution (PermissionChecker / PermissionFilter) treats a linked
12
+ # creative as its origin, and the permission cache has no invalidation
13
+ # path for a moved origin link. Promote the "origin_id is immutable"
14
+ # invariant from controller param-stripping to a model-level guard so
15
+ # no mutation path (mass-assignment, console, future controllers) can
16
+ # silently repoint a link and desync permissions.
17
+ attr_readonly :origin_id
18
+
10
19
  validate :origin_cannot_be_self
11
20
  before_validation :redirect_parent_to_origin
12
21
  end
@@ -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
 
@@ -132,12 +132,14 @@ module Collavre
132
132
  end
133
133
 
134
134
  # Whether this agent uses stateful sessions (incremental messaging).
135
- # 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).
136
137
  def supports_session?
137
138
  explicit = parsed_agent_conf.dig("session", "enabled")
138
139
  return ActiveModel::Type::Boolean.new.cast(explicit) unless explicit.nil?
140
+ return false if llm_vendor.blank?
139
141
 
140
- llm_vendor&.downcase == "openclaw"
142
+ Collavre::AiClient.vendor_supports_session?(llm_vendor)
141
143
  end
142
144
 
143
145
  encrypts :llm_api_key, deterministic: false
@@ -183,12 +185,17 @@ module Collavre
183
185
  device_on && location_on
184
186
  end
185
187
 
186
- LLM_VENDOR_OPTIONS = [
187
- [ "Google (Gemini)", "google" ],
188
- [ "OpenAI", "openai" ],
189
- [ "Anthropic", "anthropic" ],
190
- [ "OpenClaw", "openclaw" ]
191
- ].freeze
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
192
199
 
193
200
  SUPPORTED_LLM_MODELS = [
194
201
  "gemini-3.1-flash-lite",
@@ -314,7 +321,24 @@ module Collavre
314
321
  # Destroy creatives deepest-first so closure_tree always finds its parent
315
322
  def destroy_creatives_leaf_first
316
323
  all_creatives = creatives.flat_map { |c| c.self_and_descendants.to_a }.uniq
317
- 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|
318
342
  c.reload.destroy! if Creative.exists?(c.id)
319
343
  end
320
344
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Keeps denormalized real columns in lockstep with keys stored inside a JSON
5
+ # column, so database indexes can be defined over plain columns instead of
6
+ # JSON expressions.
7
+ #
8
+ # WHY: A JSON-expression index serializes per-adapter --
9
+ # `json_extract(config, '$.x')` on SQLite vs `config->>'x'` on PostgreSQL.
10
+ # Because schema.rb is dumped from the SQLite dev DB, it carries the
11
+ # `json_extract` form, which is not a PostgreSQL function, so
12
+ # `db:schema:load` crashes on the prod PostgreSQL database. Promote the JSON
13
+ # key to a real column (which indexes and dumps identically on both adapters)
14
+ # and keep it synced from the JSON column on every save. The JSON column
15
+ # stays the source of truth; the promoted columns are re-derived on each save.
16
+ #
17
+ # Usage:
18
+ # include Collavre::IndexedJsonColumns
19
+ #
20
+ # indexed_json_columns json: :config, columns: {
21
+ # repo_full_name: "repo_full_name",
22
+ # pr_number: "pr_number",
23
+ # }
24
+ #
25
+ # `json:` names the JSON attribute (source of truth). `columns:` maps each
26
+ # promoted column to the JSON key it mirrors. A before_save re-derives every
27
+ # promoted column from the JSON attribute so the plain-column indexes enforce
28
+ # the same guarantees the JSON-expression indexes did.
29
+ module IndexedJsonColumns
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ class_attribute :indexed_json_source, instance_writer: false, default: nil
34
+ class_attribute :indexed_json_column_map, instance_writer: false, default: {}.freeze
35
+ end
36
+
37
+ class_methods do
38
+ def indexed_json_columns(json:, columns:)
39
+ self.indexed_json_source = json.to_sym
40
+ self.indexed_json_column_map =
41
+ columns.each_with_object({}) { |(col, key), m| m[col.to_s] = key.to_s }.freeze
42
+ before_save :sync_indexed_json_columns
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Re-derive each promoted column from its JSON key. Reads the JSON attribute
49
+ # (source of truth) and writes through `[]=` so subtype reader overrides are
50
+ # bypassed and only genuinely changed values dirty the record.
51
+ def sync_indexed_json_columns
52
+ json = public_send(indexed_json_source) || {}
53
+ indexed_json_column_map.each do |column, json_key|
54
+ self[column] = json[json_key]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ module Collavre
2
+ # Classifies an AI user/agent into a coarse role type by scanning its
3
+ # system prompt. Shared by the orchestration and system-events context
4
+ # builders so the prompt-to-type mapping lives in exactly one place.
5
+ module AgentTypeClassifier
6
+ module_function
7
+
8
+ # Returns the agent type string ("developer", "pm", "qa", "researcher",
9
+ # "marketer", "planner") or "agent" as the default when nothing matches.
10
+ def classify(user)
11
+ prompt = user.system_prompt.to_s.downcase
12
+ case prompt
13
+ when /developer|개발/ then "developer"
14
+ when /pm|project.?manager|프로젝트/ then "pm"
15
+ when /qa|test|quality|테스트|품질/ then "qa"
16
+ when /research|조사|연구/ then "researcher"
17
+ when /market|마케팅/ then "marketer"
18
+ when /plan|기획/ then "planner"
19
+ else "agent"
20
+ end
21
+ end
22
+ end
23
+ end