collavre 0.20.3 → 0.22.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/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
module Creatives
|
|
3
3
|
class IndexQuery
|
|
4
|
+
# Cap flat search results for the lightweight picker popup (simple mode).
|
|
5
|
+
# The full-page search (no `simple` flag) is intentionally unbounded.
|
|
6
|
+
SIMPLE_SEARCH_LIMIT = 50
|
|
7
|
+
|
|
8
|
+
# Permission-check matched rows in rank order in slices of this size, so a
|
|
9
|
+
# heavily-matched query only checks the prefix needed to fill the cap rather
|
|
10
|
+
# than its entire match set.
|
|
11
|
+
PERMISSION_FILTER_BATCH = 200
|
|
12
|
+
|
|
4
13
|
Result = Struct.new(
|
|
5
14
|
:creatives,
|
|
6
15
|
:parent_creative,
|
|
@@ -63,12 +72,16 @@ module Creatives
|
|
|
63
72
|
|
|
64
73
|
def handle_filtered_query
|
|
65
74
|
scope = determine_scope
|
|
75
|
+
pipeline = FilterPipeline.new(user: user, params: params, scope: scope)
|
|
66
76
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
# The picker's flat search (simple mode) only renders matched rows ranked
|
|
78
|
+
# and capped; ancestors, progress_map and overall_progress are full-page
|
|
79
|
+
# concerns it never reads (breadcrumbs are resolved separately). Skip that
|
|
80
|
+
# work — and the per-row readable? N+1 — so a 2-char query in a large tree
|
|
81
|
+
# caps before doing unbounded resolution.
|
|
82
|
+
return simple_search_result(pipeline) if simple_search?
|
|
83
|
+
|
|
84
|
+
result = pipeline.call
|
|
72
85
|
|
|
73
86
|
return empty_result if result.matched_ids.empty?
|
|
74
87
|
|
|
@@ -83,9 +96,6 @@ module Creatives
|
|
|
83
96
|
# Sort by comment updated_at for comment filter
|
|
84
97
|
if params[:comment] == "true"
|
|
85
98
|
matched_creatives = matched_creatives.sort_by { |c| c.comments.maximum(:updated_at) || c.updated_at }.reverse
|
|
86
|
-
elsif params[:search].present? && params[:simple].present?
|
|
87
|
-
# Sort by description length (shorter = more relevant match)
|
|
88
|
-
matched_creatives = matched_creatives.sort_by { |c| c.description.to_s.length }
|
|
89
99
|
end
|
|
90
100
|
|
|
91
101
|
parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
|
|
@@ -109,6 +119,98 @@ module Creatives
|
|
|
109
119
|
end
|
|
110
120
|
end
|
|
111
121
|
|
|
122
|
+
def simple_search?
|
|
123
|
+
params[:search].present? && params[:simple].present? &&
|
|
124
|
+
params[:search_mode] != "tree" && params[:comment] != "true"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Lightweight flat-search path for the picker popup: matched rows only,
|
|
128
|
+
# permission-filtered in one batch, ranked by relevance and capped — without
|
|
129
|
+
# resolving ancestors/progress for the whole match set.
|
|
130
|
+
def simple_search_result(pipeline)
|
|
131
|
+
relation = pipeline.search_only_relation
|
|
132
|
+
ids = if relation
|
|
133
|
+
windowed_readable_ids(relation)
|
|
134
|
+
else
|
|
135
|
+
matched = pipeline.matched_ids
|
|
136
|
+
matched.empty? ? [] : ranked_readable_window(matched)
|
|
137
|
+
end
|
|
138
|
+
return empty_result if ids.empty?
|
|
139
|
+
|
|
140
|
+
by_id = Creative.where(id: ids).index_by(&:id)
|
|
141
|
+
creatives = ids.filter_map { |id| by_id[id] }
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
creatives: creatives,
|
|
145
|
+
parent: params[:id] ? Creative.find_by(id: params[:id]) : nil,
|
|
146
|
+
allowed_ids: nil,
|
|
147
|
+
overall_progress: nil,
|
|
148
|
+
progress_map: nil
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Window the ranked search matches entirely in SQL: order by relevance and
|
|
153
|
+
# pull PERMISSION_FILTER_BATCH-sized slices via LIMIT/OFFSET, permission-filtering
|
|
154
|
+
# each and stopping as soon as the cap is filled. The match itself stays a
|
|
155
|
+
# subquery (`id IN (<search relation>)`), so Ruby never holds the full match-id
|
|
156
|
+
# set at once — only one PERMISSION_FILTER_BATCH window is in memory at a time.
|
|
157
|
+
# Wrapping the join+distinct search as a subquery also keeps the outer relation
|
|
158
|
+
# join-free, so `ORDER BY LENGTH(description)` is portable (no Postgres
|
|
159
|
+
# "DISTINCT + ORDER BY must be in select list" issue). The DB still scans/sorts
|
|
160
|
+
# all matches (inherent to a leading-wildcard LIKE + global relevance ranking),
|
|
161
|
+
# but the rows crossing into Ruby are bounded per window.
|
|
162
|
+
#
|
|
163
|
+
# The loop continues until the cap is filled OR the match set is exhausted —
|
|
164
|
+
# there is no fixed window ceiling. A ceiling would silently truncate results:
|
|
165
|
+
# in a multi-user install the match scope is every `origin_id: nil` row before
|
|
166
|
+
# permission filtering, so a user's readable matches can sort after many
|
|
167
|
+
# unreadable ones, and capping the scan would return an empty/incomplete result
|
|
168
|
+
# even though matching readable creatives exist. Termination is guaranteed by the
|
|
169
|
+
# finite match set (offset advances past the last row → empty window → break);
|
|
170
|
+
# per-window memory stays bounded regardless of how deep the scan goes.
|
|
171
|
+
# PermissionFilter stays the single source of read permission. LENGTH() is
|
|
172
|
+
# portable across SQLite/Postgres/MySQL and byte-vs-char length is immaterial
|
|
173
|
+
# for a ranking heuristic.
|
|
174
|
+
def windowed_readable_ids(relation)
|
|
175
|
+
ordered = Creative.where(id: relation.select(:id))
|
|
176
|
+
.order(Arel.sql("LENGTH(creatives.description)"), :id)
|
|
177
|
+
|
|
178
|
+
readable = []
|
|
179
|
+
offset = 0
|
|
180
|
+
loop do
|
|
181
|
+
window = ordered.offset(offset).limit(PERMISSION_FILTER_BATCH).pluck(:id)
|
|
182
|
+
break if window.empty?
|
|
183
|
+
|
|
184
|
+
permitted = PermissionFilter.new(user: user).readable_ids(window).to_set
|
|
185
|
+
window.each { |id| readable << id if permitted.include?(id) }
|
|
186
|
+
break if readable.size >= SIMPLE_SEARCH_LIMIT || window.size < PERMISSION_FILTER_BATCH
|
|
187
|
+
|
|
188
|
+
offset += PERMISSION_FILTER_BATCH
|
|
189
|
+
end
|
|
190
|
+
readable.first(SIMPLE_SEARCH_LIMIT)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Fallback for when other filters are active alongside search (no single-relation
|
|
194
|
+
# form): rank the already-matched ids by relevance in the DB, then permission-filter
|
|
195
|
+
# in that order one slice at a time, stopping as soon as the cap is filled. Only the
|
|
196
|
+
# prefix needed for SIMPLE_SEARCH_LIMIT readable rows is permission-checked and
|
|
197
|
+
# materialized. The matched-id set itself is plucked up front here (it was already
|
|
198
|
+
# intersected across filters in Ruby), which is why the search-only path prefers the
|
|
199
|
+
# windowed subquery above. PermissionFilter stays the single source of read permission.
|
|
200
|
+
def ranked_readable_window(matched)
|
|
201
|
+
ranked_ids = Creative.where(id: matched)
|
|
202
|
+
.order(Arel.sql("LENGTH(creatives.description)"), :id)
|
|
203
|
+
.pluck(:id)
|
|
204
|
+
|
|
205
|
+
readable = []
|
|
206
|
+
ranked_ids.each_slice(PERMISSION_FILTER_BATCH) do |slice|
|
|
207
|
+
permitted = PermissionFilter.new(user: user).readable_ids(slice).to_set
|
|
208
|
+
slice.each { |id| readable << id if permitted.include?(id) }
|
|
209
|
+
break if readable.size >= SIMPLE_SEARCH_LIMIT
|
|
210
|
+
end
|
|
211
|
+
readable.first(SIMPLE_SEARCH_LIMIT)
|
|
212
|
+
end
|
|
213
|
+
|
|
112
214
|
def handle_id_query
|
|
113
215
|
creative = Creative.find_by(id: params[:id])
|
|
114
216
|
return empty_result unless creative && readable?(creative)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Batch "which of these creative ids can the user read?" using the O(1)
|
|
4
|
+
# CreativeSharesCache table. Extracted from FilterPipeline so the picker's
|
|
5
|
+
# has_children presence check can apply the exact same permission posture as
|
|
6
|
+
# the browse endpoint (children_with_permission) without re-deriving it.
|
|
7
|
+
#
|
|
8
|
+
# no_access wins over a public share, so user-level denials are subtracted
|
|
9
|
+
# from the public set. Owned creatives are always readable.
|
|
10
|
+
class PermissionFilter
|
|
11
|
+
def initialize(user:)
|
|
12
|
+
@user = user
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns the subset of `ids` the user may read, as an Array.
|
|
16
|
+
def readable_ids(ids)
|
|
17
|
+
ids = ids.to_a
|
|
18
|
+
return [] if ids.empty?
|
|
19
|
+
|
|
20
|
+
accessible_ids = Set.new
|
|
21
|
+
|
|
22
|
+
if user
|
|
23
|
+
user_accessible = []
|
|
24
|
+
user_denied = Set.new
|
|
25
|
+
CreativeSharesCache.where(creative_id: ids, user_id: user.id)
|
|
26
|
+
.pluck(:creative_id, :permission).each do |cid, perm|
|
|
27
|
+
perm == "no_access" ? user_denied << cid : user_accessible << cid
|
|
28
|
+
end
|
|
29
|
+
accessible_ids.merge(user_accessible)
|
|
30
|
+
|
|
31
|
+
public_ids = CreativeSharesCache.where(creative_id: ids, user_id: nil)
|
|
32
|
+
.where.not(permission: :no_access).pluck(:creative_id)
|
|
33
|
+
accessible_ids.merge(public_ids - user_denied.to_a)
|
|
34
|
+
|
|
35
|
+
owned_ids = Creative.where(id: ids, user_id: user.id).pluck(:id)
|
|
36
|
+
accessible_ids.merge(owned_ids)
|
|
37
|
+
else
|
|
38
|
+
accessible_ids = CreativeSharesCache.where(creative_id: ids, user_id: nil)
|
|
39
|
+
.where.not(permission: :no_access).pluck(:creative_id).to_set
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
accessible_ids.to_a
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :user
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Resolves, for each flat-search hit that is only reachable through a linked
|
|
4
|
+
# creative shell, the path of node ids to expand *in the signed-in user's own
|
|
5
|
+
# tree* to surface that shell — ordered root-most ancestor down to the shell
|
|
6
|
+
# itself.
|
|
7
|
+
#
|
|
8
|
+
# Why this exists: search breadcrumbs (BreadcrumbResolver) carry the hit's
|
|
9
|
+
# *origin-space* ancestors. When a shared subtree is represented by a shell
|
|
10
|
+
# nested under one of the user's own folders, those origin ancestors never
|
|
11
|
+
# include the local folder that contains the shell, so the picker's
|
|
12
|
+
# breadcrumb-jump cannot expand down to it (the shell <li> is never rendered
|
|
13
|
+
# and `_findItem` returns null). This service supplies the missing user-local
|
|
14
|
+
# prefix `[localFolder..., shellId]`; the client expands it before walking the
|
|
15
|
+
# origin chain. Display is unaffected — only navigation.
|
|
16
|
+
#
|
|
17
|
+
# Returns { hit_id (Integer) => { origin_ancestor_id (Integer) => [user_tree_id, ...] } },
|
|
18
|
+
# i.e. for each hit, a per-origin-ancestor map: every origin ancestor (incl.
|
|
19
|
+
# the hit itself) that the user holds a renderable shell for maps to the local
|
|
20
|
+
# path that surfaces *that* shell. The client anchors a breadcrumb click at the
|
|
21
|
+
# entry at/above the clicked crumb, so a higher ancestor crumb resolves through
|
|
22
|
+
# its own shell rather than a deeper one (a user may hold shells at several
|
|
23
|
+
# depths of the same shared subtree). Hits not routed through any shell are
|
|
24
|
+
# omitted (their origin path already matches rendered ids).
|
|
25
|
+
class RevealPathResolver
|
|
26
|
+
def initialize(creative_ids, user: nil, include_archived: false)
|
|
27
|
+
@ids = Array(creative_ids).map { |id| id.to_s.to_i }.uniq.reject(&:zero?)
|
|
28
|
+
@user = user
|
|
29
|
+
@include_archived = include_archived
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call
|
|
33
|
+
return {} if @ids.empty? || user.nil?
|
|
34
|
+
|
|
35
|
+
# Origin-space self+ancestor rows for every hit (generations 0 = self).
|
|
36
|
+
hit_rows = CreativeHierarchy
|
|
37
|
+
.where(descendant_id: @ids)
|
|
38
|
+
.pluck(:descendant_id, :ancestor_id, :generations)
|
|
39
|
+
return {} if hit_rows.empty?
|
|
40
|
+
|
|
41
|
+
shells_by_origin = user_shells_by_origin(hit_rows.map { |_d, a, _g| a }.uniq)
|
|
42
|
+
return {} if shells_by_origin.empty?
|
|
43
|
+
|
|
44
|
+
local_prefix = local_ancestor_paths(shells_by_origin.values.flatten.uniq)
|
|
45
|
+
|
|
46
|
+
# For each hit, map every origin ancestor (incl. self) that has a renderable
|
|
47
|
+
# user shell to the local path [localFolder..., shellId] surfacing it.
|
|
48
|
+
hit_rows.group_by { |descendant_id, _a, _g| descendant_id }
|
|
49
|
+
.each_with_object({}) do |(hit_id, rows), out|
|
|
50
|
+
paths = rows.each_with_object({}) do |(_d, ancestor_id, _g), acc|
|
|
51
|
+
next if acc.key?(ancestor_id)
|
|
52
|
+
|
|
53
|
+
shell_ids = shells_by_origin[ancestor_id]
|
|
54
|
+
next unless shell_ids
|
|
55
|
+
|
|
56
|
+
# A user may hold several shells for the same origin; pick the first
|
|
57
|
+
# whose local path is actually renderable (not behind an archived
|
|
58
|
+
# folder; [] for a root shell counts as renderable).
|
|
59
|
+
shell_id = shell_ids.find { |sid| local_prefix[sid] }
|
|
60
|
+
next unless shell_id
|
|
61
|
+
|
|
62
|
+
acc[ancestor_id] = local_prefix[shell_id] + [ shell_id ]
|
|
63
|
+
end
|
|
64
|
+
out[hit_id] = paths if paths.any?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader :ids, :user, :include_archived
|
|
71
|
+
|
|
72
|
+
# Shells the signed-in user owns whose origin is one of the hits' ancestors,
|
|
73
|
+
# grouped by origin id (a user can hold more than one shell per origin).
|
|
74
|
+
# Archived shells are excluded (unless show_archived) so the reveal path only
|
|
75
|
+
# targets nodes the browse endpoint actually renders.
|
|
76
|
+
def user_shells_by_origin(origin_ids)
|
|
77
|
+
return {} if origin_ids.empty?
|
|
78
|
+
|
|
79
|
+
scope = Creative
|
|
80
|
+
.where(user_id: user.id)
|
|
81
|
+
.where.not(origin_id: nil)
|
|
82
|
+
.where(origin_id: origin_ids)
|
|
83
|
+
scope = scope.where(archived_at: nil) unless include_archived
|
|
84
|
+
scope.pluck(:origin_id, :id).each_with_object({}) do |(origin_id, id), grouped|
|
|
85
|
+
(grouped[origin_id] ||= []) << id
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# User-local ancestors of each shell (the user's own folders above it),
|
|
90
|
+
# ordered root-most down to the shell's immediate parent. Returns nil for a
|
|
91
|
+
# shell whose path crosses an archived (unrendered) folder, since the browse
|
|
92
|
+
# path could never expand down to it.
|
|
93
|
+
def local_ancestor_paths(shell_ids)
|
|
94
|
+
rows = CreativeHierarchy
|
|
95
|
+
.where(descendant_id: shell_ids)
|
|
96
|
+
.where("generations > 0")
|
|
97
|
+
.pluck(:descendant_id, :ancestor_id, :generations)
|
|
98
|
+
by_shell = rows.group_by { |descendant_id, _a, _g| descendant_id }
|
|
99
|
+
archived = archived_ancestor_ids(rows.map { |_d, a, _g| a }.uniq)
|
|
100
|
+
|
|
101
|
+
shell_ids.index_with do |shell_id|
|
|
102
|
+
ordered = (by_shell[shell_id] || [])
|
|
103
|
+
.sort_by { |_d, _a, generations| -generations }
|
|
104
|
+
.map { |_d, ancestor_id, _g| ancestor_id }
|
|
105
|
+
ordered.any? { |ancestor_id| archived.include?(ancestor_id) } ? nil : ordered
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Ancestor folder ids that are archived (and thus hidden from the browse path
|
|
110
|
+
# unless show_archived). Empty when show_archived is set.
|
|
111
|
+
def archived_ancestor_ids(ancestor_ids)
|
|
112
|
+
return Set.new if include_archived || ancestor_ids.empty?
|
|
113
|
+
|
|
114
|
+
Creative.where(id: ancestor_ids).where.not(archived_at: nil).pluck(:id).to_set
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -128,7 +128,7 @@ module Creatives
|
|
|
128
128
|
sequence: creative.sequence,
|
|
129
129
|
link_url: view_context.collavre.creative_path(creative),
|
|
130
130
|
templates: template_payload_for(creative, has_children: filtered_children.any?),
|
|
131
|
-
inline_editor_payload: inline_editor_payload_for(creative),
|
|
131
|
+
inline_editor_payload: inline_editor_payload_for(creative, can_write: cached_can_write?(creative)),
|
|
132
132
|
children_container: children_container_payload(
|
|
133
133
|
creative,
|
|
134
134
|
filtered_children,
|
|
@@ -187,11 +187,15 @@ module Creatives
|
|
|
187
187
|
}
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
-
def inline_editor_payload_for(creative)
|
|
190
|
+
def inline_editor_payload_for(creative, can_write:)
|
|
191
|
+
effective = creative.effective_origin(Set.new)
|
|
192
|
+
origin_writable = effective.id == creative.id ? can_write : creative.has_permission?(user, :write)
|
|
191
193
|
{
|
|
192
194
|
description_raw_html: creative.effective_description(nil, true),
|
|
193
195
|
progress: creative.progress,
|
|
194
|
-
origin_id: creative.origin_id
|
|
196
|
+
origin_id: creative.origin_id,
|
|
197
|
+
content_type: effective.data&.dig("content_type"),
|
|
198
|
+
markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil
|
|
195
199
|
}
|
|
196
200
|
end
|
|
197
201
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Crons
|
|
5
|
+
# Shared parsing for SolidQueue::RecurringTask records that back our crons.
|
|
6
|
+
# The cron's creative_id/topic_id/agent_id/message live inside the task's
|
|
7
|
+
# `arguments` JSON (an array whose first element is the keyword hash), and
|
|
8
|
+
# the creative id is also encoded in the key as `cron_<creative_id>_<hex>`.
|
|
9
|
+
#
|
|
10
|
+
# Included by CronListService and Topics::OrphanedCronNotifier so the
|
|
11
|
+
# arguments format is parsed in exactly one place.
|
|
12
|
+
module RecurringTaskArguments
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def parse_arguments(task)
|
|
16
|
+
args = task.arguments
|
|
17
|
+
return {} unless args.is_a?(Array) && args.first.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
args.first.stringify_keys
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse_creative_id_from_key(key)
|
|
23
|
+
match = key.match(/\Acron_(\d+)_/)
|
|
24
|
+
match[1].to_i if match
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -110,8 +110,10 @@ module Collavre
|
|
|
110
110
|
raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.not_connected") if token.blank?
|
|
111
111
|
|
|
112
112
|
Google::Auth::UserRefreshCredentials.new(
|
|
113
|
-
client_id:
|
|
114
|
-
|
|
113
|
+
client_id: Collavre::IntegrationSettings::Resolver.get(:google_client_id).presence ||
|
|
114
|
+
Rails.application.credentials.dig(:google, :client_id),
|
|
115
|
+
client_secret: Collavre::IntegrationSettings::Resolver.get(:google_client_secret).presence ||
|
|
116
|
+
Rails.application.credentials.dig(:google, :client_secret),
|
|
115
117
|
scope: [ Google::Apis::CalendarV3::AUTH_CALENDAR_APP_CREATED ],
|
|
116
118
|
refresh_token: token
|
|
117
119
|
).tap(&:fetch_access_token!)
|
|
@@ -14,25 +14,38 @@ module Collavre
|
|
|
14
14
|
# Falls back to lightweight regex conversion for short inline fragments.
|
|
15
15
|
def markdown_to_html(text, image_refs = {})
|
|
16
16
|
return "" if text.nil?
|
|
17
|
-
input = text.dup
|
|
18
17
|
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
# Protect fenced code blocks and inline code spans from the regex
|
|
19
|
+
# rewrites below — a Markdown sample like ```` ```md\n\n``` ````
|
|
20
|
+
# must not silently upload its data URI as an Active Storage blob.
|
|
21
|
+
input = with_code_protected(text.dup) do |source|
|
|
22
|
+
# Collect reference-style data-URI images. CommonMark allows both the
|
|
23
|
+
# angle-bracket form `[alt]: <data:...>` and the bare form
|
|
24
|
+
# `[alt]: data:image/...`, optionally followed by a title.
|
|
25
|
+
source.gsub!(/^\s*\[([^\]]+)\]:\s*(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*$/) do
|
|
26
|
+
image_refs[$1] = ($2 || $3).strip
|
|
27
|
+
""
|
|
28
|
+
end
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
# Convert data-URI images to Active Storage before rendering
|
|
31
|
+
source.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
|
|
32
|
+
if (data_url = image_refs[$2])
|
|
33
|
+
data_image_to_attachment(data_url, $1)
|
|
34
|
+
else
|
|
35
|
+
"![#{$1}][#{$2}]"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Inline data-URI images. base64 contains no whitespace or `)`, so bound
|
|
40
|
+
# the URL on those and capture an optional CommonMark title separately;
|
|
41
|
+
# otherwise `data:...;base64,XYZ "caption"` would be slurped as one URL
|
|
42
|
+
# and fail the strict parser in `data_image_to_attachment`. Titles may be
|
|
43
|
+
# double-quoted, single-quoted, or parenthesized per CommonMark.
|
|
44
|
+
source.gsub!(/(?<!\\)!\[([^\]]*)\]\(\s*(data:image\/[^\s)]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/) do
|
|
45
|
+
data_image_to_attachment($2, $1)
|
|
31
46
|
end
|
|
32
|
-
end
|
|
33
47
|
|
|
34
|
-
|
|
35
|
-
data_image_to_attachment($2, $1)
|
|
48
|
+
source
|
|
36
49
|
end
|
|
37
50
|
|
|
38
51
|
# Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter)
|
|
@@ -194,8 +207,110 @@ module Collavre
|
|
|
194
207
|
end
|
|
195
208
|
end
|
|
196
209
|
|
|
210
|
+
# Rewrite data-URI image references in Markdown source to point at
|
|
211
|
+
# newly-created Active Storage blobs. Returns rewritten Markdown.
|
|
212
|
+
#
|
|
213
|
+
# Used by the inline Markdown editor to persist blob URLs in the stored
|
|
214
|
+
# `markdown_source`, so subsequent text edits around the image do not
|
|
215
|
+
# re-import the same data URI into a fresh blob on every autosave.
|
|
216
|
+
def rewrite_data_uri_images(text)
|
|
217
|
+
return text if text.nil?
|
|
218
|
+
image_refs = {}
|
|
219
|
+
|
|
220
|
+
# Protect fenced code blocks and inline code spans before rewriting —
|
|
221
|
+
# a Markdown code sample like ```` ```md\n\n``` ```` must
|
|
222
|
+
# not be silently uploaded as a blob and have its source rewritten,
|
|
223
|
+
# which would corrupt the user's code snippet on every autosave.
|
|
224
|
+
with_code_protected(text.dup) do |result|
|
|
225
|
+
# Collect reference-style data-URI image definitions. CommonMark accepts
|
|
226
|
+
# both the angle-bracket form `[alt]: <data:...>` and the bare form
|
|
227
|
+
# `[alt]: data:image/...`, optionally followed by a title. Rewrite each
|
|
228
|
+
# definition to point at a freshly-uploaded blob, normalizing to the
|
|
229
|
+
# bare form (titles are dropped since blob paths cannot collide with
|
|
230
|
+
# surrounding text).
|
|
231
|
+
result.gsub!(/^(\s*\[)([^\]]+)(\]:\s*)(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))(\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*)$/) do
|
|
232
|
+
lead, label, mid, angle_url, bare_url, tail = $1, $2, $3, $4, $5, $6
|
|
233
|
+
data_url = (angle_url || bare_url).strip
|
|
234
|
+
blob_path = data_uri_to_blob_path(data_url)
|
|
235
|
+
image_refs[label] = blob_path
|
|
236
|
+
"#{lead}#{label}#{mid}#{blob_path}#{tail}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Inline data-URI images:  or 
|
|
240
|
+
# →  / . Parse the title
|
|
241
|
+
# separately so it doesn't get captured into the data URL and break
|
|
242
|
+
# strict matching in `data_uri_to_blob_path`. Titles may be
|
|
243
|
+
# double-quoted, single-quoted, or parenthesized per CommonMark.
|
|
244
|
+
result.gsub!(/(?<!\\)!\[([^\]]*)\]\(\s*(data:image\/[^\s)]+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/) do
|
|
245
|
+
alt, data_url, title = $1, $2, $3
|
|
246
|
+
"}#{title})"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
result
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Convert a data-URI to a freshly-uploaded Active Storage blob path.
|
|
254
|
+
# Returns the original URL unchanged on parse failure.
|
|
255
|
+
def data_uri_to_blob_path(data_url)
|
|
256
|
+
return data_url unless data_url =~ %r{\Adata:(image/[\w.+-]+);base64,(.+)\z}
|
|
257
|
+
|
|
258
|
+
content_type = Regexp.last_match(1)
|
|
259
|
+
data = Base64.decode64(Regexp.last_match(2))
|
|
260
|
+
ext = Mime::Type.lookup(content_type).symbol.to_s
|
|
261
|
+
filename = "import-#{SecureRandom.hex}.#{ext}"
|
|
262
|
+
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename: filename, content_type: content_type)
|
|
263
|
+
Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)
|
|
264
|
+
end
|
|
265
|
+
|
|
197
266
|
private
|
|
198
267
|
|
|
268
|
+
# Run a regex-rewriting block on `text` with fenced code blocks,
|
|
269
|
+
# indented code blocks, and inline code spans swapped for unique tokens,
|
|
270
|
+
# then restore the original segments in the block's return value.
|
|
271
|
+
# Prevents data-URI rewrites from touching code samples that happen to
|
|
272
|
+
# contain image syntax.
|
|
273
|
+
def with_code_protected(text)
|
|
274
|
+
segments = {}
|
|
275
|
+
index = 0
|
|
276
|
+
protected_text = text
|
|
277
|
+
|
|
278
|
+
# Fenced code blocks (``` or ~~~, indented up to 3 spaces per CommonMark).
|
|
279
|
+
# The closing fence is optional: per CommonMark, an unclosed fence runs to
|
|
280
|
+
# end-of-document, so we still need to protect its contents from rewrites.
|
|
281
|
+
protected_text.gsub!(/^([ \t]{0,3})(`{3,}|~{3,})[^\n]*(?:\n(?:[\s\S]*?\n\1\2[ \t]*(?=\n|\z)|[\s\S]*\z)|\z)/) do |match|
|
|
282
|
+
token = "\x00MDPROTECT#{index}\x00"
|
|
283
|
+
segments[token] = match
|
|
284
|
+
index += 1
|
|
285
|
+
token
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Indented code blocks: contiguous lines each starting with 4+ spaces
|
|
289
|
+
# or a tab, preceded by start-of-document or a blank line (CommonRule
|
|
290
|
+
# requirement so we don't mistake list-item continuations for code).
|
|
291
|
+
protected_text.gsub!(/(\A|\n\n+)((?:(?:[ ]{4,}|\t)[^\n]*(?:\n|\z))+)/) do
|
|
292
|
+
prefix = Regexp.last_match(1)
|
|
293
|
+
block = Regexp.last_match(2)
|
|
294
|
+
token = "\x00MDPROTECT#{index}\x00"
|
|
295
|
+
segments[token] = block
|
|
296
|
+
index += 1
|
|
297
|
+
"#{prefix}#{token}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Inline single-backtick code spans. Multi-backtick spans are rare in
|
|
301
|
+
# practice; the protection above already covers fenced samples.
|
|
302
|
+
protected_text.gsub!(/`[^`\n]+?`/) do |match|
|
|
303
|
+
token = "\x00MDPROTECT#{index}\x00"
|
|
304
|
+
segments[token] = match
|
|
305
|
+
index += 1
|
|
306
|
+
token
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
rewritten = yield(protected_text)
|
|
310
|
+
segments.each { |token, original| rewritten.sub!(token, original) }
|
|
311
|
+
rewritten
|
|
312
|
+
end
|
|
313
|
+
|
|
199
314
|
def escape_table_cell(text)
|
|
200
315
|
text.to_s.gsub(/(?<!\\)\|/, '\\|')
|
|
201
316
|
end
|
|
@@ -6,9 +6,14 @@ module Collavre
|
|
|
6
6
|
def self.import(content, parent:, user:, create_root: false)
|
|
7
7
|
lines = content.to_s.lines
|
|
8
8
|
image_refs = {}
|
|
9
|
+
# CommonMark allows both the angle-bracket form `[alt]: <data:...>` and
|
|
10
|
+
# the bare form `[alt]: data:image/...`, optionally followed by a title
|
|
11
|
+
# (`"..."`, `'...'`, or `(...)`). Match both so reference-style data-URI
|
|
12
|
+
# images survive the import path and resolve to attachments downstream
|
|
13
|
+
# (mirrors MarkdownConverter#markdown_to_html).
|
|
9
14
|
lines.reject! do |ln|
|
|
10
|
-
if ln =~ /^\s*\[([^\]]+)\]:\s
|
|
11
|
-
image_refs[$1] = $2.strip
|
|
15
|
+
if ln =~ /^\s*\[([^\]]+)\]:\s*(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*$/
|
|
16
|
+
image_refs[$1] = ($2 || $3).strip
|
|
12
17
|
true
|
|
13
18
|
else
|
|
14
19
|
false
|
|
@@ -88,6 +88,8 @@ module Collavre
|
|
|
88
88
|
|
|
89
89
|
# Convenience methods
|
|
90
90
|
def arbitration_strategy
|
|
91
|
+
return "primary_first" if topic_primary_agent_id.present?
|
|
92
|
+
|
|
91
93
|
arbitration_config["strategy"]
|
|
92
94
|
end
|
|
93
95
|
|
|
@@ -96,7 +98,7 @@ module Collavre
|
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
def primary_agent_id
|
|
99
|
-
arbitration_config["primary_agent_id"]
|
|
101
|
+
topic_primary_agent_id || arbitration_config["primary_agent_id"]
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
# Bid strategy specific
|
|
@@ -160,6 +162,14 @@ module Collavre
|
|
|
160
162
|
|
|
161
163
|
config
|
|
162
164
|
end
|
|
165
|
+
|
|
166
|
+
# Read primary_agent_id directly from the topics table
|
|
167
|
+
def topic_primary_agent_id
|
|
168
|
+
topic_id = @context.dig("topic", "id")
|
|
169
|
+
return nil unless topic_id
|
|
170
|
+
|
|
171
|
+
Topic.where(id: topic_id).pick(:primary_agent_id)
|
|
172
|
+
end
|
|
163
173
|
end
|
|
164
174
|
end
|
|
165
175
|
end
|
|
@@ -59,7 +59,7 @@ module Collavre
|
|
|
59
59
|
next unless stuck_item.type == :task
|
|
60
60
|
|
|
61
61
|
task = stuck_item.item
|
|
62
|
-
next unless task.status
|
|
62
|
+
next unless %w[running delegated].include?(task.status)
|
|
63
63
|
|
|
64
64
|
task.update!(status: "failed")
|
|
65
65
|
Rails.logger.info(
|
|
@@ -73,6 +73,23 @@ module Collavre
|
|
|
73
73
|
tracker.release!(task.id)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# If this was a workflow subtask, fail the parent so the workflow
|
|
77
|
+
# advances instead of staying running with pending_creative_ids
|
|
78
|
+
# pointing at a child that's been failed underneath it.
|
|
79
|
+
if task.parent_task_id.present?
|
|
80
|
+
begin
|
|
81
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
|
|
82
|
+
task,
|
|
83
|
+
error_message: "Auto-recovered: stuck for " \
|
|
84
|
+
"#{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
|
|
85
|
+
)
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
Rails.logger.error(
|
|
88
|
+
"[StuckDetector] fail_subtask! failed for task #{task.id}: #{e.message}"
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
76
93
|
# Drain the queue for the topic so waiting tasks can execute
|
|
77
94
|
AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
78
95
|
rescue StandardError => e
|
|
@@ -89,7 +106,10 @@ module Collavre
|
|
|
89
106
|
threshold_minutes = config["task_stuck_threshold_minutes"] || 30
|
|
90
107
|
threshold_time = threshold_minutes.minutes.ago
|
|
91
108
|
|
|
92
|
-
|
|
109
|
+
# Include delegated tasks: Claude Channel tasks sit in delegated
|
|
110
|
+
# waiting for an external MCP reply; if the client disconnects, the
|
|
111
|
+
# task can otherwise stay delegated forever and block the topic queue.
|
|
112
|
+
stuck_tasks = Task.where(status: %w[running delegated])
|
|
93
113
|
.where("updated_at < ?", threshold_time)
|
|
94
114
|
|
|
95
115
|
stuck_tasks.filter_map do |task|
|