collavre 0.20.3 → 0.22.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/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Reconnect-grace cancellation for delegated tasks owned by a Claude Channel
|
|
5
|
+
# session whose WebSocket dropped without DELETE /api/v1/agent/:id.
|
|
6
|
+
#
|
|
7
|
+
# AgentChannel#unsubscribed makes the agent unroutable, but a task already
|
|
8
|
+
# in "delegated" still holds its ResourceTracker slot and (for workflow
|
|
9
|
+
# subtasks) blocks the parent workflow. The dispatch was broadcast to a
|
|
10
|
+
# now-dead stream, so no client remains to call /reply — without this job
|
|
11
|
+
# the slot stays held until StuckDetectorJob times out (minutes to hours).
|
|
12
|
+
#
|
|
13
|
+
# The grace delay lets transient WS blips self-heal: if the client
|
|
14
|
+
# reconnects before the job fires, AgentChannel#subscribe_to_agent_stream
|
|
15
|
+
# restores routing_expression to "true" and writes a fresh subscription
|
|
16
|
+
# token. The job's online-state recheck then no-ops.
|
|
17
|
+
class CancelOfflineDelegatedTasksJob < ApplicationJob
|
|
18
|
+
queue_as :default
|
|
19
|
+
|
|
20
|
+
GRACE_SECONDS = 30
|
|
21
|
+
|
|
22
|
+
def perform(agent_id, expected_token, session_id = nil)
|
|
23
|
+
agent = User.find_by(id: agent_id)
|
|
24
|
+
return unless agent&.claude_channel_agent?
|
|
25
|
+
|
|
26
|
+
# Session-scoped variant: a live sibling may keep the shared agent
|
|
27
|
+
# routable (so the agent-wide rechecks below would no-op), but the
|
|
28
|
+
# dropped session's OWN session topic is private to it — siblings filter
|
|
29
|
+
# session_topic dispatches to their own topic, so none will /reply. Cancel
|
|
30
|
+
# only that topic's delegated work. Enqueued by AgentChannel#unsubscribed
|
|
31
|
+
# when a sibling remains; mirrors the destroy path's cancel_tasks_for_topic.
|
|
32
|
+
return cancel_dropped_session_tasks(agent, session_id) if session_id.present?
|
|
33
|
+
|
|
34
|
+
# Agent came back online during the grace window — the same MCP
|
|
35
|
+
# session (or a new one) is subscribed and can answer /reply.
|
|
36
|
+
return if agent.routing_expression.present?
|
|
37
|
+
|
|
38
|
+
# A session (reconnect or a still-live sibling sharing this agent) holds
|
|
39
|
+
# a LIVE presence row — the agent is online, its delegated work is still
|
|
40
|
+
# owned. Presence is the authority now that one agent fans out to many
|
|
41
|
+
# concurrent sessions. Reap crash-orphaned rows first so a dead process's
|
|
42
|
+
# leftover row can't masquerade as a live session and strand this work.
|
|
43
|
+
AgentSubscription.reap_stale!(agent.id)
|
|
44
|
+
return if AgentSubscription.live.where(agent_id: agent.id).exists?
|
|
45
|
+
|
|
46
|
+
# A different subscription has taken over (token rotated). The new
|
|
47
|
+
# session's lifecycle owns its own cancellation; don't double-cancel.
|
|
48
|
+
if expected_token.present? &&
|
|
49
|
+
agent.routing_subscription_token.present? &&
|
|
50
|
+
agent.routing_subscription_token != expected_token
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
cancel_delegated_tasks(Task.where(agent_id: agent.id, status: "delegated"), agent)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Cancel only the dropped session's own session-topic delegated work, unless
|
|
60
|
+
# this same session reconnected within the grace window (a fresh live row
|
|
61
|
+
# under the same session_id). Work on OTHER topics is fan-out — any live
|
|
62
|
+
# sibling can still claim it — so it is deliberately left untouched.
|
|
63
|
+
def cancel_dropped_session_tasks(agent, session_id)
|
|
64
|
+
AgentSubscription.reap_stale!(agent.id)
|
|
65
|
+
return if AgentSubscription.live.where(agent_id: agent.id, session_id: session_id).exists?
|
|
66
|
+
|
|
67
|
+
topic = Topic.find_by(primary_agent_id: agent.id, session_id: session_id)
|
|
68
|
+
return unless topic
|
|
69
|
+
|
|
70
|
+
# Cancel queued/pending/running work FIRST. Otherwise cancelling the
|
|
71
|
+
# delegated task below calls dequeue_next_for_topic, which promotes the next
|
|
72
|
+
# queued task on this session topic into "delegated" and broadcasts it — but
|
|
73
|
+
# the session that owned this private topic is gone and siblings filter
|
|
74
|
+
# session_topic dispatches to their own topic, so nothing would /reply and
|
|
75
|
+
# the promoted task would hold its slot until stuck recovery. Draining the
|
|
76
|
+
# queue first leaves nothing to promote. Mirrors cancel_tasks_for_topic.
|
|
77
|
+
cancel_pending_tasks(
|
|
78
|
+
Task.where(agent_id: agent.id, topic_id: topic.id, status: %w[queued pending running]), agent
|
|
79
|
+
)
|
|
80
|
+
cancel_delegated_tasks(
|
|
81
|
+
Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated"), agent
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cancel_pending_tasks(tasks, agent)
|
|
86
|
+
return if tasks.empty?
|
|
87
|
+
|
|
88
|
+
tracker = Orchestration::ResourceTracker.for(agent)
|
|
89
|
+
tasks.find_each do |task|
|
|
90
|
+
was_running = task.status == "running"
|
|
91
|
+
task.update!(status: "cancelled")
|
|
92
|
+
tracker.release!(task.id) if was_running
|
|
93
|
+
|
|
94
|
+
next if task.parent_task_id.blank?
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
|
|
98
|
+
task, error_message: "Claude Channel session lost connection before dispatch"
|
|
99
|
+
)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
Rails.logger.error(
|
|
102
|
+
"[CancelOfflineDelegatedTasksJob] fail_subtask! failed for queued task #{task.id}: #{e.message}"
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def cancel_delegated_tasks(tasks, agent)
|
|
109
|
+
return if tasks.empty?
|
|
110
|
+
|
|
111
|
+
tracker = Orchestration::ResourceTracker.for(agent)
|
|
112
|
+
tasks.find_each do |task|
|
|
113
|
+
task.update!(status: "cancelled")
|
|
114
|
+
tracker.release!(task.id)
|
|
115
|
+
|
|
116
|
+
if task.parent_task_id.present?
|
|
117
|
+
begin
|
|
118
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
|
|
119
|
+
task, error_message: "Claude Channel session lost connection before reply"
|
|
120
|
+
)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
Rails.logger.error(
|
|
123
|
+
"[CancelOfflineDelegatedTasksJob] fail_subtask! failed for task #{task.id}: #{e.message}"
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if task.topic_id.present?
|
|
129
|
+
Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Typing-indicator / "responding" presence for Claude Channel (MCP) agents.
|
|
5
|
+
#
|
|
6
|
+
# RubyLLM agents broadcast agent_status ("thinking" → "streaming" heartbeats →
|
|
7
|
+
# "idle") from inside their long-running AiAgentJob, which is what drives the
|
|
8
|
+
# chat typing indicator and Stop button. The Claude Channel path has no such
|
|
9
|
+
# loop: AiAgentService#delegate_to_claude_channel broadcasts the dispatch and
|
|
10
|
+
# returns immediately, with the reply arriving asynchronously via /reply. So
|
|
11
|
+
# the chat showed nothing while Claude worked.
|
|
12
|
+
#
|
|
13
|
+
# This job restores parity. Enqueued once per Claude dispatch, it:
|
|
14
|
+
# - while the task is "delegated" and a session is LIVE → broadcasts a
|
|
15
|
+
# working agent_status and re-enqueues itself every HEARTBEAT_SECONDS
|
|
16
|
+
# (under the frontend's 10s AGENT_STATUS_TIMEOUT) so the indicator stays up;
|
|
17
|
+
# - when no live session remains (never connected, or dropped mid-turn) →
|
|
18
|
+
# broadcasts "idle" to clear the indicator and posts an authorless system
|
|
19
|
+
# notice so a human sees the channel is disconnected rather than a phantom
|
|
20
|
+
# "thinking"; (CancelOfflineDelegatedTasksJob owns the task-state cleanup);
|
|
21
|
+
# - once the task leaves "delegated" (a /reply completed it, or it was
|
|
22
|
+
# cancelled) → stops without re-enqueuing. The reply path broadcasts the
|
|
23
|
+
# final "idle" itself, so the indicator clears immediately on reply.
|
|
24
|
+
class ClaudeChannelPresenceJob < ApplicationJob
|
|
25
|
+
queue_as :default
|
|
26
|
+
|
|
27
|
+
# Re-broadcast cadence. Must stay below the frontend AGENT_STATUS_TIMEOUT
|
|
28
|
+
# (10s) so the indicator never flickers off between beats, with room for one
|
|
29
|
+
# missed beat (queue jitter, GC pause).
|
|
30
|
+
HEARTBEAT_SECONDS = 5
|
|
31
|
+
|
|
32
|
+
def perform(task_id)
|
|
33
|
+
task = Task.find_by(id: task_id)
|
|
34
|
+
return unless task
|
|
35
|
+
|
|
36
|
+
agent = task.agent
|
|
37
|
+
return unless agent&.claude_channel_agent?
|
|
38
|
+
|
|
39
|
+
# A /reply completed the task (or it was cancelled). The reply path already
|
|
40
|
+
# broadcast the final "idle"; nothing more to do, and crucially do NOT post
|
|
41
|
+
# a disconnect notice for a normally-finished turn.
|
|
42
|
+
return unless task.reload.status == "delegated"
|
|
43
|
+
|
|
44
|
+
creative = resolve_creative(task)
|
|
45
|
+
return unless creative
|
|
46
|
+
|
|
47
|
+
AgentSubscription.reap_stale!(agent.id)
|
|
48
|
+
|
|
49
|
+
# Reuse the canonical status broadcaster that drives the typing indicator
|
|
50
|
+
# for every other agent (AiAgentService#execute_llm_conversation), so the
|
|
51
|
+
# "thinking"/"idle" payload stays identical across both paths.
|
|
52
|
+
lifecycle = AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
|
|
53
|
+
|
|
54
|
+
if AgentSubscription.live.where(agent_id: agent.id).exists?
|
|
55
|
+
lifecycle.broadcast_status("thinking")
|
|
56
|
+
self.class.set(wait: HEARTBEAT_SECONDS.seconds).perform_later(task_id)
|
|
57
|
+
else
|
|
58
|
+
lifecycle.broadcast_status("idle")
|
|
59
|
+
post_disconnect_notice(creative, task)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def resolve_creative(task)
|
|
66
|
+
creative_id = task.creative_id || task.trigger_event_payload&.dig("creative", "id")
|
|
67
|
+
Creative.find_by(id: creative_id)&.effective_origin
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resolve_topic_id(task)
|
|
71
|
+
task.topic_id || task.trigger_event_payload&.dig("topic", "id")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Authorless, non-dispatching system message so the human sees the channel is
|
|
75
|
+
# offline. skip_dispatch is essential: an authorless comment that matched the
|
|
76
|
+
# agent's routing would otherwise spawn a fresh delegated task — an infinite
|
|
77
|
+
# disconnect-notice loop.
|
|
78
|
+
def post_disconnect_notice(creative, task)
|
|
79
|
+
topic_id = resolve_topic_id(task)
|
|
80
|
+
return unless topic_id
|
|
81
|
+
|
|
82
|
+
creative.comments.create!(
|
|
83
|
+
content: I18n.t("collavre.claude_channel.disconnected"),
|
|
84
|
+
topic_id: topic_id,
|
|
85
|
+
private: false,
|
|
86
|
+
skip_default_user: true,
|
|
87
|
+
skip_dispatch: true
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -8,6 +8,8 @@ module Collavre
|
|
|
8
8
|
# This triggers retry_on instead of silently succeeding.
|
|
9
9
|
class DispatchFailedError < StandardError; end
|
|
10
10
|
|
|
11
|
+
DROP_TRIGGER_TOPIC_NAME = "Drop Trigger"
|
|
12
|
+
|
|
11
13
|
retry_on DispatchFailedError, wait: 5.seconds, attempts: 3
|
|
12
14
|
|
|
13
15
|
# End-to-end idempotent: safe to retry at any point.
|
|
@@ -57,9 +59,8 @@ module Collavre
|
|
|
57
59
|
private
|
|
58
60
|
|
|
59
61
|
def post_trigger_failure_notice(child, parent)
|
|
60
|
-
topic = child.topics.
|
|
61
|
-
|
|
62
|
-
end
|
|
62
|
+
topic = child.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME) ||
|
|
63
|
+
create_drop_trigger_topic(child)
|
|
63
64
|
post_system_notice(child, topic, I18n.t(
|
|
64
65
|
"collavre.drop_trigger.no_agent",
|
|
65
66
|
parent_description: parent.creative_snippet
|
|
@@ -82,17 +83,45 @@ module Collavre
|
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
def find_or_create_trigger_topic(creative, agent)
|
|
85
|
-
topic = creative.topics.find_by(name:
|
|
86
|
+
topic = creative.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME)
|
|
86
87
|
return topic if topic
|
|
87
88
|
|
|
88
|
-
topic = creative
|
|
89
|
-
name: "Drop Trigger",
|
|
90
|
-
user: creative.user
|
|
91
|
-
)
|
|
89
|
+
topic = create_drop_trigger_topic(creative)
|
|
92
90
|
topic.set_primary_agent!(agent)
|
|
93
91
|
topic
|
|
94
92
|
end
|
|
95
93
|
|
|
94
|
+
# Creates the Drop Trigger topic, branching from the creative's Main topic
|
|
95
|
+
# when Main has messages. Equivalent to the user manually selecting every
|
|
96
|
+
# Main message and pressing "branch" — only the resulting topic name is
|
|
97
|
+
# fixed to "Drop Trigger".
|
|
98
|
+
def create_drop_trigger_topic(creative)
|
|
99
|
+
main = creative.main_topic(fallback_user: creative.user)
|
|
100
|
+
# Mirror the manual "select all → branch" flow: only comments the owner
|
|
101
|
+
# can see are copied. enforce_limit: false bypasses the UI's 100-comment
|
|
102
|
+
# selection cap so full Main history transfers regardless of length.
|
|
103
|
+
main_comment_ids = main.comments
|
|
104
|
+
.visible_to(creative.user)
|
|
105
|
+
.order(:created_at)
|
|
106
|
+
.pluck(:id)
|
|
107
|
+
|
|
108
|
+
if main_comment_ids.any?
|
|
109
|
+
# auto_select: false prevents the broadcast from hijacking the owner's
|
|
110
|
+
# current topic selection while this background job runs.
|
|
111
|
+
TopicBranchService.new(
|
|
112
|
+
creative: creative,
|
|
113
|
+
user: creative.user,
|
|
114
|
+
source_topic: main,
|
|
115
|
+
name: DROP_TRIGGER_TOPIC_NAME
|
|
116
|
+
).call(comment_ids: main_comment_ids, enforce_limit: false, auto_select: false)
|
|
117
|
+
else
|
|
118
|
+
creative.topics.create!(
|
|
119
|
+
name: DROP_TRIGGER_TOPIC_NAME,
|
|
120
|
+
user: creative.user
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
96
125
|
def trigger_content_key(child, parent)
|
|
97
126
|
I18n.t(
|
|
98
127
|
"collavre.drop_trigger.child_entered",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
class ApplicationMailer < ActionMailer::Base
|
|
3
|
-
default from:
|
|
3
|
+
default from: -> { Collavre::IntegrationSettings.fetch(:default_mailer_from, default: "no-reply@example.com") }
|
|
4
4
|
layout "mailer"
|
|
5
5
|
|
|
6
6
|
private
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Presence record for one live Claude Channel session subscription on the
|
|
5
|
+
# per-agent ActionCable stream. The set of LIVE rows for an agent_id is the
|
|
6
|
+
# authority for whether routing_expression should be active: a human's
|
|
7
|
+
# concurrent sessions share one agent, so routing stays on while ANY session
|
|
8
|
+
# holds a live row, and only turns off when the last one is gone.
|
|
9
|
+
#
|
|
10
|
+
# Liveness is leased, not assumed. Rows are deleted only by
|
|
11
|
+
# AgentChannel#unsubscribed, so a Puma/ActionCable crash or deploy orphans a
|
|
12
|
+
# row. last_seen_at is refreshed by the channel's periodic heartbeat; a row
|
|
13
|
+
# whose last_seen_at falls outside STALE_AFTER is dead — ignored by the live
|
|
14
|
+
# scope so it cannot pin routing on, and reaped opportunistically on the next
|
|
15
|
+
# subscribe/unsubscribe for the agent.
|
|
16
|
+
class AgentSubscription < ApplicationRecord
|
|
17
|
+
self.table_name = "agent_subscriptions"
|
|
18
|
+
|
|
19
|
+
belongs_to :agent, class_name: Collavre.configuration.user_class_name
|
|
20
|
+
|
|
21
|
+
# Heartbeat cadence (see AgentChannel#subscribe_to_agent_stream). STALE_AFTER
|
|
22
|
+
# is a multiple of it so a single missed beat (GC pause, brief stall) does
|
|
23
|
+
# not flap a live session to "dead".
|
|
24
|
+
HEARTBEAT_SECONDS = 15
|
|
25
|
+
STALE_AFTER = 45.seconds
|
|
26
|
+
|
|
27
|
+
before_validation :ensure_last_seen_at, on: :create
|
|
28
|
+
|
|
29
|
+
scope :live, -> { where(arel_table[:last_seen_at].gt(STALE_AFTER.ago)) }
|
|
30
|
+
scope :stale, -> { where(arel_table[:last_seen_at].lteq(STALE_AFTER.ago)) }
|
|
31
|
+
|
|
32
|
+
# Refresh the heartbeat for one session's row. No-op if the row is already
|
|
33
|
+
# gone (a newer subscribe rotated it, or it was reaped) — the periodic
|
|
34
|
+
# callback must not resurrect a removed presence row.
|
|
35
|
+
def self.touch!(agent_id, token)
|
|
36
|
+
where(agent_id: agent_id, token: token).update_all(last_seen_at: Time.current)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Drop crash-orphaned rows for an agent so a plain presence check is
|
|
40
|
+
# accurate. Scoped to one agent_id: cheap, and called on the agent's own
|
|
41
|
+
# subscribe/unsubscribe path.
|
|
42
|
+
def self.reap_stale!(agent_id)
|
|
43
|
+
stale.where(agent_id: agent_id).delete_all
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def ensure_last_seen_at
|
|
49
|
+
self.last_seen_at ||= Time.current
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class Channel < ApplicationRecord
|
|
3
|
+
BOT_EMAIL = "channel@collavre.local"
|
|
4
|
+
BOT_NAME = "Channel"
|
|
5
|
+
|
|
6
|
+
self.table_name = "channels"
|
|
7
|
+
|
|
8
|
+
belongs_to :topic, class_name: "Collavre::Topic"
|
|
9
|
+
|
|
10
|
+
enum :state, { active: 0, detached: 1 }, default: :active
|
|
11
|
+
|
|
12
|
+
scope :not_dismissed, -> { where(dismissed_at: nil) }
|
|
13
|
+
|
|
14
|
+
def handle(event:, payload:)
|
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #handle"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Chip fallbacks rendered before any webhook event populates latest_label /
|
|
19
|
+
# latest_link. Base class returns nil; subclasses override when they can
|
|
20
|
+
# derive a stable label/link from their own config (e.g. PR #N + URL).
|
|
21
|
+
def default_label
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_link
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Badge interface for the typing-indicator chip. Subclasses override
|
|
30
|
+
# `badge_state` to drive the chip badge color (the value becomes a CSS
|
|
31
|
+
# modifier: channel-chip-badge--<state>), and `badge_title` to provide a
|
|
32
|
+
# localized title / aria-label string. Returning nil from `badge_state`
|
|
33
|
+
# hides the badge entirely.
|
|
34
|
+
def badge_state
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def badge_title
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def detach!
|
|
43
|
+
update!(state: :detached)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dismissed?
|
|
47
|
+
dismissed_at.present?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Hide the chip from the typing-indicator row. Performs detach! as a
|
|
51
|
+
# side-effect when the channel is still active so dismissal is a single
|
|
52
|
+
# user-facing action — clicking the X always removes the chip regardless
|
|
53
|
+
# of prior state.
|
|
54
|
+
def dismiss!
|
|
55
|
+
transaction do
|
|
56
|
+
detach! if active?
|
|
57
|
+
update!(dismissed_at: Time.current) if dismissed_at.nil?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def record_event!(label:, link:)
|
|
62
|
+
update!(latest_label: label, latest_link: link, last_event_at: Time.current)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def inject_into_topic!(injected_message)
|
|
66
|
+
transaction do
|
|
67
|
+
comment = topic.creative.comments.create!(
|
|
68
|
+
user: injected_message.speaker,
|
|
69
|
+
topic_id: topic.id,
|
|
70
|
+
content: injected_message.message,
|
|
71
|
+
private: false
|
|
72
|
+
)
|
|
73
|
+
record_event!(label: injected_message.label, link: injected_message.link)
|
|
74
|
+
comment
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
after_create_commit :broadcast_chips_changed
|
|
79
|
+
after_update_commit :broadcast_chips_changed
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def broadcast_chips_changed
|
|
84
|
+
Collavre::CommentsPresenceChannel.broadcast_channel_chips_changed(topic.creative_id, topic_id: topic_id)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
class Comment < ApplicationRecord
|
|
5
|
+
# A native Claude Code tool-permission prompt surfaced into a topic as a
|
|
6
|
+
# structured approval comment (built by Api::V1::AgentsController#notify when
|
|
7
|
+
# the relayed notify carries a permission_request_id). It reuses the native
|
|
8
|
+
# approval comment UI — approver gate, approve button, ✅/🚫 decided label —
|
|
9
|
+
# but a decision does NOT execute a tool server-side (the tool runs inside
|
|
10
|
+
# the remote Claude Code process). Instead approve/deny relays the decision
|
|
11
|
+
# over the agent stream so the MCP plugin resolves the paused tool call.
|
|
12
|
+
module ClaudeChannelPermission
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
ACTION_TYPE = "claude_channel_permission"
|
|
16
|
+
|
|
17
|
+
# Raised when a decision was already recorded, so a double-click (or a
|
|
18
|
+
# concurrent approve+deny) resolves to exactly one decision.
|
|
19
|
+
class AlreadyDecided < StandardError; end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
# Re-broadcast the recorded decision for each given request_id that belongs
|
|
23
|
+
# to this agent and has been decided. Called when a Claude Channel session
|
|
24
|
+
# sends the request_ids it still holds pending after a (re)subscribe
|
|
25
|
+
# (pull-on-resubscribe): broadcast_claude_channel_permission_decision fires
|
|
26
|
+
# once into the transient agent:user:<id> stream, so a decision clicked
|
|
27
|
+
# while the plugin's WebSocket was reconnecting lands in a subscriber-less
|
|
28
|
+
# stream and is lost — leaving the suspended tool hung with no retry path.
|
|
29
|
+
#
|
|
30
|
+
# The plugin's pending set is the SOLE bound: there is no wall-clock window,
|
|
31
|
+
# so a decision made during an outage of any length is still redelivered,
|
|
32
|
+
# and a decision the plugin already consumed is simply never requested. The
|
|
33
|
+
# coarse action LIKE filter (one per requested id) is narrowed by an exact
|
|
34
|
+
# request_id match per row before re-broadcasting; replay is idempotent
|
|
35
|
+
# (PermissionCoordinator.claim ignores a request_id it no longer holds), so
|
|
36
|
+
# an over-broad LIKE match is a harmless no-op on the plugin side.
|
|
37
|
+
def replay_claude_channel_permission_decisions_for(agent_id, request_ids)
|
|
38
|
+
ids = Array(request_ids).filter_map { |r| r.to_s.presence }.uniq
|
|
39
|
+
return if ids.empty?
|
|
40
|
+
|
|
41
|
+
# Build the coarse "action LIKE '%<rid>%'" prefilter with Arel matchers
|
|
42
|
+
# rather than a raw SQL fragment so the wildcards stay bound parameters
|
|
43
|
+
# (the surrounding %…% is ours; sanitize_sql_like escapes any %/_ inside
|
|
44
|
+
# the id). No interpolation into SQL — Brakeman-clean and identical
|
|
45
|
+
# semantics to the old OR-joined LIKE.
|
|
46
|
+
matcher = ids
|
|
47
|
+
.map { |rid| arel_table[:action].matches("%#{sanitize_sql_like(rid)}%") }
|
|
48
|
+
.reduce(:or)
|
|
49
|
+
where(user_id: agent_id)
|
|
50
|
+
.where.not(action_executed_at: nil)
|
|
51
|
+
.where(matcher)
|
|
52
|
+
.find_each do |comment|
|
|
53
|
+
next unless comment.claude_channel_permission?
|
|
54
|
+
next unless ids.include?(comment.claude_channel_permission_request_id)
|
|
55
|
+
|
|
56
|
+
comment.rebroadcast_claude_channel_permission_decision
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# True when this comment's action payload is a Claude Channel permission
|
|
62
|
+
# prompt (vs. a native execute_tool/approve_tool action).
|
|
63
|
+
def claude_channel_permission?
|
|
64
|
+
claude_channel_permission_action.present?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def claude_channel_permission_request_id
|
|
68
|
+
claude_channel_permission_action&.dig("request_id")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The decision, once made, is persisted into the action payload so the
|
|
72
|
+
# rendered comment can distinguish ✅ approved from 🚫 denied.
|
|
73
|
+
def claude_channel_permission_denied?
|
|
74
|
+
claude_channel_permission_action&.dig("decision") == "deny"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Atomically record the human's allow/deny: stamp action_executed_at/by
|
|
78
|
+
# (which hides the buttons and marks the comment decided) and persist the
|
|
79
|
+
# decision into the action payload. Raises AlreadyDecided if a decision was
|
|
80
|
+
# already recorded.
|
|
81
|
+
def decide_claude_channel_permission!(behavior, by:)
|
|
82
|
+
behavior = behavior.to_s
|
|
83
|
+
raise ArgumentError, "behavior must be allow or deny" unless %w[allow deny].include?(behavior)
|
|
84
|
+
|
|
85
|
+
with_lock do
|
|
86
|
+
reload
|
|
87
|
+
raise AlreadyDecided if action_executed_at.present?
|
|
88
|
+
|
|
89
|
+
payload = claude_channel_permission_action
|
|
90
|
+
raise ArgumentError, "not a Claude Channel permission comment" unless payload
|
|
91
|
+
|
|
92
|
+
payload["decision"] = behavior
|
|
93
|
+
update!(
|
|
94
|
+
action: JSON.pretty_generate(payload),
|
|
95
|
+
action_executed_at: Time.current,
|
|
96
|
+
action_executed_by: by
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Relay the decision to the suspended session over the agent stream. The
|
|
102
|
+
# MCP plugin matches request_id against the prompts it surfaced, so sibling
|
|
103
|
+
# sessions sharing this (shared) agent ignore a request_id they never
|
|
104
|
+
# raised. task_id is intentionally absent: the decision only unblocks the
|
|
105
|
+
# paused tool call; the in-flight delegated task completes later via /reply.
|
|
106
|
+
def broadcast_claude_channel_permission_decision(behavior)
|
|
107
|
+
request_id = claude_channel_permission_request_id
|
|
108
|
+
return false if request_id.blank? || user_id.blank?
|
|
109
|
+
|
|
110
|
+
AgentChannel.broadcast_to_agent(user_id, {
|
|
111
|
+
type: "permission_decision",
|
|
112
|
+
request_id: request_id,
|
|
113
|
+
behavior: behavior.to_s,
|
|
114
|
+
agent_id: user_id
|
|
115
|
+
})
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Replay this comment's already-recorded decision (used by the resubscribe
|
|
120
|
+
# path). Unlike broadcast_claude_channel_permission_decision the behavior is
|
|
121
|
+
# read from the persisted payload rather than passed in, and only a comment
|
|
122
|
+
# that is both a permission prompt and decided re-broadcasts — a still-
|
|
123
|
+
# pending prompt has no decision to deliver.
|
|
124
|
+
def rebroadcast_claude_channel_permission_decision
|
|
125
|
+
return false unless claude_channel_permission?
|
|
126
|
+
|
|
127
|
+
decision = claude_channel_permission_action&.dig("decision")
|
|
128
|
+
return false if decision.blank?
|
|
129
|
+
|
|
130
|
+
broadcast_claude_channel_permission_decision(decision)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def claude_channel_permission_action
|
|
136
|
+
return nil if action.blank?
|
|
137
|
+
|
|
138
|
+
parsed = JSON.parse(action)
|
|
139
|
+
parsed.is_a?(Hash) && parsed["action"] == ACTION_TYPE ? parsed : nil
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|