collavre 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. metadata +42 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f4893a3f3e45871df84841cc2d16c81e9f0f3805f8cbfe5f40b4dac85ca9e89
4
- data.tar.gz: 8032ab111bea535825ddbcfa1b0be5b7469e586e05b00ad9117e3f69b373b990
3
+ metadata.gz: 9658b35d00bfa088037c3bc4ff2720913c69f43746e924545805f08482b091e9
4
+ data.tar.gz: e614bb81be4080199ee4dbf9ae248ce43ca1f88cbff11678514ecde1d66d69f6
5
5
  SHA512:
6
- metadata.gz: 4b1585a65d7385ebfca1c6b9c5ca4f76f6c5c37454b5a8b9ce550b4d11a1a10445a314202b01b958928d2093bf012d057c549d013c7ea4c94699dbe2cba494df
7
- data.tar.gz: fce204b25581ce93e72abf20dd076a8af40624d9fd49d3728d4e6c9932e5e7abd8cc345d0d87e3ffe2b35053ebf05736b93db93ddc06f4c2b6c7f909fe0c86d0
6
+ metadata.gz: be8a6c28498c6878c9eb1ae6287bdd97adfb395452b1a0296e1a7cce5e159763b904f93926bfff8c1ad3b4db077abfd0b5c77f2aa1fada447e65d291cc0bab24
7
+ data.tar.gz: a6fde45bf58c0707916f3f02e0d2293244b2c8598de2f8913555183d77c88590df29ec67f00a2a3c093378a3f2c7b0995975aaf61fb5f9d95a6c345b0fc85568
@@ -1716,6 +1716,46 @@ body.chat-fullscreen {
1716
1716
  opacity: 0.5;
1717
1717
  font-style: italic;
1718
1718
  }
1719
+
1720
+ /* Topic list popup button (pinned right of the scrolling tab bar) */
1721
+ .comment-topics-bar {
1722
+ display: flex;
1723
+ align-items: center;
1724
+ gap: 0.5em;
1725
+ }
1726
+ .comment-topics-bar .comment-topics-list {
1727
+ flex: 1;
1728
+ min-width: 0;
1729
+ }
1730
+ .topic-list-btn {
1731
+ flex-shrink: 0;
1732
+ display: inline-flex;
1733
+ align-items: center;
1734
+ justify-content: center;
1735
+ width: 24px;
1736
+ height: 24px;
1737
+ border-radius: var(--radius-3);
1738
+ border: 1px solid var(--color-border);
1739
+ background: transparent;
1740
+ cursor: pointer;
1741
+ color: var(--color-muted);
1742
+ padding: 0;
1743
+ }
1744
+ .topic-list-btn:hover {
1745
+ background: var(--color-section-bg);
1746
+ color: var(--color-text);
1747
+ }
1748
+
1749
+ /* Archived entries inside the topic list popup */
1750
+ .topic-list-item {
1751
+ display: inline-flex;
1752
+ align-items: center;
1753
+ gap: 0.3em;
1754
+ }
1755
+ .topic-list-item--archived {
1756
+ opacity: 0.5;
1757
+ font-style: italic;
1758
+ }
1719
1759
  .topic-branch-icon {
1720
1760
  font-size: 0.75em;
1721
1761
  opacity: 0.6;
@@ -94,6 +94,18 @@
94
94
  z-index: 0;
95
95
  }
96
96
 
97
+ .landing-hero-eyebrow { /* L4 — category line, above the claim */
98
+ font-size: var(--text-2);
99
+ font-weight: var(--weight-6);
100
+ letter-spacing: 0.08em;
101
+ text-transform: uppercase;
102
+ color: var(--color-active);
103
+ margin: 0 auto var(--space-4);
104
+ text-align: center;
105
+ position: relative;
106
+ z-index: 1;
107
+ }
108
+
97
109
  .landing-hero-title { /* L1 — main headline */
98
110
  font-size: var(--text-7);
99
111
  font-weight: var(--weight-8);
@@ -117,6 +129,17 @@
117
129
  z-index: 1;
118
130
  }
