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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ class BaseController < ActionController::API
7
+ before_action :authenticate!
8
+
9
+ private
10
+
11
+ def authenticate!
12
+ token = extract_bearer_token
13
+ if token.blank?
14
+ render json: { error: "Missing authentication token" }, status: :unauthorized
15
+ return
16
+ end
17
+
18
+ access_token = Doorkeeper::AccessToken.by_token(token)
19
+ unless access_token&.accessible?
20
+ render json: { error: "Invalid authentication token" }, status: :unauthorized
21
+ return
22
+ end
23
+
24
+ user = Collavre::User.find_by(id: access_token.resource_owner_id)
25
+ unless user
26
+ render json: { error: "User not found" }, status: :unauthorized
27
+ return
28
+ end
29
+
30
+ Collavre::Current.user = user
31
+ end
32
+
33
+ def extract_bearer_token
34
+ auth_header = request.headers["Authorization"]
35
+ return nil unless auth_header&.start_with?("Bearer ")
36
+
37
+ auth_header.sub("Bearer ", "")
38
+ end
39
+
40
+ def current_user
41
+ Collavre::Current.user
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -9,8 +9,35 @@ module Collavre
9
9
  # of sync with the session cookie and POSTs fail with 422.
10
10
  after_action :set_csrf_token_header
11
11
 
12
+ before_action :redirect_authenticated_root_to_home
13
+
12
14
  private
13
15
 
16
+ # If the original request was for "/" and the user is signed in,
17
+ # honor SystemSetting.home_page_path_authenticated by redirecting.
18
+ # The middleware preserves "/" as a stable URL for unauthenticated
19
+ # visitors; authenticated users get a real URL change so the address
20
+ # bar reflects state.
21
+ #
22
+ # Loop safety: the env flag is only set when PATH_INFO == "/" hits
23
+ # the middleware. Direct visits to the redirect target never trip it,
24
+ # so comparing request.path to target would be wrong here — when both
25
+ # home_page_path and home_page_path_authenticated resolve to the same
26
+ # path, the middleware has already rewritten request.path to that
27
+ # target while the browser URL is still "/", and we must still
28
+ # redirect so the address bar reflects state.
29
+ def redirect_authenticated_root_to_home
30
+ return unless request.get? || request.head?
31
+ return unless request.env["collavre.root_request"]
32
+ return unless authenticated?
33
+
34
+ target = SystemSetting.home_page_path_authenticated
35
+ return if target.blank?
36
+ return if target == "/"
37
+
38
+ redirect_to target
39
+ end
40
+
14
41
  def set_csrf_token_header
15
42
  return unless protect_against_forgery?
16
43
 
@@ -8,7 +8,7 @@ module Collavre
8
8
  return head :forbidden
9
9
  end
10
10
 
11
- blob.purge
11
+ purge_unless_referenced(blob)
12
12
  head :no_content
13
13
  rescue ActiveRecord::RecordNotFound
14
14
  head :not_found
@@ -19,10 +19,38 @@ module Collavre
19
19
 
20
20
  private
21
21
 
22
+ # A blob can be shared across creatives (description HTML copied between
23
+ # them). The editor fires this DELETE for removed nodes after its PATCH, so
24
+ # purging unconditionally could delete a blob another creative still
25
+ # references, 404-ing its description. Only purge a true orphan.
26
+ def purge_unless_referenced(blob)
27
+ signed_id = blob.signed_id
28
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%"
29
+
30
+ return if Creative.where("description LIKE ?", pattern).exists?
31
+ return if ActiveStorage::Attachment.where(blob_id: blob.id).exists?
32
+
33
+ blob.purge
34
+ end
35
+
22
36
  def authorized_to_purge?(blob)
23
37
  return false unless Current.user
24
38
 
