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.
- checksums.yaml +4 -4
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- 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 :
|
|
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
|
-
|
|
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
|
-
#
|
|
48
|
-
# (
|
|
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.
|
|
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
|
|
371
|
+
def description_cannot_change_if_read_only_source
|
|
366
372
|
return unless will_save_change_to_description?
|
|
367
|
-
return unless
|
|
368
|
-
return if
|
|
373
|
+
return unless read_only_source?
|
|
374
|
+
return if skip_read_only_source_validation
|
|
369
375
|
|
|
370
|
-
errors.add(:description, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
113
|
-
|
|
135
|
+
def clear_accumulated_permission_changes
|
|
136
|
+
@accumulated_permission_changes = nil
|
|
114
137
|
end
|
|
115
138
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
|
|
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 :
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
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
|
-
|
|
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)
|
data/app/models/collavre/task.rb
CHANGED
|
@@ -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
|
|
data/app/models/collavre/user.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|