collavre 0.5.0 → 0.7.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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
@@ -0,0 +1,161 @@
1
+ module Collavre
2
+ module Comments
3
+ class WorkCommand
4
+ include Concerns::WorkflowSupport
5
+
6
+ def initialize(comment:, user:)
7
+ @comment = comment
8
+ @user = user
9
+ @creative = comment.creative.effective_origin
10
+ end
11
+
12
+ def call
13
+ return unless work_command?
14
+ execute_work
15
+ rescue StandardError => e
16
+ Rails.logger.error("Work command failed: #{e.message}")
17
+ e.message
18
+ end
19
+
20
+ private
21
+
22
+ COMMAND_PATTERN = /\A\/work\b/i.freeze
23
+
24
+ attr_reader :comment, :user, :creative
25
+
26
+ def work_command?
27
+ comment.content.to_s.strip.match?(COMMAND_PATTERN)
28
+ end
29
+
30
+ def execute_work
31
+ subcommand = parse_subcommand
32
+ case subcommand
33
+ when :stop then execute_stop
34
+ when :resume then execute_resume
35
+ else execute_start
36
+ end
37
+ end
38
+
39
+ def parse_subcommand
40
+ content = comment.content.to_s.strip.sub(/\A\/work\s*/i, "")
41
+ case content
42
+ when /\Astop\b/i then :stop
43
+ when /\Aresume\b/i then :resume
44
+ else :start
45
+ end
46
+ end
47
+
48
+ # --- /work stop ---
49
+
50
+ def execute_stop
51
+ parent_task = find_active_workflow
52
+ return I18n.t("collavre.comments.work_command.no_active_workflow") unless parent_task
53
+
54
+ WorkflowExecutor.new(parent_task).stop!
55
+ I18n.t("collavre.comments.work_command.stopped",
56
+ agent: parent_task.agent.display_name)
57
+ end
58
+
59
+ # --- /work resume ---
60
+
61
+ def execute_resume
62
+ parent_task = find_resumable_workflow
63
+ return I18n.t("collavre.comments.work_command.no_resumable_workflow") unless parent_task
64
+
65
+ WorkflowExecutor.new(parent_task).resume!
66
+ parent_task.reload
67
+ I18n.t("collavre.comments.work_command.resumed",
68
+ agent: parent_task.agent.display_name,
69
+ remaining: (parent_task.workflow_state["pending_creative_ids"] || []).size)
70
+ end
71
+
72
+ # --- /work start (default) ---
73
+
74
+ def execute_start
75
+ worker, supervisor = find_agents
76
+ return I18n.t("collavre.comments.work_command.agent_not_found") unless worker
77
+ return I18n.t("collavre.comments.work_command.no_children") if creative.descendants.empty?
78
+
79
+ workflow_text = extract_workflow_context
80
+ child_ids = collect_dfs_creative_ids
81
+ skipped_ids = filter_already_tasked(child_ids)
82
+ pending_ids = child_ids - skipped_ids
83
+
84
+ return I18n.t("collavre.comments.work_command.all_already_tasked") if pending_ids.empty?
85
+
86
+ parent_task = create_parent_task(worker, supervisor, workflow_text, pending_ids)
87
+ WorkflowExecutor.advance!(parent_task)
88
+
89
+ I18n.t("collavre.comments.work_command.started",
90
+ agent: worker.display_name,
91
+ total: pending_ids.size,
92
+ skipped: skipped_ids.size)
93
+ end
94
+
95
+ # Parse mentioned AI agents: first = worker, second = supervisor (optional)
96
+ # Usage: /work @Worker @Supervisor: context
97
+ def find_agents
98
+ mentioned = MentionParser.resolve_all_users(comment.content.to_s).select(&:ai_user?)
99
+ worker = mentioned.first
100
+ supervisor = mentioned.second # nil if only one agent mentioned
101
+ [ worker, supervisor ]
102
+ end
103
+
104
+ def extract_workflow_context
105
+ content = comment.content.to_s.strip
106
+ content = content.sub(/\A\/work\s+/, "")
107
+ # Strip @mentions using the same pattern as MentionParser
108
+ content = content.gsub(MentionParser::MENTION_PATTERN, "")
109
+ content.strip
110
+ end
111
+
112
+ def collect_dfs_creative_ids
113
+ dfs_ids = []
114
+ dfs_traverse(creative) { |c| dfs_ids << c.id }
115
+ dfs_ids.reject { |id| id == creative.id }
116
+ end
117
+
118
+ def filter_already_tasked(creative_ids)
119
+ filter_active_or_completed(creative_ids)
120
+ end
121
+
122
+ def create_parent_task(worker, supervisor, workflow_text, pending_ids)
123
+ state = {
124
+ "pending_creative_ids" => pending_ids,
125
+ "completed_creative_ids" => [],
126
+ "current_creative_id" => nil,
127
+ "total" => pending_ids.size
128
+ }
129
+
130
+ # Store supervisor info for WorkflowExecutor to include in trigger comments
131
+ if supervisor
132
+ state["supervisor"] = { "id" => supervisor.id, "name" => supervisor.display_name }
133
+ end
134
+
135
+ Task.create!(
136
+ name: "Workflow: #{creative.description&.truncate(50)}",
137
+ status: "running",
138
+ agent: worker,
139
+ creative: creative,
140
+ workflow_context: workflow_text,
141
+ workflow_state: state,
142
+ trigger_event_name: "work_command",
143
+ trigger_event_payload: {
144
+ "creative" => { "id" => creative.id },
145
+ "comment" => { "id" => comment.id, "user_id" => comment.user_id }
146
+ }
147
+ )
148
+ end
149
+
150
+ def find_active_workflow
151
+ Task.where(creative: creative, trigger_event_name: "work_command", status: "running")
152
+ .order(created_at: :desc).first
153
+ end
154
+
155
+ def find_resumable_workflow
156
+ Task.where(creative: creative, trigger_event_name: "work_command", status: %w[failed cancelled])
157
+ .order(created_at: :desc).first
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,276 @@
1
+ module Collavre
2
+ module Comments
3
+ class WorkflowExecutor
4
+ include Concerns::WorkflowSupport
5
+
6
+ def self.advance!(parent_task)
7
+ new(parent_task).advance!
8
+ end
9
+
10
+ def initialize(parent_task)
11
+ @parent_task = parent_task
12
+ @state = parent_task.workflow_state || {}
13
+ end
14
+
15
+ def advance!
16
+ loop do
17
+ return if @parent_task.status == "cancelled"
18
+
19
+ pending = @state["pending_creative_ids"] || []
20
+
21
+ if pending.empty?
22
+ complete_workflow!
23
+ return
24
+ end
25
+
26
+ next_creative_id = pending.first
27
+ next_creative = Creative.find_by(id: next_creative_id)
28
+
29
+ unless next_creative
30
+ skip_current!
31
+ next
32
+ end
33
+
34
+ @state["current_creative_id"] = next_creative_id
35
+ @parent_task.update!(workflow_state: @state)
36
+
37
+ sub_task_context = build_subtask_context(next_creative)
38
+
39
+ sub_task = Task.create!(
40
+ name: "Work on: #{next_creative.description&.truncate(50)}",
41
+ status: "pending",
42
+ agent: @parent_task.agent,
43
+ creative: next_creative,
44
+ parent_task: @parent_task,
45
+ workflow_context: @parent_task.workflow_context,
46
+ trigger_event_name: "workflow_subtask",
47
+ trigger_event_payload: sub_task_context,
48
+ topic_id: nil
49
+ )
50
+
51
+ post_progress_notice(next_creative)
52
+ AiAgentJob.perform_later(sub_task)
53
+ return
54
+ end
55
+ end
56
+
57
+ def complete_subtask!(sub_task)
58
+ completed = @state["completed_creative_ids"] || []
59
+ pending = @state["pending_creative_ids"] || []
60
+
61
+ Rails.logger.info(
62
+ "[WorkflowExecutor] complete_subtask! task=#{sub_task.id} creative=#{sub_task.creative_id} " \
63
+ "pending=#{pending.inspect} completed=#{completed.inspect}"
64
+ )
65
+
66
+ completed << sub_task.creative_id
67
+ pending.delete(sub_task.creative_id)
68
+
69
+ @state["completed_creative_ids"] = completed
70
+ @state["pending_creative_ids"] = pending
71
+ @state["current_creative_id"] = nil
72
+ @parent_task.update!(workflow_state: @state)
73
+
74
+ total = @state["total"] || 1
75
+ progress = completed.size.to_f / total
76
+ @parent_task.creative&.update!(progress: progress.clamp(0.0, 1.0))
77
+
78
+ Rails.logger.info(
79
+ "[WorkflowExecutor] Progress updated: #{completed.size}/#{total} (#{(progress * 100).round}%)"
80
+ )
81
+
82
+ post_subtask_completed_notice(sub_task, completed.size, total)
83
+
84
+ advance!
85
+ end
86
+
87
+ def fail_subtask!(sub_task, error_message: nil)
88
+ @state["current_creative_id"] = nil
89
+ @parent_task.update!(
90
+ status: "failed",
91
+ workflow_state: @state.merge(
92
+ "failed_creative_id" => sub_task.creative_id,
93
+ "failure_reason" => error_message
94
+ )
95
+ )
96
+
97
+ post_failure_notice(sub_task, error_message)
98
+ end
99
+
100
+ def stop!
101
+ # Cancel running sub-task if any
102
+ current_sub = @parent_task.sub_tasks.where(status: %w[running queued pending]).first
103
+ current_sub&.update!(status: "cancelled")
104
+
105
+ @state["current_creative_id"] = nil
106
+ @parent_task.update!(
107
+ status: "cancelled",
108
+ workflow_state: @state
109
+ )
110
+
111
+ post_notice(
112
+ I18n.t("collavre.comments.work_command.workflow_stopped",
113
+ agent: @parent_task.agent.display_name,
114
+ completed: (@state["completed_creative_ids"] || []).size,
115
+ remaining: (@state["pending_creative_ids"] || []).size)
116
+ )
117
+ end
118
+
119
+ def resume!
120
+ # Re-check pending creatives — some may have been completed manually
121
+ pending = @state["pending_creative_ids"] || []
122
+ pending = refilter_pending(pending)
123
+ @state["pending_creative_ids"] = pending
124
+ @state["current_creative_id"] = nil
125
+
126
+ # Clear failure state
127
+ @state.delete("failed_creative_id")
128
+ @state.delete("failure_reason")
129
+
130
+ @parent_task.update!(
131
+ status: "running",
132
+ workflow_state: @state
133
+ )
134
+
135
+ post_notice(
136
+ I18n.t("collavre.comments.work_command.workflow_resumed",
137
+ agent: @parent_task.agent.display_name,
138
+ remaining: pending.size)
139
+ )
140
+
141
+ advance!
142
+ end
143
+
144
+ private
145
+
146
+ def skip_current!
147
+ pending = @state["pending_creative_ids"] || []
148
+ pending.shift
149
+ @state["pending_creative_ids"] = pending
150
+ @parent_task.update!(workflow_state: @state)
151
+ end
152
+
153
+ def complete_workflow!
154
+ @parent_task.update!(status: "done")
155
+ @parent_task.creative&.update!(progress: 1.0)
156
+
157
+ post_notice(
158
+ I18n.t("collavre.comments.work_command.workflow_completed",
159
+ agent: @parent_task.agent.display_name,
160
+ completed: (@state["completed_creative_ids"] || []).size)
161
+ )
162
+ end
163
+
164
+ def post_progress_notice(creative)
165
+ total = @state["total"] || 1
166
+ completed_count = (@state["completed_creative_ids"] || []).size
167
+ current_index = completed_count + 1
168
+ creative_desc = creative.description&.truncate(50) || "untitled"
169
+
170
+ post_notice(
171
+ I18n.t("collavre.comments.work_command.subtask_started",
172
+ agent: @parent_task.agent.display_name,
173
+ creative: creative_desc,
174
+ current: current_index,
175
+ total: total)
176
+ )
177
+ end
178
+
179
+ def post_subtask_completed_notice(sub_task, completed_count, total)
180
+ creative_desc = sub_task.creative&.description&.truncate(50) || "untitled"
181
+ progress_pct = ((completed_count.to_f / total) * 100).round
182
+
183
+ post_notice(
184
+ I18n.t("collavre.comments.work_command.subtask_completed",
185
+ agent: @parent_task.agent.display_name,
186
+ creative: creative_desc,
187
+ completed: completed_count,
188
+ total: total,
189
+ progress: progress_pct)
190
+ )
191
+ end
192
+
193
+ def post_failure_notice(sub_task, error_message)
194
+ creative_desc = sub_task.creative&.description&.truncate(50) || "unknown"
195
+ post_notice(
196
+ I18n.t("collavre.comments.work_command.workflow_failed",
197
+ agent: @parent_task.agent.display_name,
198
+ creative: creative_desc,
199
+ reason: error_message || "unknown error")
200
+ )
201
+ end
202
+
203
+ def post_notice(content)
204
+ post_workflow_notice(@parent_task, content)
205
+ end
206
+
207
+ def build_subtask_context(creative)
208
+ original_user = find_original_user
209
+
210
+ # Build rich trigger comment with creative content + workflow instruction
211
+ # This mimics a user manually asking the agent in the creative's chat
212
+ trigger_content = build_trigger_content(creative)
213
+
214
+ trigger_comment = creative.comments.create!(
215
+ content: trigger_content,
216
+ user: original_user,
217
+ topic_id: nil
218
+ )
219
+
220
+ # Context matches the format MessageBuilder expects:
221
+ # - creative.id → MessageBuilder renders creative tree markdown + chat history
222
+ # - comment.id → points to trigger comment in the child creative
223
+ # - sender → original user who issued /work
224
+ {
225
+ "creative" => { "id" => creative.id, "description" => creative.description },
226
+ "workflow" => {
227
+ "context" => @parent_task.workflow_context,
228
+ "parent_task_id" => @parent_task.id
229
+ },
230
+ "comment" => { "id" => trigger_comment.id, "content" => trigger_comment.content,
231
+ "user_id" => trigger_comment.user_id },
232
+ "sender" => { "name" => original_user.name, "id" => original_user.id }
233
+ }
234
+ end
235
+
236
+ def build_trigger_content(_creative)
237
+ # Resolve workflow_context: if it's a creative ID, render that creative's markdown
238
+ # as the instruction (like a user pasting the content). Otherwise use as-is.
239
+ # NOTE: Do NOT prefix with @Agent — the trigger comment is authored by the
240
+ # original /work user, not the agent. Including @Agent causes the agent to
241
+ # interpret it as a self-referencing instruction.
242
+ # MessageBuilder already renders current creative's markdown from context["creative"]["id"].
243
+ content = resolve_workflow_context
244
+
245
+ # If a supervisor is assigned, append instruction to consult them instead of asking the user
246
+ supervisor = @state["supervisor"]
247
+ if supervisor
248
+ supervisor_instruction = I18n.t(
249
+ "collavre.comments.work_command.supervisor_instruction",
250
+ supervisor: supervisor["name"]
251
+ )
252
+ content = "#{content}\n\n#{supervisor_instruction}"
253
+ end
254
+
255
+ content
256
+ end
257
+
258
+ def resolve_workflow_context
259
+ resolve_workflow_context_from_task(@parent_task, @state)
260
+ end
261
+
262
+ def render_creative_markdown(creative)
263
+ render_creative_markdown_for_task(@parent_task, creative)
264
+ end
265
+
266
+ def find_original_user
267
+ find_original_user_from_task(@parent_task)
268
+ end
269
+
270
+ def refilter_pending(creative_ids)
271
+ return [] if creative_ids.empty?
272
+ creative_ids - filter_active_or_completed(creative_ids)
273
+ end
274
+ end
275
+ end
276
+ end
@@ -3,9 +3,10 @@ module Creatives
3
3
  class PlanTagger