119
131
 
132
+ .landing-hero-tagline { /* L4 — the mechanism, one line */
133
+ font-size: var(--text-2);
134
+ color: var(--text-muted);
135
+ margin: 0 auto var(--space-6);
136
+ max-width: 720px;
137
+ text-align: center;
138
+ line-height: var(--leading-2);
139
+ position: relative;
140
+ z-index: 1;
141
+ }
142
+
120
143
  .landing-hero-brand { /* L1 — "콜라브" in brand color */
121
144
  font-size: var(--text-7);
122
145
  font-weight: var(--weight-8);
@@ -238,6 +261,7 @@
238
261
  background: var(--surface-section);
239
262
  }
240
263
 
264
+ /* Three tools, three cards — one row on desktop. */
241
265
  .landing-problem-grid {
242
266
  display: grid;
243
267
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
@@ -258,6 +282,12 @@
258
282
  border-color: var(--text-muted);
259
283
  }
260
284
 
285
+ /* The fourth card is the punchline — the copy the AI era added. */
286
+ .landing-problem-card-accent {
287
+ border-color: var(--color-active);
288
+ background: color-mix(in srgb, var(--color-active) 6%, var(--surface-bg));
289
+ }
290
+
261
291
  .landing-problem-icon {
262
292
  font-size: var(--text-5); /* L2 — emoji decorative */
263
293
  display: block;
@@ -432,6 +462,68 @@ body.dark-mode .landing-demo-dark { display: block; }
432
462
  body:not(.light-mode) .landing-demo-dark { display: block; }
433
463
  }
434
464
 
465
+ /* ─── Interface: the product still ─────────────────────────────────────────── */
466
+ .landing-shot-frame {
467
+ max-width: 980px;
468
+ margin: 0 auto;
469
+ border-radius: var(--radius-4);
470
+ border: var(--border-1) solid var(--border-color);
471
+ background: var(--surface-section);
472
+ overflow: hidden;
473
+ box-shadow: var(--shadow-3);
474
+ }
475
+
476
+ .landing-shot-img {
477
+ display: block;
478
+ width: 100%;
479
+ height: auto;
480
+ }
481
+
482
+ /* Same light/dark pairing the demo video uses: the screenshot is app chrome, so a
483
+ light shot on a dark page reads as a bug, not as a screenshot. */
484
+ .landing-shot-dark { display: none; }
485
+
486
+ body.dark-mode .landing-shot-light { display: none; }
487
+ body.dark-mode .landing-shot-dark { display: block; }
488
+
489
+ @media (prefers-color-scheme: dark) {
490
+ body:not(.light-mode) .landing-shot-light { display: none; }
491
+ body:not(.light-mode) .landing-shot-dark { display: block; }
492
+ }
493
+
494
+ .landing-interface-points {
495
+ list-style: none;
496
+ padding: 0;
497
+ margin: var(--space-5) auto 0;
498
+ max-width: 980px;
499
+ display: grid;
500
+ grid-template-columns: repeat(2, minmax(0, 1fr));
501
+ gap: var(--space-3) var(--space-5);
502
+ }
503
+
504
+ .landing-interface-points li {
505
+ position: relative;
506
+ padding-left: var(--space-4);
507
+ font-size: var(--text-2);
508
+ color: var(--text-muted);
509
+ line-height: var(--leading-2);
510
+ }
511
+
512
+ .landing-interface-points li::before {
513
+ content: "";
514
+ position: absolute;
515
+ left: 0;
516
+ top: 0.6em;
517
+ width: 6px;
518
+ height: 6px;
519
+ border-radius: 50%;
520
+ background: var(--color-accent, var(--text-muted));
521
+ }
522
+
523
+ @media (max-width: 780px) {
524
+ .landing-interface-points { grid-template-columns: 1fr; }
525
+ }
526
+
435
527
  /* ─── Responsive ────────────────────────────────────────────────────────────── */
