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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. 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
- result = FilterPipeline.new(
68
- user: user,
69
- params: params,
70
- scope: scope
71
- ).call
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: ENV["GOOGLE_CLIENT_ID"] || Rails.application.credentials.dig(:google, :client_id),
114
- client_secret: ENV["GOOGLE_CLIENT_SECRET"] || Rails.application.credentials.dig(:google, :client_secret),
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
- # Collect reference-style data-URI images: [alt]: <data:...>
20
- input.gsub!(/^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/) do
21
- image_refs[$1] = $2.strip
22
- ""
23
- end
18
+ # Protect fenced code blocks and inline code spans from the regex
19
+ # rewrites below — a Markdown sample like ```` ```md\n![x](data:...)\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
- # Convert data-URI images to Active Storage before rendering
26
- input.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
27
- if (data_url = image_refs[$2])
28
- data_image_to_attachment(data_url, $1)
29
- else
30
- "![#{$1}][#{$2}]"
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
- input.gsub!(/(?<!\\)!\[([^\]]*)\]\((data:image\/[^)]+)\)/) do
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![x](data:...)\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: ![alt](data:...) or ![alt](data:... "title")
240
+ # → ![alt](blob_path) / ![alt](blob_path "title"). 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
+ "![#{alt}](#{data_uri_to_blob_path(data_url)}#{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*<\s*(data:image\/[^>]+)\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 == "running"
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
- stuck_tasks = Task.where(status: "running")
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|