4
4
  Result = Struct.new(:success?, :message, keyword_init: true)
5
5
 
6
- def initialize(plan_id:, creative_ids: [])
6
+ def initialize(plan_id:, creative_ids: [], user: nil)
7
7
  @plan = Plan.find_by(id: plan_id)
8
8
  @creative_ids = Array(creative_ids).map(&:presence).compact
9
+ @user = user
9
10
  end
10
11
 
11
12
  def apply
@@ -31,10 +32,20 @@ module Creatives
31
32
 
32
33
  private
33
34
 
34
- attr_reader :plan, :creative_ids
35
+ attr_reader :plan, :creative_ids, :user
35
36
 
36
37
  def creatives
37
- Creative.where(id: creative_ids)
38
+ all_creatives = Creative.where(id: creative_ids)
39
+ return all_creatives unless user
40
+
41
+ # Scope to creatives the user has write permission for via creative_shares
42
+ permitted_ids = all_creatives.joins(:creative_shares)
43
+ .where(creative_shares: { user_id: user.id })
44
+ .where("creative_shares.permission IN (?)", %w[write admin])
45
+ .pluck(:id)
46
+ # Also include creatives owned by the user
47
+ owned_ids = all_creatives.where(user_id: user.id).pluck(:id)
48
+ Creative.where(id: (permitted_ids + owned_ids).uniq)
38
49
  end
