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,380 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Topics
|
|
5
|
+
# One newest-first window of a topic's messages.
|
|
6
|
+
#
|
|
7
|
+
# Newest-first is the ordering the window is *selected* in: offset 0 is the
|
|
8
|
+
# latest message. A conversation's recent end is the part worth reading when
|
|
9
|
+
# you can only afford part of it, and it is the part whose position does not
|
|
10
|
+
# change as the topic grows — an oldest-first offset would point somewhere
|
|
11
|
+
# different every time a message arrived.
|
|
12
|
+
class MessagePage
|
|
13
|
+
include Collavre::PublicAssetsHelper
|
|
14
|
+
|
|
15
|
+
DEFAULT_LIMIT = 50
|
|
16
|
+
MAX_LIMIT = 200
|
|
17
|
+
|
|
18
|
+
# Rendered oldest-first inside the window by default. The window is chosen
|
|
19
|
+
# from the newest end; how it reads once chosen is a separate question,
|
|
20
|
+
# and a summary written from a backwards transcript inverts cause and
|
|
21
|
+
# effect. Callers wanting the raw newest-first order pass order: "desc".
|
|
22
|
+
ORDERS = %w[asc desc].freeze
|
|
23
|
+
DEFAULT_ORDER = "asc"
|
|
24
|
+
|
|
25
|
+
Page = Struct.new(
|
|
26
|
+
:topic, :total_count, :total_chars, :offset, :limit,
|
|
27
|
+
:messages, :newest_message_id, :budget_exhausted, :budget,
|
|
28
|
+
:content_offset, :order, :next_cursor,
|
|
29
|
+
keyword_init: true
|
|
30
|
+
) do
|
|
31
|
+
def returned_count = messages.size
|
|
32
|
+
|
|
33
|
+
# What the caller reads.
|
|
34
|
+
def returned_chars = messages.sum { |m| m[:content].to_s.length }
|
|
35
|
+
|
|
36
|
+
# What the caller is charged: what emitting these messages in the
|
|
37
|
+
# requested format costs, which is not what their prose measures.
|
|
38
|
+
def billed_chars = messages.sum { |m| budget.cost(m) }
|
|
39
|
+
|
|
40
|
+
def clipped_count = messages.count { |m| m[:clipped] }
|
|
41
|
+
|
|
42
|
+
def next_content_offset
|
|
43
|
+
messages.filter_map { |message| message[:next_content_offset] }.first ||
|
|
44
|
+
(content_offset if messages.empty? && next_cursor.present?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A clipped row is not consumed. The next call stays on the same row
|
|
48
|
+
# and advances inside its content; only a complete row advances offset.
|
|
49
|
+
def has_more? = next_cursor.present?
|
|
50
|
+
|
|
51
|
+
def next_offset
|
|
52
|
+
return offset if next_content_offset.present? || (messages.empty? && has_more?)
|
|
53
|
+
|
|
54
|
+
has_more? ? offset + returned_count : nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# budget carries both the cap and the format it is counted in — see
|
|
59
|
+
# CharBudget for why those cannot be separate arguments.
|
|
60
|
+
def initialize(topic:, user:, **options)
|
|
61
|
+
options.assert_valid_keys(
|
|
62
|
+
:offset, :limit, :order, :include_system, :max_message_id, :content_offset, :cursor, :budget
|
|
63
|
+
)
|
|
64
|
+
@topic = topic
|
|
65
|
+
@user = user
|
|
66
|
+
@offset = [ options.fetch(:offset, 0).to_i, 0 ].max
|
|
67
|
+
@limit = clamp_limit(options.fetch(:limit, DEFAULT_LIMIT))
|
|
68
|
+
@order = normalized_order(options.fetch(:order, DEFAULT_ORDER))
|
|
69
|
+
@include_system = options.fetch(:include_system, false)
|
|
70
|
+
@max_message_id = options[:max_message_id]
|
|
71
|
+
@content_offset = [ options.fetch(:content_offset, 0).to_i, 0 ].max
|
|
72
|
+
@cursor = decode_cursor(options[:cursor])
|
|
73
|
+
@topic_assigned_before = @cursor&.fetch(:topic_assigned_before)
|
|
74
|
+
@budget = options.fetch(:budget, CharBudget.new)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def call
|
|
78
|
+
# CommentMoveService takes the destination topic lock before changing a
|
|
79
|
+
# comment's membership. Holding the same lock through the anchor and
|
|
80
|
+
# reads makes the assignment timestamp a commit-order boundary: a move
|
|
81
|
+
# is either wholly before this snapshot or wholly after it.
|
|
82
|
+
Topic.transaction do
|
|
83
|
+
lock_authorized_topic
|
|
84
|
+
build_page
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def build_page
|
|
91
|
+
anchor!
|
|
92
|
+
stat = MessageStats.for(
|
|
93
|
+
[ @topic ], user: @user, include_system: @include_system,
|
|
94
|
+
max_message_id: @max_message_id, topic_assigned_before: @topic_assigned_before
|
|
95
|
+
).fetch(@topic.id)
|
|
96
|
+
window = fetch_window
|
|
97
|
+
content_offset = content_offset_for(window.first)
|
|
98
|
+
messages = within_budget(serialize_window(window.first(@limit), content_offset: content_offset))
|
|
99
|
+
next_cursor = encode_cursor(next_candidate(window, messages))
|
|
100
|
+
|
|
101
|
+
Page.new(
|
|
102
|
+
topic: @topic,
|
|
103
|
+
total_count: stat.count,
|
|
104
|
+
total_chars: stat.chars,
|
|
105
|
+
offset: @offset,
|
|
106
|
+
limit: @limit,
|
|
107
|
+
messages: @order == "asc" ? messages.reverse : messages,
|
|
108
|
+
newest_message_id: @max_message_id,
|
|
109
|
+
budget_exhausted: budget_exhausted?(messages, next_cursor),
|
|
110
|
+
content_offset: content_offset,
|
|
111
|
+
order: @order,
|
|
112
|
+
next_cursor: next_cursor,
|
|
113
|
+
budget: @budget
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Do not reload @topic here: TopicSelection already authorized its
|
|
118
|
+
# creative. If a concurrent TopicMove changed that creative, the exact
|
|
119
|
+
# authorized pair no longer locks or reads anything and fails closed.
|
|
120
|
+
def lock_authorized_topic
|
|
121
|
+
Topic.where(id: @topic.id, creative_id: @topic.creative_id).lock.pick(:id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# An unanchored call picks its own anchor before it reads anything else,
|
|
125
|
+
# so the totals, the window and the advertised newest_message_id all
|
|
126
|
+
# describe one snapshot. Reading the anchor last instead lets a message
|
|
127
|
+
# that arrives mid-call land in the totals but not the window, or be
|
|
128
|
+
# named as newest without having been returned — and the caller then
|
|
129
|
+
# pages with an anchor that shifts every offset under it, repeating one
|
|
130
|
+
# message and skipping another.
|
|
131
|
+
#
|
|
132
|
+
# An empty topic anchors at 0 rather than nil, because nil is how this
|
|
133
|
+
# scope spells "unbounded": leaving it there would let a message arriving
|
|
134
|
+
# between the anchor and the totals query into both, and then advertise
|
|
135
|
+
# no anchor for the caller to pin the next page with. Zero is below every
|
|
136
|
+
# id, so it names the empty snapshot exactly, and Ruby's 0 is truthy —
|
|
137
|
+
# a caller passing it back gets the same bound rather than a fresh anchor.
|
|
138
|
+
def anchor!
|
|
139
|
+
@topic_assigned_before ||= Time.current
|
|
140
|
+
@max_message_id ||= MessageScope.for(
|
|
141
|
+
@topic, user: @user, include_system: @include_system,
|
|
142
|
+
topic_assigned_before: @topic_assigned_before
|
|
143
|
+
).maximum(:id) || 0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def scope
|
|
147
|
+
@scope ||= MessageScope.for(
|
|
148
|
+
@topic, user: @user, include_system: @include_system,
|
|
149
|
+
max_message_id: @max_message_id, topic_assigned_before: @topic_assigned_before
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# id is the tiebreak, not decoration: comments posted inside the same
|
|
154
|
+
# clock tick would otherwise order arbitrarily, and an unstable sort makes
|
|
155
|
+
# offset pagination drop and repeat rows across pages.
|
|
156
|
+
def fetch_window
|
|
157
|
+
relation = scope.includes(:user, images_attachments: :blob)
|
|
158
|
+
.order(created_at: :desc, id: :desc)
|
|
159
|
+
relation = after_cursor(relation) if @cursor
|
|
160
|
+
relation = relation.offset(@offset) unless @cursor
|
|
161
|
+
relation.limit(@limit + 1).to_a
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def serialize_window(comments, content_offset:)
|
|
165
|
+
comments.map.with_index do |comment, index|
|
|
166
|
+
serialize(comment, content_offset: index.zero? ? content_offset : 0)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A content offset belongs to the row named by the cursor. If that row
|
|
171
|
+
# leaves the topic, the inclusive keyset boundary correctly selects the
|
|
172
|
+
# next surviving row, but its content must start at zero.
|
|
173
|
+
def content_offset_for(first_comment)
|
|
174
|
+
return @content_offset unless @cursor
|
|
175
|
+
return @content_offset if cursor_row_unchanged?(first_comment)
|
|
176
|
+
|
|
177
|
+
0
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def cursor_row_unchanged?(comment)
|
|
181
|
+
comment&.id == @cursor.fetch(:id) &&
|
|
182
|
+
timestamp_micros(comment.updated_at) == @cursor.fetch(:updated_at_micros)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The cursor names the first row not yet consumed, inclusively. This is
|
|
186
|
+
# what makes it survive that row itself being deleted or moved: the same
|
|
187
|
+
# ordered boundary still selects every older surviving row. During a
|
|
188
|
+
# clipped-row continuation it names the current row instead.
|
|
189
|
+
def next_candidate(window, messages)
|
|
190
|
+
return window.first if messages.empty? || messages.first[:clipped]
|
|
191
|
+
|
|
192
|
+
window[messages.length]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def after_cursor(relation)
|
|
196
|
+
relation.where(
|
|
197
|
+
"comments.created_at < :created_at OR " \
|
|
198
|
+
"(comments.created_at = :created_at AND comments.id <= :id)",
|
|
199
|
+
created_at: @cursor.fetch(:created_at), id: @cursor.fetch(:id)
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def encode_cursor(comment)
|
|
204
|
+
return unless comment
|
|
205
|
+
|
|
206
|
+
[
|
|
207
|
+
timestamp_micros(comment.created_at), comment.id,
|
|
208
|
+
timestamp_micros(comment.updated_at), timestamp_micros(@topic_assigned_before)
|
|
209
|
+
].join(":")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def decode_cursor(value)
|
|
213
|
+
return if value.blank?
|
|
214
|
+
|
|
215
|
+
match = /\A(\d{1,20}):(\d{1,20}):(\d{1,20}):(\d{1,20})\z/.match(value.to_s)
|
|
216
|
+
raise ArgumentError, "cursor is invalid" unless match
|
|
217
|
+
|
|
218
|
+
micros = Integer(match[1], 10)
|
|
219
|
+
id = Integer(match[2], 10)
|
|
220
|
+
updated_at_micros = Integer(match[3], 10)
|
|
221
|
+
topic_assigned_before_micros = Integer(match[4], 10)
|
|
222
|
+
unless [ micros, id, updated_at_micros, topic_assigned_before_micros ].all?(&:positive?)
|
|
223
|
+
raise ArgumentError, "cursor is invalid"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
created_at: Time.at(micros / 1_000_000, micros % 1_000_000, :microsecond).utc,
|
|
228
|
+
id: id,
|
|
229
|
+
updated_at_micros: updated_at_micros,
|
|
230
|
+
topic_assigned_before: Time.at(
|
|
231
|
+
topic_assigned_before_micros / 1_000_000,
|
|
232
|
+
topic_assigned_before_micros % 1_000_000,
|
|
233
|
+
:microsecond
|
|
234
|
+
).utc
|
|
235
|
+
}
|
|
236
|
+
rescue RangeError
|
|
237
|
+
raise ArgumentError, "cursor is invalid"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def timestamp_micros(time)
|
|
241
|
+
(time.to_r * 1_000_000).to_i
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Trims the newest-first window to the character budget. A message that
|
|
245
|
+
# does not fit ends the window rather than being cut in half — except for
|
|
246
|
+
# the first one, where stopping short would return zero rows at an offset
|
|
247
|
+
# that does have rows and leave the caller paging against it forever.
|
|
248
|
+
# That one is clipped to what the budget can pay for and exposes a content
|
|
249
|
+
# continuation. The row offset stays put until all of its prose is read.
|
|
250
|
+
def within_budget(messages)
|
|
251
|
+
return messages if @budget.unlimited?
|
|
252
|
+
|
|
253
|
+
spent = 0
|
|
254
|
+
kept = []
|
|
255
|
+
messages.each do |message|
|
|
256
|
+
cost = @budget.cost(message)
|
|
257
|
+
if @budget.fits?(cost, spent: spent)
|
|
258
|
+
kept << message
|
|
259
|
+
spent += cost
|
|
260
|
+
next
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
clipped = kept.empty? ? clip(message) : nil
|
|
264
|
+
kept << clipped if clipped
|
|
265
|
+
break
|
|
266
|
+
end
|
|
267
|
+
kept
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Returns nil when the budget cannot pay even for the envelope and the
|
|
271
|
+
# notice — there is no honest way to emit a fragment that small, and the
|
|
272
|
+
# empty page carries budget_limited to say so.
|
|
273
|
+
def clip(message)
|
|
274
|
+
content = message[:content].to_s
|
|
275
|
+
room = widest_prefix(message, content)
|
|
276
|
+
return nil if room <= 0
|
|
277
|
+
|
|
278
|
+
clipped(message, content, room)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The longest prefix of the prose whose rendered cost still fits.
|
|
282
|
+
# Subtraction would do for markdown, where a character of content costs a
|
|
283
|
+
# character of output, but json escapes the prose while serializing it, so
|
|
284
|
+
# a slice can emit wider than it reads and arithmetic only gives an upper
|
|
285
|
+
# bound. Cost rises monotonically with the prefix, so the exact answer is
|
|
286
|
+
# a bisection — eighteen serializations at the largest cap, and only on
|
|
287
|
+
# the clip path, which is one message per call at most.
|
|
288
|
+
def widest_prefix(message, content)
|
|
289
|
+
low = 0
|
|
290
|
+
high = content.length
|
|
291
|
+
while low < high
|
|
292
|
+
mid = (low + high + 1) / 2
|
|
293
|
+
if @budget.fits?(@budget.cost(clipped(message, content, mid)))
|
|
294
|
+
low = mid
|
|
295
|
+
else
|
|
296
|
+
high = mid - 1
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
low
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def clipped(message, content, take)
|
|
303
|
+
start = message[:content_offset].to_i
|
|
304
|
+
finish = start + take
|
|
305
|
+
total = message[:content_total_chars] || content.length
|
|
306
|
+
|
|
307
|
+
message.merge(
|
|
308
|
+
content: content[0, take],
|
|
309
|
+
content_offset: start,
|
|
310
|
+
content_end_offset: finish,
|
|
311
|
+
content_total_chars: total,
|
|
312
|
+
next_content_offset: finish,
|
|
313
|
+
clip_notice: clip_notice(finish, total),
|
|
314
|
+
clipped: true
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Counted against the budget like any other content, and phrased for the
|
|
319
|
+
# reader of the transcript: the caller sees the clip where the words stop,
|
|
320
|
+
# not only in a flag on the payload it may have rendered away.
|
|
321
|
+
def clip_notice(next_offset, total)
|
|
322
|
+
"…[clipped at #{next_offset} of #{total} chars — continue with content_offset: #{next_offset}]"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def serialize(comment, content_offset:)
|
|
326
|
+
content = serialized_content(comment)
|
|
327
|
+
start = [ content_offset, content.length ].min
|
|
328
|
+
message = {
|
|
329
|
+
id: comment.id,
|
|
330
|
+
author: comment.user&.display_name || "system",
|
|
331
|
+
author_id: comment.user_id,
|
|
332
|
+
agent: comment.user&.ai_user? || false,
|
|
333
|
+
created_at: comment.created_at&.iso8601,
|
|
334
|
+
content: content[start..] || ""
|
|
335
|
+
}
|
|
336
|
+
return message if start.zero?
|
|
337
|
+
|
|
338
|
+
message.merge(
|
|
339
|
+
content_offset: start,
|
|
340
|
+
content_end_offset: content.length,
|
|
341
|
+
content_total_chars: content.length
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Image metadata lives inside the pageable content rather than beside it.
|
|
346
|
+
# That keeps image-only comments meaningful while letting max_chars and
|
|
347
|
+
# content_offset bound and continue an arbitrarily large attachment list
|
|
348
|
+
# through the same contract as the message prose.
|
|
349
|
+
def serialized_content(comment)
|
|
350
|
+
text = Collavre::HtmlText.plain(comment.content).strip
|
|
351
|
+
[ text.presence, image_markers(comment).presence ].compact.join("\n\n")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def image_markers(comment)
|
|
355
|
+
comment.images.map.with_index(1) do |image, index|
|
|
356
|
+
"[Image attachment #{index}: #{image.filename.sanitized} " \
|
|
357
|
+
"(#{image.content_type}, #{image.byte_size} bytes) — #{public_asset_url(image.blob)}]"
|
|
358
|
+
end.join("\n")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def budget_exhausted?(messages, next_cursor)
|
|
362
|
+
return false if @budget.unlimited?
|
|
363
|
+
|
|
364
|
+
messages.any? { |message| message[:clipped] } ||
|
|
365
|
+
(messages.size < @limit && next_cursor.present?)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def clamp_limit(limit)
|
|
369
|
+
limit = limit.to_i
|
|
370
|
+
return DEFAULT_LIMIT if limit <= 0
|
|
371
|
+
|
|
372
|
+
[ limit, MAX_LIMIT ].min
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def normalized_order(order)
|
|
376
|
+
ORDERS.include?(order.to_s) ? order.to_s : DEFAULT_ORDER
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Topics
|
|
5
|
+
# Renders the topic_messages payload as markdown.
|
|
6
|
+
#
|
|
7
|
+
# Renders the *payload*, not the records, so the markdown and json formats
|
|
8
|
+
# of the same call cannot disagree about what was returned — the json branch
|
|
9
|
+
# returns the identical hash this reads.
|
|
10
|
+
#
|
|
11
|
+
# Markdown is the default format because the content is prose. Wrapping
|
|
12
|
+
# conversation in JSON escaping costs a large fraction of the character
|
|
13
|
+
# budget the paging exists to protect, and buys nothing: the caller is going
|
|
14
|
+
# to read the words, not index into them.
|
|
15
|
+
module MessagePageMarkdown
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(payload)
|
|
19
|
+
sections = payload[:topics].map { |topic| render_topic(topic) }
|
|
20
|
+
sections << truncation_notice if payload[:truncated]
|
|
21
|
+
sections.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render_topic(entry)
|
|
25
|
+
return render_error(entry) if entry[:error]
|
|
26
|
+
return "#{title(entry)}\n#{skipped_notice(entry)}\n" if entry[:skipped_reason]
|
|
27
|
+
|
|
28
|
+
[ header(entry), *continuation(entry), "", *body(entry) ].join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_error(entry)
|
|
32
|
+
"## [topic #{entry[:topic_id]}] unavailable\n#{entry[:error]}\n"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def title(entry)
|
|
36
|
+
"## [topic #{entry[:topic_id]}] #{entry[:topic_name]} (creative #{entry[:creative_id]})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def header(entry)
|
|
40
|
+
"#{title(entry)}\n" \
|
|
41
|
+
"#{entry[:total_count]} messages, ~#{entry[:total_chars]} raw chars — " \
|
|
42
|
+
"showing #{shown_range(entry)} of #{entry[:total_count]}, newest-first window"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def shown_range(entry)
|
|
46
|
+
return "none" if entry[:returned_count].to_i.zero?
|
|
47
|
+
|
|
48
|
+
"#{entry[:offset]}-#{entry[:offset] + entry[:returned_count] - 1}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The next call is spelled out rather than left as a next_offset field to
|
|
52
|
+
# decode. max_message_id is repeated into it because that is the whole
|
|
53
|
+
# point of the anchor — a caller that drops it silently re-reads messages
|
|
54
|
+
# as the topic grows underneath the pagination.
|
|
55
|
+
#
|
|
56
|
+
# include_system is repeated for the same reason, and it matters more:
|
|
57
|
+
# the anchor shifts rows, but a narrower scope renumbers them. Paging
|
|
58
|
+
# into a set that no longer contains the system rows the first page
|
|
59
|
+
# counted lands the offset past messages that were never returned.
|
|
60
|
+
def continuation(entry)
|
|
61
|
+
return [] unless entry[:has_more]
|
|
62
|
+
|
|
63
|
+
[ "More: topic_messages(topic_ids: #{entry[:topic_id]}, offset: #{entry[:next_offset]}, " \
|
|
64
|
+
"cursor: \"#{entry[:next_cursor]}\", " \
|
|
65
|
+
"max_message_id: #{entry[:newest_message_id]}, limit: #{entry[:limit]}, " \
|
|
66
|
+
"max_chars: #{entry[:max_chars]}#{content_option(entry)}" \
|
|
67
|
+
"#{order_option(entry)}#{scope_option(entry)})" ]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def content_option(entry)
|
|
71
|
+
entry[:next_content_offset] ? ", content_offset: #{entry[:next_content_offset]}" : ""
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def order_option(entry)
|
|
75
|
+
entry[:order] == "desc" ? ', order: "desc"' : ""
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def scope_option(entry)
|
|
79
|
+
entry[:include_system] ? ", include_system: true" : ""
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def skipped_notice(entry)
|
|
83
|
+
"Not fetched: #{entry[:skipped_reason]}. Re-request this topic alone, or raise max_chars."
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Oldest-to-newest within the window by default, so the transcript reads
|
|
87
|
+
# forwards even though the window was chosen from the newest end.
|
|
88
|
+
def body(entry)
|
|
89
|
+
entry[:messages].map do |message|
|
|
90
|
+
rendered = "[#{message[:id]}] #{message[:created_at]} #{message[:author]}" \
|
|
91
|
+
"#{message[:agent] ? ' (agent)' : ''}\n#{message[:content]}\n"
|
|
92
|
+
message[:clip_notice] ? "#{rendered}#{message[:clip_notice]}\n" : rendered
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def truncation_notice
|
|
97
|
+
"> Output hit max_chars. Topics above may be partial — see each topic's " \
|
|
98
|
+
"\"More:\" line, or request fewer topics per call.\n"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Topics
|
|
5
|
+
# The one definition of "the messages of a topic" that the read tools share,
|
|
6
|
+
# so a count reported by topic_list and a page returned by topic_messages
|
|
7
|
+
# can never describe different sets.
|
|
8
|
+
module MessageScope
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# include_system: false is the default because the caller is almost always
|
|
12
|
+
# summarizing a conversation, and authorless rows are the timeline's
|
|
13
|
+
# furniture — "⏳" concurrency notices, channel announcements. Leaving
|
|
14
|
+
# them in would spend the caller's character budget on text nobody wrote.
|
|
15
|
+
#
|
|
16
|
+
# without_approval_action is NOT part of that switch. Approval-surface
|
|
17
|
+
# rows are excluded unconditionally, because Comment.without_approval_action
|
|
18
|
+
# carries an invariant these tools do not get to opt out of: an approval
|
|
19
|
+
# prompt must never reach an agent as history or trigger context. Every
|
|
20
|
+
# other agent-context query in the engine applies it the same way, and a
|
|
21
|
+
# read tool is exactly the back door that invariant exists to close.
|
|
22
|
+
#
|
|
23
|
+
# visible_to is applied unconditionally for the same reason: a private
|
|
24
|
+
# comment belongs to its author and approver, and a tool must not be the
|
|
25
|
+
# way around that.
|
|
26
|
+
#
|
|
27
|
+
# max_message_id excludes rows created after the first page. The topic
|
|
28
|
+
# assignment anchor excludes older rows moved in after that page, while
|
|
29
|
+
# MessagePage's keyset cursor lets rows leave without shifting unread
|
|
30
|
+
# survivors ahead of a numeric offset.
|
|
31
|
+
def for(topic, user:, include_system: false, max_message_id: nil, topic_assigned_before: nil)
|
|
32
|
+
for_topics(
|
|
33
|
+
[ topic ], user: user, include_system: include_system,
|
|
34
|
+
max_message_id: max_message_id, topic_assigned_before: topic_assigned_before
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Pair every topic id with the creative that was authorized. TopicMove
|
|
39
|
+
# preserves the topic id while relocating all of its comments, so an
|
|
40
|
+
# id-only query could read the destination creative after the permission
|
|
41
|
+
# check. The exact pair also avoids cross-topic matches in batch totals.
|
|
42
|
+
def for_topics(topics, user:, include_system: false, max_message_id: nil, topic_assigned_before: nil)
|
|
43
|
+
membership = topic_membership(topics)
|
|
44
|
+
return Comment.none unless membership
|
|
45
|
+
|
|
46
|
+
scope = Comment.where(membership).visible_to(user).without_approval_action
|
|
47
|
+
scope = scope.where.not(user_id: nil) unless include_system
|
|
48
|
+
scope = scope.where("comments.id <= ?", max_message_id) if max_message_id
|
|
49
|
+
scope = scope.where("comments.topic_assigned_at <= ?", topic_assigned_before) if topic_assigned_before
|
|
50
|
+
scope
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def topic_membership(topics)
|
|
54
|
+
table = Comment.arel_table
|
|
55
|
+
Array(topics).map do |topic|
|
|
56
|
+
table[:topic_id].eq(topic.id).and(table[:creative_id].eq(topic.creative_id))
|
|
57
|
+
end.reduce { |combined, pair| combined.or(pair) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Topics
|
|
5
|
+
# Per-topic message totals for a set of topics, in grouped aggregate queries.
|
|
6
|
+
#
|
|
7
|
+
# This is the call that makes fan-out planning possible: before an agent
|
|
8
|
+
# spends its context on message bodies it asks how much there is, and
|
|
9
|
+
# decides how to chunk. Counting by loading is exactly what that is meant to
|
|
10
|
+
# avoid, so nothing here instantiates a Comment.
|
|
11
|
+
module MessageStats
|
|
12
|
+
Stat = Struct.new(:count, :chars, :last_at, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
# Marker prose, attachment index, byte-size digits, separators and the
|
|
15
|
+
# signed-id path are bounded conservatively here. Filename is charged
|
|
16
|
+
# twice because it appears in both the label and URL; content type and a
|
|
17
|
+
# configured public-assets host are charged at their actual lengths.
|
|
18
|
+
ATTACHMENT_MARKER_OVERHEAD_CHARS = 300
|
|
19
|
+
|
|
20
|
+
EMPTY = Stat.new(count: 0, chars: 0, last_at: nil).freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Returns { topic_id => Stat }, with EMPTY for topics that have no
|
|
25
|
+
# visible messages (a grouped query returns no row for them at all).
|
|
26
|
+
#
|
|
27
|
+
# `chars` combines LENGTH over the stored HTML with a conservative image
|
|
28
|
+
# marker estimate. It remains a planning estimate rather than an exact
|
|
29
|
+
# rendered size: exact text requires stripping every body, which is the
|
|
30
|
+
# load this query exists to avoid.
|
|
31
|
+
# max_message_id must reach the totals as well as the window. has_more? is
|
|
32
|
+
# (offset + returned) < total, so a total counting past the anchor would
|
|
33
|
+
# keep claiming there is another page after the snapshot has been fully
|
|
34
|
+
# read — the caller pages forever against rows its anchor excludes.
|
|
35
|
+
def for(topics, user:, include_system: false, max_message_id: nil, topic_assigned_before: nil)
|
|
36
|
+
topics = Array(topics)
|
|
37
|
+
return {} if topics.empty?
|
|
38
|
+
|
|
39
|
+
scope = MessageScope.for_topics(
|
|
40
|
+
topics, user: user, include_system: include_system,
|
|
41
|
+
max_message_id: max_message_id, topic_assigned_before: topic_assigned_before
|
|
42
|
+
)
|
|
43
|
+
rows = grouped_rows(scope)
|
|
44
|
+
attachment_chars = attachment_chars_by_topic(scope)
|
|
45
|
+
totals = rows.to_h do |topic_id, count, chars, last_at|
|
|
46
|
+
total_chars = chars.to_i + attachment_chars.fetch(topic_id, 0)
|
|
47
|
+
[ topic_id, Stat.new(count: count.to_i, chars: total_chars, last_at: as_time(last_at)) ]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
topics.to_h { |topic| [ topic.id, totals[topic.id] || EMPTY ] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# An aggregate selected through Arel.sql carries no column type, so the
|
|
54
|
+
# adapter hands MAX(created_at) back as the raw driver value — a String on
|
|
55
|
+
# Postgres. Callers expect a Time they can format, so normalize here
|
|
56
|
+
# rather than at each of them.
|
|
57
|
+
def as_time(value)
|
|
58
|
+
return value if value.nil? || value.is_a?(Time)
|
|
59
|
+
|
|
60
|
+
Time.zone.parse(value.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def grouped_rows(scope)
|
|
64
|
+
scope.group(:topic_id).pluck(
|
|
65
|
+
:topic_id,
|
|
66
|
+
Arel.sql("COUNT(*)"),
|
|
67
|
+
Arel.sql("COALESCE(SUM(LENGTH(comments.content)), 0)"),
|
|
68
|
+
Arel.sql("MAX(comments.created_at)")
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def attachment_chars_by_topic(scope)
|
|
73
|
+
host_chars = Collavre::IntegrationSettings.fetch(:public_assets_host).to_s.length
|
|
74
|
+
|
|
75
|
+
attachment_rows(scope).to_h do |topic_id, count, filename_chars, content_type_chars|
|
|
76
|
+
estimate = count.to_i * (ATTACHMENT_MARKER_OVERHEAD_CHARS + host_chars) +
|
|
77
|
+
filename_chars.to_i * 2 + content_type_chars.to_i
|
|
78
|
+
[ topic_id, estimate ]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def attachment_rows(scope)
|
|
83
|
+
scope.joins(images_attachments: :blob).group(:topic_id).pluck(
|
|
84
|
+
:topic_id,
|
|
85
|
+
Arel.sql("COUNT(active_storage_attachments.id)"),
|
|
86
|
+
Arel.sql("COALESCE(SUM(LENGTH(active_storage_blobs.filename)), 0)"),
|
|
87
|
+
Arel.sql("COALESCE(SUM(LENGTH(active_storage_blobs.content_type)), 0)")
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Topics
|
|
5
|
+
class MoveBlocker
|
|
6
|
+
class MoveBlockedError < StandardError; end
|
|
7
|
+
class ActiveTaskError < MoveBlockedError; end
|
|
8
|
+
class PendingDeliveryError < MoveBlockedError; end
|
|
9
|
+
class RecurringTaskError < MoveBlockedError; end
|
|
10
|
+
class TriggerLoopError < MoveBlockedError; end
|
|
11
|
+
|
|
12
|
+
def initialize(topic)
|
|
13
|
+
@topic = topic
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
if Task.where(topic_id: topic.id, status: Task::ACTIVE_STATUSES).exists?
|
|
18
|
+
raise ActiveTaskError, I18n.t("collavre.topics.move.active_tasks")
|
|
19
|
+
end
|
|
20
|
+
if pending_delivery_restoration?
|
|
21
|
+
raise PendingDeliveryError, I18n.t("collavre.topics.move.pending_deliveries")
|
|
22
|
+
end
|
|
23
|
+
if trigger_loop_topic?
|
|
24
|
+
raise TriggerLoopError, I18n.t("collavre.topics.move.trigger_loop")
|
|
25
|
+
end
|
|
26
|
+
return unless Crons::RecurringTopicTasks.new(topic.id).any?
|
|
27
|
+
|
|
28
|
+
raise RecurringTaskError, I18n.t("collavre.topics.move.recurring_tasks")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :topic
|
|
34
|
+
|
|
35
|
+
def pending_delivery_restoration?
|
|
36
|
+
Task.where(topic_id: topic.id).where.not(status: Task::ACTIVE_STATUSES).find_each.any? do |task|
|
|
37
|
+
Orchestration::DeliveryRecord.pending_restoration?(task)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def trigger_loop_topic?
|
|
42
|
+
creative_data = Creative.where(id: topic.creative_id).pick(:data)
|
|
43
|
+
creative_data&.dig("trigger", "loop", "trigger_topic_id").to_i == topic.id
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|