25
- attachment_owned_by_current_user?(blob) || editable_creative_reference?(blob)
39
+ attachment_owned_by_current_user?(blob) ||
40
+ editable_creative_reference?(blob) ||
41
+ orphan_blob?(blob)
42
+ end
43
+
44
+ # Authorize purging a true orphan (attached to nothing, in no description).
45
+ # Covers a node removed before its blob was ever attached (removed-then-save,
46
+ # so reconcile never sees it), which can prove neither ownership nor a
47
+ # writable reference and would otherwise 403, stranding the blob. Safe: any
48
+ # in-use blob still fails this check. Mirrors purge_unless_referenced.
49
+ def orphan_blob?(blob)
50
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(blob.signed_id)}%"
51
+
52
+ !ActiveStorage::Attachment.where(blob_id: blob.id).exists? &&
53
+ !Creative.where("description LIKE ?", pattern).exists?
26
54
  end
27
55
 
28
56
  def attachment_owned_by_current_user?(blob)
@@ -0,0 +1,23 @@
1
+ module Collavre
2
+ class ChannelsController < ApplicationController
3
+ include Collavre::CreativePermissionGuard
4
+
5
+ before_action :set_channel
6
+ before_action :require_creative_write!
7
+
8
+ def destroy
9
+ @channel.dismiss!
10
+ head :no_content
11
+ end
12
+
13
+ private
14
+
15
+ def set_channel
16
+ # Look up among not-yet-dismissed channels (active OR detached). The X
17
+ # button must work on detached chips too — those linger so the user can
18
+ # still see the final merge/close badge until they choose to clear it.
19
+ @channel = Channel.not_dismissed.find(params[:id])
20
+ @creative = @channel.topic.creative.effective_origin
21
+ end
22
+ end
23
+ end
@@ -6,7 +6,7 @@ module Collavre
6
6
  include Collavre::Comments::BatchOperations
7
7
 
8
8
  before_action :set_creative
9
- before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :update_action, :download_images, :remove_image ]
9
+ before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :deny, :update_action, :download_images, :remove_image ]
10
10
 
11
11
  def fullscreen
12
12
  # Render the creative index page with comments popup auto-opened in fullscreen.
