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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/app/channels/collavre/comments_presence_channel.rb +6 -3
  3. data/app/controllers/collavre/admin/orchestration_controller.rb +177 -0
  4. data/app/controllers/collavre/comments_controller.rb +10 -1
  5. data/app/javascript/controllers/comments/presence_controller.js +5 -1
  6. data/app/jobs/collavre/ai_agent_job.rb +55 -4
  7. data/app/jobs/collavre/cron_action_job.rb +53 -0
  8. data/app/jobs/collavre/stuck_detector_job.rb +34 -0
  9. data/app/models/collavre/comment.rb +15 -15
  10. data/app/models/collavre/creative.rb +1 -1
  11. data/app/models/collavre/orchestrator_policy.rb +124 -0
  12. data/app/models/collavre/task.rb +3 -0
  13. data/app/models/collavre/user.rb +1 -1
  14. data/app/services/collavre/ai_agent_service.rb +32 -1
  15. data/app/services/collavre/ai_client.rb +4 -3
  16. data/app/services/collavre/comments/command_processor.rb +4 -1
  17. data/app/services/collavre/comments/topic_command.rb +101 -0
  18. data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
  19. data/app/services/collavre/orchestration/agent_orchestrator.rb +142 -0
  20. data/app/services/collavre/orchestration/arbiter.rb +257 -0
  21. data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
  22. data/app/services/collavre/orchestration/matcher.rb +98 -0
  23. data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
  24. data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
  25. data/app/services/collavre/orchestration/scheduler.rb +145 -0
  26. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
  27. data/app/services/collavre/orchestration/stuck_detector.rb +303 -0
  28. data/app/services/collavre/system_events/context_builder.rb +34 -7
  29. data/app/services/collavre/system_events/dispatcher.rb +2 -7
  30. data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
  31. data/app/services/collavre/tools/cron_create_service.rb +73 -0
  32. data/app/services/collavre/tools/cron_list_service.rb +65 -0
  33. data/app/services/collavre/tools/cron_update_service.rb +82 -0
  34. data/app/views/admin/shared/_tabs.html.erb +1 -0
  35. data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
  36. data/app/views/collavre/creatives/index.html.erb +1 -1
  37. data/config/locales/ai_agent.en.yml +21 -0
  38. data/config/locales/ai_agent.ko.yml +21 -0
  39. data/config/locales/comments.en.yml +8 -0
  40. data/config/locales/comments.ko.yml +8 -0
  41. data/config/routes.rb +5 -1
  42. data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
  43. data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
  44. data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
  45. data/lib/collavre/version.rb +1 -1
  46. metadata +24 -13
  47. data/app/controllers/collavre/github_auth_controller.rb +0 -25
  48. data/app/models/collavre/github_account.rb +0 -10
  49. data/app/models/collavre/github_repository_link.rb +0 -19
  50. data/app/services/collavre/github/client.rb +0 -112
  51. data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
  52. data/app/services/collavre/github/pull_request_processor.rb +0 -181
  53. data/app/services/collavre/github/webhook_provisioner.rb +0 -130
  54. data/app/services/collavre/system_events/router.rb +0 -96
  55. data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
  56. data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
  57. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
  58. 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 mentioned_user(chat_context)
23
- # This mimics the chat.mentioned_user function requested
24
- # It assumes chat_context has 'content' or similar, or we might need to look up the comment
25
- # For now, let's assume the context passed in already has the necessary info or we extract it.
26
- # If the event is comment_created, the payload usually has the comment content.
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
- # Simple regex to find the first mention
32
- match = content.match(/\A@([^:]+?):\s*/) || content.match(/\A@(\S+)\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
- # Build context once to ensure consistency between Router and Job
10
- enriched_context = Collavre::SystemEvents::ContextBuilder.new(context).build
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>