collavre 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +0 -1
- data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
- data/app/assets/stylesheets/collavre/creatives.css +346 -37
- data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
- data/app/assets/stylesheets/collavre/gnb.css +64 -3
- data/app/assets/stylesheets/collavre/landing.css +108 -3
- data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +14 -0
- data/app/assets/stylesheets/collavre/print.css +0 -1
- data/app/assets/stylesheets/collavre/search_popup.css +1 -1
- data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
- data/app/assets/stylesheets/collavre/workspace.css +307 -0
- data/app/channels/collavre/agent_channel.rb +14 -21
- data/app/channels/collavre/comments_presence_channel.rb +181 -21
- data/app/channels/collavre/inbox_badge_channel.rb +7 -1
- data/app/components/collavre/inbox/badge_component.rb +6 -13
- data/app/components/collavre/progress_filter_component.html.erb +2 -2
- data/app/components/collavre/progress_filter_component.rb +20 -0
- data/app/controllers/collavre/admin/settings_controller.rb +4 -1
- data/app/controllers/collavre/agent_connections_controller.rb +165 -0
- data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
- data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
- data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
- data/app/controllers/collavre/application_controller.rb +13 -2
- data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
- data/app/controllers/collavre/comments_controller.rb +45 -131
- data/app/controllers/collavre/concerns/shareable.rb +9 -3
- data/app/controllers/collavre/creatives_controller.rb +144 -11
- data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
- data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
- data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
- data/app/controllers/collavre/features_controller.rb +57 -0
- data/app/controllers/collavre/invites_controller.rb +12 -1
- data/app/controllers/collavre/llm_models_controller.rb +8 -0
- data/app/controllers/collavre/notices_controller.rb +20 -0
- data/app/controllers/collavre/tasks_controller.rb +40 -6
- data/app/controllers/collavre/topics_controller.rb +130 -53
- data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
- data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
- data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
- data/app/errors/collavre/turn_deadline_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +38 -0
- data/app/helpers/collavre/creatives_helper.rb +56 -22
- data/app/helpers/collavre/navigation_helper.rb +12 -0
- data/app/javascript/collavre.js +1 -2
- data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
- data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
- data/app/javascript/components/creative_tree_row.js +62 -22
- data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
- data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
- data/app/javascript/controllers/__tests__/index.test.js +42 -0
- data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
- data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
- data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
- data/app/javascript/controllers/agent_connection_controller.js +357 -0
- data/app/javascript/controllers/agent_vendor_controller.js +23 -0
- data/app/javascript/controllers/comment_controller.js +3 -1
- data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
- data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
- data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
- data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
- data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
- data/app/javascript/controllers/comments/form_controller.js +795 -14
- data/app/javascript/controllers/comments/list_controller.js +338 -52
- data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
- data/app/javascript/controllers/comments/popup_controller.js +425 -83
- data/app/javascript/controllers/comments/presence_controller.js +317 -55
- data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
- data/app/javascript/controllers/comments/topics_controller.js +1772 -100
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
- data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
- data/app/javascript/controllers/creatives/tree_controller.js +140 -31
- data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
- data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
- data/app/javascript/controllers/gateway_check_controller.js +36 -0
- data/app/javascript/controllers/index.js +25 -1
- data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
- data/app/javascript/controllers/link_creative_controller.js +89 -18
- data/app/javascript/controllers/progress_filter_controller.js +12 -22
- data/app/javascript/controllers/search_popup_controller.js +64 -46
- data/app/javascript/controllers/share_user_search_controller.js +2 -2
- data/app/javascript/controllers/topic_list_controller.js +44 -7
- data/app/javascript/controllers/workspace_tree_controller.js +662 -0
- data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
- data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
- data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
- data/app/javascript/creatives/tree_renderer.js +65 -22
- data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
- data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
- data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
- data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
- data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
- data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
- data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
- data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
- data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
- data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
- data/app/javascript/lib/api/creatives.js +32 -13
- data/app/javascript/lib/api/csrf_fetch.js +6 -4
- data/app/javascript/lib/api/topics.js +63 -5
- data/app/javascript/lib/chat_drafts.js +786 -0
- data/app/javascript/lib/common_popup.js +164 -25
- data/app/javascript/lib/creative_link_prefetch.js +27 -0
- data/app/javascript/lib/last_visited_creative.js +97 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
- data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
- data/app/javascript/lib/lexical/creative_link_node.js +101 -0
- data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
- data/app/javascript/lib/lexical/link_toolbar.js +23 -0
- data/app/javascript/lib/turbo_stream_actions.js +23 -16
- data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
- data/app/javascript/lib/utils/filter_navigation.js +100 -0
- data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
- data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
- data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
- data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
- data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
- data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
- data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
- data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
- data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
- data/app/javascript/modules/command_args_form.js +0 -12
- data/app/javascript/modules/command_menu.js +80 -38
- data/app/javascript/modules/creative_link_picker.js +56 -0
- data/app/javascript/modules/creative_row_editor.js +446 -243
- data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
- data/app/javascript/modules/creative_tree_dom.js +4 -0
- data/app/javascript/modules/creative_tree_empty_state.js +80 -0
- data/app/javascript/modules/dom_listener_registry.js +23 -0
- data/app/javascript/modules/mention_menu.js +2 -3
- data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
- data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
- data/app/javascript/utils/caret_position.js +10 -0
- data/app/javascript/utils/chevron_icons.js +7 -0
- data/app/jobs/collavre/ai_agent_job.rb +262 -72
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
- data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
- data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
- data/app/jobs/collavre/comment_notification_job.rb +14 -0
- data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
- data/app/jobs/collavre/compress_job.rb +33 -22
- data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
- data/app/jobs/collavre/cron_action_job.rb +36 -18
- data/app/jobs/collavre/drop_trigger_job.rb +23 -9
- data/app/jobs/collavre/merge_comments_job.rb +33 -16
- data/app/jobs/collavre/push_notification_job.rb +62 -18
- data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
- data/app/jobs/collavre/stuck_detector_job.rb +1 -1
- data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
- data/app/models/collavre/agent_gateway.rb +200 -0
- data/app/models/collavre/agent_subscription.rb +4 -4
- data/app/models/collavre/agent_workspace.rb +210 -0
- data/app/models/collavre/comment/broadcastable.rb +22 -52
- data/app/models/collavre/comment/notifiable.rb +154 -77
- data/app/models/collavre/comment/topic_membership.rb +41 -0
- data/app/models/collavre/comment.rb +336 -31
- data/app/models/collavre/comment_notification_delivery.rb +61 -0
- data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
- data/app/models/collavre/comment_presence_store.rb +272 -12
- data/app/models/collavre/comment_read_pointer.rb +9 -15
- data/app/models/collavre/creative/describable.rb +8 -2
- data/app/models/collavre/creative/permissible.rb +11 -0
- data/app/models/collavre/creative.rb +29 -4
- data/app/models/collavre/creative_share.rb +109 -4
- data/app/models/collavre/current.rb +1 -0
- data/app/models/collavre/llm_model.rb +62 -0
- data/app/models/collavre/system_setting.rb +12 -1
- data/app/models/collavre/task.rb +121 -2
- data/app/models/collavre/topic.rb +51 -0
- data/app/models/collavre/user.rb +101 -41
- data/app/models/collavre/user_creative_preference.rb +3 -1
- data/app/services/collavre/agent_provisioning/archive.rb +108 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
- data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
- data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
- data/app/services/collavre/ai_agent/message_builder.rb +103 -16
- data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
- data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
- data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
- data/app/services/collavre/ai_agent_service.rb +108 -20
- data/app/services/collavre/ai_client.rb +211 -21
- data/app/services/collavre/cli_proxy/client.rb +147 -0
- data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
- data/app/services/collavre/cli_proxy/identity.rb +47 -0
- data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
- data/app/services/collavre/command_menu_service.rb +0 -10
- data/app/services/collavre/comment_move_service.rb +96 -7
- data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
- data/app/services/collavre/comments/action_executor.rb +48 -20
- data/app/services/collavre/comments/command_processor.rb +0 -1
- data/app/services/collavre/comments/list_response.rb +133 -0
- data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
- data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
- data/app/services/collavre/comments/read_receipt_index.rb +152 -0
- data/app/services/collavre/comments/topic_command.rb +72 -1
- data/app/services/collavre/comments/topic_mutation.rb +20 -0
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
- data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
- data/app/services/collavre/creatives/reorderer.rb +15 -3
- data/app/services/collavre/creatives/tree_builder.rb +1 -1
- data/app/services/collavre/creatives/tree_formatter.rb +5 -6
- data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
- data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
- data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
- data/app/services/collavre/home_page_path.rb +55 -0
- data/app/services/collavre/html_text.rb +64 -0
- data/app/services/collavre/http_client.rb +11 -2
- data/app/services/collavre/inbox_reply_service.rb +1 -1
- data/app/services/collavre/markdown_converter.rb +13 -1
- data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
- data/app/services/collavre/orchestration/arbiter.rb +48 -5
- data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
- data/app/services/collavre/orchestration/delivery_record.rb +792 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
- data/app/services/collavre/orchestration/matcher.rb +132 -17
- data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
- data/app/services/collavre/orchestration/scheduler.rb +10 -4
- data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
- data/app/services/collavre/orchestration/selection.rb +43 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
- data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
- data/app/services/collavre/orchestration/topic_slot.rb +73 -0
- data/app/services/collavre/system_events/context_builder.rb +60 -11
- data/app/services/collavre/system_events/dispatcher.rb +58 -12
- data/app/services/collavre/system_events/envelope.rb +73 -0
- data/app/services/collavre/system_events/vocabulary.rb +45 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
- data/app/services/collavre/tools/cron_create_service.rb +32 -18
- data/app/services/collavre/tools/id_list.rb +38 -0
- data/app/services/collavre/tools/topic_authorizer.rb +61 -7
- data/app/services/collavre/tools/topic_branch_service.rb +85 -0
- data/app/services/collavre/tools/topic_create_service.rb +117 -0
- data/app/services/collavre/tools/topic_list_service.rb +101 -0
- data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
- data/app/services/collavre/tools/topic_messages_service.rb +346 -0
- data/app/services/collavre/tools/topic_move_service.rb +108 -0
- data/app/services/collavre/tools/topic_selection.rb +59 -0
- data/app/services/collavre/tools/topic_update_service.rb +165 -0
- data/app/services/collavre/topic_branch_service.rb +39 -10
- data/app/services/collavre/topics/agent_resolver.rb +87 -0
- data/app/services/collavre/topics/char_budget.rb +70 -0
- data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
- data/app/services/collavre/topics/message_page.rb +380 -0
- data/app/services/collavre/topics/message_page_markdown.rb +102 -0
- data/app/services/collavre/topics/message_scope.rb +61 -0
- data/app/services/collavre/topics/message_stats.rb +92 -0
- data/app/services/collavre/topics/move_blocker.rb +47 -0
- data/app/services/collavre/topics/next_name.rb +35 -0
- data/app/services/collavre/topics/pin_rejection.rb +25 -0
- data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
- data/app/services/collavre/topics/reserved_name.rb +18 -0
- data/app/services/collavre/topics/serializer.rb +92 -0
- data/app/services/collavre/topics/topic_destroy.rb +30 -0
- data/app/services/collavre/topics/topic_move.rb +80 -0
- data/app/services/collavre/topics/topic_move_effects.rb +35 -0
- data/app/services/collavre/topics/topic_mutation.rb +27 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
- data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
- data/app/views/collavre/agent_connections/show.html.erb +77 -0
- data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
- data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
- data/app/views/collavre/agent_gateways/index.html.erb +54 -0
- data/app/views/collavre/agent_gateways/new.html.erb +2 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
- data/app/views/collavre/comments/_empty_state.html.erb +33 -0
- data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
- data/app/views/collavre/comments/_feature_card.html.erb +35 -0
- data/app/views/collavre/comments/_list.html.erb +3 -2
- data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
- data/app/views/collavre/creatives/_add_button.html.erb +17 -19
- data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
- data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
- data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
- data/app/views/collavre/creatives/index.html.erb +79 -18
- data/app/views/collavre/desktop_setup/show.html.erb +114 -0
- data/app/views/collavre/features/index.html.erb +42 -0
- data/app/views/collavre/features/show.html.erb +52 -0
- data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
- data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
- data/app/views/collavre/invites/show.html.erb +14 -1
- data/app/views/collavre/landing/show.html.erb +5 -1
- data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
- data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
- data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
- data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
- data/app/views/collavre/user_themes/index.html.erb +1 -1
- data/app/views/collavre/users/_llm_fields.html.erb +65 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
- data/app/views/collavre/users/edit_ai.html.erb +33 -31
- data/app/views/collavre/users/new.html.erb +4 -2
- data/app/views/collavre/users/new_ai.html.erb +14 -32
- data/app/views/collavre/users/show.html.erb +13 -1
- data/app/views/layouts/collavre/landing.html.erb +9 -4
- data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
- data/config/locales/admin.en.yml +3 -1
- data/config/locales/admin.ko.yml +3 -1
- data/config/locales/agent_gateway.en.yml +217 -0
- data/config/locales/agent_gateway.ko.yml +217 -0
- data/config/locales/ai_agent.en.yml +0 -1
- data/config/locales/ai_agent.ko.yml +0 -1
- data/config/locales/comments.en.yml +72 -30
- data/config/locales/comments.ko.yml +72 -30
- data/config/locales/creatives.en.yml +32 -1
- data/config/locales/creatives.ko.yml +32 -1
- data/config/locales/features.en.yml +140 -0
- data/config/locales/features.ko.yml +140 -0
- data/config/locales/invites.en.yml +3 -0
- data/config/locales/invites.ko.yml +3 -0
- data/config/locales/landing.en.yml +2 -1
- data/config/locales/landing.ko.yml +2 -1
- data/config/locales/tools.en.yml +20 -0
- data/config/locales/tools.ko.yml +20 -0
- data/config/locales/users.en.yml +15 -14
- data/config/locales/users.ko.yml +14 -14
- data/config/routes.rb +45 -19
- data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
- data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
- data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
- data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
- data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
- data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
- data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
- data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
- data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
- data/db/migrate/20260809000001_create_llm_models.rb +64 -0
- data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
- data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
- data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
- data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
- data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
- data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
- data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
- data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
- data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
- data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
- data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
- data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
- data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
- data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
- data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
- data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
- data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
- data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
- data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
- data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
- data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
- data/db/seeds.rb +1 -51
- data/lib/collavre/engine.rb +108 -1
- data/lib/collavre/feature_card_registry.rb +168 -0
- data/lib/collavre/hashed_access_token_lookup.rb +22 -0
- data/lib/collavre/sensitive_request_silencer.rb +23 -0
- data/lib/collavre/user_extensions.rb +0 -2
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +1 -0
- data/skills/collavre/SKILL.md +165 -0
- data/skills/collavre/package.json +8 -0
- data/skills/collavre/references/tool-reference.md +347 -0
- data/skills/collavre/scripts/collavre +634 -0
- data/skills/collavre/scripts/html_entities.js +24 -0
- metadata +228 -30
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
- data/app/controllers/collavre/inbox_items_controller.rb +0 -70
- data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
- data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
- data/app/javascript/lib/typo_correction.js +0 -146
- data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
- data/app/javascript/modules/creative_guide.js +0 -67
- data/app/javascript/modules/typo_corrector.js +0 -534
- data/app/jobs/collavre/inbox_summary_job.rb +0 -24
- data/app/mailers/collavre/inbox_mailer.rb +0 -19
- data/app/models/collavre/inbox_item.rb +0 -89
- data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
- data/app/services/collavre/comments/work_command.rb +0 -161
- data/app/services/collavre/comments/workflow_executor.rb +0 -276
- data/app/services/collavre/gemini_parent_recommender.rb +0 -77
- data/app/services/collavre/mobile/event_summarizer.rb +0 -40
- data/app/services/collavre/typo_corrector.rb +0 -188
- data/app/views/collavre/inbox_items/_item.html.erb +0 -18
- data/app/views/collavre/inbox_items/_items.html.erb +0 -3
- data/app/views/collavre/inbox_items/_list.html.erb +0 -7
- data/app/views/collavre/inbox_items/index.html.erb +0 -1
- data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
- data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
- data/app/views/collavre/users/typo_correction.html.erb +0 -50
- data/config/locales/mobile.en.yml +0 -16
- data/config/locales/mobile.ko.yml +0 -16
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
class PushNotificationJob < ApplicationJob
|
|
3
|
+
# Comment bodies are unbounded and go out as the notification body verbatim,
|
|
4
|
+
# so FCM rejects the oversized ones permanently — retrying cannot help, the
|
|
5
|
+
# payload is the defect. Two rejections were observed in production: "Message
|
|
6
|
+
# is too large. The maximum is 4K (4096 bytes)." above ~4.8KB, and a bare
|
|
7
|
+
# "INVALID_ARGUMENT: Invalid argument." starting at 2440 bytes. Budget the
|
|
8
|
+
# whole request at half of FCM's documented ceiling, which also sits below the
|
|
9
|
+
# smallest payload FCM has actually refused for us. A notification body is a
|
|
10
|
+
# preview anyway; no shade renders more than a few lines.
|
|
11
|
+
MAX_REQUEST_BYTES = 2048
|
|
12
|
+
ELLIPSIS = "…".freeze
|
|
13
|
+
TITLE = "새 알림".freeze
|
|
14
|
+
|
|
3
15
|
queue_as :default
|
|
4
16
|
|
|
5
17
|
def perform(user_id, message:, link: nil)
|
|
@@ -27,22 +39,9 @@ class PushNotificationJob < ApplicationJob
|
|
|
27
39
|
private
|
|
28
40
|
|
|
29
41
|
def send_v1(service, project_id, token, message, link)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
webpush = Google::Apis::FcmV1::WebpushConfig.new(
|
|
36
|
-
fcm_options: Google::Apis::FcmV1::WebpushFcmOptions.new(link: link)
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
msg = Google::Apis::FcmV1::Message.new(
|
|
40
|
-
token: token,
|
|
41
|
-
notification: notification,
|
|
42
|
-
webpush: webpush,
|
|
43
|
-
data: { path: link }
|
|
44
|
-
)
|
|
45
|
-
Rails.logger.info("Sending push to token: #{token} with message: #{message} and link: #{link}")
|
|
42
|
+
body = fit_body(token, message, link)
|
|
43
|
+
msg = build_message(token, body, link)
|
|
44
|
+
Rails.logger.info("Sending push to token: #{token} with #{body.bytesize} body bytes and link: #{link}")
|
|
46
45
|
|
|
47
46
|
request = Google::Apis::FcmV1::SendMessageRequest.new(message: msg)
|
|
48
47
|
|
|
@@ -51,16 +50,61 @@ class PushNotificationJob < ApplicationJob
|
|
|
51
50
|
Rails.logger.info("✅ Push sent successfully: #{token} #{response.inspect}")
|
|
52
51
|
response
|
|
53
52
|
rescue Google::Apis::ClientError => e
|
|
53
|
+
# A 4xx is about this one registration, not the notification: the endpoint
|
|
54
|
+
# is gone, or that push service refused the payload. Raising would abandon
|
|
55
|
+
# every device queued behind it, which is how one stale subscription used
|
|
56
|
+
# to silence a user's other browsers. Transient errors (auth, 5xx) still
|
|
57
|
+
# propagate so the job retries.
|
|
54
58
|
if invalid_registration_token?(e)
|
|
55
59
|
Rails.logger.warn("⚠️ Removing invalid FCM token: #{token} (#{e.message})")
|
|
56
60
|
Device.where(fcm_token: token).delete_all
|
|
57
|
-
nil
|
|
58
61
|
else
|
|
59
|
-
|
|
62
|
+
Rails.logger.warn("⚠️ FCM rejected the push for token #{token}: #{e.message}")
|
|
60
63
|
end
|
|
64
|
+
nil
|
|
61
65
|
end
|
|
62
66
|
end
|
|
63
67
|
|
|
68
|
+
def build_message(token, body, link)
|
|
69
|
+
Google::Apis::FcmV1::Message.new(
|
|
70
|
+
token: token,
|
|
71
|
+
notification: Google::Apis::FcmV1::Notification.new(title: TITLE, body: body),
|
|
72
|
+
webpush: Google::Apis::FcmV1::WebpushConfig.new(
|
|
73
|
+
fcm_options: Google::Apis::FcmV1::WebpushFcmOptions.new(link: link)
|
|
74
|
+
),
|
|
75
|
+
data: { path: link }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Everything except the body — token, title, link (twice) and the JSON
|
|
80
|
+
# envelope — is fixed, so measure it and spend whatever is left on the body.
|
|
81
|
+
# Deriving the budget instead of hardcoding a character count keeps the cap
|
|
82
|
+
# correct when a long deep link crowds out the text.
|
|
83
|
+
def fit_body(token, message, link)
|
|
84
|
+
text = message.to_s
|
|
85
|
+
return text if request_bytesize(token, text, link) <= MAX_REQUEST_BYTES
|
|
86
|
+
|
|
87
|
+
budget = MAX_REQUEST_BYTES - request_bytesize(token, "", link) - ELLIPSIS.bytesize
|
|
88
|
+
return ELLIPSIS if budget <= 0
|
|
89
|
+
|
|
90
|
+
truncate_bytes(text, budget) + ELLIPSIS
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def request_bytesize(token, body, link)
|
|
94
|
+
Google::Apis::FcmV1::SendMessageRequest
|
|
95
|
+
.new(message: build_message(token, body, link))
|
|
96
|
+
.to_json
|
|
97
|
+
.bytesize
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# byteslice can cut a multi-byte character in half; scrub drops the dangling
|
|
101
|
+
# bytes rather than shipping invalid UTF-8 that FCM would reject in turn.
|
|
102
|
+
def truncate_bytes(text, max_bytes)
|
|
103
|
+
return text if text.bytesize <= max_bytes
|
|
104
|
+
|
|
105
|
+
text.byteslice(0, max_bytes).scrub("")
|
|
106
|
+
end
|
|
107
|
+
|
|
64
108
|
def invalid_registration_token?(error)
|
|
65
109
|
status_code = error.respond_to?(:status_code) ? error.status_code : nil
|
|
66
110
|
return false unless status_code == 404
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Backstop for Orchestration::DeliveryRecord.restore!, which runs from a
|
|
5
|
+
# status callback and therefore after the point where its own failure could
|
|
6
|
+
# still be undone. See DeliveryRecord.restore_missed! for why nothing has to
|
|
7
|
+
# be written down for this to work.
|
|
8
|
+
#
|
|
9
|
+
# Scheduled in config/recurring.yml.
|
|
10
|
+
class RestoreDroppedDispatchesJob < ApplicationJob
|
|
11
|
+
queue_as :default
|
|
12
|
+
|
|
13
|
+
def perform
|
|
14
|
+
Orchestration::DeliveryRecord.restore_missed!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Collavre
|
|
4
|
-
# StuckDetectorJob runs periodically to
|
|
4
|
+
# StuckDetectorJob runs periodically to recover and escalate stuck tasks.
|
|
5
5
|
#
|
|
6
6
|
# This job should be scheduled to run every 5-10 minutes via cron or similar.
|
|
7
7
|
#
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
class TouchCreativeSubtreeJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(creative_id)
|
|
8
|
+
creative = Creative.find_by(id: creative_id)
|
|
9
|
+
return unless creative
|
|
10
|
+
|
|
11
|
+
creative.descendants.update_all(updated_at: Time.current)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
class AgentGateway < ApplicationRecord
|
|
5
|
+
self.table_name = "agent_gateways"
|
|
6
|
+
|
|
7
|
+
# Must match the proxy's USER_IDENTITY_HMAC_SECRET.
|
|
8
|
+
MIN_IDENTITY_SECRET_BYTES = 32
|
|
9
|
+
DESKTOP_NATIVE_CREDENTIAL_ATTRIBUTES = %i[admin_key completion_key identity_secret].freeze
|
|
10
|
+
|
|
11
|
+
belongs_to :owner, class_name: "Collavre::User"
|
|
12
|
+
has_many :agents, class_name: "Collavre::User", dependent: :restrict_with_error
|
|
13
|
+
has_many :agent_workspaces, class_name: "Collavre::AgentWorkspace", dependent: :destroy
|
|
14
|
+
|
|
15
|
+
enum :workspace_mode, { shared: 0, per_user: 1 }, default: :shared
|
|
16
|
+
|
|
17
|
+
encrypts :admin_key, deterministic: false
|
|
18
|
+
encrypts :completion_key, deterministic: false
|
|
19
|
+
encrypts :identity_secret, deterministic: false
|
|
20
|
+
|
|
21
|
+
normalizes :name, with: ->(value) { value.to_s.strip }
|
|
22
|
+
normalizes :base_url, with: ->(value) { value.to_s.strip.sub(%r{/+\z}, "") }
|
|
23
|
+
normalizes :tenant_id, with: ->(value) { value.to_s.strip }
|
|
24
|
+
|
|
25
|
+
validates :name, :base_url, :admin_key, :tenant_id, presence: true
|
|
26
|
+
validates :name, uniqueness: { scope: :owner_id }
|
|
27
|
+
validates :tenant_id, format: { with: /\A[A-Za-z0-9][A-Za-z0-9._:@\/-]{0,199}\z/ }
|
|
28
|
+
validate :base_url_is_http
|
|
29
|
+
validate :base_url_is_safe_for_owner
|
|
30
|
+
validate :desktop_managed_base_url_is_immutable
|
|
31
|
+
validate :desktop_managed_credentials_are_immutable
|
|
32
|
+
validate :desktop_managed_gateway_uses_shared_workspace
|
|
33
|
+
validate :identity_secret_is_usable
|
|
34
|
+
validate :completion_key_is_present_when_assigned_to_agents
|
|
35
|
+
around_update :serialize_completion_key_removal
|
|
36
|
+
before_update :validate_completion_key_removal_under_lock
|
|
37
|
+
after_update :reconcile_workspaces_after_gateway_change, if: :workspace_credentials_changed?
|
|
38
|
+
|
|
39
|
+
scope :active, -> { where(active: true) }
|
|
40
|
+
|
|
41
|
+
def chat_capable?
|
|
42
|
+
completion_key.present?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def completion_base_url
|
|
46
|
+
"#{proxy_base_url}/v1"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def proxy_path(path)
|
|
50
|
+
suffix = path.start_with?("/") ? path : "/#{path}"
|
|
51
|
+
"#{proxy_base_url}#{suffix}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The signed native setup handoff is the only flow permitted to retarget a
|
|
55
|
+
# desktop-managed gateway. Its loopback exception must not be reusable by
|
|
56
|
+
# an owner editing the gateway through the regular settings UI.
|
|
57
|
+
def update_from_desktop_registration!(attributes)
|
|
58
|
+
@desktop_registration_update = true
|
|
59
|
+
update!(attributes)
|
|
60
|
+
ensure
|
|
61
|
+
@desktop_registration_update = false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Desktop-managed gateways are created only by the signed native setup
|
|
65
|
+
# handoff and always target the local proxy. They remain usable when their
|
|
66
|
+
# original owner later loses the system administrator role.
|
|
67
|
+
def desktop_loopback?
|
|
68
|
+
return false unless desktop_managed?
|
|
69
|
+
|
|
70
|
+
uri = URI.parse(base_url.to_s)
|
|
71
|
+
uri.scheme == "http" &&
|
|
72
|
+
uri.host == "127.0.0.1" &&
|
|
73
|
+
uri.port.present? &&
|
|
74
|
+
uri.userinfo.blank? &&
|
|
75
|
+
uri.query.blank? &&
|
|
76
|
+
uri.fragment.blank?
|
|
77
|
+
rescue URI::InvalidURIError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def proxy_base_url
|
|
84
|
+
base_url.delete_suffix("/v1")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def base_url_is_http
|
|
88
|
+
uri = URI.parse(base_url.to_s)
|
|
89
|
+
return if uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.blank? && uri.query.blank? && uri.fragment.blank?
|
|
90
|
+
|
|
91
|
+
errors.add(:base_url, :invalid)
|
|
92
|
+
rescue URI::InvalidURIError
|
|
93
|
+
errors.add(:base_url, :invalid)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def base_url_is_safe_for_owner
|
|
97
|
+
return if owner&.system_admin?
|
|
98
|
+
return if desktop_loopback?
|
|
99
|
+
return if CliProxy::EndpointPolicy.new.safe_literal?(base_url)
|
|
100
|
+
|
|
101
|
+
errors.add(:base_url, :unsafe)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def desktop_managed_base_url_is_immutable
|
|
105
|
+
return unless persisted? && desktop_managed? && will_save_change_to_base_url?
|
|
106
|
+
return if @desktop_registration_update
|
|
107
|
+
|
|
108
|
+
errors.add(:base_url, :immutable)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Keychain is the source of truth for the local proxy credentials. The
|
|
112
|
+
# regular Rails settings form cannot synchronize it, so only the signed
|
|
113
|
+
# native registration handoff may rotate these values.
|
|
114
|
+
def desktop_managed_credentials_are_immutable
|
|
115
|
+
return unless persisted? && desktop_managed?
|
|
116
|
+
return if @desktop_registration_update
|
|
117
|
+
|
|
118
|
+
DESKTOP_NATIVE_CREDENTIAL_ATTRIBUTES.each do |attribute|
|
|
119
|
+
next unless public_send("will_save_change_to_#{attribute}?")
|
|
120
|
+
|
|
121
|
+
errors.add(attribute, :immutable)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The macOS bundle deliberately runs one shared local proxy. Per-user
|
|
126
|
+
# routing requires a worker and identity secret that desktop never starts.
|
|
127
|
+
def desktop_managed_gateway_uses_shared_workspace
|
|
128
|
+
return unless desktop_managed? && !shared?
|
|
129
|
+
|
|
130
|
+
errors.add(:workspace_mode, :desktop_shared_only)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def identity_secret_is_usable
|
|
134
|
+
if identity_secret.blank?
|
|
135
|
+
# A single-agent shared gateway may fall back to the proxy's unscoped workspace.
|
|
136
|
+
errors.add(:identity_secret, :blank) if per_user? || agents.many?
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
return if identity_secret.to_s.bytesize >= MIN_IDENTITY_SECRET_BYTES
|
|
141
|
+
|
|
142
|
+
errors.add(:identity_secret, :too_short, count: MIN_IDENTITY_SECRET_BYTES)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def completion_key_is_present_when_assigned_to_agents
|
|
146
|
+
return if completion_key.present? || !agents.exists?
|
|
147
|
+
|
|
148
|
+
errors.add(:completion_key, :required_for_agents)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# An agent assignment and key removal both depend on the same invariant.
|
|
152
|
+
# Re-check under the gateway row lock so two requests that validated a
|
|
153
|
+
# previously key-bearing, unassigned gateway cannot both commit.
|
|
154
|
+
def serialize_completion_key_removal
|
|
155
|
+
return yield unless will_save_change_to_completion_key? && completion_key.blank?
|
|
156
|
+
|
|
157
|
+
locked_gateway = self.class.find(id)
|
|
158
|
+
locked_gateway.with_lock do
|
|
159
|
+
begin
|
|
160
|
+
@completion_key_removal_lock = locked_gateway
|
|
161
|
+
yield
|
|
162
|
+
ensure
|
|
163
|
+
@completion_key_removal_lock = nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate_completion_key_removal_under_lock
|
|
169
|
+
return unless @completion_key_removal_lock&.agents&.exists?
|
|
170
|
+
|
|
171
|
+
errors.add(:completion_key, :required_for_agents)
|
|
172
|
+
throw :abort
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def workspace_credentials_changed?
|
|
176
|
+
saved_change_to_base_url? || saved_change_to_tenant_id? || saved_change_to_workspace_mode? || deactivated?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def deactivated?
|
|
180
|
+
saved_change_to_active? && !active?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def reconcile_workspaces_after_gateway_change
|
|
184
|
+
if saved_change_to_base_url? || saved_change_to_tenant_id? || deactivated?
|
|
185
|
+
agent_workspaces.destroy_all
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
agents.find_each do |agent|
|
|
190
|
+
workspaces = agent_workspaces.where(agent: agent)
|
|
191
|
+
next unless workspaces.exists?
|
|
192
|
+
|
|
193
|
+
workspaces.destroy_all
|
|
194
|
+
next unless active?
|
|
195
|
+
|
|
196
|
+
AgentWorkspace.resolve!(agent: agent, user: per_user? ? agent.creator : nil)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
module Collavre
|
|
4
4
|
# Presence record for one live Claude Channel session subscription on the
|
|
5
5
|
# per-agent ActionCable stream. The set of LIVE rows for an agent_id is the
|
|
6
|
-
# authority for whether
|
|
7
|
-
# concurrent sessions share one agent, so
|
|
8
|
-
# holds a live row, and
|
|
6
|
+
# authority for whether Claude Channel dispatch is active: a human's
|
|
7
|
+
# concurrent sessions share one agent, so dispatch stays on while ANY session
|
|
8
|
+
# holds a live row, and turns off when the last one is gone.
|
|
9
9
|
#
|
|
10
10
|
# Liveness is leased, not assumed. Rows are deleted only by
|
|
11
11
|
# AgentChannel#unsubscribed, so a Puma/ActionCable crash or deploy orphans a
|
|
12
12
|
# row. last_seen_at is refreshed by the channel's periodic heartbeat; a row
|
|
13
13
|
# whose last_seen_at falls outside STALE_AFTER is dead — ignored by the live
|
|
14
|
-
# scope so it cannot
|
|
14
|
+
# scope so it cannot keep dispatch active, and reaped opportunistically on the next
|
|
15
15
|
# subscribe/unsubscribe for the agent.
|
|
16
16
|
class AgentSubscription < ApplicationRecord
|
|
17
17
|
self.table_name = "agent_subscriptions"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Collavre
|
|
6
|
+
class AgentWorkspace < ApplicationRecord
|
|
7
|
+
self.table_name = "agent_workspaces"
|
|
8
|
+
|
|
9
|
+
belongs_to :agent, class_name: "Collavre::User"
|
|
10
|
+
belongs_to :user, class_name: "Collavre::User", optional: true
|
|
11
|
+
belongs_to :agent_gateway, class_name: "Collavre::AgentGateway"
|
|
12
|
+
|
|
13
|
+
encrypts :manifest_token, deterministic: false
|
|
14
|
+
encrypts :callback_token, deterministic: false
|
|
15
|
+
|
|
16
|
+
# Mirrors the proxy's STABLE_ID_RE: the credential axis selects the OS worker.
|
|
17
|
+
CREDENTIAL_ID_FORMAT = /\A[A-Za-z0-9][A-Za-z0-9._:@\/-]{0,199}\z/
|
|
18
|
+
# Mirrors the proxy's WORKSPACE_ID_RE. Narrower than the credential format
|
|
19
|
+
# because this value becomes one path segment below the worker's HOME, so
|
|
20
|
+
# "/" and "." must stay out of it.
|
|
21
|
+
WORKSPACE_ID_FORMAT = /\A[A-Za-z0-9][A-Za-z0-9_:@-]{0,199}\z/
|
|
22
|
+
|
|
23
|
+
validates :proxy_credential_id, :proxy_workspace_id, :manifest_token,
|
|
24
|
+
:manifest_token_digest, :callback_token, presence: true
|
|
25
|
+
validates :proxy_credential_id, format: { with: CREDENTIAL_ID_FORMAT }
|
|
26
|
+
validates :proxy_workspace_id, format: { with: WORKSPACE_ID_FORMAT }
|
|
27
|
+
validates :manifest_token_digest, uniqueness: true
|
|
28
|
+
|
|
29
|
+
before_validation :derive_manifest_token_digest, if: :will_save_change_to_manifest_token?
|
|
30
|
+
before_destroy :revoke_callback_token
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def resolve!(agent:, user:)
|
|
34
|
+
loop do
|
|
35
|
+
gateway_id = agent.class.where(id: agent.id).pick(:agent_gateway_id)
|
|
36
|
+
raise ArgumentError, "Agent has no gateway" unless gateway_id
|
|
37
|
+
|
|
38
|
+
gateway = Collavre::AgentGateway.find(gateway_id)
|
|
39
|
+
workspace = gateway.with_lock do
|
|
40
|
+
agent.with_lock do
|
|
41
|
+
# User validation takes the gateway lock before updating the agent.
|
|
42
|
+
# Use the same lock order, then verify the association under both
|
|
43
|
+
# locks. A concurrent reassignment makes this attempt retry against
|
|
44
|
+
# the newly persisted gateway instead of creating stale credentials.
|
|
45
|
+
next unless agent.agent_gateway_id == gateway.id
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "Agent gateway is inactive" unless gateway.active?
|
|
48
|
+
|
|
49
|
+
gateway.shared? ? resolve_shared!(agent, gateway) : resolve_per_user!(agent, user, gateway)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return workspace if workspace
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def find_by_manifest_token!(agent_id:, token:)
|
|
58
|
+
supplied = token.to_s
|
|
59
|
+
workspace = find_by!(agent_id: agent_id, manifest_token_digest: manifest_digest(supplied))
|
|
60
|
+
return workspace if ActiveSupport::SecurityUtils.secure_compare(supplied, workspace.manifest_token.to_s)
|
|
61
|
+
|
|
62
|
+
raise ActiveRecord::RecordNotFound
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def manifest_digest(token)
|
|
66
|
+
Digest::SHA256.hexdigest(token)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The workspace axis is always the agent: skills, workspace config, and the
|
|
70
|
+
# provisioning lockfile are per agent regardless of the gateway mode.
|
|
71
|
+
def proxy_workspace_id_for(agent)
|
|
72
|
+
"agent-#{agent.id}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The credential axis selects the proxy worker that holds the engine
|
|
76
|
+
# logins. A per-user gateway keys it by Collavre user so one login covers
|
|
77
|
+
# every agent that user reaches; a shared gateway keys it by agent so all
|
|
78
|
+
# of its users keep sharing the one login.
|
|
79
|
+
def proxy_credential_id_for(agent, user)
|
|
80
|
+
user ? "user-#{user.id}" : "agent-#{agent.id}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Nothing here reads proxy_user_id; it is written so a process still
|
|
84
|
+
# running the pre-split code can resolve rows this release creates —
|
|
85
|
+
# the previous container during a Kamal rollout, or the whole app after
|
|
86
|
+
# a rollback. Drop this together with the column in the contract
|
|
87
|
+
# migration that follows this release.
|
|
88
|
+
def legacy_proxy_user_id_for(agent, user)
|
|
89
|
+
return "agent-#{agent.id}" unless user
|
|
90
|
+
|
|
91
|
+
"agent-#{agent.id}--user-#{user.id}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def resolve_shared!(agent, gateway)
|
|
97
|
+
existing = find_by(agent: agent, agent_gateway: gateway, user_id: nil)
|
|
98
|
+
return existing if identity_current?(existing, agent, nil)
|
|
99
|
+
|
|
100
|
+
existing&.destroy!
|
|
101
|
+
create_workspace!(agent: agent, user: nil, gateway: gateway)
|
|
102
|
+
rescue ActiveRecord::RecordNotUnique
|
|
103
|
+
find_by!(agent: agent, agent_gateway: gateway, user_id: nil)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def resolve_per_user!(agent, user, gateway)
|
|
107
|
+
raise ArgumentError, "A user is required for a per-user workspace" unless user
|
|
108
|
+
|
|
109
|
+
existing = find_by(agent: agent, user: user, agent_gateway: gateway)
|
|
110
|
+
return existing if identity_current?(existing, agent, user)
|
|
111
|
+
|
|
112
|
+
existing&.destroy!
|
|
113
|
+
|
|
114
|
+
if user.id == agent.created_by_id
|
|
115
|
+
find_by(agent: agent, agent_gateway: gateway, user_id: nil)&.destroy!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
create_workspace!(agent: agent, user: user, gateway: gateway)
|
|
119
|
+
rescue ActiveRecord::RecordNotUnique
|
|
120
|
+
find_by!(agent: agent, user: user, agent_gateway: gateway)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A row minted under an older identity scheme addresses a proxy workspace
|
|
124
|
+
# that no longer matches its agent/user pair, so it is replaced rather than
|
|
125
|
+
# reused — its credentials would resolve to the wrong worker or directory.
|
|
126
|
+
def identity_current?(workspace, agent, user)
|
|
127
|
+
return false unless workspace
|
|
128
|
+
|
|
129
|
+
workspace.proxy_workspace_id == proxy_workspace_id_for(agent) &&
|
|
130
|
+
workspace.proxy_credential_id == proxy_credential_id_for(agent, user)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def create_workspace!(agent:, user:, gateway:)
|
|
134
|
+
transaction do
|
|
135
|
+
callback_token = issue_callback_token!(gateway: gateway, owner: user || agent)
|
|
136
|
+
|
|
137
|
+
create!(
|
|
138
|
+
agent: agent,
|
|
139
|
+
user: user,
|
|
140
|
+
agent_gateway: gateway,
|
|
141
|
+
proxy_workspace_id: proxy_workspace_id_for(agent),
|
|
142
|
+
proxy_credential_id: proxy_credential_id_for(agent, user),
|
|
143
|
+
proxy_user_id: legacy_proxy_user_id_for(agent, user),
|
|
144
|
+
manifest_token: SecureRandom.urlsafe_base64(32),
|
|
145
|
+
callback_token: callback_token
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def issue_callback_token!(gateway:, owner:)
|
|
151
|
+
application = Doorkeeper::Application.find_or_create_by!(
|
|
152
|
+
owner: gateway.owner,
|
|
153
|
+
name: "Collavre Agent Gateway",
|
|
154
|
+
redirect_uri: "urn:ietf:wg:oauth:2.0:oob"
|
|
155
|
+
) do |app|
|
|
156
|
+
app.scopes = "public"
|
|
157
|
+
app.confidential = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
access_token = Doorkeeper::AccessToken.create!(
|
|
161
|
+
application: application,
|
|
162
|
+
resource_owner_id: owner.id,
|
|
163
|
+
scopes: "public",
|
|
164
|
+
expires_in: nil,
|
|
165
|
+
use_refresh_token: false
|
|
166
|
+
)
|
|
167
|
+
plaintext = access_token.token
|
|
168
|
+
access_token.update_column(:token, Collavre::HashedAccessTokenLookup.encode(plaintext))
|
|
169
|
+
|
|
170
|
+
plaintext
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def config_payload(base_url:)
|
|
175
|
+
{ url: base_url.sub(%r{/+\z}, ""), token: callback_token }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def rotate_tokens!
|
|
179
|
+
gateway = agent_gateway
|
|
180
|
+
gateway.with_lock do
|
|
181
|
+
workspace_agent = agent
|
|
182
|
+
workspace_agent.with_lock do
|
|
183
|
+
raise ActiveRecord::RecordNotFound unless workspace_agent.agent_gateway_id == gateway.id
|
|
184
|
+
raise ArgumentError, "Agent gateway is inactive" unless gateway.active?
|
|
185
|
+
|
|
186
|
+
with_lock do
|
|
187
|
+
raise ActiveRecord::RecordNotFound unless agent_gateway_id == gateway.id
|
|
188
|
+
|
|
189
|
+
old_access_token = Doorkeeper::AccessToken.by_token(callback_token)
|
|
190
|
+
new_callback_token = self.class.send(:issue_callback_token!, gateway: gateway, owner: user || workspace_agent)
|
|
191
|
+
update!(callback_token: new_callback_token)
|
|
192
|
+
old_access_token&.revoke
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
self
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def derive_manifest_token_digest
|
|
203
|
+
self.manifest_token_digest = self.class.manifest_digest(manifest_token.to_s)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def revoke_callback_token
|
|
207
|
+
Doorkeeper::AccessToken.by_token(callback_token)&.revoke
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|