@@ -0,0 +1,79 @@
1
+ module Collavre
2
+ module Creatives
3
+ # Bearer-only multipart upload endpoint for agents/CLI. Creates an
4
+ # ActiveStorage blob from the uploaded bytes, embeds the matching node into
5
+ # the Creative's description, and lets after_save reconcile attach it to
6
+ # creative.files. No server-local paths, no session/CSRF.
7
+ class AttachmentsController < Collavre::ApplicationController
8
+ include Collavre::PublicAssetsHelper
9
+
10
+ allow_unauthenticated_access
11
+ skip_forgery_protection
12
+ before_action :authenticate_bearer!
13
+
14
+ def create
15
+ creative = Collavre::Creative.find_by(id: params[:creative_id])
16
+ return render(json: { error: "Creative not found" }, status: :not_found) unless creative
17
+
18
+ unless creative.has_permission?(Current.user, :write)
19
+ return render(json: { error: "No write permission" }, status: :forbidden)
20
+ end
21
+
22
+ unless creative.attachments_embeddable?
23
+ return render(json: { error: "Cannot attach files to GitHub-synced content" }, status: :unprocessable_entity)
24
+ end
25
+
26
+ file = params[:file]
27
+ return render(json: { error: "No file provided" }, status: :unprocessable_entity) unless file.respond_to?(:read)
28
+
29
+ io = file.to_io
30
+ content_type = resolved_content_type(file, io)
31
+ io.rewind
32
+ blob = ActiveStorage::Blob.create_and_upload!(
33
+ io: io,
34
+ filename: file.original_filename,
35
+ content_type: content_type
36
+ )
37
+
38
+ creative.embed_attachment_blob!(blob)
39
+
40
+ render json: {
41
+ signed_id: blob.signed_id,
42
+ filename: blob.filename.to_s,
43
+ content_type: blob.content_type,
44
+ byte_size: blob.byte_size,
45
+ url: public_asset_url(blob)
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ # The bundled `collavre` CLI sends application/octet-stream for every part,
52
+ # so treat octet-stream (and blank) as "unknown" and sniff via Marcel —
53
+ # otherwise png/mp4 render as generic download links, not inline media.
54
+ def resolved_content_type(file, io)
55
+ declared = file.content_type.presence
56
+ return declared if declared && declared != "application/octet-stream"
57
+
58
+ Marcel::MimeType.for(io, name: file.original_filename).presence ||
59
+ "application/octet-stream"
60
+ end
61
+
62
+ # Resolve a Doorkeeper bearer token to Current.user. The MCP middleware
63
+ # only does this for /mcp paths, so this endpoint authenticates itself.
64
+ def authenticate_bearer!
65
+ token_string = Doorkeeper::OAuth::Token.from_request(
66
+ request, *Doorkeeper.configuration.access_token_methods
67
+ )
68
+ token = Doorkeeper::AccessToken.by_token(token_string) if token_string.present?
69
+ user = Collavre::User.find_by(id: token.resource_owner_id) if token&.accessible?
70
+
71
+ if user
72
+ Current.user = user
73
+ else
74
+ render json: { error: "Unauthorized" }, status: :unauthorized
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -118,6 +118,7 @@ module Collavre
118
118
 
119
119
  trigger_loop_data = @creative.data&.dig("trigger", "loop")
120
120
  parent_trigger_enabled = @creative.parent&.drop_trigger_enabled? || false
121
+ can_edit = @creative.has_permission?(Current.user, :write)
121
122
 
122
123
  etag = [
123
124
  "creative",
@@ -132,7 +133,9 @@ module Collavre
132
133
  "trigger_v3",
133
134
  trigger_loop_data&.dig("state"),
134
135
  trigger_loop_data&.dig("current_iteration"),
135
- parent_trigger_enabled
136
+ parent_trigger_enabled,
137
+ "can_edit",
138
+ can_edit
136
139
  ].join(":")
137
140
 
138
141
  if stale?(etag: etag, last_modified: last_modified, public: false)
@@ -142,6 +145,13 @@ module Collavre
142
145
  else
143
146
  @creative.ancestors.count + 1
144
147
  end
148
+ sanitized_data = @creative.effective_origin(Set.new).data
149
+ # markdown_source is exposed via the top-level `markdown_source:` field for writers;
150
+ # exclude it from the editable `data` payload so the metadata YAML editor can't
151
+ # round-trip a stale copy back into data["markdown_source"] on update_metadata.
152
+ if sanitized_data.is_a?(Hash) && sanitized_data.key?("markdown_source")
153
+ sanitized_data = sanitized_data.except("markdown_source")
154
+ end
145
155
  render json: {
146
156
  id: @creative.id,
147
157
  description: @creative.effective_description,
@@ -153,10 +163,12 @@ module Collavre
153
163
  depth: depth,
154
164
  prompt: @creative.prompt_for(Current.user),
155
165
  has_children: children_count > 0,
156
- data: @creative.effective_origin(Set.new).data,
166
+ data: sanitized_data,
167
+ content_type: effective.data&.dig("content_type"),
168
+ markdown_source: can_edit ? effective.data&.dig("markdown_source") : nil,
157
169
  trigger_loop: trigger_loop_data,
158
170
  is_trigger_task: parent_trigger_enabled,
159
- can_edit: @creative.has_permission?(Current.user, :write)
171
+ can_edit: can_edit
160
172
  }
161
173
  end
162
174
  end
@@ -190,7 +202,16 @@ module Collavre
190
202
  @creative = result.creative
191
203
 
192
204
  if result.success?
193
- render json: { id: @creative.id }
205
+ # Expose the post-rewrite markdown source so the client can sync its
206
+ # textarea after the server replaces inline data: URIs with blob paths,
207
+ # matching the update endpoint contract. Without this, a freshly created
208
+ # markdown creative with a pasted data: URI would re-import the blob on
209
+ # the next keystroke save.
210
+ render json: {
211
+ id: @creative.id,
212
+ content_type: @creative.data&.dig("content_type"),
213
+ markdown_source: @creative.data&.dig("markdown_source")
214
+ }
194
215
  else
195
216
  render json: { errors: result.errors }, status: :unprocessable_entity
196
217
  end
@@ -259,8 +280,17 @@ module Collavre
259
280
  id: base.id,
260
281
  progress: base.progress,
261
282
  progress_html: view_context.render_creative_progress(base),
262
- has_children: base.children.exists?
283
+ has_children: base.children.exists?,
284
+ content_type: base.data&.dig("content_type")
263
285
  }