39
50
 
40
51
  def valid?
@@ -1,9 +1,31 @@
1
1
  module Collavre
2
2
  module Creatives
3
+ # Compact markdown tree formatter for AI Agent consumption.
4
+ # Used by: creative_retrieval_service, GeminiParentRecommender, Agent context injection.
5
+ #
6
+ # Output format (header declared once, rows are values only):
7
+ # <!-- format: [id] description (progress%) -->
8
+ # - [123] My Task (50%)
9
+ # - [124] Subtask A (100%)
10
+ # - [125] Subtask B (0%)
11
+ #
12
+ # Options:
13
+ # max_depth: Max tree depth (nil = unlimited)
14
+ # include_header: Include format comment header (default: true)
15
+ # include_comments: Include recent comments per node (default: false)
16
+ # use_permissions: Use permission-filtered children (default: true)
3
17
  class TreeFormatter
18
+ def initialize(max_depth: nil, include_header: true, include_comments: false, use_permissions: true)
19
+ @max_depth = max_depth
20
+ @include_header = include_header
21
+ @include_comments = include_comments
22
+ @use_permissions = use_permissions
23
+ end
24
+
4
25
  def format(creatives)
5
26
  roots = Array(creatives)
6
27
  lines = []
28
+ lines << "<!-- format: [id] description (progress%) -->" if @include_header
7
29
 
