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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- 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/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- 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/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- 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 +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- 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/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +42 -10
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- 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/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -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/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -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/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- 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/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- 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/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- 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 +27 -0
- data/app/models/collavre/user.rb +90 -10
- 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 +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- 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/index_query.rb +85 -16
- 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 +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- 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/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +96 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -5,6 +5,22 @@ module Collavre
|
|
|
5
5
|
|
|
6
6
|
self.table_name = "channels"
|
|
7
7
|
|
|
8
|
+
include Collavre::IndexedJsonColumns
|
|
9
|
+
|
|
10
|
+
# Denormalized columns that mirror same-named keys in `config`. They exist
|
|
11
|
+
# so the channels UNIQUE indexes are plain-column indexes (which dump
|
|
12
|
+
# identically to schema.rb on SQLite and PostgreSQL) instead of JSON
|
|
13
|
+
# expression indexes (json_extract vs config->>), which serialize
|
|
14
|
+
# per-adapter and crash `db:schema:load` on PostgreSQL. `config` stays the
|
|
15
|
+
# source of truth; these columns are re-derived on every save. Declared
|
|
16
|
+
# here beside the table whose unique indexes are defined in this engine's
|
|
17
|
+
# migration, so subtype engines need not know about the denormalization.
|
|
18
|
+
indexed_json_columns json: :config, columns: {
|
|
19
|
+
repo_full_name: "repo_full_name",
|
|
20
|
+
pr_number: "pr_number",
|
|
21
|
+
worktree_id: "worktree_id"
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
belongs_to :topic, class_name: "Collavre::Topic"
|
|
9
25
|
|
|
10
26
|
enum :state, { active: 0, detached: 1 }, default: :active
|
|
@@ -3,6 +3,22 @@ module Collavre
|
|
|
3
3
|
module Approvable
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
|
+
# A message that renders an approval button (pending) or an
|
|
7
|
+
# approved/denied status label (decided) in the chat list — i.e. it
|
|
8
|
+
# carries an `action` JSON payload. Both the `has_pending_action`
|
|
9
|
+
# button (action.present? && action_executed_at.blank?) and the
|
|
10
|
+
# ✅ approved / 🚫 denied label (action_executed_at.present?, which implies
|
|
11
|
+
# action.present?) roll up to this single condition.
|
|
12
|
+
#
|
|
13
|
+
# Such a message is a HUMAN decision surface and must never be dispatched
|
|
14
|
+
# to an AI agent — regardless of who authored it or whether it has already
|
|
15
|
+
# been decided. This is the single source of truth for that invariant,
|
|
16
|
+
# enforced at every dispatch seam (Comment#dispatch_to_orchestration and
|
|
17
|
+
# AiAgent::A2aDispatcher#dispatch).
|
|
18
|
+
def approval_action?
|
|
19
|
+
action.present?
|
|
20
|
+
end
|
|
21
|
+
|
|
6
22
|
def can_be_approved_by?(user)
|
|
7
23
|
approval_status(user) == :ok
|
|
8
24
|
end
|
|
@@ -3,6 +3,9 @@ module Collavre
|
|
|
3
3
|
module Broadcastable
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
|
+
# The desktop and mobile inbox badge DOM ids kept in sync in real time.
|
|
7
|
+
INBOX_BADGE_TARGETS = %w[desktop-inbox-badge mobile-inbox-badge].freeze
|
|
8
|
+
|
|
6
9
|
included do
|
|
7
10
|
after_create_commit :broadcast_create
|
|
8
11
|
after_update_commit :broadcast_update
|
|
@@ -112,21 +115,57 @@ module Collavre
|
|
|
112
115
|
def broadcast_inbox_badge(inbox_creative, owner, count: nil)
|
|
113
116
|
return unless inbox_creative && owner
|
|
114
117
|
|
|
115
|
-
count ||=
|
|
118
|
+
count ||= inbox_badge_count(inbox_creative, owner)
|
|
116
119
|
|
|
117
|
-
|
|
120
|
+
INBOX_BADGE_TARGETS.each do |target_id|
|
|
118
121
|
Turbo::StreamsChannel.broadcast_replace_to(
|
|
119
122
|
[ "inbox", owner ],
|
|
120
123
|
target: target_id,
|
|
121
124
|
partial: "inbox/badge_component/count",
|
|
122
|
-
locals:
|
|
123
|
-
count: count,
|
|
124
|
-
badge_id: target_id,
|
|
125
|
-
show_zero: false
|
|
126
|
-
}
|
|
125
|
+
locals: inbox_badge_locals(count, target_id)
|
|
127
126
|
)
|
|
128
127
|
end
|
|
129
128
|
end
|
|
129
|
+
|
|
130
|
+
# Render the same inbox badge replacements as a Turbo Stream string so a
|
|
131
|
+
# channel can transmit them straight to its own confirmed subscriber
|
|
132
|
+
# (see InboxBadgeChannel), instead of re-broadcasting to the sibling
|
|
133
|
+
# ["inbox", user] stream and risking a reconnect race. Returns nil when
|
|
134
|
+
# there is nothing to render.
|
|
135
|
+
def inbox_badge_turbo_stream(inbox_creative, owner, count: nil)
|
|
136
|
+
return unless inbox_creative && owner
|
|
137
|
+
|
|
138
|
+
count ||= inbox_badge_count(inbox_creative, owner)
|
|
139
|
+
|
|
140
|
+
INBOX_BADGE_TARGETS.map do |target_id|
|
|
141
|
+
Turbo::StreamsChannel.turbo_stream_action_tag(
|
|
142
|
+
:replace,
|
|
143
|
+
target: target_id,
|
|
144
|
+
template: ApplicationController.render(
|
|
145
|
+
partial: "inbox/badge_component/count",
|
|
146
|
+
formats: [ :html ],
|
|
147
|
+
locals: inbox_badge_locals(count, target_id)
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
end.join.html_safe
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# Inbox badge count when no caller-supplied count is available (e.g. the
|
|
156
|
+
# reconnect snapshot in InboxBadgeChannel). Mirrors broadcast_badge's
|
|
157
|
+
# suppression: a user actively viewing the inbox (present in
|
|
158
|
+
# CommentPresenceStore) sees 0, so a reconnect can't repaint unread items
|
|
159
|
+
# over the suppressed badge.
|
|
160
|
+
def inbox_badge_count(inbox_creative, owner)
|
|
161
|
+
return 0 if CommentPresenceStore.list(inbox_creative.id).include?(owner.id)
|
|
162
|
+
|
|
163
|
+
Collavre::Inbox::BadgeComponent.new(user: owner, creative: inbox_creative).count
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def inbox_badge_locals(count, target_id)
|
|
167
|
+
{ count: count, badge_id: target_id, show_zero: false }
|
|
168
|
+
end
|
|
130
169
|
end
|
|
131
170
|
|
|
132
171
|
private
|
|
@@ -118,7 +118,7 @@ module Collavre
|
|
|
118
118
|
def notify_write_users
|
|
119
119
|
return if private? || !user
|
|
120
120
|
return if streaming_placeholder?
|
|
121
|
-
return if
|
|
121
|
+
return if suppress_inbox_notification? # only the System topic is the alarm stream
|
|
122
122
|
notification_recipients.each do |recipient|
|
|
123
123
|
create_inbox_comment(
|
|
124
124
|
recipient,
|
|
@@ -131,7 +131,7 @@ module Collavre
|
|
|
131
131
|
def notify_mentions
|
|
132
132
|
return if private?
|
|
133
133
|
return if streaming_placeholder?
|
|
134
|
-
return if
|
|
134
|
+
return if suppress_inbox_notification? # only the System topic is the alarm stream
|
|
135
135
|
notify_mentioned_users
|
|
136
136
|
end
|
|
137
137
|
|
|
@@ -146,7 +146,7 @@ module Collavre
|
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
def notify_ai_write_users
|
|
149
|
-
return if
|
|
149
|
+
return if suppress_inbox_notification? # only the System topic is the alarm stream
|
|
150
150
|
notification_recipients.each do |recipient|
|
|
151
151
|
create_inbox_comment(
|
|
152
152
|
recipient,
|
|
@@ -157,12 +157,22 @@ module Collavre
|
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
def notify_ai_mentions
|
|
160
|
-
return if
|
|
160
|
+
return if suppress_inbox_notification? # only the System topic is the alarm stream
|
|
161
161
|
notify_mentioned_users
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
+
# #1301 made every inbox topic EXCEPT System dispatch like a normal topic;
|
|
165
|
+
# the alarm stream follows suit. Suppress notifications ONLY for the System
|
|
166
|
+
# topic itself (its system-authored notices must not cascade into more
|
|
167
|
+
# notices — a loop). Inbox#Main and other inbox topics notify normally, so
|
|
168
|
+
# an agent reply there reaches the absent owner. Mirrors the dispatch gate
|
|
169
|
+
# in Comment#dispatch_to_orchestration.
|
|
170
|
+
def suppress_inbox_notification?
|
|
171
|
+
creative&.inbox? && inbox_system_topic?
|
|
172
|
+
end
|
|
173
|
+
|
|
164
174
|
def notify_approver
|
|
165
|
-
return unless approver.present? &&
|
|
175
|
+
return unless approver.present? && approval_action?
|
|
166
176
|
return if approver == user
|
|
167
177
|
return if creative&.inbox? # Don't notify about inbox comments
|
|
168
178
|
|
|
@@ -3,13 +3,55 @@ module Collavre
|
|
|
3
3
|
self.table_name = "comments"
|
|
4
4
|
|
|
5
5
|
STREAMING_PLACEHOLDER_CONTENT = "..."
|
|
6
|
+
# Authorless "⏳" waiting-notice system messages posted when an agent is
|
|
7
|
+
# deferred for topic concurrency. AgentOrchestrator.cleanup_waiting_notices!
|
|
8
|
+
# matches the same prefix to remove them once the waiter is dequeued.
|
|
9
|
+
WAITING_NOTICE_PREFIX = "⏳"
|
|
6
10
|
|
|
7
11
|
# Use non-namespaced partial path for backward compatibility
|
|
8
12
|
def to_partial_path
|
|
9
13
|
"comments/comment"
|
|
10
14
|
end
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
# A system "⏳" waiting notice (no author) telling a user their agent is
|
|
17
|
+
# deferred because another task holds the topic's running slot.
|
|
18
|
+
def waiting_notice?
|
|
19
|
+
user_id.nil? && content.to_s.start_with?(WAITING_NOTICE_PREFIX)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The task holding this topic's concurrency slot — the blocker this waiting
|
|
23
|
+
# notice is about. Lets the notice render a stop button that cancels the
|
|
24
|
+
# blocker (freeing the topic so the deferred waiter proceeds) instead of
|
|
25
|
+
# being an anonymous dead end. Resolved at render time rather than stored on
|
|
26
|
+
# task_id, which Task#reply_comment keys on (a shared task_id would make the
|
|
27
|
+
# blocker's reply_comment ambiguous).
|
|
28
|
+
#
|
|
29
|
+
# Two gates keep the button honest:
|
|
30
|
+
# 1. Only THIS notice's own topic-concurrency defer qualifies. The same "⏳"
|
|
31
|
+
# notice is also posted for :delayed decisions (busy / rate_limited),
|
|
32
|
+
# which schedule a delayed job WITHOUT queuing a topic waiter —
|
|
33
|
+
# cancelling some unrelated running task would not unblock them.
|
|
34
|
+
# topic_concurrency_defer is set only on the :deferred path, so a
|
|
35
|
+
# :delayed notice never shows the button even when an unrelated queued
|
|
36
|
+
# waiter happens to share the topic. The queued_for_topic check then
|
|
37
|
+
# confirms a waiter is still actually pending on the slot.
|
|
38
|
+
# 2. Resolve the blocker over occupying_topic_slot, not just running/
|
|
39
|
+
# delegated: a holder paused on pending_approval still occupies the slot
|
|
40
|
+
# and is cancellable, so the button must stay visible for it.
|
|
41
|
+
# Returns nil once no slot holder remains — at which point the notice itself
|
|
42
|
+
# is cleaned up.
|
|
43
|
+
def topic_blocking_task
|
|
44
|
+
return @topic_blocking_task if defined?(@topic_blocking_task)
|
|
45
|
+
|
|
46
|
+
@topic_blocking_task =
|
|
47
|
+
if topic_concurrency_defer? && topic_id &&
|
|
48
|
+
Collavre::Task.queued_for_topic(topic_id, creative_id).exists?
|
|
49
|
+
Collavre::Task.occupying_topic_slot(topic_id, creative_id)
|
|
50
|
+
.includes(:agent).order(:created_at).first
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
belongs_to :creative, class_name: "Collavre::Creative", counter_cache: true
|
|
13
55
|
belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
|
|
14
56
|
belongs_to :approver, class_name: Collavre.configuration.user_class_name, optional: true
|
|
15
57
|
belongs_to :action_executed_by, class_name: Collavre.configuration.user_class_name, optional: true
|
|
@@ -19,6 +61,11 @@ module Collavre
|
|
|
19
61
|
|
|
20
62
|
scope :public_only, -> { where(private: false) }
|
|
21
63
|
|
|
64
|
+
# SQL inverse of Comment#approval_action? (action.present?). Approval-surface
|
|
65
|
+
# messages must never reach an AI agent — not only at the dispatch seams but
|
|
66
|
+
# also as chat-history/trigger context — so agent-context queries exclude them.
|
|
67
|
+
scope :without_approval_action, -> { where("action IS NULL OR action = ''") }
|
|
68
|
+
|
|
22
69
|
scope :visible_to, ->(user) {
|
|
23
70
|
where(
|
|
24
71
|
"comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
|
|
@@ -50,6 +97,10 @@ module Collavre
|
|
|
50
97
|
|
|
51
98
|
attribute :skip_default_user, :boolean, default: false
|
|
52
99
|
attribute :skip_dispatch, :boolean, default: false
|
|
100
|
+
# Set by AgentOrchestrator.cleanup_waiting_notices! so destroying a notice as
|
|
101
|
+
# part of *promoting* a waiter does not run the user-delete cancel cascade
|
|
102
|
+
# (which would cancel other still-queued waiters in the same topic).
|
|
103
|
+
attribute :suppress_waiter_cancellation, :boolean, default: false
|
|
53
104
|
|
|
54
105
|
before_validation :use_origin_creative
|
|
55
106
|
before_validation :assign_default_user, on: :create
|
|
@@ -142,12 +193,18 @@ module Collavre
|
|
|
142
193
|
Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
143
194
|
end
|
|
144
195
|
|
|
145
|
-
# Cancel queued tasks when their waiting notice
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
196
|
+
# Cancel queued tasks when a user DELETES their waiting notice. Gated to
|
|
197
|
+
# topic_concurrency_defer notices: only the :deferred path queues a topic
|
|
198
|
+
# waiter, so a :delayed (busy / rate_limited) "⏳" notice — which shares the
|
|
199
|
+
# prefix but has no waiter of its own — must not cancel an unrelated queued
|
|
200
|
+
# waiter that happens to share the topic. Also skipped when the notice is
|
|
201
|
+
# removed by promotion cleanup (suppress_waiter_cancellation): there the
|
|
202
|
+
# waiter is being advanced, not abandoned, and cancelling other still-queued
|
|
203
|
+
# waiters would drop their work (multi-slot orphan recovery must not cancel
|
|
204
|
+
# the rest).
|
|
205
|
+
if waiting_notice? && topic_concurrency_defer? && !suppress_waiter_cancellation
|
|
206
|
+
cancel_queued_tasks_for_waiting_notice
|
|
207
|
+
end
|
|
151
208
|
end
|
|
152
209
|
|
|
153
210
|
def cancel_queued_tasks_for_waiting_notice
|
|
@@ -161,21 +218,25 @@ module Collavre
|
|
|
161
218
|
return if private?
|
|
162
219
|
return if skip_default_user # system notices should not trigger AI
|
|
163
220
|
return if skip_dispatch # explicit opt-out (e.g., command processor responses)
|
|
221
|
+
return if approval_action? # approval button / approved message: human decision surface, never dispatch to an agent
|
|
164
222
|
return unless user_id # nil user = system message
|
|
165
223
|
return if user&.ai_user? # AI replies use A2aDispatcher, not this callback
|
|
166
224
|
return unless creative
|
|
167
|
-
# Inbox creatives hold the user's notifications
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
# the
|
|
178
|
-
|
|
225
|
+
# Inbox creatives hold the user's notifications AND ordinary conversations.
|
|
226
|
+
# Only the System topic is special: it carries alarms/notifications (stuck
|
|
227
|
+
# recovery, share notices, …) and must never trigger AI. Every OTHER inbox
|
|
228
|
+
# topic — Main, Content, user threads, Claude Channel session topics — is an
|
|
229
|
+
# ordinary conversation surface and dispatches exactly like a normal
|
|
230
|
+
# (non-inbox) topic.
|
|
231
|
+
#
|
|
232
|
+
# A live Claude Channel session holds inbox-wide :feedback +
|
|
233
|
+
# routing_expression="true", so it would otherwise be selected by the
|
|
234
|
+
# Matcher for *every* dispatched inbox comment (leaking ordinary inbox
|
|
235
|
+
# threads into the live session). That confinement now lives in
|
|
236
|
+
# Orchestration::Matcher, which scopes a Claude session agent to its own
|
|
237
|
+
# registered session topic — keeping non-System topics truly identical to a
|
|
238
|
+
# normal topic whether or not a session is live.
|
|
239
|
+
return if creative.inbox? && inbox_system_topic?
|
|
179
240
|
|
|
180
241
|
# A Claude Channel session suspended on a native tool-permission prompt
|
|
181
242
|
# parks its in-flight dispatch as a `delegated` task carrying a
|
|
@@ -200,19 +261,12 @@ module Collavre
|
|
|
200
261
|
raise # re-raise so calling jobs (e.g. DropTriggerJob) can retry
|
|
201
262
|
end
|
|
202
263
|
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
# channel ai_user can be assigned as primary_agent on an ordinary inbox
|
|
210
|
-
# topic via TopicsController#set_primary_agent without ever registering a
|
|
211
|
-
# session. Gating on the agent alone would dispatch that ordinary thread and
|
|
212
|
-
# leak it to the live Claude session. session_id is exactly what
|
|
213
|
-
# ClaudeChannelAdapter#session_topic? keys on, so the two stay consistent.
|
|
214
|
-
def claude_channel_session_topic?
|
|
215
|
-
topic&.session_id.present? && topic&.primary_agent&.claude_channel_agent?
|
|
264
|
+
# The inbox System topic is the alarm/notification stream and must never
|
|
265
|
+
# trigger AI orchestration. Matched by name (Creative::SYSTEM_TOPIC_NAME),
|
|
266
|
+
# the same topic Creative#system_topic finds/creates and that stuck-recovery
|
|
267
|
+
# and share notices post into.
|
|
268
|
+
def inbox_system_topic?
|
|
269
|
+
topic&.name == Creative::SYSTEM_TOPIC_NAME
|
|
216
270
|
end
|
|
217
271
|
|
|
218
272
|
def assign_default_user
|
|
@@ -225,6 +279,7 @@ module Collavre
|
|
|
225
279
|
def resume_trigger_loop_if_awaiting
|
|
226
280
|
return unless user_id # must have a user (not system)
|
|
227
281
|
return if user&.ai_user? # must be a human, not an AI agent
|
|
282
|
+
return if approval_action? # approval surface is not a user-resume signal; the resumed @agent turn would otherwise carry it into history
|
|
228
283
|
return unless creative
|
|
229
284
|
|
|
230
285
|
# Use pessimistic lock to prevent duplicate resume from concurrent comments
|
|
@@ -4,7 +4,7 @@ module Collavre
|
|
|
4
4
|
class CommentVersion < ApplicationRecord
|
|
5
5
|
self.table_name = "comment_versions"
|
|
6
6
|
|
|
7
|
-
belongs_to :comment, class_name: "Collavre::Comment"
|
|
7
|
+
belongs_to :comment, class_name: "Collavre::Comment", counter_cache: true
|
|
8
8
|
belongs_to :review_comment, class_name: "Collavre::Comment", optional: true
|
|
9
9
|
|
|
10
10
|
validates :content, presence: true
|
|
@@ -5,6 +5,11 @@ module Collavre
|
|
|
5
5
|
|
|
6
6
|
included do
|
|
7
7
|
attr_accessor :content_type_input
|
|
8
|
+
# Which editing surface authored this Markdown: "rich" (Lexical) reopens
|
|
9
|
+
# in the rich editor, "source" (or absent/legacy) reopens in the advanced
|
|
10
|
+
# textarea. Stored in data["editor"]; decoupled from the storage format
|
|
11
|
+
# (content_type), which is now Markdown for both surfaces.
|
|
12
|
+
attr_accessor :markdown_editor
|
|
8
13
|
attr_reader :markdown_source
|
|
9
14
|
|
|
10
15
|
def markdown_source=(value)
|
|
@@ -13,7 +18,7 @@ module Collavre
|
|
|
13
18
|
|
|
14
19
|
validates :description, presence: true, unless: -> { origin_id.present? }
|
|
15
20
|
validate :description_cannot_change_if_has_origin, on: :update
|
|
16
|
-
validate :
|
|
21
|
+
validate :description_cannot_change_if_read_only_source, on: :update
|
|
17
22
|
|
|
18
23
|
before_validation :convert_markdown_to_html
|
|
19
24
|
before_save :sanitize_description_html
|
|
@@ -24,7 +29,13 @@ module Collavre
|
|
|
24
29
|
# Linked Creative의 description을 안전하게 반환
|
|
25
30
|
def effective_description(variation_id = nil, html = true)
|
|
26
31
|
if variation_id.present?
|
|
27
|
-
|
|
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
|
|
28
39
|
return variation_tag.value if variation_tag&.value.present?
|
|
29
40
|
end
|
|
30
41
|
description_val = origin_id.nil? ? description : origin.description
|
|
@@ -39,11 +50,11 @@ module Collavre
|
|
|
39
50
|
CGI.unescapeHTML(ActionController::Base.helpers.strip_tags(effective_origin.description || "")).truncate(24, omission: "...")
|
|
40
51
|
end
|
|
41
52
|
|
|
42
|
-
#
|
|
43
|
-
# (
|
|
53
|
+
# Read-only-source creatives reject any description change
|
|
54
|
+
# (description_cannot_change_if_read_only_source), so embedding would raise
|
|
44
55
|
# and orphan the blob. Callers MUST check this before creating the blob.
|
|
45
56
|
def attachments_embeddable?
|
|
46
|
-
!effective_origin.
|
|
57
|
+
!effective_origin.read_only_source?
|
|
47
58
|
end
|
|
48
59
|
|
|
49
60
|
# Append an attachment node and save; after_save reconcile attaches the
|
|
@@ -128,6 +139,9 @@ module Collavre
|
|
|
128
139
|
prev_source = data["markdown_source"].to_s
|
|
129
140
|
prev_type = data["content_type"]
|
|
130
141
|
self.data["content_type"] = "markdown"
|
|
142
|
+
# Remember which surface authored this. Absent param (MCP/tool writes)
|
|
143
|
+
# keeps the prior choice, defaulting to the advanced textarea.
|
|
144
|
+
self.data["editor"] = markdown_editor.presence || data["editor"] || "source"
|
|
131
145
|
if new_source != prev_source || prev_type != "markdown"
|
|
132
146
|
# Rewrite inline data-URI images to freshly-uploaded blob paths
|
|
133
147
|
# FIRST, then persist the rewritten source. Subsequent edits
|
|
@@ -149,6 +163,7 @@ module Collavre
|
|
|
149
163
|
self.data ||= {}
|
|
150
164
|
self.data.delete("content_type")
|
|
151
165
|
self.data.delete("markdown_source")
|
|
166
|
+
self.data.delete("editor")
|
|
152
167
|
elsif !new_record? && description_changed? && data&.dig("content_type") == "markdown"
|
|
153
168
|
# Description was rewritten through a non-markdown path (tool/MCP
|
|
154
169
|
# update, direct base.update(description: ...), etc.) on a creative
|
|
@@ -158,6 +173,7 @@ module Collavre
|
|
|
158
173
|
# Demote to HTML mode so the persisted source matches description.
|
|
159
174
|
self.data.delete("content_type")
|
|
160
175
|
self.data.delete("markdown_source")
|
|
176
|
+
self.data.delete("editor")
|
|
161
177
|
end
|
|
162
178
|
end
|
|
163
179
|
|
|
@@ -181,21 +197,67 @@ module Collavre
|
|
|
181
197
|
end
|
|
182
198
|
end
|
|
183
199
|
|
|
200
|
+
# The Lexical editor stores text color / background as inline <span
|
|
201
|
+
# style="...">. Tighten every style attribute to ONLY validated color /
|
|
202
|
+
# background-color declarations BEFORE sanitization, so allowing `style`
|
|
203
|
+
# in the safelist can't smuggle layout/position/url() payloads.
|
|
204
|
+
scrub_inline_color_styles(scrubbed)
|
|
205
|
+
|
|
184
206
|
self.description = ActionController::Base.helpers.sanitize(
|
|
185
207
|
scrubbed.to_html,
|
|
186
208
|
tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags + media_tags + %w[input],
|
|
187
|
-
attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + task_list_attrs + media_attrs + %w[data-lexical]
|
|
209
|
+
attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + task_list_attrs + media_attrs + %w[data-lexical style]
|
|
188
210
|
)
|
|
189
211
|
end
|
|
190
212
|
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
213
|
+
# Only these CSS color values may appear in a stored inline style — mirrors
|
|
214
|
+
# the JS markdown serializer's allowlist (markdown_serialize.js). Blocks
|
|
215
|
+
# url(), expression(), javascript:, and angle brackets.
|
|
216
|
+
SAFE_COLOR_VALUE = /\A(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|var\(--[a-zA-Z0-9\-_]+\)|[a-zA-Z]+)\z/
|
|
217
|
+
|
|
218
|
+
def scrub_inline_color_styles(fragment)
|
|
219
|
+
fragment.css("[style]").each do |node|
|
|
220
|
+
declarations = node["style"].to_s.split(";").filter_map do |decl|
|
|
221
|
+
key, value = decl.split(":", 2)
|
|
222
|
+
next if key.nil? || value.nil?
|
|
223
|
+
|
|
224
|
+
key = key.strip.downcase
|
|
225
|
+
value = value.strip
|
|
226
|
+
next unless %w[color background-color].include?(key)
|
|
227
|
+
next if value =~ /url\(|expression|javascript:|@import|[<>]/i
|
|
228
|
+
next unless value =~ SAFE_COLOR_VALUE
|
|
229
|
+
|
|
230
|
+
"#{key}: #{value}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if declarations.any?
|
|
234
|
+
node["style"] = declarations.join("; ")
|
|
235
|
+
else
|
|
236
|
+
node.remove_attribute("style")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Sync creative.files to the blobs referenced in the description HTML
|
|
242
|
+
# (attach new, detach removed). Must never raise during save — malformed
|
|
243
|
+
# HTML yields [] and a no-op.
|
|
244
|
+
#
|
|
245
|
+
# The rich (Lexical) editor is now Markdown-canonical and serializes
|
|
246
|
+
# inserted images/videos/files as raw blob-URL tags inside markdown_source,
|
|
247
|
+
# which markdown_to_html renders into `description`. So reconcile must run
|
|
248
|
+
# for markdown mode too, otherwise rich-editor uploads never reach
|
|
249
|
+
# creative.files (and the attachment list/remove services miss them).
|
|
194
250
|
#
|
|
195
|
-
# Markdown
|
|
196
|
-
#
|
|
251
|
+
# Markdown detach is narrowed (not skipped): markdown creatives may carry
|
|
252
|
+
# legacy blobs attached directly but never embedded in the derived
|
|
253
|
+
# description (AttachmentBackfill skips markdown, so reconcile is their only
|
|
254
|
+
# touch point) — those must survive. But media the user actually removed
|
|
255
|
+
# in-editor WAS referenced in the prior description and no longer is; that
|
|
256
|
+
# must detach, or creative.files keeps listing a deleted file (the editor's
|
|
257
|
+
# purge DELETE can't compensate while the attachment still exists). So for
|
|
258
|
+
# markdown we detach only blobs that were previously referenced. HTML keeps
|
|
259
|
+
# full detach (backfill embeds its orphans, so none should linger).
|
|
197
260
|
def reconcile_description_attachments
|
|
198
|
-
return if data&.dig("content_type") == "markdown"
|
|
199
261
|
# Linked creatives don't own their description (it lives on the origin)
|
|
200
262
|
# and their own column is blank, so reconcile would treat every legacy
|
|
201
263
|
# attachment as an orphan and purge it on the next save (e.g. a
|
|
@@ -212,8 +274,16 @@ module Collavre
|
|
|
212
274
|
current = files.includes(:blob).to_a
|
|
213
275
|
current_blob_ids = current.map(&:blob_id).to_set
|
|
214
276
|
|
|
277
|
+
markdown = data&.dig("content_type") == "markdown"
|
|
215
278
|
to_attach = referenced.reject { |b| current_blob_ids.include?(b.id) }
|
|
216
|
-
|
|
279
|
+
unreferenced = current.reject { |a| referenced_ids.include?(a.blob_id) }
|
|
280
|
+
to_detach =
|
|
281
|
+
if markdown
|
|
282
|
+
prev_referenced_ids = blob_ids_referenced_in(description_before_last_save)
|
|
283
|
+
unreferenced.select { |a| prev_referenced_ids.include?(a.blob_id) }
|
|
284
|
+
else
|
|
285
|
+
unreferenced
|
|
286
|
+
end
|
|
217
287
|
return if to_attach.empty? && to_detach.empty?
|
|
218
288
|
|
|
219
289
|
to_attach.each { |blob| files.attach(blob) }
|
|
@@ -266,9 +336,13 @@ module Collavre
|
|
|
266
336
|
end
|
|
267
337
|
|
|
268
338
|
def extract_signed_ids_from_description
|
|
269
|
-
|
|
339
|
+
extract_signed_ids_from(description)
|
|
340
|
+
end
|
|
270
341
|
|
|
271
|
-
|
|
342
|
+
def extract_signed_ids_from(html)
|
|
343
|
+
return [] if html.blank?
|
|
344
|
+
|
|
345
|
+
html = html.to_s
|
|
272
346
|
|
|
273
347
|
ids = html.scan(%r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/?#]+)}).flatten
|
|
274
348
|
ids += html.scan(%r{/rails/active_storage/blobs/([^/?#]+)}).flatten
|
|
@@ -277,18 +351,29 @@ module Collavre
|
|
|
277
351
|
ids.uniq
|
|
278
352
|
end
|
|
279
353
|
|
|
354
|
+
# Blob ids that `html` references, resolving each signed id to its blob.
|
|
355
|
+
# Used to tell user-removed media (was referenced, now gone) apart from
|
|
356
|
+
# legacy orphans (never referenced) during markdown reconcile.
|
|
357
|
+
def blob_ids_referenced_in(html)
|
|
358
|
+
extract_signed_ids_from(html).filter_map do |sid|
|
|
359
|
+
ActiveStorage::Blob.find_signed(sid)&.id
|
|
360
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
|
|
361
|
+
nil
|
|
362
|
+
end.to_set
|
|
363
|
+
end
|
|
364
|
+
|
|
280
365
|
def description_cannot_change_if_has_origin
|
|
281
366
|
if origin_id.present? && will_save_change_to_description?
|
|
282
367
|
errors.add(:description, "cannot be changed directly when linked to an origin")
|
|
283
368
|
end
|
|
284
369
|
end
|
|
285
370
|
|
|
286
|
-
def
|
|
371
|
+
def description_cannot_change_if_read_only_source
|
|
287
372
|
return unless will_save_change_to_description?
|
|
288
|
-
return unless
|
|
289
|
-
return if
|
|
373
|
+
return unless read_only_source?
|
|
374
|
+
return if skip_read_only_source_validation
|
|
290
375
|
|
|
291
|
-
errors.add(:description, "
|
|
376
|
+
errors.add(:description, I18n.t("collavre.creatives.errors.description_read_only_source"))
|
|
292
377
|
end
|
|
293
378
|
end
|
|
294
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
|