286
+ # Expose the post-rewrite markdown source so the client can sync its
287
+ # textarea after the server replaces inline data: URIs with blob paths.
288
+ # Gated on write permission so a read-only share recipient moving a
289
+ # linked creative (parent_id-only PATCH bypasses the origin_changes
290
+ # write check) cannot read the origin's raw Markdown source.
291
+ if @creative.has_permission?(Current.user, :write)
292
+ response_data[:markdown_source] = base.data&.dig("markdown_source")
293
+ end
264
294
  # Build ancestor chain for progress updates (closure_tree: 1 SELECT via hierarchy table)
265
295
  ancestor_records = base.ancestors.order(:id)
266
296
  if ancestor_records.any?
@@ -359,6 +389,20 @@ module Collavre
359
389
  render json: { error: "Invalid JSON: #{e.message}" }, status: :unprocessable_entity
360
390
  return
361
391
  end
392
+ unless new_data.is_a?(Hash)
393
+ render json: { error: t("collavre.creatives.errors.metadata_must_be_object") }, status: :unprocessable_entity
394
+ return
395
+ end
396
+ # Reserved markdown fields are not editable via metadata; preserve current values so a stale
397
+ # YAML payload from the metadata popup can't overwrite a concurrent markdown edit.
398
+ current_data = creative.data || {}
399
+ %w[markdown_source content_type].each do |key|
400
+ if current_data.key?(key)
401
+ new_data[key] = current_data[key]
402
+ else
403
+ new_data.delete(key)
404
+ end
405
+ end
362
406
  previous_enabled = creative.drop_trigger_enabled?
363
407
 
364
408
  if creative.update(data: new_data)
@@ -499,7 +543,7 @@ module Collavre
499
543
  end
500
544
 
501
545
  def creative_params
502
- params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id)
546
+ params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input)
503
547
  end
504
548
 
505
549
  def any_filter_active?
@@ -519,12 +563,102 @@ module Collavre
519
563
 
520
564
  def serialize_creatives(collection)
521
565
  if params[:simple].present?
522
- collection.map { |c| { id: c.id, description: c.effective_description(nil, false), progress: c.progress } }
566
+ # Preload origins so effective_origin (used per linked-shell row in both
567
+ # children_presence_set and the origin_id mapping below) resolves from
568
+ # memory instead of firing a query per shell. Only browse can hold shells;
569
+ # search is scoped to origin_id: nil, so this is a no-op there.
570
+ ActiveRecord::Associations::Preloader.new(records: collection.to_a, associations: :origin).call
571
+ children_ids = children_presence_set(collection)
572
+ searching = params[:search].present?
573
+ breadcrumbs = searching ? ::Creatives::BreadcrumbResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
574
+ # For hits routed through a linked shell, the path to expand in the
575
+ # user's own tree (local folders -> shell) so a breadcrumb jump can
576
+ # reach a shell nested under a collapsed folder.
577
+ reveal_paths = searching ? ::Creatives::RevealPathResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
578
+ collection.map do |c|
579
+ item = {
580
+ id: c.id,
581
+ description: c.effective_description(nil, false),
582
+ progress: c.progress,
583
+ has_children: children_ids.include?(c.id)
584
+ }
585
+ reveal = reveal_paths[c.id]
586
+ path = breadcrumbs[c.id]
587
+ if path.present?
588
+ item[:path] = mask_unreachable_crumbs(path, reveal)
589
+ end
590
+ item[:reveal_path] = reveal if reveal.present?
591
+ # For linked shells, expose the effective origin id so the picker can
592
+ # map a search breadcrumb (origin ids) back to the rendered shell node.
593
+ item[:origin_id] = c.effective_origin.id if c.origin_id
594
+ item
595
+ end
523
596
  else
