collavre 0.2.5 → 0.3.1
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/channels/collavre/comments_presence_channel.rb +6 -3
- data/app/controllers/collavre/admin/orchestration_controller.rb +177 -0
- data/app/controllers/collavre/comments_controller.rb +10 -1
- data/app/javascript/controllers/comments/presence_controller.js +5 -1
- data/app/jobs/collavre/ai_agent_job.rb +55 -4
- data/app/jobs/collavre/cron_action_job.rb +53 -0
- data/app/jobs/collavre/stuck_detector_job.rb +34 -0
- data/app/models/collavre/comment.rb +15 -15
- data/app/models/collavre/creative.rb +1 -1
- data/app/models/collavre/orchestrator_policy.rb +124 -0
- data/app/models/collavre/task.rb +3 -0
- data/app/models/collavre/user.rb +1 -1
- data/app/services/collavre/ai_agent_service.rb +32 -1
- data/app/services/collavre/ai_client.rb +4 -3
- data/app/services/collavre/comments/command_processor.rb +4 -1
- data/app/services/collavre/comments/topic_command.rb +101 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +142 -0
- data/app/services/collavre/orchestration/arbiter.rb +257 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
- data/app/services/collavre/orchestration/matcher.rb +98 -0
- data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
- data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
- data/app/services/collavre/orchestration/scheduler.rb +145 -0
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +303 -0
- data/app/services/collavre/system_events/context_builder.rb +34 -7
- data/app/services/collavre/system_events/dispatcher.rb +2 -7
- data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
- data/app/services/collavre/tools/cron_create_service.rb +73 -0
- data/app/services/collavre/tools/cron_list_service.rb +65 -0
- data/app/services/collavre/tools/cron_update_service.rb +82 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
- data/app/views/collavre/creatives/index.html.erb +1 -1
- data/config/locales/ai_agent.en.yml +21 -0
- data/config/locales/ai_agent.ko.yml +21 -0
- data/config/locales/comments.en.yml +8 -0
- data/config/locales/comments.ko.yml +8 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
- data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
- data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
- data/lib/collavre/version.rb +1 -1
- metadata +24 -13
- data/app/controllers/collavre/github_auth_controller.rb +0 -25
- data/app/models/collavre/github_account.rb +0 -10
- data/app/models/collavre/github_repository_link.rb +0 -19
- data/app/services/collavre/github/client.rb +0 -112
- data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
- data/app/services/collavre/github/pull_request_processor.rb +0 -181
- data/app/services/collavre/github/webhook_provisioner.rb +0 -130
- data/app/services/collavre/system_events/router.rb +0 -96
- data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
- data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
- data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
- data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +0 -5
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# StuckDetector detects stuck tasks and creatives, then auto-escalates to admins.
|
|
6
|
+
#
|
|
7
|
+
# Detection conditions:
|
|
8
|
+
# 1. Running tasks that haven't progressed for N minutes
|
|
9
|
+
# 2. Tasks with too many retries (escalated but not handled)
|
|
10
|
+
# 3. Creatives with no progress for extended periods
|
|
11
|
+
#
|
|
12
|
+
# Auto-escalation:
|
|
13
|
+
# - Creates InboxItem for admin users
|
|
14
|
+
# - Optionally creates a system comment
|
|
15
|
+
#
|
|
16
|
+
class StuckDetector
|
|
17
|
+
# Result object for detection
|
|
18
|
+
Result = Struct.new(:stuck_items, :escalated_count, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
# StuckItem represents a detected stuck item
|
|
21
|
+
StuckItem = Struct.new(:type, :item, :reason, :stuck_since, :escalation_targets, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
def initialize(policy_resolver: nil)
|
|
24
|
+
@policy_resolver = policy_resolver || PolicyResolver.new({})
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Run detection, auto-recovery, and escalation
|
|
28
|
+
# Returns Result with stuck items and escalation count
|
|
29
|
+
def detect_and_escalate
|
|
30
|
+
config = stuck_detection_config
|
|
31
|
+
return Result.new(stuck_items: [], escalated_count: 0) unless config["enabled"]
|
|
32
|
+
|
|
33
|
+
stuck_items = []
|
|
34
|
+
stuck_items.concat(detect_stuck_tasks(config))
|
|
35
|
+
stuck_items.concat(detect_stalled_creatives(config))
|
|
36
|
+
|
|
37
|
+
auto_recover_stuck_tasks(stuck_items)
|
|
38
|
+
escalated_count = escalate_stuck_items(stuck_items, config)
|
|
39
|
+
|
|
40
|
+
Result.new(stuck_items: stuck_items, escalated_count: escalated_count)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Detect only (no escalation)
|
|
44
|
+
def detect
|
|
45
|
+
config = stuck_detection_config
|
|
46
|
+
return [] unless config["enabled"]
|
|
47
|
+
|
|
48
|
+
stuck_items = []
|
|
49
|
+
stuck_items.concat(detect_stuck_tasks(config))
|
|
50
|
+
stuck_items.concat(detect_stalled_creatives(config))
|
|
51
|
+
stuck_items
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Auto-recover stuck tasks by marking them as failed and draining the queue.
|
|
57
|
+
def auto_recover_stuck_tasks(stuck_items)
|
|
58
|
+
stuck_items.each do |stuck_item|
|
|
59
|
+
next unless stuck_item.type == :task
|
|
60
|
+
|
|
61
|
+
task = stuck_item.item
|
|
62
|
+
next unless task.status == "running"
|
|
63
|
+
|
|
64
|
+
task.update!(status: "failed")
|
|
65
|
+
Rails.logger.info(
|
|
66
|
+
"[StuckDetector] Auto-recovered task #{task.id} (agent=#{task.agent_id}): " \
|
|
67
|
+
"marked as failed after #{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Release resources held by the stuck task
|
|
71
|
+
if task.agent
|
|
72
|
+
tracker = ResourceTracker.for(task.agent)
|
|
73
|
+
tracker.release!(task.id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Drain the queue for the topic so waiting tasks can execute
|
|
77
|
+
AgentOrchestrator.dequeue_next_for_topic(task.topic_id)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Rails.logger.error("[StuckDetector] Auto-recovery failed for task #{task.id}: #{e.message}")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stuck_detection_config
|
|
84
|
+
@policy_resolver.resolve("stuck_detection")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Detect tasks that are stuck in running state
|
|
88
|
+
def detect_stuck_tasks(config)
|
|
89
|
+
threshold_minutes = config["task_stuck_threshold_minutes"] || 30
|
|
90
|
+
threshold_time = threshold_minutes.minutes.ago
|
|
91
|
+
|
|
92
|
+
stuck_tasks = Task.where(status: "running")
|
|
93
|
+
.where("updated_at < ?", threshold_time)
|
|
94
|
+
|
|
95
|
+
stuck_tasks.filter_map do |task|
|
|
96
|
+
# Skip if already escalated recently
|
|
97
|
+
next if recently_escalated?(task)
|
|
98
|
+
|
|
99
|
+
escalation_targets = find_escalation_targets(task)
|
|
100
|
+
next if escalation_targets.empty?
|
|
101
|
+
|
|
102
|
+
StuckItem.new(
|
|
103
|
+
type: :task,
|
|
104
|
+
item: task,
|
|
105
|
+
reason: :no_progress,
|
|
106
|
+
stuck_since: task.updated_at,
|
|
107
|
+
escalation_targets: escalation_targets
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Detect creatives that have stalled (no activity for extended period)
|
|
113
|
+
def detect_stalled_creatives(config)
|
|
114
|
+
threshold_minutes = config["creative_stall_threshold_minutes"] || 120
|
|
115
|
+
threshold_time = threshold_minutes.minutes.ago
|
|
116
|
+
|
|
117
|
+
# Find creatives with incomplete status but no recent activity
|
|
118
|
+
# Only check creatives that have at least one AI agent with access
|
|
119
|
+
stalled = []
|
|
120
|
+
|
|
121
|
+
Creative.where("progress < 1.0")
|
|
122
|
+
.where("updated_at < ?", threshold_time)
|
|
123
|
+
.find_each do |creative|
|
|
124
|
+
# Skip if no AI agents have access
|
|
125
|
+
ai_agents = creative.creative_shares.joins(:user)
|
|
126
|
+
.where(users: { llm_vendor: [ nil, "" ].map { |v| v } })
|
|
127
|
+
.or(creative.creative_shares.joins(:user).where.not(users: { llm_vendor: nil }))
|
|
128
|
+
.where.not(permission: "no_access")
|
|
129
|
+
.map(&:user)
|
|
130
|
+
.select(&:ai_user?)
|
|
131
|
+
|
|
132
|
+
next if ai_agents.empty?
|
|
133
|
+
|
|
134
|
+
# Skip if already escalated recently
|
|
135
|
+
next if recently_escalated_creative?(creative)
|
|
136
|
+
|
|
137
|
+
escalation_targets = find_creative_escalation_targets(creative)
|
|
138
|
+
next if escalation_targets.empty?
|
|
139
|
+
|
|
140
|
+
stalled << StuckItem.new(
|
|
141
|
+
type: :creative,
|
|
142
|
+
item: creative,
|
|
143
|
+
reason: :stalled,
|
|
144
|
+
stuck_since: creative.updated_at,
|
|
145
|
+
escalation_targets: escalation_targets
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
stalled
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def find_escalation_targets(task)
|
|
153
|
+
# Find admin users for the task's creative
|
|
154
|
+
creative_id = task.trigger_event_payload&.dig("creative", "id")
|
|
155
|
+
return [] unless creative_id
|
|
156
|
+
|
|
157
|
+
creative = Creative.find_by(id: creative_id)
|
|
158
|
+
return [] unless creative
|
|
159
|
+
|
|
160
|
+
find_creative_escalation_targets(creative)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def find_creative_escalation_targets(creative)
|
|
164
|
+
# Find users with admin permission on the creative or its ancestors
|
|
165
|
+
admin_users = []
|
|
166
|
+
|
|
167
|
+
([ creative ] + creative.ancestors.to_a).each do |c|
|
|
168
|
+
c.creative_shares.where(permission: "admin").includes(:user).each do |share|
|
|
169
|
+
admin_users << share.user unless share.user.ai_user?
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Also include the creative owner
|
|
174
|
+
admin_users << creative.user if creative.user && !creative.user.ai_user?
|
|
175
|
+
|
|
176
|
+
admin_users.uniq
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def recently_escalated?(task)
|
|
180
|
+
# Check if we've already escalated this task in the last hour
|
|
181
|
+
cache_key = "stuck_detector:task:#{task.id}"
|
|
182
|
+
Rails.cache.exist?(cache_key)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def recently_escalated_creative?(creative)
|
|
186
|
+
cache_key = "stuck_detector:creative:#{creative.id}"
|
|
187
|
+
Rails.cache.exist?(cache_key)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def mark_escalated(item)
|
|
191
|
+
cache_key = case item.type
|
|
192
|
+
when :task then "stuck_detector:task:#{item.item.id}"
|
|
193
|
+
when :creative then "stuck_detector:creative:#{item.item.id}"
|
|
194
|
+
end
|
|
195
|
+
# Don't re-escalate for 1 hour
|
|
196
|
+
Rails.cache.write(cache_key, true, expires_in: 1.hour)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def escalate_stuck_items(stuck_items, config)
|
|
200
|
+
escalated_count = 0
|
|
201
|
+
|
|
202
|
+
stuck_items.each do |stuck_item|
|
|
203
|
+
escalated = escalate_item(stuck_item, config)
|
|
204
|
+
if escalated
|
|
205
|
+
mark_escalated(stuck_item)
|
|
206
|
+
escalated_count += 1
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
escalated_count
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def escalate_item(stuck_item, config)
|
|
214
|
+
stuck_item.escalation_targets.each do |target|
|
|
215
|
+
create_inbox_notification(target, stuck_item)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Optionally create a system comment
|
|
219
|
+
if config["create_system_comment"] && stuck_item.type == :task
|
|
220
|
+
create_system_comment(stuck_item)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
true
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
Rails.logger.error("[StuckDetector] Failed to escalate: #{e.message}")
|
|
226
|
+
false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def create_inbox_notification(owner, stuck_item)
|
|
230
|
+
message_key, message_params = build_message(stuck_item)
|
|
231
|
+
|
|
232
|
+
creative = case stuck_item.type
|
|
233
|
+
when :task
|
|
234
|
+
creative_id = stuck_item.item.trigger_event_payload&.dig("creative", "id")
|
|
235
|
+
Creative.find_by(id: creative_id)
|
|
236
|
+
when :creative
|
|
237
|
+
stuck_item.item
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
InboxItem.create!(
|
|
241
|
+
owner: owner,
|
|
242
|
+
message_key: message_key,
|
|
243
|
+
message_params: message_params,
|
|
244
|
+
creative: creative,
|
|
245
|
+
link: creative ? Collavre::Engine.routes.url_helpers.creative_path(creative) : nil
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def build_message(stuck_item)
|
|
250
|
+
case stuck_item.type
|
|
251
|
+
when :task
|
|
252
|
+
task = stuck_item.item
|
|
253
|
+
minutes_stuck = ((Time.current - stuck_item.stuck_since) / 60).round
|
|
254
|
+
|
|
255
|
+
[
|
|
256
|
+
"collavre.stuck_detection.task_stuck",
|
|
257
|
+
{
|
|
258
|
+
task_name: task.name,
|
|
259
|
+
agent_name: task.agent&.display_name || "Unknown",
|
|
260
|
+
minutes: minutes_stuck
|
|
261
|
+
}
|
|
262
|
+
]
|
|
263
|
+
when :creative
|
|
264
|
+
creative = stuck_item.item
|
|
265
|
+
hours_stuck = ((Time.current - stuck_item.stuck_since) / 3600).round
|
|
266
|
+
|
|
267
|
+
[
|
|
268
|
+
"collavre.stuck_detection.creative_stalled",
|
|
269
|
+
{
|
|
270
|
+
creative_title: creative.description&.truncate(50) || "Untitled",
|
|
271
|
+
hours: hours_stuck,
|
|
272
|
+
progress: ((creative.progress || 0) * 100).round
|
|
273
|
+
}
|
|
274
|
+
]
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def create_system_comment(stuck_item)
|
|
279
|
+
return unless stuck_item.type == :task
|
|
280
|
+
|
|
281
|
+
task = stuck_item.item
|
|
282
|
+
creative_id = task.trigger_event_payload&.dig("creative", "id")
|
|
283
|
+
creative = Creative.find_by(id: creative_id)
|
|
284
|
+
return unless creative
|
|
285
|
+
|
|
286
|
+
topic_id = task.topic_id
|
|
287
|
+
|
|
288
|
+
content = I18n.t(
|
|
289
|
+
"collavre.stuck_detection.system_comment",
|
|
290
|
+
task_name: task.name,
|
|
291
|
+
agent_name: task.agent&.display_name || "Unknown"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
Comment.create!(
|
|
295
|
+
creative: creative,
|
|
296
|
+
content: content,
|
|
297
|
+
user: nil, # System comment
|
|
298
|
+
topic_id: topic_id
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -14,22 +14,49 @@ module Collavre
|
|
|
14
14
|
ctx["chat"]["mentioned_user"] ||= mentioned_user(ctx["chat"])
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Add sender context for A2A communication
|
|
18
|
+
ctx["sender"] ||= build_sender_context(ctx)
|
|
19
|
+
|
|
17
20
|
ctx
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
private
|
|
21
24
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
def build_sender_context(ctx)
|
|
26
|
+
user_id = ctx.dig("comment", "user_id")
|
|
27
|
+
return nil unless user_id
|
|
28
|
+
|
|
29
|
+
user = User.find_by(id: user_id)
|
|
30
|
+
return nil unless user
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
"id" => user.id,
|
|
34
|
+
"name" => user.name,
|
|
35
|
+
"display_name" => user.respond_to?(:display_name) ? user.display_name : user.name,
|
|
36
|
+
"is_ai" => user.ai_user?,
|
|
37
|
+
"type" => user.ai_user? ? extract_agent_type(user) : "human"
|
|
38
|
+
}
|
|
39
|
+
end
|
|
27
40
|
|
|
41
|
+
def extract_agent_type(user)
|
|
42
|
+
prompt = user.system_prompt.to_s.downcase
|
|
43
|
+
case prompt
|
|
44
|
+
when /developer|개발/ then "developer"
|
|
45
|
+
when /pm|project.?manager|프로젝트/ then "pm"
|
|
46
|
+
when /qa|test|quality|테스트|품질/ then "qa"
|
|
47
|
+
when /research|조사|연구/ then "researcher"
|
|
48
|
+
when /market|마케팅/ then "marketer"
|
|
49
|
+
when /plan|기획/ then "planner"
|
|
50
|
+
else "agent"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mentioned_user(chat_context)
|
|
28
55
|
content = chat_context["content"]
|
|
29
56
|
return nil unless content
|
|
30
57
|
|
|
31
|
-
#
|
|
32
|
-
match = content.match(/\A@([^:]+?):\s*/)
|
|
58
|
+
# Canonical mention format: @name: (with colon)
|
|
59
|
+
match = content.match(/\A@([^:]+?):\s*/)
|
|
33
60
|
return nil unless match
|
|
34
61
|
|
|
35
62
|
name = match[1].strip
|
|
@@ -6,13 +6,8 @@ module Collavre
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def dispatch(event_name, context)
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
agents = Router.new.route(event_name, enriched_context)
|
|
12
|
-
|
|
13
|
-
agents.each do |agent|
|
|
14
|
-
AiAgentJob.perform_later(agent.id, event_name, enriched_context)
|
|
15
|
-
end
|
|
9
|
+
# Delegate to AgentOrchestrator for unified routing/scheduling
|
|
10
|
+
Orchestration::AgentOrchestrator.dispatch(event_name, context)
|
|
16
11
|
end
|
|
17
12
|
end
|
|
18
13
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CronCancelService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
|
|
9
|
+
tool_name "cron_cancel"
|
|
10
|
+
tool_description "Cancel (delete) a recurring scheduled job by its key. Only jobs for creatives you have write access to can be cancelled."
|
|
11
|
+
|
|
12
|
+
tool_param :key, description: "The unique key of the cron job to cancel (from cron_list).", required: true
|
|
13
|
+
|
|
14
|
+
sig { params(key: String).returns(T::Hash[Symbol, T.untyped]) }
|
|
15
|
+
def call(key:)
|
|
16
|
+
raise "Current.user is required" unless Current.user
|
|
17
|
+
|
|
18
|
+
task = SolidQueue::RecurringTask.find_by(key: key, static: false)
|
|
19
|
+
return { error: "Cron job not found", key: key } unless task
|
|
20
|
+
|
|
21
|
+
args = parse_arguments(task)
|
|
22
|
+
creative_id = args["creative_id"] || parse_creative_id_from_key(key)
|
|
23
|
+
creative = Creative.find_by(id: creative_id)
|
|
24
|
+
|
|
25
|
+
unless creative&.has_permission?(Current.user, :write)
|
|
26
|
+
return { error: "No write permission to cancel this cron job", key: key }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
task.destroy!
|
|
30
|
+
|
|
31
|
+
{ success: true, key: key, message: "Cron job cancelled successfully" }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse_arguments(task)
|
|
37
|
+
args = task.arguments
|
|
38
|
+
return {} unless args.is_a?(Array) && args.first.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
args.first.stringify_keys
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_creative_id_from_key(key)
|
|
44
|
+
match = key.match(/\Acron_(\d+)_/)
|
|
45
|
+
match[1].to_i if match
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CronCreateService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
|
|
9
|
+
tool_name "cron_create"
|
|
10
|
+
tool_description "Create a new recurring scheduled job. The job will periodically post a message to a creative's topic, triggering agent orchestration. Schedule uses cron syntax (e.g., '*/5 * * * *' for every 5 minutes, '0 9 * * *' for daily at 9am)."
|
|
11
|
+
|
|
12
|
+
tool_param :creative_id, description: "The creative ID to post recurring messages to.", required: true
|
|
13
|
+
tool_param :topic_id, description: "The topic ID within the creative to post to.", required: true
|
|
14
|
+
tool_param :schedule, description: "Cron schedule expression (e.g., '0 9 * * *' for daily at 9am, '*/30 * * * *' for every 30 minutes).", required: true
|
|
15
|
+
tool_param :message, description: "The message content to post on each execution. This triggers the agent orchestration pipeline.", required: true
|
|
16
|
+
tool_param :description, description: "Human-readable description of what this cron job does.", required: false
|
|
17
|
+
|
|
18
|
+
sig do
|
|
19
|
+
params(
|
|
20
|
+
creative_id: Integer,
|
|
21
|
+
topic_id: Integer,
|
|
22
|
+
schedule: String,
|
|
23
|
+
message: String,
|
|
24
|
+
description: T.nilable(String)
|
|
25
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
26
|
+
end
|
|
27
|
+
def call(creative_id:, topic_id:, schedule:, message:, description: nil)
|
|
28
|
+
raise "Current.user is required" unless Current.user
|
|
29
|
+
|
|
30
|
+
creative = Creative.find_by(id: creative_id)
|
|
31
|
+
return { error: "Creative not found", id: creative_id } unless creative
|
|
32
|
+
unless creative.has_permission?(Current.user, :write)
|
|
33
|
+
return { error: "No write permission on this Creative", id: creative_id }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
topic = Topic.find_by(id: topic_id, creative_id: creative.effective_origin.id)
|
|
37
|
+
return { error: "Topic not found or does not belong to this creative", topic_id: topic_id } unless topic
|
|
38
|
+
|
|
39
|
+
parsed = Fugit.parse(schedule)
|
|
40
|
+
unless parsed.is_a?(Fugit::Cron)
|
|
41
|
+
return { error: "Invalid cron schedule: #{schedule}. Use cron syntax like '0 9 * * *'." }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
suffix = SecureRandom.hex(4)
|
|
45
|
+
key = "cron_#{creative_id}_#{suffix}"
|
|
46
|
+
|
|
47
|
+
task = SolidQueue::RecurringTask.create!(
|
|
48
|
+
key: key,
|
|
49
|
+
class_name: "Collavre::CronActionJob",
|
|
50
|
+
schedule: schedule,
|
|
51
|
+
queue_name: "default",
|
|
52
|
+
static: false,
|
|
53
|
+
description: description || "Cron job for creative #{creative_id}",
|
|
54
|
+
arguments: [ {
|
|
55
|
+
creative_id: creative_id,
|
|
56
|
+
topic_id: topic_id,
|
|
57
|
+
agent_id: Current.user.id,
|
|
58
|
+
message: message
|
|
59
|
+
} ]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
success: true,
|
|
64
|
+
key: task.key,
|
|
65
|
+
schedule: task.schedule,
|
|
66
|
+
description: task.description,
|
|
67
|
+
creative_id: creative_id,
|
|
68
|
+
next_run: task.next_time&.iso8601
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CronListService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
|
|
9
|
+
tool_name "cron_list"
|
|
10
|
+
tool_description "List recurring scheduled jobs. Returns all cron jobs for creatives the current user has access to. Filter by creative_id to see jobs for a specific creative."
|
|
11
|
+
|
|
12
|
+
tool_param :creative_id, description: "Filter by creative ID. If omitted, lists all accessible cron jobs.", required: false
|
|
13
|
+
|
|
14
|
+
sig { params(creative_id: T.nilable(Integer)).returns(T::Hash[Symbol, T.untyped]) }
|
|
15
|
+
def call(creative_id: nil)
|
|
16
|
+
raise "Current.user is required" unless Current.user
|
|
17
|
+
|
|
18
|
+
tasks = SolidQueue::RecurringTask.where(static: false)
|
|
19
|
+
|
|
20
|
+
if creative_id.present?
|
|
21
|
+
creative = Creative.find_by(id: creative_id)
|
|
22
|
+
return { error: "Creative not found", id: creative_id } unless creative
|
|
23
|
+
unless creative.has_permission?(Current.user, :read)
|
|
24
|
+
return { error: "No read permission on this Creative", id: creative_id }
|
|
25
|
+
end
|
|
26
|
+
tasks = tasks.where("key LIKE ?", "cron_#{creative_id}_%")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
results = tasks.filter_map do |task|
|
|
30
|
+
args = parse_arguments(task)
|
|
31
|
+
task_creative_id = args["creative_id"] || parse_creative_id_from_key(task.key)
|
|
32
|
+
task_creative = Creative.find_by(id: task_creative_id)
|
|
33
|
+
next unless task_creative&.has_permission?(Current.user, :read)
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
key: task.key,
|
|
37
|
+
schedule: task.schedule,
|
|
38
|
+
description: task.description,
|
|
39
|
+
creative_id: task_creative_id,
|
|
40
|
+
topic_id: args["topic_id"],
|
|
41
|
+
agent_id: args["agent_id"],
|
|
42
|
+
message: args["message"],
|
|
43
|
+
created_at: task.created_at&.iso8601
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{ success: true, cron_jobs: results, count: results.size }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_arguments(task)
|
|
53
|
+
args = task.arguments
|
|
54
|
+
return {} unless args.is_a?(Array) && args.first.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
args.first.stringify_keys
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_creative_id_from_key(key)
|
|
60
|
+
match = key.match(/\Acron_(\d+)_/)
|
|
61
|
+
match[1].to_i if match
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CronUpdateService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
|
|
9
|
+
tool_name "cron_update"
|
|
10
|
+
tool_description "Update the schedule, message, or description of an existing recurring job. Only jobs for creatives you have write access to can be updated."
|
|
11
|
+
|
|
12
|
+
tool_param :key, description: "The unique key of the cron job to update.", required: true
|
|
13
|
+
tool_param :schedule, description: "New cron schedule expression (optional).", required: false
|
|
14
|
+
tool_param :message, description: "New message content (optional).", required: false
|
|
15
|
+
tool_param :description, description: "New description (optional).", required: false
|
|
16
|
+
|
|
17
|
+
sig do
|
|
18
|
+
params(
|
|
19
|
+
key: String,
|
|
20
|
+
schedule: T.nilable(String),
|
|
21
|
+
message: T.nilable(String),
|
|
22
|
+
description: T.nilable(String)
|
|
23
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
24
|
+
end
|
|
25
|
+
def call(key:, schedule: nil, message: nil, description: nil)
|
|
26
|
+
raise "Current.user is required" unless Current.user
|
|
27
|
+
|
|
28
|
+
task = SolidQueue::RecurringTask.find_by(key: key, static: false)
|
|
29
|
+
return { error: "Cron job not found", key: key } unless task
|
|
30
|
+
|
|
31
|
+
args = parse_arguments(task)
|
|
32
|
+
creative_id = args["creative_id"] || parse_creative_id_from_key(key)
|
|
33
|
+
creative = Creative.find_by(id: creative_id)
|
|
34
|
+
|
|
35
|
+
unless creative&.has_permission?(Current.user, :write)
|
|
36
|
+
return { error: "No write permission to update this cron job", key: key }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if schedule.present?
|
|
40
|
+
parsed = Fugit.parse(schedule)
|
|
41
|
+
unless parsed.is_a?(Fugit::Cron)
|
|
42
|
+
return { error: "Invalid cron schedule: #{schedule}. Use cron syntax like '0 9 * * *'." }
|
|
43
|
+
end
|
|
44
|
+
task.schedule = schedule
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if message.present?
|
|
48
|
+
new_args = args.merge("message" => message)
|
|
49
|
+
task.arguments = [ new_args.symbolize_keys ]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
task.description = description if description.present?
|
|
53
|
+
|
|
54
|
+
unless task.save
|
|
55
|
+
return { error: "Failed to update cron job", details: task.errors.full_messages }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
success: true,
|
|
60
|
+
key: task.key,
|
|
61
|
+
schedule: task.schedule,
|
|
62
|
+
description: task.description,
|
|
63
|
+
next_run: task.next_time&.iso8601
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def parse_arguments(task)
|
|
70
|
+
args = task.arguments
|
|
71
|
+
return {} unless args.is_a?(Array) && args.first.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
args.first.stringify_keys
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_creative_id_from_key(key)
|
|
77
|
+
match = key.match(/\Acron_(\d+)_/)
|
|
78
|
+
match[1].to_i if match
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<div class="tab-list">
|
|
2
2
|
<%= link_to t('admin.tabs.system'), main_app.admin_path, class: "tab-button #{'active' if controller_name == 'settings'}" %>
|
|
3
3
|
<%= link_to t('admin.tabs.users'), collavre.users_path, class: "tab-button #{'active' if controller_name == 'users'}" %>
|
|
4
|
+
<%= link_to t('admin.tabs.orchestration'), collavre.admin_orchestration_path, class: "tab-button #{'active' if controller_name == 'orchestration'}" %>
|
|
4
5
|
</div>
|