436
528
  @media (max-width: 640px) {
437
529
  .landing-container {
@@ -81,14 +81,16 @@
81
81
  }
82
82
 
83
83
  #link-creative-modal,
84
- #topic-search-modal {
84
+ #topic-search-modal,
85
+ #topic-list-modal {
85
86
  min-width: 320px;
86
87
  z-index: calc(var(--layer-modal) + 10);
87
88
  }
88
89
 
89
90
  /* When chat is fullscreen, these modals must sit above it */
90
91
  body.chat-fullscreen #link-creative-modal,
91
- body.chat-fullscreen #topic-search-modal {
92
+ body.chat-fullscreen #topic-search-modal,
93
+ body.chat-fullscreen #topic-list-modal {
92
94
  z-index: calc(var(--layer-fullscreen) + 1);
93
95
  }
94
96
 
@@ -79,76 +79,16 @@ module Collavre
79
79
 
80
80
  def update
81
81
  SystemSetting.transaction do
82
- # Help Link
83
- help_link_setting = SystemSetting.find_or_initialize_by(key: "help_menu_link")
84
- help_link_setting.value = params[:help_link].to_s.strip
85
- help_link_setting.save!
86
-
87
- # MCP Tool Approval
88
- mcp_setting = SystemSetting.find_or_initialize_by(key: "mcp_tool_approval_required")
89
- mcp_setting.value = params[:mcp_tool_approval] == "1" ? "true" : "false"
90
- mcp_setting.save!
91
-
92
- # Creatives Login Required
93
- creatives_login_setting = SystemSetting.find_or_initialize_by(key: "creatives_login_required")
94
- creatives_login_setting.value = params[:creatives_login_required] == "1" ? "true" : "false"
95
- creatives_login_setting.save!
96
-
97
- # Home Page Paths (unauthenticated default + authenticated override)
82
+ settings_from_params.each do |key, value|
83
+ SystemSetting.find_or_initialize_by(key: key).update!(value: value)
84
+ end
85
+
86
+ # Home Page Paths (unauthenticated default + authenticated override).
87
+ # Kept separate because they validate/normalize and may raise.
98
88
  save_home_page_path("home_page_path", params[:home_page_path])
99
89
  save_home_page_path("home_page_path_authenticated", params[:home_page_path_authenticated])
100
90
 