524
597
  collection.map { |c| { id: c.id, description: c.effective_description, progress: c.progress } }
525
598
  end
526
599
  end
527
600
 
601
+ # A breadcrumb jump expands the tree from a rendered root (or, for a shared
602
+ # subtree, a linked shell) down to the clicked crumb. If an ancestor above a
603
+ # crumb is itself unrenderable (unreadable, or archived while archived rows
604
+ # aren't shown) and no linked shell re-roots the path at/below it, the
605
+ # descendant can't be expanded either — so mask it too. BreadcrumbResolver
606
+ # masks the unrenderable ancestor itself; this masks everything downstream of
607
+ # it on the plain origin chain, matching exactly what the tree can render.
608
+ #
609
+ # `reveal` is RevealPathResolver's per-origin map: a crumb whose origin id is
610
+ # a key re-roots navigation through its own shell (the client anchors at the
611
+ # nearest reveal entry at/above the clicked crumb), so it clears the block for
612
+ # itself and its descendants regardless of an archived/unreadable origin
613
+ # above it.
614
+ def mask_unreachable_crumbs(path, reveal)
615
+ blocked = false
616
+ path.map do |crumb|
617
+ if reveal&.key?(crumb[:id])
618
+ blocked = false
619
+ crumb
620
+ elsif crumb[:restricted]
621
+ blocked = true
622
+ crumb
623
+ elsif blocked
624
+ crumb.merge(restricted: true, description: nil)
625
+ else
626
+ crumb
627
+ end
628
+ end
629
+ end
630
+
631
+ # Batched "does this node have a child the user can actually browse to?"
632
+ # lookup so the picker tree renders expand toggles without an N+1.
633
+ #
634
+ # Must match exactly what expanding the node shows (IndexQuery#handle_id_query
635
+ # -> children_with_permission, minus archived unless show_archived), or the
636
+ # toggle either hides a reachable subtree or opens to an empty branch (and
637
+ # leaks that hidden children exist). Two alignments are needed:
638
+ # 1. Linked shells (origin_id set) store children under the effective
639
+ # origin (redirect_parent_to_origin + children->origin migration), so
640
+ # resolve each row to its effective origin before the lookup.
641
+ # 2. Apply the same archived + read-permission filters as the browse path.
642
+ def children_presence_set(collection)
643
+ return Set.new if collection.empty?
644
+
645
+ origin_id_by_id = collection.to_h { |c| [ c.id, c.effective_origin.id ] }
646
+
647
+ candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
648
+ candidates = candidates.where(archived_at: nil) unless params[:show_archived]
649
+ child_rows = candidates.pluck(:id, :parent_id) # [child_id, origin_id]
650
+ return Set.new if child_rows.empty?
651
+
652
+ readable = ::Creatives::PermissionFilter
653
+ .new(user: Current.user).readable_ids(child_rows.map(&:first)).to_set
654
+ origins_with_visible_children = child_rows
655
+ .each_with_object(Set.new) { |(child_id, origin_id), set| set << origin_id if readable.include?(child_id) }
656
+
657
+ collection.each_with_object(Set.new) do |c, set|
658
+ set << c.id if origins_with_visible_children.include?(origin_id_by_id[c.id])
659
+ end
660
+ end
661
+
528
662
  def reorderer