8
30
  roots.each do |root|
9
31
  format_node(root, 0, lines)
@@ -12,25 +34,43 @@ module Collavre
12
34
  lines.join("\n")
13
35
  end
14
36
 
37
+ # Extract plain text description from a creative (shared helper)
38
+ def self.plain_description(creative)
39
+ raw = creative.effective_description(nil, true)
40
+ ActionView::Base.full_sanitizer.sanitize(raw).strip
41
+ end
42
+
15
43
  private
16
44
 
17
45
  def format_node(node, depth, lines)
18
- indent = " " * (depth * 4)
19
- desc = ActionController::Base.helpers.strip_tags(node.effective_description(nil, false))
20
- progress = node.progress || 0.0
21
-
22
- node_data = { id: node.id, progress: progress, desc: desc }
23
- lines << "#{indent}- #{node_data.to_json}"
24
-
25
- # We need to handle children. If the node is a new instance (in tests),
26
- # children might depend on how it's set up.
27
- # In real app, we use children association.
28
- # To match the logic in views/controllers where we might want specific ordering:
29
- # We'll validly assume `children` association is available.
30
- node.children.each do |child|
46
+ return if @max_depth && depth > @max_depth
47
+
48
+ indent = " " * depth
49
+ desc = self.class.plain_description(node)
50
+ progress = ((node.progress || 0.0) * 100).round
51
+
52
+ lines << "#{indent}- [#{node.id}] #{desc} (#{progress}%)"
53
+
54
+ if @include_comments
55
+ node.comments.order(created_at: :desc).limit(3).reverse_each do |comment|
56
+ comment_text = ActionView::Base.full_sanitizer.sanitize(comment.content).strip.truncate(100)
57
+ lines << "#{indent} > #{comment_text}"
58
+ end
59
+ end
60
+
61
+ children_for(node).each do |child|
31
62
  format_node(child, depth + 1, lines)