101
- # Account Lockout Settings
102
- max_attempts = params[:max_login_attempts].to_i
103
- max_attempts = SystemSetting::DEFAULT_MAX_LOGIN_ATTEMPTS if max_attempts < 1
104
- SystemSetting.find_or_initialize_by(key: "max_login_attempts").tap { |s| s.value = max_attempts.to_s; s.save! }
105
-
106
- lockout_duration = params[:lockout_duration_minutes].to_i
107
- lockout_duration = SystemSetting::DEFAULT_LOCKOUT_DURATION_MINUTES if lockout_duration < 1
108
- SystemSetting.find_or_initialize_by(key: "lockout_duration_minutes").tap { |s| s.value = lockout_duration.to_s; s.save! }
109
-
110
- # Password Policy Settings
111
- password_min_length = [ [ params[:password_min_length].to_i, SystemSetting::DEFAULT_PASSWORD_MIN_LENGTH ].max, 72 ].min
112
- SystemSetting.find_or_initialize_by(key: "password_min_length").tap { |s| s.value = password_min_length.to_s; s.save! }
113
-
114
- # Session Timeout Settings
115
- session_timeout = [ params[:session_timeout_minutes].to_i, 0 ].max
116
- SystemSetting.find_or_initialize_by(key: "session_timeout_minutes").tap { |s| s.value = session_timeout.to_s; s.save! }
117
-
118
- # Rate Limiting - Password Reset
119
- pw_reset_limit = params[:password_reset_rate_limit].to_i
120
- pw_reset_limit = SystemSetting::DEFAULT_PASSWORD_RESET_RATE_LIMIT if pw_reset_limit < 1
121
- SystemSetting.find_or_initialize_by(key: "password_reset_rate_limit").tap { |s| s.value = pw_reset_limit.to_s; s.save! }
122
-
123
- pw_reset_period = params[:password_reset_rate_period_minutes].to_i
124
- pw_reset_period = SystemSetting::DEFAULT_PASSWORD_RESET_RATE_PERIOD_MINUTES if pw_reset_period < 1
125
- SystemSetting.find_or_initialize_by(key: "password_reset_rate_period_minutes").tap { |s| s.value = pw_reset_period.to_s; s.save! }
126
-
127
- # Rate Limiting - API
128
- api_limit = params[:api_rate_limit].to_i
129
- api_limit = SystemSetting::DEFAULT_API_RATE_LIMIT if api_limit < 1
130
- SystemSetting.find_or_initialize_by(key: "api_rate_limit").tap { |s| s.value = api_limit.to_s; s.save! }
131
-
132
- api_period = params[:api_rate_period_minutes].to_i
133
- api_period = SystemSetting::DEFAULT_API_RATE_PERIOD_MINUTES if api_period < 1
134
- SystemSetting.find_or_initialize_by(key: "api_rate_period_minutes").tap { |s| s.value = api_period.to_s; s.save! }
135
-
136
- # LLM Settings
137
- llm_timeout = params[:llm_request_timeout_seconds].to_i
138
- llm_timeout = SystemSetting::DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS if llm_timeout < 30
139
- SystemSetting.find_or_initialize_by(key: "llm_request_timeout_seconds").tap { |s| s.value = llm_timeout.to_s; s.save! }
140
-
141
- # Auth Providers
142
- auth_providers = Array(params[:auth_providers]).reject(&:blank?)
143
- if auth_providers.empty?
144
- auth_setting = SystemSetting.new(key: "auth_providers_enabled")
145
- auth_setting.errors.add(:base, t("admin.settings.auth_provider_required"))
146
- raise ActiveRecord::RecordInvalid, auth_setting
147
- end
148
-
149
- all_provider_keys = Rails.application.config.auth_providers.map { |p| p[:key].to_s }
150
- disabled_providers = all_provider_keys - auth_providers
151
- SystemSetting.find_or_initialize_by(key: "auth_providers_disabled").tap { |s| s.value = disabled_providers.join(","); s.save! }
91
+ save_auth_providers!
152
92
  end
153
93
 
154
94
  redirect_to collavre.admin_settings_path, notice: t("admin.settings.updated")
@@ -174,6 +114,51 @@ module Collavre
174
114
 
175
115
  private
176
116
 
