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
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# Folds several un-started agent tasks for one conversation turn into one.
|
|
6
|
+
#
|
|
7
|
+
# A burst of external events (a PR comment sync, a webhook batch) creates
|
|
8
|
+
# several comments in the same topic within milliseconds, and each one
|
|
9
|
+
# dispatches its own task. With topic_max_concurrent_jobs = 1 the extras
|
|
10
|
+
# pile up as `queued` waiters, and AgentOrchestrator.refresh_deferred_context!
|
|
11
|
+
# points every one of them at the same latest comment — so the agent answers
|
|
12
|
+
# the same message once per waiter.
|
|
13
|
+
#
|
|
14
|
+
# Cancelling the extras is not sufficient: session-backed agents receive
|
|
15
|
+
# only the :trigger message (SessionContextResolver#incremental_payload
|
|
16
|
+
# drops chat history), so the intermediate comments would be lost entirely.
|
|
17
|
+
# Their ids are therefore merged into the survivor's payload under
|
|
18
|
+
# "merged_comment_ids", and MessageBuilder folds them into the trigger.
|
|
19
|
+
class TaskCoalescer
|
|
20
|
+
PAYLOAD_KEY = "merged_comment_ids"
|
|
21
|
+
|
|
22
|
+
# Set when a payload's anchor is moved onto a comment the task was not
|
|
23
|
+
# created for. AgentOrchestrator.delivered_comment_ids has to tell that
|
|
24
|
+
# apart from a task answering its own trigger, and the only evidence
|
|
25
|
+
# otherwise was comment.created_at against task.created_at — two rows
|
|
26
|
+
# stamped by whichever process wrote them, which is why nothing else in
|
|
27
|
+
# this turn's machinery orders by time either. A tie or a skew reads an
|
|
28
|
+
# acquired anchor as an original one and the comment is answered twice,
|
|
29
|
+
# or an original one as acquired and a promoted waiter is left with no
|
|
30
|
+
# candidate and cancelled.
|
|
31
|
+
ACQUIRED_ANCHOR_KEY = "acquired_comment_id"
|
|
32
|
+
|
|
33
|
+
# The statuses a survivor may still be in when a fold runs: both enqueue
|
|
34
|
+
# doors hand over a `queued` row and both start-of-turn doors a `pending`
|
|
35
|
+
# one. Anything else means it lost its turn between the caller reading it
|
|
36
|
+
# and this transaction.
|
|
37
|
+
UNSTARTED_STATUSES = %w[queued pending].freeze
|
|
38
|
+
|
|
39
|
+
# Cancel un-started siblings of `keep_task` and absorb their trigger
|
|
40
|
+
# comments into it.
|
|
41
|
+
#
|
|
42
|
+
# @param keep_task [Collavre::Task] the survivor
|
|
43
|
+
# @param scope [:older, :all] which queued siblings to supersede.
|
|
44
|
+
# :older (default) is for the enqueue path, where `keep_task` is itself
|
|
45
|
+
# a fresh `queued` row and two concurrent dispatches must not cancel
|
|
46
|
+
# each other. :all is for the promotion path, where `keep_task` has
|
|
47
|
+
# already left `queued` — nothing can cancel it, and the waiters left
|
|
48
|
+
# behind are *newer*, so the id guard would absorb nothing.
|
|
49
|
+
# @return [Array<Integer>] ids of the tasks that were absorbed
|
|
50
|
+
def self.coalesce!(keep_task, scope: :older)
|
|
51
|
+
new(keep_task, scope: scope).coalesce!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Point a payload at a new trigger comment, recording the move.
|
|
55
|
+
#
|
|
56
|
+
# Both doors that move an anchor go through this — the refresh carrying a
|
|
57
|
+
# waiter forward, and the re-anchor rescuing a task whose anchor was
|
|
58
|
+
# deleted. They differ only in how they rebuild the merged list, and
|
|
59
|
+
# keeping the anchor write itself in one place is what makes "this turn
|
|
60
|
+
# acquired that comment" a fact the payload carries rather than one a
|
|
61
|
+
# later reader has to reconstruct.
|
|
62
|
+
#
|
|
63
|
+
# A refresh that lands back on the anchor it started from is a documented
|
|
64
|
+
# no-op, so the mark is written only when the id actually changes, and
|
|
65
|
+
# never cleared: an anchor moved twice is still not the turn's own.
|
|
66
|
+
#
|
|
67
|
+
# A payload with no anchor at all is left unmarked. There is no move to
|
|
68
|
+
# record, and this whole record is read to decide whether a *waiter* may
|
|
69
|
+
# keep its turn — so where the payload cannot say, the side that cannot
|
|
70
|
+
# discard someone's work is the one to take.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash] the updated payload (not saved)
|
|
73
|
+
def self.reanchor_payload(payload, comment)
|
|
74
|
+
previous_id = payload.dig("comment", "id")
|
|
75
|
+
# The anchor block is rebuilt from Comment#dispatch_payload rather than
|
|
76
|
+
# enumerated here. That method is the declared single source of truth
|
|
77
|
+
# for what a comment_created dispatch carries, and everything in it
|
|
78
|
+
# describes the anchor: "from_ai" is what AiAgentJob#record_loop_breaker_
|
|
79
|
+
# turn reads to decide whether a turn was agent-started, so a move that
|
|
80
|
+
# drops it exempts agent-to-agent work from the creative-retry breaker,
|
|
81
|
+
# and "quoted_comment_id" is what binds a review to the comment it
|
|
82
|
+
# quotes. Enumerating the keys here means the list falls behind the
|
|
83
|
+
# payload the ordinary door builds, silently, one key at a time.
|
|
84
|
+
#
|
|
85
|
+
# Rebuilt, never merged onto: a key absent for this comment must be
|
|
86
|
+
# absent afterwards. Carrying the previous anchor's "from_ai" or its
|
|
87
|
+
# quoted comment forward is the same defect pointing the other way.
|
|
88
|
+
moved = payload.merge(
|
|
89
|
+
comment.dispatch_payload.slice(:comment).deep_stringify_keys,
|
|
90
|
+
"chat" => SystemEvents::ContextBuilder.reanchor_chat(comment.content)
|
|
91
|
+
)
|
|
92
|
+
if previous_id && previous_id.to_i != comment.id
|
|
93
|
+
moved[ACQUIRED_ANCHOR_KEY] = comment.id
|
|
94
|
+
|
|
95
|
+
# The per-user CLI Proxy workspace is part of the anchor's security
|
|
96
|
+
# context. A waiter initially carried for one person can be promoted
|
|
97
|
+
# onto a later comment from another, so retaining the old principal
|
|
98
|
+
# would run the new prompt with the first person's callback token.
|
|
99
|
+
# Human anchors identify their own principal. An AI/system anchor
|
|
100
|
+
# cannot reconstruct its initiating person from the Comment row, so
|
|
101
|
+
# retain an explicit nil sentinel. Downstream resolution distinguishes
|
|
102
|
+
# this security decision from an ordinary payload that never carried a
|
|
103
|
+
# principal and must not fall through to the agent creator.
|
|
104
|
+
if comment.user && !comment.user.ai_user?
|
|
105
|
+
if moved.key?("workspace_user_id")
|
|
106
|
+
moved["workspace_user_id"] = comment.user_id
|
|
107
|
+
end
|
|
108
|
+
else
|
|
109
|
+
moved["workspace_user_id"] = nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
# Both keys ContextBuilder *derives* from the anchor are rebuilt here
|
|
113
|
+
# too — dispatch_payload does not carry them, because ContextBuilder
|
|
114
|
+
# fills each one in with `||=` and does not run again on either of
|
|
115
|
+
# these paths. "chat"/"mentioned_user" moves with the anchor above
|
|
116
|
+
# (ContextBuilder.reanchor_chat); "sender" below.
|
|
117
|
+
#
|
|
118
|
+
# The payload's "sender" labels the trigger and is what
|
|
119
|
+
# ClaudeChannelAdapter sends as author_id/author_name, and
|
|
120
|
+
# SystemEvents::ContextBuilder only ever fills it in with `||=` — it does
|
|
121
|
+
# not run again on either of these paths. A burst spanning two people
|
|
122
|
+
# would otherwise put the first speaker's name on the second's words.
|
|
123
|
+
SystemEvents::ContextBuilder.reanchor_sender(moved, comment)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Merge extra comment ids into a task payload without touching siblings.
|
|
127
|
+
# Used when a refresh moves the anchor to a newer comment: the previous
|
|
128
|
+
# anchor still has to reach the agent.
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash] the updated payload (not saved)
|
|
131
|
+
def self.absorb_into_payload(payload, comment_ids)
|
|
132
|
+
merged = Array(payload[PAYLOAD_KEY]) + Array(comment_ids)
|
|
133
|
+
anchor_id = payload.dig("comment", "id")
|
|
134
|
+
merged = merged.compact.map(&:to_i).uniq
|
|
135
|
+
merged -= [ anchor_id.to_i ] if anchor_id
|
|
136
|
+
payload.merge(PAYLOAD_KEY => merged.sort)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Restrict a re-anchor query to comments that keep the task on the same
|
|
140
|
+
# potential workspace principal. The current anchor is always retained as
|
|
141
|
+
# the no-op candidate: an A2A payload may carry a human principal even
|
|
142
|
+
# though its anchor was authored by an agent, while moving onto another
|
|
143
|
+
# agent comment would deliberately turn that principal into explicit nil.
|
|
144
|
+
# A verified human boundary applies before a per-user gateway is selected
|
|
145
|
+
# because configuration may change while the task is queued. A fallback
|
|
146
|
+
# creator is not evidence of who initiated an ordinary turn, so it keeps
|
|
147
|
+
# the legacy refresh behavior until a per-user gateway requires fail-closed
|
|
148
|
+
# handling.
|
|
149
|
+
def self.reanchor_scope_for_workspace_principal(scope, task)
|
|
150
|
+
principal = workspace_principal_for(task)
|
|
151
|
+
return scope if principal.first == :fallback && !workspace_principal_isolated?(task.agent)
|
|
152
|
+
|
|
153
|
+
anchor_id = (task.trigger_event_payload || {}).dig("comment", "id")
|
|
154
|
+
compatible =
|
|
155
|
+
case principal.first
|
|
156
|
+
when :human
|
|
157
|
+
scope.where(user_id: principal.second)
|
|
158
|
+
when :unprovable
|
|
159
|
+
scope.where(user_id: nil).or(scope.where(user_id: User.ai_agents.select(:id)))
|
|
160
|
+
else
|
|
161
|
+
scope.none
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
anchor_id ? compatible.or(scope.where(id: anchor_id)) : compatible
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def self.workspace_principal_for(task)
|
|
168
|
+
workspace_principals_for([ task ]).fetch(task.id)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.workspace_principal_isolated?(agent)
|
|
172
|
+
agent&.cli_proxy_agent? && agent.agent_gateway.per_user?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def initialize(keep_task, scope: :older)
|
|
176
|
+
@keep = keep_task
|
|
177
|
+
@scope = scope
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def coalesce!
|
|
181
|
+
return [] if review_trigger?(@keep)
|
|
182
|
+
|
|
183
|
+
absorbed_ids = []
|
|
184
|
+
|
|
185
|
+
Task.transaction do
|
|
186
|
+
# Lock the survivor together with its siblings, and re-read its status
|
|
187
|
+
# from the locked row. The survivor is the caller's snapshot: deleting
|
|
188
|
+
# its anchor cancels it through Comment#cancel_pending_tasks, which
|
|
189
|
+
# holds no lock this method waits on. Folding on a stale object would
|
|
190
|
+
# cancel every still-valid sibling and merge the whole burst onto a
|
|
191
|
+
# task AiAgentJob abandons on sight — and those siblings have no task
|
|
192
|
+
# of their own left to answer them.
|
|
193
|
+
#
|
|
194
|
+
# One id-ordered statement rather than locking @keep first: in the
|
|
195
|
+
# :older scope the survivor's id is above every sibling's, so taking it
|
|
196
|
+
# first would descend where a concurrent fold ascends, which is the
|
|
197
|
+
# shape a deadlock needs.
|
|
198
|
+
locked = Task.where(id: superseded_scope.pluck(:id) + [ @keep.id ])
|
|
199
|
+
.order(:id).lock.index_by(&:id)
|
|
200
|
+
keep = locked[@keep.id]
|
|
201
|
+
next unless keep && UNSTARTED_STATUSES.include?(keep.status)
|
|
202
|
+
|
|
203
|
+
# Status is re-checked against the locked rows too: a sibling promoted
|
|
204
|
+
# or cancelled since the id read above is no longer ours to supersede.
|
|
205
|
+
siblings = reject_review_triggers(
|
|
206
|
+
locked.values.select { |t| t.id != keep.id && t.status == "queued" }
|
|
207
|
+
)
|
|
208
|
+
siblings = reject_other_workspace_principals(keep, siblings)
|
|
209
|
+
comment_ids = siblings.flat_map { |t| trigger_comment_ids(t) }
|
|
210
|
+
|
|
211
|
+
siblings.each do |task|
|
|
212
|
+
task.task_actions.create!(
|
|
213
|
+
action_type: "superseded",
|
|
214
|
+
status: "done",
|
|
215
|
+
payload: { "superseded_by_task_id" => @keep.id }
|
|
216
|
+
)
|
|
217
|
+
task.update!(status: "cancelled")
|
|
218
|
+
absorbed_ids << task.id
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# A waiter absorbed here may have parked with a per-deferral notice of
|
|
222
|
+
# its own — the policy only has to have been off when it deferred and
|
|
223
|
+
# on by the time this fold runs. That notice speaks for this one
|
|
224
|
+
# waiter, so with the waiter cancelled it is a stop button that
|
|
225
|
+
# selects a task no longer in the queue and cancels nothing at all.
|
|
226
|
+
#
|
|
227
|
+
# Nothing else would collect it either: the cancelled task will never
|
|
228
|
+
# be promoted through cleanup_waiter_notice!, and the drained sweep
|
|
229
|
+
# needs the topic queue to empty — which the survivor of this very
|
|
230
|
+
# fold prevents. Same transaction as the cancellation, so the two
|
|
231
|
+
# cannot come apart.
|
|
232
|
+
Comment.remove_waiter_notices!(
|
|
233
|
+
creative_id: @keep.creative_id, topic_id: @keep.topic_id, task_ids: absorbed_ids
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if siblings.any?
|
|
237
|
+
# Merge onto the locked row's payload, but write through the
|
|
238
|
+
# caller's own object: dequeue_next_for_topic hands that same
|
|
239
|
+
# instance to refresh_deferred_context! immediately after this, and
|
|
240
|
+
# it has to see the ids merged here rather than its pre-fold copy.
|
|
241
|
+
payload = self.class.absorb_into_payload(keep.trigger_event_payload || {}, comment_ids)
|
|
242
|
+
@keep.update!(trigger_event_payload: payload)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
if absorbed_ids.any?
|
|
247
|
+
Rails.logger.info(
|
|
248
|
+
"[TaskCoalescer] Task #{@keep.id} absorbed #{absorbed_ids.size} queued sibling(s): " \
|
|
249
|
+
"#{absorbed_ids.join(', ')}"
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
absorbed_ids
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
# Only `queued` tasks are safe to supersede. A `pending` task may already
|
|
258
|
+
# be riding an enqueued AiAgentJob; cancelling it makes that job return
|
|
259
|
+
# early *without* draining the topic queue, stalling the topic.
|
|
260
|
+
#
|
|
261
|
+
# `id < keep.id` rather than "every other sibling": two dispatches
|
|
262
|
+
# coalescing concurrently would otherwise cancel each other and leave no
|
|
263
|
+
# survivor. Each run only ever supersedes strictly older rows.
|
|
264
|
+
def superseded_scope
|
|
265
|
+
rel = Task.where(
|
|
266
|
+
agent_id: @keep.agent_id,
|
|
267
|
+
topic_id: @keep.topic_id,
|
|
268
|
+
creative_id: @keep.creative_id,
|
|
269
|
+
trigger_event_name: @keep.trigger_event_name,
|
|
270
|
+
status: "queued"
|
|
271
|
+
).where.not(id: @keep.id)
|
|
272
|
+
rel = rel.where("id < ?", @keep.id) if @scope == :older
|
|
273
|
+
rel.order(:id)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A Review request is an action bound to one comment, not one more message
|
|
277
|
+
# in the burst. AiAgentService and ResponseFinalizer read review behaviour
|
|
278
|
+
# off the surviving anchor alone (Comment#review_message? plus its
|
|
279
|
+
# quoted_comment), so folding across that boundary breaks both ways: an
|
|
280
|
+
# absorbed review silently degrades into a normal reply, and a surviving
|
|
281
|
+
# review overwrites the quoted comment with text that also answers an
|
|
282
|
+
# unrelated message. Reviews therefore neither absorb nor get absorbed —
|
|
283
|
+
# they keep their own turn, which is the only shape ReviewHandler can run.
|
|
284
|
+
def review_trigger?(task)
|
|
285
|
+
anchor_id = (task.trigger_event_payload || {}).dig("comment", "id")
|
|
286
|
+
anchor_id.present? && Comment.review_message_ids([ anchor_id ]).any?
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# One query for the whole burst rather than review_trigger? per sibling.
|
|
290
|
+
def reject_review_triggers(siblings)
|
|
291
|
+
anchors = siblings.filter_map { |t| (t.trigger_event_payload || {}).dig("comment", "id") }
|
|
292
|
+
review_ids = Comment.review_message_ids(anchors).to_set
|
|
293
|
+
return siblings if review_ids.empty?
|
|
294
|
+
|
|
295
|
+
siblings.reject do |task|
|
|
296
|
+
review_ids.include?((task.trigger_event_payload || {}).dig("comment", "id").to_i)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Coalesced comments are rendered into one trigger and execute under the
|
|
301
|
+
# survivor's single workspace principal. Folding across people would let
|
|
302
|
+
# one person's prompt run with another person's provider login and
|
|
303
|
+
# callback permissions, so only siblings whose effective principal is
|
|
304
|
+
# identical may be absorbed.
|
|
305
|
+
def reject_other_workspace_principals(keep, siblings)
|
|
306
|
+
return siblings if siblings.empty?
|
|
307
|
+
|
|
308
|
+
principals = self.class.send(:workspace_principals_for, [ keep ] + siblings)
|
|
309
|
+
keep_principal = principals.fetch(keep.id)
|
|
310
|
+
siblings.select { |task| principals.fetch(task.id) == keep_principal }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def self.workspace_principals_for(tasks)
|
|
314
|
+
payloads = tasks.to_h { |task| [ task.id, task.trigger_event_payload || {} ] }
|
|
315
|
+
explicit_ids = payloads.values.filter_map do |payload|
|
|
316
|
+
payload["workspace_user_id"] if payload.key?("workspace_user_id")
|
|
317
|
+
end
|
|
318
|
+
anchor_ids = payloads.values.filter_map { |payload| payload.dig("comment", "id") }
|
|
319
|
+
agent_creators = User.where(id: tasks.map(&:agent_id).compact.uniq).pluck(:id, :created_by_id).to_h
|
|
320
|
+
creator_ids = agent_creators.values.compact
|
|
321
|
+
users = User.where(id: explicit_ids + creator_ids).index_by(&:id)
|
|
322
|
+
comments = Comment.where(id: anchor_ids).includes(:user).index_by(&:id)
|
|
323
|
+
|
|
324
|
+
tasks.to_h do |task|
|
|
325
|
+
payload = payloads.fetch(task.id)
|
|
326
|
+
principal = effective_workspace_principal(task, payload, users, comments, agent_creators)
|
|
327
|
+
[ task.id, principal ]
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
private_class_method :workspace_principals_for
|
|
331
|
+
|
|
332
|
+
def self.effective_workspace_principal(task, payload, users, comments, agent_creators)
|
|
333
|
+
if payload.key?("workspace_user_id")
|
|
334
|
+
carried = users[payload["workspace_user_id"].to_i]
|
|
335
|
+
return carried && !carried.ai_user? ? [ :human, carried.id ] : [ :unprovable ]
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
comment = comments[payload.dig("comment", "id").to_i]
|
|
339
|
+
return [ :human, comment.user_id ] if comment&.user && !comment.user.ai_user?
|
|
340
|
+
|
|
341
|
+
creator = users[agent_creators[task.agent_id]]
|
|
342
|
+
[ :fallback, creator&.id ]
|
|
343
|
+
end
|
|
344
|
+
private_class_method :effective_workspace_principal
|
|
345
|
+
|
|
346
|
+
# The comment a task was going to answer, plus anything it had already
|
|
347
|
+
# absorbed itself — coalescing has to be transitive or a second round
|
|
348
|
+
# drops the first round's merges.
|
|
349
|
+
def trigger_comment_ids(task)
|
|
350
|
+
payload = task.trigger_event_payload || {}
|
|
351
|
+
([ payload.dig("comment", "id") ] + Array(payload[PAYLOAD_KEY])).compact
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# The single rule for who may hold a topic's concurrency slot, shared by the
|
|
6
|
+
# two paths that hand one out: AiAgentJob's admission of a fresh dispatch and
|
|
7
|
+
# AgentOrchestrator.dequeue_next_for_topic's promotion of a parked waiter.
|
|
8
|
+
#
|
|
9
|
+
# Keeping the rule in one place is the point. When only admission enforced
|
|
10
|
+
# it, promotion could still hand a second slot to an agent that already held
|
|
11
|
+
# one, or overfill a topic outright — and TaskCoalescer folds only `queued`
|
|
12
|
+
# rows, so two concurrent turns for one agent can never be merged afterwards.
|
|
13
|
+
module TopicSlot
|
|
14
|
+
# Serialize every admission and promotion for one topic scope on one
|
|
15
|
+
# stable row. SELECT ... FOR UPDATE on Postgres; the SQLite visitor drops
|
|
16
|
+
# the lock clause, where writes are serialized anyway.
|
|
17
|
+
#
|
|
18
|
+
# A creative's Main topic carries topic_id == nil, which is a real scope
|
|
19
|
+
# (occupancy is counted as topic_id: nil within the creative) but has no
|
|
20
|
+
# topic row to lock — fall back to the creative, which is just as stable
|
|
21
|
+
# and is shared by exactly the dispatches that compete for that slot.
|
|
22
|
+
# Tasks carry topic_id with no foreign key, so a deleted topic leaves rows
|
|
23
|
+
# pointing at a row that is gone — and a delayed job can carry that id long
|
|
24
|
+
# after the fact. Returning the missing topic's nil without trying the
|
|
25
|
+
# creative would run admission, promotion and notice dedup with no
|
|
26
|
+
# serialization at all: silently the one state this lock exists to prevent.
|
|
27
|
+
def self.lock!(topic_id, creative_id = nil)
|
|
28
|
+
if topic_id
|
|
29
|
+
topic = Topic.lock.find_by(id: topic_id)
|
|
30
|
+
return topic if topic
|
|
31
|
+
end
|
|
32
|
+
return Creative.lock.find_by(id: creative_id) if creative_id
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.matches_context?(locked_scope, topic_id, creative_id)
|
|
38
|
+
expected_class = topic_id ? Topic : Creative
|
|
39
|
+
expected_id = topic_id || creative_id
|
|
40
|
+
locked_scope.is_a?(expected_class) && locked_scope.id.to_s == expected_id.to_s &&
|
|
41
|
+
(topic_id.nil? || locked_scope.creative_id.to_s == creative_id.to_s)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.lock_matches_context?(topic_id, creative_id)
|
|
45
|
+
matches_context?(lock!(topic_id, creative_id), topic_id, creative_id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# May `agent_id` take a slot in this topic scope right now?
|
|
49
|
+
#
|
|
50
|
+
# Deliberately NOT gated on coalesce_pending_tasks?: that switch governs
|
|
51
|
+
# whether waiters are folded together, not whether topic_max_concurrent_jobs
|
|
52
|
+
# is enforced. Skipping the check when it is off would let a burst start
|
|
53
|
+
# one concurrent turn per comment in a topic limited to one.
|
|
54
|
+
#
|
|
55
|
+
# Occupancy counts running/delegated (executing) plus pending (claimed,
|
|
56
|
+
# job not started) and pending_approval (paused, keeps its resource and
|
|
57
|
+
# does not drain the queue) — treating any of those as free is exactly how
|
|
58
|
+
# a second turn slips in.
|
|
59
|
+
def self.available_for?(agent_id, topic_id, creative_id, context)
|
|
60
|
+
topic_max = PolicyResolver.new(context || {}).topic_max_concurrent_jobs
|
|
61
|
+
return true unless topic_max
|
|
62
|
+
|
|
63
|
+
occupying = Task.occupying_topic_slot(topic_id, creative_id)
|
|
64
|
+
return false if occupying.count >= topic_max
|
|
65
|
+
|
|
66
|
+
# A free slot is not automatically THIS agent's to take. topic_max > 1
|
|
67
|
+
# exists to run *different* agents in parallel, so an agent that already
|
|
68
|
+
# has a turn in flight here must wait rather than answer itself twice.
|
|
69
|
+
!occupying.where(agent_id: agent_id).exists?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -20,13 +20,15 @@ module Collavre
|
|
|
20
20
|
ctx
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
# The sender block for a user, or nil when there is nobody to attribute to.
|
|
24
|
+
#
|
|
25
|
+
# Public because `build` only ever fills the key in (`||=`) and never runs
|
|
26
|
+
# again on a promotion path: re-anchoring a coalesced task moves the trigger
|
|
27
|
+
# to a different comment, so whoever moves it has to rebuild the sender from
|
|
28
|
+
# the new author or the turn is labelled with the wrong speaker. Both callers
|
|
29
|
+
# must produce the same shape — a stub missing "is_ai" silently flips the
|
|
30
|
+
# A2A gate in AgentContextBuilder.
|
|
31
|
+
def self.sender_context_for(user)
|
|
30
32
|
return nil unless user
|
|
31
33
|
|
|
32
34
|
{
|
|
@@ -38,12 +40,59 @@ module Collavre
|
|
|
38
40
|
}
|
|
39
41
|
end
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
# The "chat" block for a comment the trigger has just been moved onto.
|
|
44
|
+
#
|
|
45
|
+
# Public for the same reason `sender_context_for` is: `build` only ever
|
|
46
|
+
# fills "mentioned_user" in (`||=`) and does not run again on a
|
|
47
|
+
# re-anchoring path, and AiAgentJob asks Matcher#assignment_permits?
|
|
48
|
+
# against the raw payload. A mention left behind by the move therefore
|
|
49
|
+
# reads as "no mention at all" — and an explicit mention is precisely what
|
|
50
|
+
# outranks a topic's primary-agent assignment, so a restored or promoted
|
|
51
|
+
# dispatch that *was* addressed to this agent gets refused for a topic
|
|
52
|
+
# that belongs to someone else.
|
|
53
|
+
#
|
|
54
|
+
# The block is rebuilt rather than merged onto: a mention that finds
|
|
55
|
+
# nobody must leave the key absent, since Matcher reads its presence as
|
|
56
|
+
# the mention that outranks the assignment.
|
|
57
|
+
def self.reanchor_chat(content)
|
|
58
|
+
chat = { "content" => content }
|
|
59
|
+
mention = mentioned_user_for(content)
|
|
60
|
+
mention ? chat.merge("mentioned_user" => mention) : chat
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.mentioned_user_for(content)
|
|
43
64
|
return nil unless content
|
|
44
65
|
|
|
45
|
-
|
|
46
|
-
|
|
66
|
+
MentionParser.resolve_user(content)&.as_json(only: [ :id, :name, :email ])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Point a payload's sender at the comment the trigger has just been moved
|
|
70
|
+
# onto. Only when the author actually changed: a payload may carry a sender
|
|
71
|
+
# its producer shaped deliberately, and re-anchoring inside one user's own
|
|
72
|
+
# burst is no reason to touch it.
|
|
73
|
+
#
|
|
74
|
+
# A rebuild that finds no user drops the key rather than leaving a wrong
|
|
75
|
+
# one: ClaudeChannelAdapter then falls back to the comment's own user_id and
|
|
76
|
+
# MessageBuilder omits the speaker label instead of naming the wrong person.
|
|
77
|
+
def self.reanchor_sender(payload, comment)
|
|
78
|
+
return payload unless payload.key?("sender")
|
|
79
|
+
return payload if payload.dig("sender", "id") == comment.user_id
|
|
80
|
+
|
|
81
|
+
sender = sender_context_for(comment.user)
|
|
82
|
+
sender ? payload.merge("sender" => sender) : payload.except("sender")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def build_sender_context(ctx)
|
|
88
|
+
user_id = ctx.dig("comment", "user_id")
|
|
89
|
+
return nil unless user_id
|
|
90
|
+
|
|
91
|
+
self.class.sender_context_for(User.find_by(id: user_id))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mentioned_user(chat_context)
|
|
95
|
+
self.class.mentioned_user_for(chat_context["content"])
|
|
47
96
|
end
|
|
48
97
|
end
|
|
49
98
|
end
|
|
@@ -1,22 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Collavre
|
|
2
4
|
module SystemEvents
|
|
5
|
+
# The only entry point to orchestration. It validates the event type and
|
|
6
|
+
# stamps its envelope before the payload is persisted or scheduled.
|
|
3
7
|
class Dispatcher
|
|
4
|
-
def self.dispatch(event_name, context
|
|
5
|
-
|
|
8
|
+
def self.dispatch(event_name, context, source: nil, parent: nil, selected_agents: nil, selection: nil,
|
|
9
|
+
context_for: nil, scheduling_hooks: nil)
|
|
10
|
+
new.dispatch(
|
|
11
|
+
event_name, context, source: source, parent: parent,
|
|
12
|
+
selected_agents: selected_agents, selection: selection, context_for: context_for,
|
|
13
|
+
scheduling_hooks: scheduling_hooks
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def dispatch(event_name, context, source: nil, parent: nil, selected_agents: nil, selection: nil,
|
|
18
|
+
context_for: nil, scheduling_hooks: nil)
|
|
19
|
+
definition = Vocabulary.fetch(event_name)
|
|
20
|
+
ctx = (context || {}).deep_stringify_keys
|
|
21
|
+
envelope = resolve_envelope(definition.name, ctx, source, parent)
|
|
22
|
+
ctx[Envelope::KEY] = envelope.to_h
|
|
23
|
+
|
|
24
|
+
warn_missing_keys(definition, ctx, envelope)
|
|
25
|
+
log_dispatch(definition, ctx, envelope)
|
|
26
|
+
|
|
27
|
+
Orchestration::AgentOrchestrator.dispatch(
|
|
28
|
+
definition.name, ctx, selected_agents: selected_agents, selection: selection,
|
|
29
|
+
context_for: context_for, scheduling_hooks: scheduling_hooks
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def resolve_envelope(event_name, context, source, parent)
|
|
36
|
+
return Envelope.child(event_name, parent: parent, source: source) if parent
|
|
37
|
+
|
|
38
|
+
existing = Envelope.in(context)
|
|
39
|
+
return Envelope.root(event_name, source: source) if existing.nil?
|
|
40
|
+
return existing if existing.name == event_name
|
|
41
|
+
|
|
42
|
+
Envelope.child(event_name, parent: existing, source: source)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def warn_missing_keys(definition, context, envelope)
|
|
46
|
+
missing = Vocabulary.missing_keys(definition.name, context)
|
|
47
|
+
return if missing.empty?
|
|
48
|
+
|
|
49
|
+
Rails.logger.warn(
|
|
50
|
+
"[SystemEvents::Dispatcher] event=#{definition.name} " \
|
|
51
|
+
"event_id=#{envelope.id} missing_keys=#{missing.join(',')}"
|
|
52
|
+
)
|
|
6
53
|
end
|
|
7
54
|
|
|
8
|
-
def
|
|
9
|
-
comment_id = context.dig("comment", "id")
|
|
10
|
-
comment_user_id = context.dig("comment", "user_id")
|
|
11
|
-
creative_id = context.dig("creative", "id")
|
|
55
|
+
def log_dispatch(definition, context, envelope)
|
|
12
56
|
Rails.logger.info(
|
|
13
|
-
"[SystemEvents::Dispatcher] event=#{
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
57
|
+
"[SystemEvents::Dispatcher] event=#{definition.name} " \
|
|
58
|
+
"event_id=#{envelope.id} correlation_id=#{envelope.correlation_id} " \
|
|
59
|
+
"causation_id=#{envelope.causation_id || '-'} depth=#{envelope.depth} " \
|
|
60
|
+
"source=#{envelope.source} " \
|
|
61
|
+
"comment_id=#{context.dig('comment', 'id')} " \
|
|
62
|
+
"comment_user_id=#{context.dig('comment', 'user_id')} " \
|
|
63
|
+
"creative_id=#{context.dig('creative', 'id')} " \
|
|
64
|
+
"caller=#{caller_locations(1, 5)&.map { |location| "#{File.basename(location.path)}:#{location.lineno}" }&.join(' <- ')}"
|
|
17
65
|
)
|
|
18
|
-
# Delegate to AgentOrchestrator for unified routing/scheduling
|
|
19
|
-
Orchestration::AgentOrchestrator.dispatch(event_name, context)
|
|
20
66
|
end
|
|
21
67
|
end
|
|
22
68
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module SystemEvents
|
|
5
|
+
# JSON-safe metadata that identifies an orchestration dispatch and its
|
|
6
|
+
# causal chain. It travels inside tasks.trigger_event_payload under KEY.
|
|
7
|
+
class Envelope < Data.define(
|
|
8
|
+
:id, :name, :correlation_id, :causation_id, :depth, :source, :occurred_at
|
|
9
|
+
)
|
|
10
|
+
KEY = "event"
|
|
11
|
+
UNKNOWN_SOURCE = "unknown"
|
|
12
|
+
|
|
13
|
+
def self.root(name, source: nil)
|
|
14
|
+
id = SecureRandom.uuid
|
|
15
|
+
new(
|
|
16
|
+
id: id,
|
|
17
|
+
name: name.to_s,
|
|
18
|
+
correlation_id: id,
|
|
19
|
+
causation_id: nil,
|
|
20
|
+
depth: 0,
|
|
21
|
+
source: (source.presence || UNKNOWN_SOURCE).to_s,
|
|
22
|
+
occurred_at: Time.current.utc.iso8601
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.child(name, parent:, source: nil)
|
|
27
|
+
parent = from(parent) unless parent.is_a?(Envelope)
|
|
28
|
+
return root(name, source: source) if parent.nil?
|
|
29
|
+
|
|
30
|
+
root(name, source: source).with(
|
|
31
|
+
correlation_id: parent.correlation_id,
|
|
32
|
+
causation_id: parent.id,
|
|
33
|
+
depth: parent.depth.to_i + 1
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.from(hash)
|
|
38
|
+
return hash if hash.is_a?(Envelope)
|
|
39
|
+
return nil unless hash.respond_to?(:to_h)
|
|
40
|
+
|
|
41
|
+
hash = hash.to_h.deep_stringify_keys
|
|
42
|
+
id = hash["id"]
|
|
43
|
+
return nil if id.blank?
|
|
44
|
+
|
|
45
|
+
new(
|
|
46
|
+
id: id,
|
|
47
|
+
name: hash["name"].to_s,
|
|
48
|
+
correlation_id: hash["correlation_id"].presence || id,
|
|
49
|
+
causation_id: hash["causation_id"].presence,
|
|
50
|
+
depth: hash["depth"].to_i,
|
|
51
|
+
source: hash["source"].presence || UNKNOWN_SOURCE,
|
|
52
|
+
occurred_at: hash["occurred_at"].presence
|
|
53
|
+
)
|
|
54
|
+
rescue TypeError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.in(context)
|
|
59
|
+
return nil unless context.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
from(context[KEY] || context[KEY.to_sym])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def root?
|
|
65
|
+
causation_id.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_h
|
|
69
|
+
super.deep_stringify_keys
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|