32
63
  end
33
64
  end
65
+
66
+ def children_for(node)
67
+ if @use_permissions
68
+ node.linked_children
69
+ else
70
+ # When children are pre-set on association target (e.g. GeminiParentRecommender)
71
+ node.children
72
+ end
73
+ end
34
74
  end
35
75
  end
36
76
  end
@@ -33,9 +33,9 @@ module Collavre
33
33
  category_ids = categories.index_by(&:id)
34
34
  top_level_categories = categories.reject { |c| category_ids.key?(c.parent_id) }
35
35
 
36
- tree_text = Creatives::TreeFormatter.new.format(top_level_categories)
36
+ tree_text = Creatives::TreeFormatter.new(use_permissions: false).format(top_level_categories)
37
37
 
38
- prompt = build_prompt(tree_text, ActionController::Base.helpers.strip_tags(creative.description).to_s)
38
+ prompt = build_prompt(tree_text, Creatives::TreeFormatter.plain_description(creative))
39
39
  Rails.logger.info("### prompt=#{prompt}")
40
40
 
41
41
  response = @client.chat([ { role: :user, parts: [ { text: prompt } ] } ])
@@ -45,7 +45,7 @@ module Collavre
45
45
  ids.map do |id|
46
46
  c = Creative.find_by(id: id)