117
+ # Maps each SystemSetting key to its normalized string value derived from
118
+ # the request params. Collapses the previously repetitive per-setting
119
+ # blocks into a single data-driven loop in #update.
120
+ def settings_from_params
121
+ {
122
+ "help_menu_link" => params[:help_link].to_s.strip,
123
+ "mcp_tool_approval_required" => boolean_setting(:mcp_tool_approval),
124
+ "creatives_login_required" => boolean_setting(:creatives_login_required),
125
+ "max_login_attempts" => int_setting(:max_login_attempts, SystemSetting::DEFAULT_MAX_LOGIN_ATTEMPTS),
126
+ "lockout_duration_minutes" => int_setting(:lockout_duration_minutes, SystemSetting::DEFAULT_LOCKOUT_DURATION_MINUTES),
127
+ "password_min_length" => [ [ params[:password_min_length].to_i, SystemSetting::DEFAULT_PASSWORD_MIN_LENGTH ].max, 72 ].min.to_s,
128
+ "session_timeout_minutes" => [ params[:session_timeout_minutes].to_i, 0 ].max.to_s,
129
+ "password_reset_rate_limit" => int_setting(:password_reset_rate_limit, SystemSetting::DEFAULT_PASSWORD_RESET_RATE_LIMIT),
130
+ "password_reset_rate_period_minutes" => int_setting(:password_reset_rate_period_minutes, SystemSetting::DEFAULT_PASSWORD_RESET_RATE_PERIOD_MINUTES),
131
+ "api_rate_limit" => int_setting(:api_rate_limit, SystemSetting::DEFAULT_API_RATE_LIMIT),
132
+ "api_rate_period_minutes" => int_setting(:api_rate_period_minutes, SystemSetting::DEFAULT_API_RATE_PERIOD_MINUTES),
133
+ "llm_request_timeout_seconds" => int_setting(:llm_request_timeout_seconds, SystemSetting::DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS, min: 30)
134
+ }
135
+ end
136
+
137
+ # Checkbox param ("1" when checked) -> "true"/"false" string value.
138
+ def boolean_setting(param_key)
139
+ params[param_key] == "1" ? "true" : "false"
140
+ end
141
+
142
+ # Integer param that falls back to a default when below the minimum floor.
143
+ def int_setting(param_key, default, min: 1)
144
+ value = params[param_key].to_i
145
+ value = default if value < min
146
+ value.to_s
147
+ end
148
+
149
+ def save_auth_providers!
150
+ auth_providers = Array(params[:auth_providers]).reject(&:blank?)
151
+ if auth_providers.empty?
152
+ auth_setting = SystemSetting.new(key: "auth_providers_enabled")
153
+ auth_setting.errors.add(:base, t("admin.settings.auth_provider_required"))
154
+ raise ActiveRecord::RecordInvalid, auth_setting
155
+ end
156
+
157
+ all_provider_keys = Rails.application.config.auth_providers.map { |p| p[:key].to_s }
158
+ disabled_providers = all_provider_keys - auth_providers
159
+ SystemSetting.find_or_initialize_by(key: "auth_providers_disabled").update!(value: disabled_providers.join(","))
160
+ end
161
+
177
162
  def save_home_page_path(key, raw_value)
178
163
  input = raw_value.to_s.strip
179
164
  setting = SystemSetting.find_or_initialize_by(key: key)
@@ -29,14 +29,14 @@ module Collavre
29
29
  return
30
30
  end
31
31
 
32
- ai_user = find_or_create_session_agent(agent_name)
32
+ ai_user = session_provisioner.find_or_create_agent(agent_name)
33
33
  unless ai_user
34
34
  render json: { error: "Session agent email is already in use by a different account" }, status: :conflict
35
35
  return
36
36
  end
37
37
 
38
38
  inbox = Creative.inbox_for(current_user)
39
- topic = find_or_create_session_topic(inbox, ai_user, session_id, params[:session_label])
39
+ topic = session_provisioner.find_or_create_topic(inbox, ai_user, session_id, params[:session_label])
40
40
  topic.unarchive! if topic.archived?
41
41
  topic.set_primary_agent!(ai_user)
42
42
 
@@ -202,7 +202,7 @@ module Collavre
202
202
  )
203
203
 
204
204
  if comment.save
205
- finalize_claimed_task(agent, claimed_task, comment) if claimed_task
205
+ task_claim_service.finalize(agent: agent, task: claimed_task, comment: comment) if claimed_task
206
206
  dispatch_a2a(agent, comment)
207
207
  render json: { comment_id: comment.id }, status: :created
208
208
  else
@@ -463,90 +463,6 @@ module Collavre
463
463
  nil
464
464
  end
465
465
 