529
663
  @reorderer ||= ::Creatives::Reorderer.new(user: Current.user)
530
664
  end
@@ -0,0 +1,8 @@
1
+ module Collavre
2
+ class LandingController < ApplicationController
3
+ allow_unauthenticated_access
4
+
5
+ def show
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Serves ActiveStorage blobs through a CDN-friendly path:
5
+ # /public-assets/blobs/:signed_id/*filename
6
+ #
7
+ # The signed_id is the only capability — no auth required. This makes the URL
8
+ # safe to embed in landing pages and other publicly-rendered HTML. CloudFront
9
+ # caches by full URL, so rotating signed_id (re-attach) invalidates effectively.
10
+ class PublicAssetsController < ActionController::Base
11
+ include ActiveStorage::SetCurrent
12
+ include ActiveStorage::Streaming
13
+
14
+ PUBLIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
15
+
16
+ def show
17
+ blob = ActiveStorage::Blob.find_signed!(params[:signed_id])
18
+ response.headers["Cache-Control"] = PUBLIC_CACHE_CONTROL
19
+ send_blob_stream blob, disposition: "inline"
20
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
21
+ head :not_found
22
+ end
23
+ end
24
+ end
@@ -10,21 +10,29 @@ module Collavre
10
10
  return head :forbidden
11
11
  end
12
12
 
13
- unless %w[running pending queued pending_approval].include?(task.status)
13
+ unless %w[running pending queued pending_approval delegated].include?(task.status)
14
14
  return head :unprocessable_entity
15
15
  end
16
16
 
17
+ was_delegated = task.status == "delegated"
17
18
  task.update!(status: "cancelled")
18
19
 
19
- abort_openclaw_session(task)
20
+ # Delegated tasks have no active worker to run the ensure-block release,
21
+ # so free the agent slot and drain the topic queue here.
22
+ if was_delegated && task.agent
23
+ Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
24
+ Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
25
+ end
26
+
27
+ abort_agent_session(task)
20
28
 
21
29
  head :ok
22
30
  end
23
31
 
24
32
  private
25
33
 
26
- def abort_openclaw_session(task)
27
- Collavre::OpenclawAbortService.call(agent: task.agent, task: task)
34
+ def abort_agent_session(task)
35
+ Collavre::AgentSessionAbort.call(agent: task.agent, task: task)
28
36
  end
29
37
  end
30
38
  end
@@ -3,7 +3,7 @@ module Collavre
3
3
  include Collavre::CreativePermissionGuard
4
4
 
5
5
  before_action :set_creative
6
- before_action :require_creative_read!, only: %i[next_name]
6
+ before_action :require_creative_read!, only: %i[next_name channel_chips]
7
7
  before_action :require_creative_admin!, only: %i[update destroy move reorder]
8
8
  before_action :require_creative_write!, only: %i[create archive unarchive set_primary_agent]
9
9
 
@@ -12,8 +12,15 @@ module Collavre
12
12
  can_manage = @creative.has_permission?(Current.user, :admin) || is_owner
13
13
  can_create_topic = can_manage || @creative.has_permission?(Current.user, :write)
14
14
 
15
- active_topics = @creative.topics.active.order(:created_at).to_a
16
- preload_primary_agents(active_topics)
15
+ # Eagerly ensure Main (and System for inboxes) exist BEFORE loading
16
+ # active_topics. Otherwise the first inbox visit sees no System topic in
17
+ # the sidebar, and when a later notification creates it via
18
+ # find_or_create_by!, the unread badge appears but the user has no way to
19
+ # open the topic.
20
+ main_topic = @creative.main_topic(fallback_user: Current.user)
21
+ system_topic = @creative.inbox? ? @creative.system_topic(fallback_user: Current.user) : nil
22
+
23
+ active_topics = @creative.topics.active.includes(primary_agent: { avatar_attachment: :blob }).order(:created_at).to_a
17
24
  archived_topics = @creative.topics.archived.order(:created_at)