47
47
  next unless c
48
- path = c.ancestors.reverse.map { |a| ActionController::Base.helpers.strip_tags(a.description) } + [ ActionController::Base.helpers.strip_tags(c.description) ]
48
+ path = c.ancestors.reverse.map { |a| Creatives::TreeFormatter.plain_description(a) } + [ Creatives::TreeFormatter.plain_description(c) ]
49
49
  { id: id, path: path.join(" > ") }
50
50
  end.compact
51
51
  end
@@ -55,7 +55,7 @@ module Collavre
55
55
  def default_client
56
56
  AiClient.new(
57
57
  vendor: "google",
58
- model: "gemini-2.5-flash",
58
+ model: "gemini-3-flash-preview",
59
59
  system_prompt: nil
60
60
  )
61
61
  end
@@ -111,9 +111,7 @@ module Collavre
111
111
  sections << I18n.t("collavre.ai_agent.collaboration.rules_header")
112
112
  sections << (collab["mention_rule"] || I18n.t("collavre.ai_agent.collaboration.mention_rule"))
113
113
  sections << (collab["confidence_rule"] || I18n.t("collavre.ai_agent.collaboration.confidence_rule"))
114
- if @policy_resolver&.self_reflection_enabled?
115
- sections << I18n.t("collavre.ai_agent.collaboration.confidence_format_instruction")
116
- end
114
+ sections << I18n.t("collavre.ai_agent.collaboration.confidence_format_instruction")
117
115
  sections << (collab["escalation_rule"] || I18n.t("collavre.ai_agent.collaboration.escalation_rule"))
