collavre 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -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
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ module Mobile
7
+ # The heart of the loop. GET surfaces pending approvals (+ recent agent
8
+ # replies) with a stable spoken ref number and a decision-ready summary.
9
+ # POST :id/respond branches on the referenced event KIND:
10
+ # (A) approval/permission comment → the spoken response is a BUTTON
11
+ # press (allow|deny) → decide_claude_channel_permission! + relay.
12
+ # (B) ordinary agent message → the spoken response is FREE TEXT passed
13
+ # verbatim back to the agent as a reply comment (no server parsing).
14
+ class AgentEventsController < BaseController
15
+ # Spoken decision verbs. The app no longer addresses approvals by
16
+ # ordinal, so a reply to an approval event is just an allow/deny verb;
17
+ # anything else asks for clarification rather than firing a decision.
18
+ APPROVE = /(승인|허용|적용|approve|allow|confirm|네|좋아|오케이|\bok\b|\byes\b)/i
19
+ DENY = /(거절|거부|반려|deny|reject|취소|아니|\bno\b)/i
20
+
21
+ def index
22
+ approvals = pending_approvals
23
+ notices = system_inbox_messages
24
+
25
+ # Every undecided approval is emitted on every poll — the server keeps
26
+ # no per-client cursor. Re-speaking is prevented on the CLIENT (an
27
+ # in-memory "already spoken" set), the same at-least-once shape as
28
+ # notices: a pending approval keeps surfacing until it is decided
29
+ # (which clears it from pending_approvals) or the app restarts (when a
30
+ # still-pending approval SHOULD be re-surfaced). The old `since` cursor
31
+ # filtered here, but a batch high-water-mark could burn past an approval
32
+ # whose created_at trailed a newer notice, losing it forever.
33
+ events = approvals.map do |c|
34
+ {
35
+ id: c.id, type: "approval_requested",
36
+ title: title_for_topic(c.topic_id),
37
+ summary: summarizer.approval_summary(comment: c, label: label_for_topic(c.topic_id)),
38
+ speak: true, requires_response: true, topic_id: c.topic_id,
39
+ created_at: c.created_at.iso8601(6)
40
+ }
41
+ end
42
+
43
+ events += notices.map do |c|
44
+ # The notice stands in for the origin comment it quotes; the app
45
+ # lists/reads it against the ORIGIN thread and replies route there.
46
+ origin_topic_id = c.quoted_comment&.topic_id || c.topic_id
47
+ {
48
+ id: c.id, type: "agent_reply",
49
+ title: title_for_topic(origin_topic_id),
50
+ summary: speakable(c.content),
51
+ speak: true, requires_response: c.quoted_comment_id.present?,
52
+ topic_id: origin_topic_id, created_at: c.created_at.iso8601(6)
53
+ }
54
+ end
55
+
56
+ render json: events.sort_by { |e| e[:created_at] }
57
+ end
58
+
59
+ def respond
60
+ comment = Collavre::Comment.find_by(id: params[:id])
61
+ unless comment && authorized_comment?(comment)
62
+ return render json: { error: "Event not found" }, status: :not_found
63
+ end
64
+
65
+ # Acting on a notice means the user has heard it — clear it from the
66
+ # unread inbox set so it isn't read again on the next poll.
67
+ mark_inbox_read(comment)
68
+
69
+ if comment.claude_channel_permission?
70
+ decide_on(comment)
71
+ else
72
+ relay_free_text(comment)
73
+ end
74
+ end
75
+
76
+ # The app calls this when it finishes reading a message ALOUD (whether or
77
+ # not the user then replies). Reading = hearing it, so the inbox read
78
+ # pointer advances and the notice stops surfacing — the SAME read-state
79
+ # the inbox badge uses. A crash before this call leaves the notice unread,
80
+ # so the next poll re-reads it (at-least-once, not the lossy old cursor).
81
+ def read
82
+ comment = Collavre::Comment.find_by(id: params[:id])
83
+ unless comment && authorized_comment?(comment)
84
+ return render json: { error: "Event not found" }, status: :not_found
85
+ end
86
+
87
+ mark_inbox_read(comment)
88
+ head :ok
89
+ end
90
+
91
+ private
92
+
93
+ # The user's Inbox → System topic is the per-user alarm stream: mentions,
94
+ # agent replies, share notices, … land here as system-authored
95
+ # (user_id: nil) comments that QUOTE the origin comment. This is the
96
+ # canonical, agent-ownership-independent feed the voice loop reads aloud.
97
+ #
98
+ # Emission is gated by the inbox's OWN read-state — the CommentReadPointer
99
+ # (comments with id > last_read_comment_id are unread) that also drives the
100
+ # inbox badge — NOT by the client's `since` cursor. A `since` cursor is a
101
+ # fetch-time high-water-mark: any notice fetched-but-never-spoken (crash,
102
+ # background, busy queue, restart) gets burned past and is lost forever,
103
+ # even though it stays unread in the inbox. Driving off the read pointer
104
+ # makes "unread in the inbox" and "read aloud by the app" the same set; the
105
+ # app marks a notice read (POST :id/read) only after actually speaking it.
106
+ def system_inbox_messages
107
+ inbox = current_user_inbox or return []
108
+ last_read = inbox_read_pointer&.last_read_comment_id || 0
109
+
110
+ scope = Collavre::Comment.where(creative_id: inbox.id, topic_id: inbox.system_topic.id)
111
+ .where("comments.id > ?", last_read)
112
+ .order(:id)
113
+ .limit(50)
114
+ # Approval prompts are surfaced (actionably) by pending_approvals; their
115
+ # System-topic FYI duplicate must not be read a second time.
116
+ scope.reject { |c| c.quoted_comment&.claude_channel_permission? }
117
+ end
118
+
119
+ def inbox_read_pointer
120
+ inbox = current_user_inbox or return nil
121
+
122
+ Collavre::CommentReadPointer.find_by(user: current_user, creative: inbox)
123
+ end
124
+
125
+ # Advance the inbox read pointer to (at least) this notice — forward-only,
126
+ # so a later message is never un-read. Only inbox-creative comments move the
127
+ # inbox pointer; calling this for an approval (which lives in the origin
128
+ # creative and is owned by pending_approvals) is a harmless no-op.
129
+ def mark_inbox_read(comment)
130
+ inbox = current_user_inbox or return
131
+ return unless comment.creative_id == inbox.id
132
+
133
+ pointer = Collavre::CommentReadPointer.find_or_initialize_by(user: current_user, creative: inbox)
134
+ new_id = [ pointer.last_read_comment_id || 0, comment.id ].max
135
+ pointer.update(last_read_comment_id: new_id)
136
+ end
137
+
138
+ def current_user_inbox
139
+ return @current_user_inbox if defined?(@current_user_inbox)
140
+
141
+ @current_user_inbox = Collavre::Creative.inbox_for(current_user)
142
+ end
143
+
144
+ # Read the whole message aloud, but spoken: strip markdown link syntax to
145
+ # its text and collapse whitespace so TTS doesn't read raw URLs.
146
+ def speakable(text)
147
+ text.to_s.gsub(/\[([^\]]+)\]\([^)]*\)/, '\1').gsub(/\s+/, " ").strip
148
+ end
149
+
150
+ def authorized_comment?(comment)
151
+ return true if comment.approver_id == current_user.id
152
+ return true if own_inbox_notice?(comment)
153
+
154
+ author = comment.user
155
+ author.respond_to?(:created_by_id) && agent_ids.include?(author.id)
156
+ end
157
+
158
+ # A system-authored notice in the caller's own Inbox#System topic. Owning
159
+ # the inbox is the authorization proof (the notice has no approver/author).
160
+ def own_inbox_notice?(comment)
161
+ inbox = current_user_inbox or return false
162
+
163
+ comment.user_id.nil? &&
164
+ comment.creative_id == inbox.id &&
165
+ comment.topic_id == inbox.system_topic.id
166
+ end
167
+
168
+ # (A) bounded decision: spoken response IS the approve/deny button.
169
+ def decide_on(comment)
170
+ behavior = behavior_from(params[:response])
171
+
172
+ unless %w[allow deny].include?(behavior)
173
+ return render_speak(:clarify_decision, action: { type: "needs_clarification", comment_id: comment.id })
174
+ end
175
+
176
+ case decide_permission(comment, behavior)
177
+ when :unauthorized
178
+ render_speak(:not_authorized, action: { type: "not_authorized", comment_id: comment.id }, status: :forbidden)
179
+ when :already_decided
180
+ render_speak(:already_decided, action: { type: "already_decided", comment_id: comment.id })
181
+ else
182
+ render_speak(
183
+ behavior == "allow" ? :approved : :denied,
184
+ action: { type: behavior == "allow" ? "approved" : "denied", comment_id: comment.id }
185
+ )
186
+ end
187
+ end
188
+
189
+ # (B) free-form: relay the utterance verbatim as a human reply.
190
+ def relay_free_text(comment)
191
+ text = params[:response].to_s.strip
192
+ return render_speak(:empty_response, action: { type: "noop" }) if text.blank?
193
+
194
+ # A System-inbox notice is a stand-in for the origin comment it quotes;
195
+ # the reply must land on that origin thread (the System topic itself
196
+ # never dispatches AI), not on the notice.
197
+ target = comment.quoted_comment || comment
198
+ topic = target.topic
199
+ creative = topic&.creative&.effective_origin || target.creative
200
+ # quoted_comment threads the reply to the message being answered, but
201
+ # review_type: :question keeps review_message? false — otherwise the
202
+ # agent's response would update the quoted comment IN PLACE (review flow)
203
+ # instead of posting a new reply, so no new comment is created and the
204
+ # Inbox#System alarm never fires. Same guard as InboxReplyService (#1301).
205
+ reply = creative.comments.create!(
206
+ content: text, user: current_user, topic: topic,
207
+ quoted_comment: target, review_type: :question
208
+ )
209
+
210
+ render_speak(:relayed, action: { type: "relayed", comment_id: reply.id, topic_id: topic&.id })
211
+ end
212
+
213
+ def behavior_from(response)
214
+ text = response.to_s.strip
215
+ return "allow" if text.match?(APPROVE)
216
+ return "deny" if text.match?(DENY)
217
+
218
+ nil
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end