18
25
 
19
26
  last_topic_id = if Current.user
@@ -22,8 +29,8 @@ module Collavre
22
29
  .pick(:last_topic_id)
23
30
  end
24
31
 
25
- system_topic_id = @creative.inbox? ? @creative.topics.find_by(name: Creative::SYSTEM_TOPIC_NAME)&.id : nil
26
- main_topic_id = @creative.main_topic(fallback_user: Current.user).id
32
+ system_topic_id = system_topic&.id
33
+ main_topic_id = main_topic.id
27
34
 
28
35
  render json: {
29
36
  topics: active_topics.map { |t| topic_json(t) },
@@ -33,7 +40,8 @@ module Collavre
33
40
  last_topic_id: last_topic_id,
34
41
  is_inbox: @creative.inbox?,
35
42
  system_topic_id: system_topic_id,
36
- main_topic_id: main_topic_id
43
+ main_topic_id: main_topic_id,
44
+ effective_creative_id: @creative.id
37
45
  }
38
46
  end
39
47
 
@@ -73,6 +81,11 @@ module Collavre
73
81
  render json: { name: generate_next_topic_name }
74
82
  end
75
83
 
84
+ def channel_chips
85
+ topic = @creative.topics.find(params[:id])
86
+ render partial: "collavre/comments/channel_chips", locals: { topic: topic }
87
+ end
88
+
76
89
  def update
77
90
  topic = @creative.topics.find(params[:id])
78
91
 
@@ -92,8 +105,23 @@ module Collavre
92
105
  end
93
106
 
94
107
  topic_id = topic.id
108
+ topic_name = topic.name
95
109
  topic.destroy
96
110
 
111
+ # Best-effort orphaned-cron notice. Must never break the core deletion:
112
+ # if it raises, swallow + log so broadcast/head still run.
113
+ begin
114
+ Collavre::Topics::OrphanedCronNotifier.new(
115
+ topic_id: topic_id,
116
+ topic_name: topic_name
117
+ ).call
118
+ rescue StandardError => e
119
+ Rails.logger.error(
120
+ "[TopicsController#destroy] OrphanedCronNotifier failed for topic " \
121
+ "#{topic_id}: #{e.class} #{e.message}"
122
+ )
123
+ end
124
+
97
125
  broadcast_topic_event("deleted", topic_id: topic_id)
98
126
  head :no_content
99
127
  end
@@ -194,32 +222,10 @@ module Collavre
194
222
  "#{prefix}#{next_number}"
195
223
  end
196
224
 
197
- # Batch-load primary agents for all topics to avoid N+1 queries
198
- def preload_primary_agents(topics)
199
- topic_ids = topics.map(&:id)
200
- return if topic_ids.empty?
201
-
202
- policies = OrchestratorPolicy.where(
203
- policy_type: "arbitration",
204
- scope_type: "Topic",
205
- scope_id: topic_ids
206
- ).index_by { |p| p.scope_id.to_i }
207
-
208
- agent_ids = policies.values.filter_map { |p| p.config&.dig("primary_agent_id") }
209
- agents = agent_ids.present? ? User.where(id: agent_ids).includes(avatar_attachment: :blob).index_by(&:id) : {}
210
-
211
- topics.each do |topic|
212
- policy = policies[topic.id]
213
- agent_id = policy&.config&.dig("primary_agent_id")
214
- topic.instance_variable_set(:@_primary_agent, agents&.dig(agent_id))
215
- end
216
- end
217
-
218
225
  def topic_json(topic)
219
226
  data = topic.slice(:id, :name, :source_topic_id)
220
- agent = topic.instance_variable_get(:@_primary_agent) || topic.primary_agent
221
- if agent
222
- data[:primary_agent] = agent_json(agent)
227
+ if topic.primary_agent
228
+ data[:primary_agent] = agent_json(topic.primary_agent)
223
229
  end
224
230
  data
225
231
  end