118
116
  sections << (collab["review_rule"] || I18n.t("collavre.ai_agent.collaboration.review_rule"))
119
117
  sections << ""
@@ -16,8 +16,8 @@ module Collavre
16
16
  new(event_name: event_name, context: context).dispatch
17
17
  end
18
18
 
19
- def self.dequeue_next_for_topic(topic_id)
20
- task = Task.queued_for_topic(topic_id).first
19
+ def self.dequeue_next_for_topic(topic_id, creative_id = nil)
20
+ task = Task.queued_for_topic(topic_id, creative_id).first
21
21
  return unless task
22
22
 
23
23
  updated = Task.where(id: task.id, status: "queued").update_all(status: "pending")
@@ -29,7 +29,7 @@ module Collavre
29
29
  if task.status == "cancelled"
30
30
  # refresh_deferred_context! cancelled this task (no eligible comment),
31
31
  # try the next queued task for this topic.
32
- dequeue_next_for_topic(topic_id)
32
+ dequeue_next_for_topic(topic_id, creative_id)
33
33
  else
34
34
  AiAgentJob.perform_later(task)
35
35
  end
@@ -127,6 +127,16 @@ module Collavre
127
127
  agent = decision[:agent]
128
128
  log_decision(decision)
129
129
 
130
+ # Guard: skip if agent already has a running task for this comment
131
+ comment_id = @context.dig("comment", "id")
132
+ if comment_id && Task.duplicate_running_for_comment?(agent.id, comment_id)
133
+ Rails.logger.warn(
134
+ "[AgentOrchestrator] Skipping enqueue: agent #{agent.id} already has a running task " \
135
+ "for comment #{comment_id}"
136
+ )
137
+ next
138
+ end
139
+
130
140
  case decision[:timing]
131
141
  when :immediate
132
142
  AiAgentJob.perform_later(agent.id, @event_name, @context)
@@ -138,7 +148,8 @@ module Collavre
138
148
  trigger_event_name: @event_name,
139
149
  trigger_event_payload: @context,
140
150
  agent: agent,
141
- topic_id: @context.dig("topic", "id")
151
+ topic_id: @context.dig("topic", "id"),
152
+ creative_id: @context.dig("creative", "id")
142
153
  )
143
154
  post_waiting_notice(agent, decision)
144
155
  agent
@@ -28,11 +28,6 @@ module Collavre
28
28
  "rate_limit_per_minute" => 20,
29
29
  "backoff_strategy" => "exponential",
30
30
  "topic_max_concurrent_jobs" => 1,
31
- # Self-reflection settings
32
- "self_reflection_enabled" => false,
33
- "confidence_threshold" => 70,
34
- "max_retries" => 3,
35
- "retry_delay_seconds" => 5,
36
31
  # Loop breaker settings
37
32
  "loop_breaker_enabled" => true,
38
33
  "ping_pong_threshold" => 5, # Max back-and-forth between same agents
@@ -113,20 +108,6 @@ module Collavre
113
108
  arbitration_config["bid_fallback_enabled"] != false
114
109
  end
115
110
 
116
- # Self-reflection settings
117
- def self_reflection_enabled?
118
- scheduling_config["self_reflection_enabled"] == true
119
- end
120
-
121
- def self_reflection_config
122
- {
123
- "enabled" => scheduling_config["self_reflection_enabled"],
124
- "confidence_threshold" => scheduling_config["confidence_threshold"],
125
- "max_retries" => scheduling_config["max_retries"],
126
- "retry_delay_seconds" => scheduling_config["retry_delay_seconds"]
127
- }
128
- end
129
-
130
111
  # Loop breaker settings
131
112
  def loop_breaker_enabled?
132
113
  scheduling_config["loop_breaker_enabled"] == true