466
- # Atomically claim a delegated task for completion. Two-step:
467
- # 1. Find a candidate task in delegated state, scoped to this
468
- # agent + topic. With task_id supplied, exact match (required
469
- # under topic concurrency > 1 where multiple delegated tasks
470
- # coexist; the client echoes the dispatch's task_id). Without
471
- # task_id (legacy clients), oldest-first.
472
- # 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
473
- # status == 'delegated' under the lock, then update! to 'done'.
474
- # Concurrent claimers block on the lock; the loser sees the
475
- # already-flipped status post-lock and returns nil so the
476
- # caller can refuse the duplicate.
477
- # update_all (NOT update!) is required to skip Task's
478
- # after_update_commit callbacks at claim time. The callbacks fire
479
- # check_trigger_loop_completion (which enqueues TriggerLoopCheckJob)
480
- # and broadcast_stop_button_removal (which reads reply_comment).
481
- # Both depend on the reply comment already existing — but reply()
482
- # claims BEFORE comment.save to win the race against concurrent
483
- # /reply calls. If update! fired the trigger-loop check here, the
484
- # job could run (cooldown_seconds: 0) before comment.save commits,
485
- # find no agent comment, and leave the loop stuck in "running".
486
- # finalize_claimed_task replays both callbacks after the comment is
487
- # persisted via Task#fire_completion_callbacks_after_external_claim.
488
- def claim_delegated_task(agent, topic, requested_task_id)
489
- scope = Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated")
490
- candidate =
491
- if requested_task_id.present?
492
- scope.find_by(id: requested_task_id)
493
- else
494
- scope.order(:created_at).first
495
- end
496
- return nil unless candidate
497
-
498
- claimed = nil
499
- Task.transaction do
500
- locked = Task.lock.find_by(id: candidate.id)
501
- next unless locked && locked.status == "delegated"
502
-
503
- Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
504
- claimed = locked.reload
505
- end
506
- claimed
507
- end
508
-
509
- # Post-claim side effects, run only after the reply comment is saved.
510
- # Links the comment to the claimed task, releases the ResourceTracker
511
- # slot the AiAgentJob held under task.id, advances the parent
512
- # workflow (if any), and drains the topic queue — mirroring
513
- # AiAgentJob#perform's success path for non-delegated runs.
514
- def finalize_claimed_task(agent, task, comment)
515
- comment.update_column(:task_id, task.id)
516
-
517
- Orchestration::ResourceTracker.for(agent).release!(task.id)
518
-
519
- if task.parent_task_id.present?
520
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
521
- end
522
-
523
- Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
524
-
525
- # Replay the after_update_commit callbacks that were bypassed by
526
- # update_all in claim_delegated_task — now that the reply comment
527
- # is linked, TriggerLoopCheckJob can read it and decide whether to
528
- # advance/await/complete the drop-trigger loop, and the stop-button
529
- # broadcast has a comment to render.
530
- task.fire_completion_callbacks_after_external_claim
531
-
532
- # Clear the typing indicator immediately on reply. ClaudeChannelPresenceJob
533
- # would also stop on its next beat (task no longer "delegated"), but that
534
- # is up to HEARTBEAT_SECONDS away — broadcast idle now so the indicator
535
- # drops the moment Claude's reply lands.
536
- broadcast_claude_idle(agent, task, comment)
537
- end
538
-
539
- # Clear the chat typing indicator via the canonical status broadcaster
540
- # (the same one AiAgentService uses for every other agent), so the Claude
541
- # path emits an identical "idle" agent_status payload.
542
- def broadcast_claude_idle(agent, task, comment)
543
- creative = comment.creative&.effective_origin
544
- return unless creative
545
-
546
- AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
547
- .broadcast_status("idle")
548
- end
549
-
550
466
  # When an MCP session unregisters, any tasks still in delegated state
551
467
  # are abandoned — the client that would have called /reply is gone.
552
468
  # Mirror the cancel path so the agent's slot and each topic's queue
@@ -665,111 +581,24 @@ module Collavre
665
581
  ).dispatch
666
582
  end
667
583
 
