collavre 0.21.0 → 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/comments_popup.css +51 -3
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +16 -5
- 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/attachments_controller.rb +30 -2
- 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 +91 -1
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +15 -0
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- 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 +6 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/list_controller.js +17 -2
- data/app/javascript/controllers/comments/presence_controller.js +56 -5
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/lib/api/creatives.js +13 -0
- 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/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/models/collavre/agent_subscription.rb +52 -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 +139 -2
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +5 -0
- data/app/models/collavre/user.rb +4 -0
- 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/attachment_backfill.rb +26 -0
- 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/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +29 -63
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +7 -5
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +1 -1
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +27 -11
- data/app/views/collavre/comments/_comment.html.erb +10 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -2
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/config/locales/channels.en.yml +2 -0
- data/config/locales/channels.ko.yml +2 -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 +3 -0
- data/config/locales/comments.ko.yml +3 -0
- data/config/locales/creatives.en.yml +2 -0
- data/config/locales/creatives.ko.yml +2 -0
- data/config/locales/integrations.en.yml +13 -2
- data/config/locales/integrations.ko.yml +13 -2
- data/config/routes.rb +12 -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/lib/collavre/engine.rb +0 -1
- data/lib/collavre/integration_settings/key_definition.rb +6 -0
- data/lib/collavre/integration_settings/registry.rb +7 -2
- data/lib/collavre/version.rb +1 -1
- metadata +31 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
class AgentsController < BaseController
|
|
7
|
+
# POST /api/v1/agent/register
|
|
8
|
+
# Registers a Claude Code session, separating two identities:
|
|
9
|
+
# - Agent (agent_name): the user-facing unit. One shared ai_user per
|
|
10
|
+
# (human, agent_name). Without an explicit AGENT_NAME the plugin
|
|
11
|
+
# sends a default name so every session collapses onto one agent.
|
|
12
|
+
# - Session (session_id): one Topic per Claude Code session, keyed by
|
|
13
|
+
# a stable id (derived per cwd by the plugin, stable across
|
|
14
|
+
# --resume). Multiple session topics can share one primary_agent.
|
|
15
|
+
#
|
|
16
|
+
# Back-compat: older plugins send only :name. It is treated as BOTH the
|
|
17
|
+
# agent and the session identity, reproducing the prior
|
|
18
|
+
# one-agent-one-topic-per-name behavior.
|
|
19
|
+
def register
|
|
20
|
+
agent_name = params[:agent_name].to_s.strip
|
|
21
|
+
session_id = params[:session_id].to_s.strip
|
|
22
|
+
legacy_name = params[:name].to_s.strip
|
|
23
|
+
agent_name = legacy_name if agent_name.blank?
|
|
24
|
+
session_id = legacy_name if session_id.blank?
|
|
25
|
+
|
|
26
|
+
if agent_name.blank? || session_id.blank?
|
|
27
|
+
render json: { error: "name (or agent_name and session_id) is required" },
|
|
28
|
+
status: :unprocessable_entity
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ai_user = find_or_create_session_agent(agent_name)
|
|
33
|
+
unless ai_user
|
|
34
|
+
render json: { error: "Session agent email is already in use by a different account" }, status: :conflict
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
inbox = Creative.inbox_for(current_user)
|
|
39
|
+
topic = find_or_create_session_topic(inbox, ai_user, session_id, params[:session_label])
|
|
40
|
+
topic.unarchive! if topic.archived?
|
|
41
|
+
topic.set_primary_agent!(ai_user)
|
|
42
|
+
|
|
43
|
+
# find_or_create_by! keeps the SELECT-first path (so a sequential
|
|
44
|
+
# re-register reuses the existing share without tripping CreativeShare's
|
|
45
|
+
# model-level user_id/creative_id uniqueness validation). But concurrent
|
|
46
|
+
# sibling registrations can both pass that SELECT before either commits;
|
|
47
|
+
# the loser then hits the DB unique index. Rescue that race and re-find
|
|
48
|
+
# instead of surfacing a 500/conflict.
|
|
49
|
+
begin
|
|
50
|
+
CreativeShare.find_or_create_by!(creative: inbox, user: ai_user) do |s|
|
|
51
|
+
s.permission = :feedback
|
|
52
|
+
s.shared_by = current_user
|
|
53
|
+
end
|
|
54
|
+
rescue ActiveRecord::RecordNotUnique
|
|
55
|
+
CreativeShare.find_by!(creative: inbox, user: ai_user)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
render json: {
|
|
59
|
+
agent_id: ai_user.id,
|
|
60
|
+
agent_name: ai_user.name,
|
|
61
|
+
topic_id: topic.id,
|
|
62
|
+
topic_name: topic.name,
|
|
63
|
+
session_id: session_id,
|
|
64
|
+
inbox_creative_id: inbox.id,
|
|
65
|
+
ws_url: "/cable"
|
|
66
|
+
}, status: :ok
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# DELETE /api/v1/agent/:id
|
|
70
|
+
# Archives the agent's topic (session ended).
|
|
71
|
+
# topic_id is required: register reuses the same ai_user across all
|
|
72
|
+
# sessions for the same human, so the primary_agent association alone
|
|
73
|
+
# cannot identify which session is ending. Without an explicit topic_id
|
|
74
|
+
# we could archive a sibling session's active topic.
|
|
75
|
+
def destroy
|
|
76
|
+
ai_user = User.find_by(id: params[:id])
|
|
77
|
+
unless ai_user&.ai_user? && ai_user.created_by_id == current_user.id
|
|
78
|
+
render json: { error: "Agent not found" }, status: :not_found
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if params[:topic_id].blank?
|
|
83
|
+
render json: { error: "topic_id is required" }, status: :unprocessable_entity
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
inbox = Creative.inbox_for(current_user)
|
|
88
|
+
topic = inbox.topics.active.find_by(id: params[:topic_id])
|
|
89
|
+
# Ensure the topic actually belongs to this agent so a mismatched
|
|
90
|
+
# topic_id can't archive an unrelated inbox conversation.
|
|
91
|
+
topic = nil unless topic && topic.primary_agent&.id == ai_user.id
|
|
92
|
+
|
|
93
|
+
# The plugin closes the WebSocket and IMMEDIATELY calls DELETE, so
|
|
94
|
+
# AgentChannel#unsubscribed may not have dropped this session's presence
|
|
95
|
+
# row yet. Drop it up front — keyed by the ending session's id (sent by
|
|
96
|
+
# the plugin, or derived from the required topic) — so this session's
|
|
97
|
+
# own still-live row cannot masquerade as a live sibling and skip the
|
|
98
|
+
# last-session teardown (which would pin routing_expression on and, if
|
|
99
|
+
# the WS close is never processed, leave nothing to clear it). Done
|
|
100
|
+
# under the agent row lock to serialize against a concurrent subscribe/
|
|
101
|
+
# unsubscribe on the shared agent — the same lock AgentChannel uses.
|
|
102
|
+
exiting_session_id = params[:session_id].presence || topic&.session_id
|
|
103
|
+
last_session = false
|
|
104
|
+
ai_user.with_lock do
|
|
105
|
+
if exiting_session_id.present?
|
|
106
|
+
AgentSubscription
|
|
107
|
+
.where(agent_id: ai_user.id, session_id: exiting_session_id)
|
|
108
|
+
.delete_all
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unless sibling_sessions_live?(ai_user)
|
|
112
|
+
last_session = true
|
|
113
|
+
# Last (or only) session for this agent. Clear routing_expression
|
|
114
|
+
# FIRST so Orchestration::Matcher#match_by_expression stops
|
|
115
|
+
# dispatching new comments to this now-clientless agent while we
|
|
116
|
+
# drain its task graph below; otherwise a comment arriving mid-drain
|
|
117
|
+
# could enqueue a new task right after we cancelled the last one.
|
|
118
|
+
# (Routing is reactivated on the next AgentChannel subscribe, not on
|
|
119
|
+
# re-register.)
|
|
120
|
+
ai_user.update_column(:routing_expression, nil) if ai_user.routing_expression.present?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if last_session
|
|
125
|
+
# Cancel queued/pending tasks BEFORE draining topic queues. The topic
|
|
126
|
+
# queue (Task.queued_for_topic) is scoped by topic_id, not agent_id,
|
|
127
|
+
# so dequeue_next_for_topic would otherwise flip a queued task for
|
|
128
|
+
# this clientless agent to pending and enqueue AiAgentJob — which then
|
|
129
|
+
# broadcasts to a stream with no subscriber and strands the task in
|
|
130
|
+
# delegated until stuck recovery.
|
|
131
|
+
cancel_pending_tasks_for_session(ai_user)
|
|
132
|
+
|
|
133
|
+
# Fail any tasks still delegated to this agent — including dispatches
|
|
134
|
+
# routed to *work* topics outside the registration inbox. With no live
|
|
135
|
+
# session left, agent_id uniquely scopes this agent's delegated work;
|
|
136
|
+
# scoping by topic_id alone would leak the common case (a /work
|
|
137
|
+
# dispatch on a project topic) until stuck detection times out.
|
|
138
|
+
cancel_delegated_tasks_for_session(ai_user)
|
|
139
|
+
elsif topic
|
|
140
|
+
# One shared agent fans out to many concurrent sessions (the default
|
|
141
|
+
# AGENT_NAME case). Another session is still LIVE, so agent-wide
|
|
142
|
+
# cleanup would clear the shared routing_expression and cancel the
|
|
143
|
+
# sibling's in-flight work — exactly the hazard AgentChannel#unsubscribed
|
|
144
|
+
# guards against via presence. Tear down ONLY this ending session:
|
|
145
|
+
# cancel work on its own topic (whose client is gone, so it would
|
|
146
|
+
# otherwise hold a slot / block the topic queue until stuck recovery).
|
|
147
|
+
# The shared routing and any work on other topics belong to the live
|
|
148
|
+
# sibling and are left untouched.
|
|
149
|
+
cancel_tasks_for_topic(ai_user, topic)
|
|
150
|
+
end
|
|
151
|
+
topic.archive! if topic
|
|
152
|
+
|
|
153
|
+
head :no_content
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# POST /api/v1/agent/reply
|
|
157
|
+
# Creates a comment in a topic as the AI agent
|
|
158
|
+
def reply
|
|
159
|
+
topic = Topic.find_by(id: params[:topic_id])
|
|
160
|
+
unless topic
|
|
161
|
+
render json: { error: "Topic not found" }, status: :not_found
|
|
162
|
+
return
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
creative = topic.creative&.effective_origin
|
|
166
|
+
unless creative
|
|
167
|
+
render json: { error: "Creative not found" }, status: :not_found
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
unless creative.has_permission?(current_user, :feedback)
|
|
172
|
+
render json: { error: "Not authorized" }, status: :forbidden
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
agent = resolve_reply_agent(topic, params[:task_id])
|
|
177
|
+
unless agent
|
|
178
|
+
render json: { error: "Not authorized" }, status: :forbidden
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Atomically claim the delegated task BEFORE saving the reply.
|
|
183
|
+
# Two concurrent /reply requests with the same task_id can both
|
|
184
|
+
# pass resolve_reply_agent (which is a read-only scope check) and,
|
|
185
|
+
# without an atomic WHERE status='delegated' transition, both would
|
|
186
|
+
# save separate comments and both would run completion logic —
|
|
187
|
+
# producing duplicate linked replies for one dispatch. Claim first,
|
|
188
|
+
# save second, so the loser sees rows_updated == 0 and bails out
|
|
189
|
+
# before touching the comments table.
|
|
190
|
+
claimed_task = claim_delegated_task(agent, topic, params[:task_id])
|
|
191
|
+
if params[:task_id].present? && claimed_task.nil?
|
|
192
|
+
render json: { error: "Task already completed or not delegated" }, status: :conflict
|
|
193
|
+
return
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
comment = creative.comments.build(
|
|
197
|
+
content: params[:text].to_s,
|
|
198
|
+
topic: topic,
|
|
199
|
+
user: agent,
|
|
200
|
+
skip_default_user: true,
|
|
201
|
+
skip_dispatch: true
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if comment.save
|
|
205
|
+
finalize_claimed_task(agent, claimed_task, comment) if claimed_task
|
|
206
|
+
dispatch_a2a(agent, comment)
|
|
207
|
+
render json: { comment_id: comment.id }, status: :created
|
|
208
|
+
else
|
|
209
|
+
# Restore the dispatch so the MCP client can retry — the failure
|
|
210
|
+
# is text-level (validation), not task-level.
|
|
211
|
+
claimed_task&.update!(status: "delegated")
|
|
212
|
+
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# POST /api/v1/agent/notify
|
|
217
|
+
# Posts an out-of-band informational comment to a topic as the agent
|
|
218
|
+
# WITHOUT completing any task or dispatching. Unlike /reply, this never
|
|
219
|
+
# touches the task graph: it surfaces notices (e.g. native channel
|
|
220
|
+
# permission prompts relayed by Claude Code mid-turn) into the topic
|
|
221
|
+
# chat while the originating task stays in flight. skip_dispatch avoids
|
|
222
|
+
# spawning a new delegated task for the agent's own message.
|
|
223
|
+
def notify
|
|
224
|
+
topic = Topic.find_by(id: params[:topic_id])
|
|
225
|
+
unless topic
|
|
226
|
+
render json: { error: "Topic not found" }, status: :not_found
|
|
227
|
+
return
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
creative = topic.creative&.effective_origin
|
|
231
|
+
unless creative
|
|
232
|
+
render json: { error: "Creative not found" }, status: :not_found
|
|
233
|
+
return
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
unless creative.has_permission?(current_user, :feedback)
|
|
237
|
+
render json: { error: "Not authorized" }, status: :forbidden
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The poster must be this session's own Claude Channel agent, owned by
|
|
242
|
+
# the token holder — so /notify can't be used to ventriloquize an
|
|
243
|
+
# unrelated agent or post into a topic the caller doesn't drive.
|
|
244
|
+
agent = resolve_notify_agent(topic, params[:task_id])
|
|
245
|
+
unless agent
|
|
246
|
+
render json: { error: "Not authorized" }, status: :forbidden
|
|
247
|
+
return
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
comment =
|
|
251
|
+
if params[:permission_request_id].present?
|
|
252
|
+
build_permission_comment(creative, topic, agent)
|
|
253
|
+
else
|
|
254
|
+
creative.comments.build(
|
|
255
|
+
content: params[:text].to_s,
|
|
256
|
+
topic: topic,
|
|
257
|
+
user: agent,
|
|
258
|
+
skip_default_user: true,
|
|
259
|
+
skip_dispatch: true
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if comment.save
|
|
264
|
+
park_pending_permission(topic, agent, params[:task_id], params[:permission_request_id])
|
|
265
|
+
render json: { comment_id: comment.id }, status: :created
|
|
266
|
+
else
|
|
267
|
+
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
# Build a structured tool-permission comment that reuses the native
|
|
273
|
+
# approval UI (approver gate + approve/deny buttons). The prompt text is
|
|
274
|
+
# rendered server-side via I18n (localized for the viewer), not formatted
|
|
275
|
+
# by the plugin. The action payload carries the request_id so the
|
|
276
|
+
# eventual approve/deny relays the exact decision to the suspended
|
|
277
|
+
# session. approver is the token holder driving this Claude session — the
|
|
278
|
+
# only human who should resolve its prompts.
|
|
279
|
+
def build_permission_comment(creative, topic, agent)
|
|
280
|
+
tool_name = params[:tool_name].to_s.strip.presence || "tool"
|
|
281
|
+
args_raw = sanitize_permission_arguments(params[:arguments])
|
|
282
|
+
description = params[:description].to_s.strip
|
|
283
|
+
|
|
284
|
+
action_payload = {
|
|
285
|
+
"action" => Comment::ClaudeChannelPermission::ACTION_TYPE,
|
|
286
|
+
"request_id" => params[:permission_request_id].to_s,
|
|
287
|
+
"tool_name" => tool_name,
|
|
288
|
+
"description" => description,
|
|
289
|
+
"arguments" => args_raw
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# The API base controller authenticates the bearer token but does not
|
|
293
|
+
# run the host app's locale switching, so I18n.locale is the process
|
|
294
|
+
# default here. Render the persisted prompt in the token holder's
|
|
295
|
+
# locale explicitly — they are the approver who will read it.
|
|
296
|
+
content = I18n.with_locale(current_user&.locale.presence || I18n.default_locale) do
|
|
297
|
+
I18n.t(
|
|
298
|
+
"collavre.claude_channel.permission.message",
|
|
299
|
+
tool_name: format_permission_tool_name(tool_name),
|
|
300
|
+
description: format_permission_description(description),
|
|
301
|
+
arguments: format_permission_arguments(args_raw)
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
creative.comments.build(
|
|
306
|
+
content: content,
|
|
307
|
+
topic: topic,
|
|
308
|
+
user: agent,
|
|
309
|
+
approver: current_user,
|
|
310
|
+
action: JSON.pretty_generate(action_payload),
|
|
311
|
+
skip_default_user: true,
|
|
312
|
+
skip_dispatch: true
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Coerce the arguments param into a JSON-safe value: a permitted Hash, a
|
|
317
|
+
# plain string, or nil. ActionController::Parameters must be unwrapped or
|
|
318
|
+
# JSON.pretty_generate raises on unpermitted parameters.
|
|
319
|
+
def sanitize_permission_arguments(arguments)
|
|
320
|
+
return nil if arguments.blank?
|
|
321
|
+
|
|
322
|
+
arguments.respond_to?(:to_unsafe_h) ? arguments.to_unsafe_h : arguments
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Render the (already sanitized) tool arguments for the prompt body. The
|
|
326
|
+
# result is interpolated inside a ```json fence and the comment is later
|
|
327
|
+
# passed through renderCommentMarkdown, so the value must never be able to
|
|
328
|
+
# close that fence. A string input_preview (the documented shape for many
|
|
329
|
+
# tools, e.g. a Bash command) is therefore JSON-serialized rather than
|
|
330
|
+
# emitted raw: that escapes embedded newlines to \n, collapsing it to a
|
|
331
|
+
# single line so no payload line can begin a ``` delimiter and break out
|
|
332
|
+
# into live markdown that misleads the approver.
|
|
333
|
+
def format_permission_arguments(arguments)
|
|
334
|
+
return I18n.t("collavre.claude_channel.permission.no_arguments") if arguments.blank?
|
|
335
|
+
|
|
336
|
+
JSON.pretty_generate(arguments)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Render the optional human-readable permission summary Claude Code sends
|
|
340
|
+
# alongside the structured fields. Absent for most tools, so it collapses
|
|
341
|
+
# to an empty string (no stray blockquote); when present it is the only
|
|
342
|
+
# plain-language description of what the approver is allowing.
|
|
343
|
+
def format_permission_description(description)
|
|
344
|
+
return "" if description.blank?
|
|
345
|
+
|
|
346
|
+
# The description renders into a "> %{text}" blockquote, so any newline
|
|
347
|
+
# would drop the remainder onto a fresh line where it could open a
|
|
348
|
+
# heading or ```fence and escape into live markdown. Flatten line breaks
|
|
349
|
+
# to whitespace so the whole summary stays inside the one blockquote line
|
|
350
|
+
# (inline backticks there are harmless — a fence must start a line).
|
|
351
|
+
flattened = description.gsub(/\s*\R\s*/, " ").strip
|
|
352
|
+
I18n.t("collavre.claude_channel.permission.description", text: flattened)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Render the tool name for the prompt. Unlike description/arguments it is
|
|
356
|
+
# interpolated into a "**%{tool_name}**" emphasis span and the comment is
|
|
357
|
+
# later passed through renderCommentMarkdown, so an unescaped value (e.g. a
|
|
358
|
+
# third-party MCP tool name) could close the surrounding "**", or — via an
|
|
359
|
+
# embedded newline — start a fresh-line heading/fence and misrepresent which
|
|
360
|
+
# tool the approver is authorizing. Flatten line breaks to whitespace and
|
|
361
|
+
# backslash-escape markdown metacharacters so the name always renders
|
|
362
|
+
# literally. The raw name is still kept in the action payload.
|
|
363
|
+
def format_permission_tool_name(tool_name)
|
|
364
|
+
flattened = tool_name.gsub(/\s*\R\s*/, " ").strip
|
|
365
|
+
flattened.gsub(/([\\`*_{}\[\]()#+\-.!~>|<])/) { "\\#{$1}" }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# When the relayed comment is a native tool-permission prompt (carries a
|
|
369
|
+
# permission_request_id), park the in-flight dispatch as "awaiting a
|
|
370
|
+
# decision" by stamping pending_tool_call on its delegated task.
|
|
371
|
+
# Comment#dispatch_to_orchestration then relays the human's subsequent
|
|
372
|
+
# allow/deny straight to this suspended session instead of queuing it
|
|
373
|
+
# behind the delegated topic slot (which would deadlock — the task holds
|
|
374
|
+
# the slot it is itself waiting to be unblocked on). Cleared when the
|
|
375
|
+
# task is completed (/reply) or cancelled (unregister/stuck recovery).
|
|
376
|
+
def park_pending_permission(topic, agent, requested_task_id, request_id)
|
|
377
|
+
return if request_id.blank? || requested_task_id.blank?
|
|
378
|
+
|
|
379
|
+
task = Task.where(topic_id: topic.id, status: "delegated", agent_id: agent.id)
|
|
380
|
+
.find_by(id: requested_task_id)
|
|
381
|
+
task&.update_column(:pending_tool_call, {
|
|
382
|
+
"request_id" => request_id.to_s,
|
|
383
|
+
"requested_at" => Time.current.iso8601
|
|
384
|
+
})
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Identify which Claude Channel agent a /notify posts as.
|
|
388
|
+
#
|
|
389
|
+
# Mirrors resolve_reply_agent's task_id-first resolution but never
|
|
390
|
+
# touches the task graph (notify only authorizes, it does not complete).
|
|
391
|
+
# A native permission prompt can be raised during a dispatch that
|
|
392
|
+
# selected this session via routing_expression on a *work* topic whose
|
|
393
|
+
# primary_agent is unset or a different agent (the case documented on
|
|
394
|
+
# resolve_reply_agent). The echoed active-dispatch task_id authorizes
|
|
395
|
+
# the dispatched session agent directly; without it the topic-
|
|
396
|
+
# primary_agent gate would 403 and the prompt would never surface in
|
|
397
|
+
# Collavre.
|
|
398
|
+
#
|
|
399
|
+
# task_id absent → fall back to topic.primary_agent (the registration
|
|
400
|
+
# inbox default, where the session IS primary — locally-initiated
|
|
401
|
+
# turns). task_id present-but-unresolved → nil (no fall-through), same
|
|
402
|
+
# as resolve_reply_agent: a present id targeting a vanished/foreign task
|
|
403
|
+
# must not silently post as primary_agent.
|
|
404
|
+
def resolve_notify_agent(topic, requested_task_id)
|
|
405
|
+
if requested_task_id.present?
|
|
406
|
+
task = Task.where(topic_id: topic.id, status: "delegated").find_by(id: requested_task_id)
|
|
407
|
+
agent = task&.agent
|
|
408
|
+
return nil unless agent && agent.claude_channel_agent? && agent.created_by_id == current_user.id
|
|
409
|
+
|
|
410
|
+
return agent
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
agent = topic.primary_agent
|
|
414
|
+
return agent if agent&.claude_channel_agent? && agent.created_by_id == current_user.id
|
|
415
|
+
|
|
416
|
+
nil
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Identify which Claude Channel agent this reply is for.
|
|
420
|
+
#
|
|
421
|
+
# Prefer the echoed task_id from the dispatch payload: the matcher can
|
|
422
|
+
# route to a Claude Channel agent via routing_expression on a topic
|
|
423
|
+
# whose primary_agent is unset or a different agent (e.g. multiple AI
|
|
424
|
+
# agents share a topic, or this agent only has feedback permission on
|
|
425
|
+
# the creative without being the topic's primary). In those cases the
|
|
426
|
+
# topic.primary_agent gate would 403 the reply and leave the delegated
|
|
427
|
+
# task hanging.
|
|
428
|
+
#
|
|
429
|
+
# When task_id is provided, look it up scoped to this topic and the
|
|
430
|
+
# delegated state, then take the agent from the task. The token holder
|
|
431
|
+
# is still required to own the agent (created_by_id == current_user.id)
|
|
432
|
+
# and the agent must be a Claude Channel agent so this endpoint can't
|
|
433
|
+
# be used to ventriloquize an unrelated AI agent.
|
|
434
|
+
#
|
|
435
|
+
# When task_id is absent, fall back to topic.primary_agent for
|
|
436
|
+
# back-compat with older plugin builds that don't echo task_id.
|
|
437
|
+
#
|
|
438
|
+
# If task_id IS supplied but the delegated task lookup fails (late
|
|
439
|
+
# reply after cancel/timeout, stale id, wrong topic), do NOT fall
|
|
440
|
+
# through to primary_agent — a present-but-unresolved task_id means
|
|
441
|
+
# the client believes it is answering a specific dispatch that no
|
|
442
|
+
# longer exists. Saving the reply against primary_agent would create
|
|
443
|
+
# an unlinked comment while complete_delegated_task finds nothing,
|
|
444
|
+
# potentially duplicating a reply after the task was already
|
|
445
|
+
# completed/cancelled. Return nil so reply renders 403.
|
|
446
|
+
def resolve_reply_agent(topic, requested_task_id)
|
|
447
|
+
if requested_task_id.present?
|
|
448
|
+
task = Task.where(topic_id: topic.id, status: "delegated").find_by(id: requested_task_id)
|
|
449
|
+
agent = task&.agent
|
|
450
|
+
return nil unless agent && agent.claude_channel_agent? && agent.created_by_id == current_user.id
|
|
451
|
+
|
|
452
|
+
return agent
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Legacy fallback (no task_id) must apply the same Claude Channel
|
|
456
|
+
# gate as the task_id path above. Without it, a feedback-permission
|
|
457
|
+
# caller could POST /reply on an ordinary topic whose primary_agent
|
|
458
|
+
# is a normal AI user and save an unlinked comment authored as that
|
|
459
|
+
# agent. Restrict to Claude Channel agents owned by the caller.
|
|
460
|
+
agent = topic.primary_agent
|
|
461
|
+
return agent if agent&.claude_channel_agent? && agent.created_by_id == current_user.id
|
|
462
|
+
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Atomically claim a delegated task for completion. Two-step:
|
|
467
|
+
# 1. Find a candidate task in delegated state, scoped to this
|
|
468
|
+
# agent + topic. With task_id supplied, exact match (required
|
|
469
|
+
# under topic concurrency > 1 where multiple delegated tasks
|
|
470
|
+
# coexist; the client echoes the dispatch's task_id). Without
|
|
471
|
+
# task_id (legacy clients), oldest-first.
|
|
472
|
+
# 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
|
|
473
|
+
# status == 'delegated' under the lock, then update! to 'done'.
|
|
474
|
+
# Concurrent claimers block on the lock; the loser sees the
|
|
475
|
+
# already-flipped status post-lock and returns nil so the
|
|
476
|
+
# caller can refuse the duplicate.
|
|
477
|
+
# update_all (NOT update!) is required to skip Task's
|
|
478
|
+
# after_update_commit callbacks at claim time. The callbacks fire
|
|
479
|
+
# check_trigger_loop_completion (which enqueues TriggerLoopCheckJob)
|
|
480
|
+
# and broadcast_stop_button_removal (which reads reply_comment).
|
|
481
|
+
# Both depend on the reply comment already existing — but reply()
|
|
482
|
+
# claims BEFORE comment.save to win the race against concurrent
|
|
483
|
+
# /reply calls. If update! fired the trigger-loop check here, the
|
|
484
|
+
# job could run (cooldown_seconds: 0) before comment.save commits,
|
|
485
|
+
# find no agent comment, and leave the loop stuck in "running".
|
|
486
|
+
# finalize_claimed_task replays both callbacks after the comment is
|
|
487
|
+
# persisted via Task#fire_completion_callbacks_after_external_claim.
|
|
488
|
+
def claim_delegated_task(agent, topic, requested_task_id)
|
|
489
|
+
scope = Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated")
|
|
490
|
+
candidate =
|
|
491
|
+
if requested_task_id.present?
|
|
492
|
+
scope.find_by(id: requested_task_id)
|
|
493
|
+
else
|
|
494
|
+
scope.order(:created_at).first
|
|
495
|
+
end
|
|
496
|
+
return nil unless candidate
|
|
497
|
+
|
|
498
|
+
claimed = nil
|
|
499
|
+
Task.transaction do
|
|
500
|
+
locked = Task.lock.find_by(id: candidate.id)
|
|
501
|
+
next unless locked && locked.status == "delegated"
|
|
502
|
+
|
|
503
|
+
Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
|
|
504
|
+
claimed = locked.reload
|
|
505
|
+
end
|
|
506
|
+
claimed
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Post-claim side effects, run only after the reply comment is saved.
|
|
510
|
+
# Links the comment to the claimed task, releases the ResourceTracker
|
|
511
|
+
# slot the AiAgentJob held under task.id, advances the parent
|
|
512
|
+
# workflow (if any), and drains the topic queue — mirroring
|
|
513
|
+
# AiAgentJob#perform's success path for non-delegated runs.
|
|
514
|
+
def finalize_claimed_task(agent, task, comment)
|
|
515
|
+
comment.update_column(:task_id, task.id)
|
|
516
|
+
|
|
517
|
+
Orchestration::ResourceTracker.for(agent).release!(task.id)
|
|
518
|
+
|
|
519
|
+
if task.parent_task_id.present?
|
|
520
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
524
|
+
|
|
525
|
+
# Replay the after_update_commit callbacks that were bypassed by
|
|
526
|
+
# update_all in claim_delegated_task — now that the reply comment
|
|
527
|
+
# is linked, TriggerLoopCheckJob can read it and decide whether to
|
|
528
|
+
# advance/await/complete the drop-trigger loop, and the stop-button
|
|
529
|
+
# broadcast has a comment to render.
|
|
530
|
+
task.fire_completion_callbacks_after_external_claim
|
|
531
|
+
|
|
532
|
+
# Clear the typing indicator immediately on reply. ClaudeChannelPresenceJob
|
|
533
|
+
# would also stop on its next beat (task no longer "delegated"), but that
|
|
534
|
+
# is up to HEARTBEAT_SECONDS away — broadcast idle now so the indicator
|
|
535
|
+
# drops the moment Claude's reply lands.
|
|
536
|
+
broadcast_claude_idle(agent, task, comment)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Clear the chat typing indicator via the canonical status broadcaster
|
|
540
|
+
# (the same one AiAgentService uses for every other agent), so the Claude
|
|
541
|
+
# path emits an identical "idle" agent_status payload.
|
|
542
|
+
def broadcast_claude_idle(agent, task, comment)
|
|
543
|
+
creative = comment.creative&.effective_origin
|
|
544
|
+
return unless creative
|
|
545
|
+
|
|
546
|
+
AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
|
|
547
|
+
.broadcast_status("idle")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# When an MCP session unregisters, any tasks still in delegated state
|
|
551
|
+
# are abandoned — the client that would have called /reply is gone.
|
|
552
|
+
# Mirror the cancel path so the agent's slot and each topic's queue
|
|
553
|
+
# both free up immediately rather than waiting for stuck detection.
|
|
554
|
+
# Scopes by agent_id only: per-session ai_users mean every delegated
|
|
555
|
+
# task for this agent belongs to this session, on any topic.
|
|
556
|
+
# True when a concurrent session still drives this shared agent. Reaps
|
|
557
|
+
# crash-orphaned presence rows first so a dead process's leftover row
|
|
558
|
+
# can't fool the gate into preserving routing for a sibling that is gone.
|
|
559
|
+
def sibling_sessions_live?(agent)
|
|
560
|
+
AgentSubscription.reap_stale!(agent.id)
|
|
561
|
+
AgentSubscription.live.where(agent_id: agent.id).exists?
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Scope cancellation to one ending session's topic (used when a live
|
|
565
|
+
# sibling shares the agent, so an agent-wide sweep is unsafe). Tasks on
|
|
566
|
+
# this topic are unambiguously the ending session's — its session topic
|
|
567
|
+
# is private to it.
|
|
568
|
+
def cancel_tasks_for_topic(agent, topic)
|
|
569
|
+
cancel_pending_tasks(Task.where(topic_id: topic.id, status: %w[queued pending running]), agent)
|
|
570
|
+
cancel_delegated_tasks(Task.where(topic_id: topic.id, status: "delegated"), agent)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def cancel_delegated_tasks_for_session(agent)
|
|
574
|
+
cancel_delegated_tasks(Task.where(agent_id: agent.id, status: "delegated"), agent)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def cancel_delegated_tasks(tasks, agent)
|
|
578
|
+
return if tasks.empty?
|
|
579
|
+
|
|
580
|
+
tracker = Orchestration::ResourceTracker.for(agent)
|
|
581
|
+
tasks.find_each do |task|
|
|
582
|
+
task.update!(status: "cancelled", pending_tool_call: nil)
|
|
583
|
+
tracker.release!(task.id)
|
|
584
|
+
|
|
585
|
+
if task.parent_task_id.present?
|
|
586
|
+
begin
|
|
587
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
|
|
588
|
+
task, error_message: "Claude Channel session unregistered before reply"
|
|
589
|
+
)
|
|
590
|
+
rescue StandardError => e
|
|
591
|
+
Rails.logger.error(
|
|
592
|
+
"[AgentsController] fail_subtask! failed for task #{task.id}: #{e.message}"
|
|
593
|
+
)
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Cancel pre-delegation tasks for this session's agent:
|
|
602
|
+
# - queued: waiting in topic queue, no slot reserved
|
|
603
|
+
# - pending: between dequeue and AiAgentJob#perform, no slot reserved
|
|
604
|
+
# - running: AiAgentJob is mid-perform; for Claude Channel agents the
|
|
605
|
+
# slot is reserved with task.id between tracker.reserve! and the
|
|
606
|
+
# "running → delegated" transition. AiAgentJob#perform reloads the
|
|
607
|
+
# task before the delegated transition and exits if it sees
|
|
608
|
+
# "cancelled", so we close that race here.
|
|
609
|
+
#
|
|
610
|
+
# Release the tracker slot defensively for running tasks — release! is
|
|
611
|
+
# idempotent (Set#delete no-ops on missing key) and we don't know from
|
|
612
|
+
# the DB whether AiAgentJob had already reached tracker.reserve!.
|
|
613
|
+
# Parent subtasks are still failed so workflow executors don't deadlock
|
|
614
|
+
# waiting for a reply that will never come.
|
|
615
|
+
def cancel_pending_tasks_for_session(agent)
|
|
616
|
+
cancel_pending_tasks(Task.where(agent_id: agent.id, status: %w[queued pending running]), agent)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def cancel_pending_tasks(tasks, agent)
|
|
620
|
+
return if tasks.empty?
|
|
621
|
+
|
|
622
|
+
tracker = Orchestration::ResourceTracker.for(agent)
|
|
623
|
+
drained_topics = {}
|
|
624
|
+
tasks.find_each do |task|
|
|
625
|
+
was_running = task.status == "running"
|
|
626
|
+
task.update!(status: "cancelled")
|
|
627
|
+
tracker.release!(task.id) if was_running
|
|
628
|
+
drained_topics[task.topic_id] ||= task.creative_id
|
|
629
|
+
|
|
630
|
+
if task.parent_task_id.present?
|
|
631
|
+
begin
|
|
632
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
|
|
633
|
+
task, error_message: "Claude Channel session unregistered before dispatch"
|
|
634
|
+
)
|
|
635
|
+
rescue StandardError => e
|
|
636
|
+
Rails.logger.error(
|
|
637
|
+
"[AgentsController] fail_subtask! failed for queued task #{task.id}: #{e.message}"
|
|
638
|
+
)
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Drain each affected topic's queue once, AFTER all of this session's
|
|
644
|
+
# pre-dispatch tasks are cancelled. The delegated path dequeues per
|
|
645
|
+
# task, but it never cancels queued tasks; this path does. A per-task
|
|
646
|
+
# dequeue here could promote one of our own still-queued tasks to
|
|
647
|
+
# pending mid-loop, only for the loop to cancel it — and its AiAgentJob
|
|
648
|
+
# early-returns on "cancelled" without dequeuing, re-stranding a live
|
|
649
|
+
# sibling's queued task on the same shared topic. Draining once at the
|
|
650
|
+
# end (when none of our tasks remain queued) promotes the sibling's
|
|
651
|
+
# task instead of ours.
|
|
652
|
+
drained_topics.each do |topic_id, creative_id|
|
|
653
|
+
Orchestration::AgentOrchestrator.dequeue_next_for_topic(topic_id, creative_id)
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def dispatch_a2a(agent, comment)
|
|
658
|
+
AiAgent::A2aDispatcher.new(
|
|
659
|
+
agent: agent,
|
|
660
|
+
reply_comment: comment,
|
|
661
|
+
context: {
|
|
662
|
+
"creative" => { "id" => comment.creative_id },
|
|
663
|
+
"topic" => { "id" => comment.topic_id }
|
|
664
|
+
}
|
|
665
|
+
).dispatch
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Deterministic, collision-free email key for a (current_user, agent_name)
|
|
669
|
+
# agent. The readable slug is lossy — "qa/bot", "qa bot" and "qa--bot" all
|
|
670
|
+
# squeeze to "qa-bot", and any all-symbol name collapses to "session" — so
|
|
671
|
+
# a short digest of the normalized raw name disambiguates distinct
|
|
672
|
+
# configured names that would otherwise alias onto ONE shared agent
|
|
673
|
+
# identity (ActionCable stream, routing state, tasks, creative shares).
|
|
674
|
+
# The slug stays for human readability; the digest decides identity. Same
|
|
675
|
+
# normalized name -> same key, so idempotent re-register/reuse is
|
|
676
|
+
# preserved. Normalization (strip+downcase) keeps case/whitespace-only
|
|
677
|
+
# differences folded, matching the prior behavior — only the lossy
|
|
678
|
+
# punctuation/spacing collapse is fixed.
|
|
679
|
+
def session_agent_email(agent_name)
|
|
680
|
+
normalized = agent_name.to_s.strip.downcase
|
|
681
|
+
slug = normalized.gsub(/[^a-z0-9-]+/, "-").squeeze("-").gsub(/\A-|-\z/, "")
|
|
682
|
+
slug = "session" if slug.blank?
|
|
683
|
+
digest = Digest::SHA256.hexdigest(normalized)[0, 10]
|
|
684
|
+
"claude-channel-#{current_user.id}-#{slug}-#{digest}@agent.collavre.local"
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# One ai_user per (current_user, agent_name) so a human's sessions share
|
|
688
|
+
# one Agent identity. Same agent_name re-registering reuses the existing
|
|
689
|
+
# row — idempotent retries (and every additional session) don't
|
|
690
|
+
# proliferate agents.
|
|
691
|
+
#
|
|
692
|
+
# Returns nil when a row with the deterministic email already exists
|
|
693
|
+
# but is owned by someone else or isn't a Claude Channel ai_user. The
|
|
694
|
+
# email format is human-derivable (current_user.id + slug + digest), so a
|
|
695
|
+
# foreign row could be planted by signup/import; silently reusing it
|
|
696
|
+
# would attach the caller's inbox feedback share to that foreign User
|
|
697
|
+
# and leave the plugin's AgentChannel subscription rejected on
|
|
698
|
+
# ownership mismatch. Caller renders 409 in that case.
|
|
699
|
+
def find_or_create_session_agent(agent_name)
|
|
700
|
+
email = session_agent_email(agent_name)
|
|
701
|
+
|
|
702
|
+
existing = User.find_by(email: email)
|
|
703
|
+
return verified_session_agent(existing) if existing
|
|
704
|
+
|
|
705
|
+
# routing_expression: nil so the new ai_user is not matchable until
|
|
706
|
+
# the client claims the per-agent stream via AgentChannel. See the
|
|
707
|
+
# comment on verified_session_agent.
|
|
708
|
+
User.create!(
|
|
709
|
+
email: email,
|
|
710
|
+
name: "Claude Channel (#{agent_name})",
|
|
711
|
+
password: SecureRandom.hex(32),
|
|
712
|
+
llm_vendor: "anthropic",
|
|
713
|
+
llm_model: "claude-code",
|
|
714
|
+
created_by_id: current_user.id,
|
|
715
|
+
searchable: false,
|
|
716
|
+
routing_expression: nil
|
|
717
|
+
)
|
|
718
|
+
rescue ActiveRecord::RecordNotUnique
|
|
719
|
+
# A concurrent registration for the same (user, agent_name) won the
|
|
720
|
+
# users.email unique race. The desired row now exists — re-find and
|
|
721
|
+
# re-verify ownership instead of surfacing a 500 that aborts one of the
|
|
722
|
+
# two simultaneously launching plugin instances.
|
|
723
|
+
verified_session_agent(User.find_by(email: email))
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Returns the agent only when it is the current user's own Claude Channel
|
|
727
|
+
# agent; nil otherwise (the caller renders :conflict). Routing activation
|
|
728
|
+
# stays deferred to AgentChannel#subscribe_to_agent_stream so the agent
|
|
729
|
+
# becomes matchable only once a WebSocket subscriber exists for
|
|
730
|
+
# agent:user:<id> — otherwise comments matched between this POST returning
|
|
731
|
+
# and the client's subsequent cable subscribe would broadcast into an
|
|
732
|
+
# empty stream, stranding delegated tasks until stuck recovery.
|
|
733
|
+
def verified_session_agent(ai_user)
|
|
734
|
+
return nil unless ai_user &&
|
|
735
|
+
ai_user.created_by_id == current_user.id &&
|
|
736
|
+
ai_user.ai_user? &&
|
|
737
|
+
ai_user.claude_channel_agent?
|
|
738
|
+
|
|
739
|
+
ai_user
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# One Topic per (agent, session_id). On re-register (including --resume
|
|
743
|
+
# from the same cwd, which yields the same session_id) the existing
|
|
744
|
+
# topic is reused — even if archived — so the conversation persists
|
|
745
|
+
# instead of orphaning. A fresh session gets a new topic under the same
|
|
746
|
+
# shared agent, which is how one agent fans out to many sessions.
|
|
747
|
+
def find_or_create_session_topic(inbox, ai_user, session_id, session_label)
|
|
748
|
+
existing = inbox.topics.find_by(primary_agent_id: ai_user.id, session_id: session_id)
|
|
749
|
+
return existing if existing
|
|
750
|
+
|
|
751
|
+
label = session_label.to_s.strip.presence || session_id
|
|
752
|
+
inbox.topics.create!(
|
|
753
|
+
name: unique_topic_name(inbox, "Claude #{label}"),
|
|
754
|
+
user: current_user,
|
|
755
|
+
primary_agent_id: ai_user.id,
|
|
756
|
+
session_id: session_id
|
|
757
|
+
)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# Topics carry a UNIQUE (creative_id, name) index, so two sessions whose
|
|
761
|
+
# friendly labels collide (e.g. both rooted at a dir named "src") cannot
|
|
762
|
+
# share a name. Append the smallest numeric suffix that is free.
|
|
763
|
+
def unique_topic_name(inbox, desired)
|
|
764
|
+
return desired unless inbox.topics.exists?(name: desired)
|
|
765
|
+
|
|
766
|
+
n = 2
|
|
767
|
+
loop do
|
|
768
|
+
candidate = "#{desired} (#{n})"
|
|
769
|
+
return candidate unless inbox.topics.exists?(name: candidate)
|
|
770
|
+
|
|
771
|
+
n += 1
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
end
|