668
- # Deterministic, collision-free email key for a (current_user, agent_name)
669
- # agent. The readable slug is lossy — "qa/bot", "qa bot" and "qa--bot" all
670
- # squeeze to "qa-bot", and any all-symbol name collapses to "session" — so
671
- # a short digest of the normalized raw name disambiguates distinct
672
- # configured names that would otherwise alias onto ONE shared agent
673
- # identity (ActionCable stream, routing state, tasks, creative shares).
674
- # The slug stays for human readability; the digest decides identity. Same
675
- # normalized name -> same key, so idempotent re-register/reuse is
676
- # preserved. Normalization (strip+downcase) keeps case/whitespace-only
677
- # differences folded, matching the prior behavior — only the lossy
678
- # punctuation/spacing collapse is fixed.
679
- def session_agent_email(agent_name)
680
- normalized = agent_name.to_s.strip.downcase
681
- slug = normalized.gsub(/[^a-z0-9-]+/, "-").squeeze("-").gsub(/\A-|-\z/, "")
682
- slug = "session" if slug.blank?
683
- digest = Digest::SHA256.hexdigest(normalized)[0, 10]
684
- "claude-channel-#{current_user.id}-#{slug}-#{digest}@agent.collavre.local"
685
- end
686
-
687
- # One ai_user per (current_user, agent_name) so a human's sessions share
688
- # one Agent identity. Same agent_name re-registering reuses the existing
689
- # row — idempotent retries (and every additional session) don't
690
- # proliferate agents.
691
- #
692
- # Returns nil when a row with the deterministic email already exists
693
- # but is owned by someone else or isn't a Claude Channel ai_user. The
694
- # email format is human-derivable (current_user.id + slug + digest), so a
695
- # foreign row could be planted by signup/import; silently reusing it
696
- # would attach the caller's inbox feedback share to that foreign User
697
- # and leave the plugin's AgentChannel subscription rejected on
698
- # ownership mismatch. Caller renders 409 in that case.
699
- def find_or_create_session_agent(agent_name)
700
- email = session_agent_email(agent_name)
701
-
702
- existing = User.find_by(email: email)
703
- return verified_session_agent(existing) if existing
704
-
705
- # routing_expression: nil so the new ai_user is not matchable until
706
- # the client claims the per-agent stream via AgentChannel. See the
707
- # comment on verified_session_agent.
708
- User.create!(
709
- email: email,
710
- name: "Claude Channel (#{agent_name})",
711
- password: SecureRandom.hex(32),
712
- llm_vendor: "anthropic",
713
- llm_model: "claude-code",
714
- created_by_id: current_user.id,
715
- searchable: false,
716
- routing_expression: nil
717
- )
718
- rescue ActiveRecord::RecordNotUnique
719
- # A concurrent registration for the same (user, agent_name) won the
720
- # users.email unique race. The desired row now exists — re-find and
721
- # re-verify ownership instead of surfacing a 500 that aborts one of the
722
- # two simultaneously launching plugin instances.
723
- verified_session_agent(User.find_by(email: email))
584
+ # Provisions the (agent, topic) identities for a Claude Code
585
+ # registration. Memoized per request; behavior extracted verbatim.
586
+ def session_provisioner
587
+ @session_provisioner ||= AiAgent::SessionProvisioner.new(current_user)
724
588
  end
725
589
 
726
- # Returns the agent only when it is the current user's own Claude Channel
727
- # agent; nil otherwise (the caller renders :conflict). Routing activation
728
- # stays deferred to AgentChannel#subscribe_to_agent_stream so the agent
729
- # becomes matchable only once a WebSocket subscriber exists for
730
- # agent:user:<id> — otherwise comments matched between this POST returning
731
- # and the client's subsequent cable subscribe would broadcast into an
732
- # empty stream, stranding delegated tasks until stuck recovery.
733
- def verified_session_agent(ai_user)
734
- return nil unless ai_user &&
735
- ai_user.created_by_id == current_user.id &&
736
- ai_user.ai_user? &&
737
- ai_user.claude_channel_agent?
738
-
739
- ai_user
590
+ # Claims and finalizes delegated tasks on /reply. Stateless, so a single
591
+ # instance is reused for the request.
592
+ def task_claim_service
593
+ @task_claim_service ||= AiAgent::TaskClaimService.new
740
594
  end
741
595
 
742
- # One Topic per (agent, session_id). On re-register (including --resume
743
- # from the same cwd, which yields the same session_id) the existing
744
- # topic is reused even if archived — so the conversation persists
745
- # instead of orphaning. A fresh session gets a new topic under the same
746
- # shared agent, which is how one agent fans out to many sessions.
747
- def find_or_create_session_topic(inbox, ai_user, session_id, session_label)
748
- existing = inbox.topics.find_by(primary_agent_id: ai_user.id, session_id: session_id)
749
- return existing if existing
750
-
751
- label = session_label.to_s.strip.presence || session_id
752
- inbox.topics.create!(
753
- name: unique_topic_name(inbox, "Claude #{label}"),
754
- user: current_user,
755
- primary_agent_id: ai_user.id,
756
- session_id: session_id
757
- )
758
- end
759
-
760
- # Topics carry a UNIQUE (creative_id, name) index, so two sessions whose
761
- # friendly labels collide (e.g. both rooted at a dir named "src") cannot
762
- # share a name. Append the smallest numeric suffix that is free.
763
- def unique_topic_name(inbox, desired)
764
- return desired unless inbox.topics.exists?(name: desired)
765
-
766
- n = 2
767
- loop do
768
- candidate = "#{desired} (#{n})"
769
- return candidate unless inbox.topics.exists?(name: candidate)
770
-
771
- n += 1
772
- end
596
+ # Thin delegator to TaskClaimService#claim. Kept as a controller method
597
+ # (rather than calling the service inline in #reply) so the concurrency
598
+ # test that patches AgentsController#claim_delegated_task to inject a race
599
+ # keeps exercising the same seam.
600
+ def claim_delegated_task(agent, topic, requested_task_id)
601
+ task_claim_service.claim(agent: agent, topic: topic, requested_task_id: requested_task_id)
773
602
  end
774
603
  end
775
604
  end
@@ -4,41 +4,23 @@ module Collavre
4
4
  module Api
5
5
  module V1
6
6
  class BaseController < ActionController::API
7
+ include Collavre::Api::Authenticatable
8
+
7
9
  before_action :authenticate!
8
10
 
11
+ rescue_from Collavre::ApiError do |e|
12
+ render json: { error: e.message }, status: e.status
13
+ end
14
+
9
15
  private
10
16
 
11
17
  def authenticate!
12
18
  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
19
+ raise Collavre::ApiError.new("Missing authentication token", status: :unauthorized) if token.blank?
23
20
 
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
21
+ unless find_user_by_bearer_token(token)
22
+ raise Collavre::ApiError.new("Invalid authentication token", status: :unauthorized)
28
23
  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
24
  end
43
25
  end
44
26
  end
@@ -64,9 +64,19 @@ module Collavre
64
64
  signed_id = blob.signed_id
65
65
  pattern = "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%"
66
66
 
67
- Creative.where("description LIKE ?", pattern).any? do |creative|
68
- creative.has_permission?(Current.user, :write)
69
- end
67
+ creatives = Creative.where("description LIKE ?", pattern).includes(:origin)
68
+ return false if creatives.empty?
69
+
70
+ # Permission is evaluated on the origin for linked creatives. Resolve every
71
+ # referencing creative to its permission base, then delegate the write-rank
72
+ # deny-invariant to the canonical PermissionFilter (owner wins; a
73
+ # user-specific entry — including an explicit no_access deny — takes
74
+ # precedence over the public share). readable_ids batch-loads the cache
75
+ # entries once, preserving the prior N+1 avoidance.
76
+ bases = creatives.map { |c| c.origin_id.nil? ? c : c.origin }.compact.uniq
77
+ Collavre::Creatives::PermissionFilter.new(user: Current.user)
78
+ .readable_ids(bases.map(&:id), min_permission: :write)
79
+ .any?
70
80
  end
71
81
  end
72
82
  end