brainiac 0.0.9 → 0.0.10
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/Gemfile.lock +2 -11
- data/bin/brainiac +946 -316
- data/bin/brainiac-completion.bash +93 -18
- data/brainiac.gemspec +2 -2
- data/lib/brainiac/config.rb +9 -21
- data/lib/brainiac/cron.rb +41 -26
- data/lib/brainiac/handlers/github.rb +3 -28
- data/lib/brainiac/handlers/zoho.rb +25 -33
- data/lib/brainiac/helpers.rb +6 -16
- data/lib/brainiac/notifications.rb +96 -0
- data/lib/brainiac/prompts.rb +2 -102
- data/lib/brainiac/restart.rb +2 -10
- data/lib/brainiac/routes/api.rb +2 -14
- data/lib/brainiac/version.rb +1 -1
- data/receiver.rb +9 -60
- data/skills/brainiac-plugins/SKILL.md +220 -0
- metadata +5 -25
- data/lib/brainiac/handlers/discord/api.rb +0 -196
- data/lib/brainiac/handlers/discord/config.rb +0 -134
- data/lib/brainiac/handlers/discord/delivery.rb +0 -196
- data/lib/brainiac/handlers/discord/gateway.rb +0 -212
- data/lib/brainiac/handlers/discord/message.rb +0 -921
- data/lib/brainiac/handlers/discord/reactions.rb +0 -215
- data/lib/brainiac/handlers/discord.rb +0 -24
- data/templates/discord.json.example +0 -17
|
@@ -8,8 +8,18 @@ _brainiac() {
|
|
|
8
8
|
|
|
9
9
|
local brainiac_dir="${BRAINIAC_DIR:-$HOME/.brainiac}"
|
|
10
10
|
|
|
11
|
-
# Top-level commands
|
|
12
|
-
local commands="server stop restart logs status register unregister list show brain
|
|
11
|
+
# Top-level commands (built-in + installed plugins)
|
|
12
|
+
local commands="server stop restart logs status register unregister list show brain cron provider role agent config path version help setup projects card-map handler plugin install uninstall plugins"
|
|
13
|
+
|
|
14
|
+
# Add installed plugin names as top-level commands
|
|
15
|
+
if [[ -f "$brainiac_dir/plugins.json" ]]; then
|
|
16
|
+
local plugin_names
|
|
17
|
+
plugin_names=$(ruby -rjson -e '
|
|
18
|
+
config = JSON.parse(File.read(ARGV[0]))
|
|
19
|
+
(config["plugins"] || []).each { |p| puts p.is_a?(Hash) ? p["name"] : p.to_s }
|
|
20
|
+
' "$brainiac_dir/plugins.json" 2>/dev/null)
|
|
21
|
+
commands="$commands $plugin_names"
|
|
22
|
+
fi
|
|
13
23
|
|
|
14
24
|
# Helper: list agent keys from registry
|
|
15
25
|
_brainiac_agents() {
|
|
@@ -32,6 +42,13 @@ _brainiac() {
|
|
|
32
42
|
fi
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
# Helper: list project keys
|
|
46
|
+
_brainiac_projects() {
|
|
47
|
+
if [[ -f "$brainiac_dir/projects.json" ]]; then
|
|
48
|
+
ruby -rjson -e 'JSON.parse(File.read(ARGV[0])).each_key { |k| puts k }' "$brainiac_dir/projects.json" 2>/dev/null
|
|
49
|
+
fi
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
# Determine position in command
|
|
36
53
|
case $cword in
|
|
37
54
|
1)
|
|
@@ -43,6 +60,14 @@ _brainiac() {
|
|
|
43
60
|
local cmd="${words[1]}"
|
|
44
61
|
|
|
45
62
|
case "$cmd" in
|
|
63
|
+
plugin)
|
|
64
|
+
case $cword in
|
|
65
|
+
2)
|
|
66
|
+
COMPREPLY=($(compgen -W "new" -- "$cur"))
|
|
67
|
+
;;
|
|
68
|
+
esac
|
|
69
|
+
;;
|
|
70
|
+
|
|
46
71
|
role)
|
|
47
72
|
case $cword in
|
|
48
73
|
2)
|
|
@@ -90,7 +115,6 @@ _brainiac() {
|
|
|
90
115
|
4)
|
|
91
116
|
local subcmd="${words[3]}"
|
|
92
117
|
if [[ "$subcmd" == "env" ]]; then
|
|
93
|
-
# Suggest --delete or existing env var names for this agent
|
|
94
118
|
local agent_key="${words[2]}"
|
|
95
119
|
local env_keys=""
|
|
96
120
|
if [[ -f "$brainiac_dir/agents.json" ]]; then
|
|
@@ -104,7 +128,6 @@ _brainiac() {
|
|
|
104
128
|
fi
|
|
105
129
|
;;
|
|
106
130
|
5)
|
|
107
|
-
# After --delete, suggest env var names
|
|
108
131
|
if [[ "${words[3]}" == "env" && "${words[4]}" == "--delete" ]]; then
|
|
109
132
|
local agent_key="${words[2]}"
|
|
110
133
|
local env_keys=""
|
|
@@ -149,20 +172,6 @@ _brainiac() {
|
|
|
149
172
|
esac
|
|
150
173
|
;;
|
|
151
174
|
|
|
152
|
-
discord)
|
|
153
|
-
case $cword in
|
|
154
|
-
2)
|
|
155
|
-
COMPREPLY=($(compgen -W "config default map owner token agents status" -- "$cur"))
|
|
156
|
-
;;
|
|
157
|
-
3)
|
|
158
|
-
local subcmd="${words[2]}"
|
|
159
|
-
if [[ "$subcmd" == "token" ]]; then
|
|
160
|
-
COMPREPLY=($(compgen -W "$(_brainiac_agents)" -- "$cur"))
|
|
161
|
-
fi
|
|
162
|
-
;;
|
|
163
|
-
esac
|
|
164
|
-
;;
|
|
165
|
-
|
|
166
175
|
cron)
|
|
167
176
|
case $cword in
|
|
168
177
|
2)
|
|
@@ -176,8 +185,74 @@ _brainiac() {
|
|
|
176
185
|
2)
|
|
177
186
|
COMPREPLY=($(compgen -W "list default" -- "$cur"))
|
|
178
187
|
;;
|
|
188
|
+
3)
|
|
189
|
+
if [[ "${words[2]}" == "default" ]]; then
|
|
190
|
+
COMPREPLY=($(compgen -W "$(_brainiac_projects)" -- "$cur"))
|
|
191
|
+
fi
|
|
192
|
+
;;
|
|
179
193
|
esac
|
|
180
194
|
;;
|
|
195
|
+
|
|
196
|
+
install)
|
|
197
|
+
# Suggest known plugin names that aren't installed
|
|
198
|
+
if [[ $cword -eq 2 ]]; then
|
|
199
|
+
COMPREPLY=($(compgen -W "--path --version" -- "$cur"))
|
|
200
|
+
fi
|
|
201
|
+
;;
|
|
202
|
+
|
|
203
|
+
uninstall)
|
|
204
|
+
if [[ $cword -eq 2 && -f "$brainiac_dir/plugins.json" ]]; then
|
|
205
|
+
local installed
|
|
206
|
+
installed=$(ruby -rjson -e '
|
|
207
|
+
config = JSON.parse(File.read(ARGV[0]))
|
|
208
|
+
(config["plugins"] || []).each { |p| puts p.is_a?(Hash) ? p["name"] : p.to_s }
|
|
209
|
+
' "$brainiac_dir/plugins.json" 2>/dev/null)
|
|
210
|
+
COMPREPLY=($(compgen -W "$installed" -- "$cur"))
|
|
211
|
+
fi
|
|
212
|
+
;;
|
|
213
|
+
|
|
214
|
+
*)
|
|
215
|
+
# Check if cmd is an installed plugin — delegate completion to it
|
|
216
|
+
if [[ -f "$brainiac_dir/plugins.json" ]]; then
|
|
217
|
+
local is_plugin
|
|
218
|
+
is_plugin=$(ruby -rjson -e '
|
|
219
|
+
config = JSON.parse(File.read(ARGV[0]))
|
|
220
|
+
names = (config["plugins"] || []).map { |p| p.is_a?(Hash) ? p["name"] : p.to_s }
|
|
221
|
+
puts "yes" if names.include?(ARGV[1])
|
|
222
|
+
' "$brainiac_dir/plugins.json" "$cmd" 2>/dev/null)
|
|
223
|
+
|
|
224
|
+
if [[ "$is_plugin" == "yes" && $cword -eq 2 ]]; then
|
|
225
|
+
# Get plugin subcommands via its CLI module
|
|
226
|
+
local subcmds
|
|
227
|
+
subcmds=$(ruby -rjson -e '
|
|
228
|
+
brainiac_dir = ENV["BRAINIAC_DIR"] || File.join(Dir.home, ".brainiac")
|
|
229
|
+
config = JSON.parse(File.read(File.join(brainiac_dir, "plugins.json")))
|
|
230
|
+
entry = (config["plugins"] || []).find { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == ARGV[0] }
|
|
231
|
+
exit unless entry
|
|
232
|
+
|
|
233
|
+
module Brainiac; module Plugins; end; end
|
|
234
|
+
|
|
235
|
+
if entry.is_a?(Hash) && entry["path"]
|
|
236
|
+
$LOAD_PATH.unshift(File.join(entry["path"], "lib"))
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
cli_file = if entry.is_a?(Hash) && entry["path"]
|
|
240
|
+
File.join(entry["path"], "lib", "brainiac", "plugins", ARGV[0], "cli.rb")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
if cli_file && File.exist?(cli_file)
|
|
244
|
+
load cli_file
|
|
245
|
+
pascal = ARGV[0].split(/[-_]/).map(&:capitalize).join
|
|
246
|
+
mod = Brainiac::Plugins.const_get(pascal) if Brainiac::Plugins.const_defined?(pascal)
|
|
247
|
+
if mod&.respond_to?(:completions)
|
|
248
|
+
puts mod.completions.join("\n")
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
' "$cmd" 2>/dev/null)
|
|
252
|
+
COMPREPLY=($(compgen -W "$subcmds" -- "$cur"))
|
|
253
|
+
fi
|
|
254
|
+
fi
|
|
255
|
+
;;
|
|
181
256
|
esac
|
|
182
257
|
}
|
|
183
258
|
|
data/brainiac.gemspec
CHANGED
|
@@ -4,7 +4,8 @@ Gem::Specification.new do |s|
|
|
|
4
4
|
s.name = "brainiac"
|
|
5
5
|
s.version = Brainiac::VERSION
|
|
6
6
|
s.summary = "AI agent webhook receiver and dispatcher"
|
|
7
|
-
s.description = "Webhook receiver that listens for
|
|
7
|
+
s.description = "Webhook receiver that listens for GitHub and Zoho Mail events, then dispatches work to AI agent CLIs. " \
|
|
8
|
+
"Additional channels (Discord, Fizzy) available via plugins."
|
|
8
9
|
s.authors = ["Andy Davis"]
|
|
9
10
|
s.homepage = "https://github.com/stowzilla/brainiac"
|
|
10
11
|
s.license = "MIT"
|
|
@@ -16,7 +17,6 @@ Gem::Specification.new do |s|
|
|
|
16
17
|
s.add_dependency "puma", "~> 7.2"
|
|
17
18
|
s.add_dependency "rackup", "~> 2.3"
|
|
18
19
|
s.add_dependency "sinatra", "~> 4.1"
|
|
19
|
-
s.add_dependency "websocket-client-simple", "~> 0.8.0"
|
|
20
20
|
|
|
21
21
|
s.add_development_dependency "minitest", "~> 5.25"
|
|
22
22
|
s.add_development_dependency "rake", "~> 13.0"
|
data/lib/brainiac/config.rb
CHANGED
|
@@ -172,19 +172,6 @@ DEFAULT_PROJECT = {
|
|
|
172
172
|
"allowed_efforts" => %w[low medium high xhigh max]
|
|
173
173
|
}.freeze
|
|
174
174
|
|
|
175
|
-
# --- Discord (optional) ---
|
|
176
|
-
# Discord is enabled when any agent in the registry has a discord_bot_token
|
|
177
|
-
# Requires the websocket-client-simple gem.
|
|
178
|
-
|
|
179
|
-
DISCORD_ENABLED = begin
|
|
180
|
-
require "websocket-client-simple"
|
|
181
|
-
true
|
|
182
|
-
rescue LoadError
|
|
183
|
-
warn "WARNING: websocket-client-simple gem not found. Discord bot disabled."
|
|
184
|
-
warn "Install with: gem install websocket-client-simple"
|
|
185
|
-
false
|
|
186
|
-
end
|
|
187
|
-
|
|
188
175
|
# --- Version check ---
|
|
189
176
|
|
|
190
177
|
# Check if local brainiac is behind origin/master.
|
|
@@ -210,13 +197,13 @@ def check_brainiac_version
|
|
|
210
197
|
{ behind: true, local_sha: local_sha[0..6], remote_sha: remote_sha[0..6], commits_behind: count.strip.to_i }
|
|
211
198
|
end
|
|
212
199
|
|
|
213
|
-
#
|
|
214
|
-
# Reads from
|
|
215
|
-
def
|
|
216
|
-
|
|
217
|
-
return nil unless File.exist?(
|
|
200
|
+
# Owner identifier (for version-outdated notifications).
|
|
201
|
+
# Reads from brainiac.json.
|
|
202
|
+
def owner_id
|
|
203
|
+
brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json")
|
|
204
|
+
return nil unless File.exist?(brainiac_config_file)
|
|
218
205
|
|
|
219
|
-
JSON.parse(File.read(
|
|
206
|
+
JSON.parse(File.read(brainiac_config_file))["owner_id"]
|
|
220
207
|
rescue JSON::ParserError
|
|
221
208
|
nil
|
|
222
209
|
end
|
|
@@ -224,8 +211,9 @@ end
|
|
|
224
211
|
# --- Dashboard auth ---
|
|
225
212
|
|
|
226
213
|
DASHBOARD_TOKEN = begin
|
|
227
|
-
|
|
228
|
-
JSON.parse(File.read(
|
|
214
|
+
brainiac_config_file = File.join(BRAINIAC_DIR, "brainiac.json")
|
|
215
|
+
config = File.exist?(brainiac_config_file) ? JSON.parse(File.read(brainiac_config_file)) : {}
|
|
216
|
+
config["dashboard_token"]
|
|
229
217
|
rescue JSON::ParserError
|
|
230
218
|
nil
|
|
231
219
|
end
|
data/lib/brainiac/cron.rb
CHANGED
|
@@ -200,12 +200,17 @@ end
|
|
|
200
200
|
|
|
201
201
|
# Add a new cron job
|
|
202
202
|
def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, discord_channel_id: nil,
|
|
203
|
+
notify_channel: nil, notify_target: nil,
|
|
203
204
|
forum_title: nil, forum_reply_to_latest: false, repeat_count: nil)
|
|
204
205
|
parsed = parse_cron_expression(schedule)
|
|
205
206
|
return { error: "Invalid cron expression" } unless parsed
|
|
206
207
|
return { error: "Must provide either prompt or script, not both" } if prompt && script
|
|
207
208
|
return { error: "Must provide either prompt or script" } unless prompt || script
|
|
208
209
|
|
|
210
|
+
# Normalize: discord_channel_id is legacy shorthand for notify_channel: discord, notify_target: <id>
|
|
211
|
+
notify_channel ||= :discord if discord_channel_id
|
|
212
|
+
notify_target ||= discord_channel_id
|
|
213
|
+
|
|
209
214
|
job = {
|
|
210
215
|
id: id,
|
|
211
216
|
schedule: schedule,
|
|
@@ -217,6 +222,8 @@ def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, ena
|
|
|
217
222
|
prompt: prompt,
|
|
218
223
|
script: script,
|
|
219
224
|
enabled: enabled,
|
|
225
|
+
notify_channel: notify_channel&.to_s,
|
|
226
|
+
notify_target: notify_target,
|
|
220
227
|
discord_channel_id: discord_channel_id,
|
|
221
228
|
forum_title: forum_title,
|
|
222
229
|
forum_reply_to_latest: forum_reply_to_latest,
|
|
@@ -308,7 +315,7 @@ def execute_script_job(job, project)
|
|
|
308
315
|
log_file = File.join(project["repo_path"], "tmp/cron-script-#{job[:id]}-#{timestamp}.log")
|
|
309
316
|
FileUtils.mkdir_p(File.dirname(log_file))
|
|
310
317
|
|
|
311
|
-
draft_file =
|
|
318
|
+
draft_file = prepare_script_notification_draft(job, timestamp) if job[:notify_target] || job[:discord_channel_id]
|
|
312
319
|
|
|
313
320
|
LOG.info "[Cron] Running script #{script_path} for job #{job[:id]}, tail -f #{log_file}"
|
|
314
321
|
|
|
@@ -327,16 +334,17 @@ def execute_script_job(job, project)
|
|
|
327
334
|
end
|
|
328
335
|
end
|
|
329
336
|
|
|
330
|
-
# Prepare a
|
|
331
|
-
def
|
|
332
|
-
|
|
337
|
+
# Prepare a notification draft file for a script job. Returns the draft file path.
|
|
338
|
+
def prepare_script_notification_draft(job, timestamp)
|
|
339
|
+
notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft")
|
|
340
|
+
FileUtils.mkdir_p(notify_dir)
|
|
341
|
+
draft_file = File.join(notify_dir, "cron-script-#{timestamp}-#{job[:id]}.md")
|
|
333
342
|
meta_file = "#{draft_file}.meta.json"
|
|
334
343
|
|
|
335
|
-
FileUtils.mkdir_p(File.dirname(draft_file))
|
|
336
|
-
|
|
337
344
|
script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-")
|
|
338
345
|
meta = {
|
|
339
|
-
|
|
346
|
+
notify_channel: (job[:notify_channel] || "discord").to_s,
|
|
347
|
+
notify_target: job[:notify_target] || job[:discord_channel_id],
|
|
340
348
|
agent_key: script_agent_key,
|
|
341
349
|
agent_name: job[:agent] || "Script",
|
|
342
350
|
cron_job_id: job[:id],
|
|
@@ -353,10 +361,13 @@ def deliver_script_output(job, log_file, draft_file)
|
|
|
353
361
|
return unless File.exist?(log_file)
|
|
354
362
|
|
|
355
363
|
output = File.read(log_file).strip
|
|
364
|
+
has_notify = job[:notify_target] || job[:discord_channel_id]
|
|
356
365
|
|
|
357
|
-
if
|
|
366
|
+
if has_notify && draft_file && !output.empty?
|
|
358
367
|
File.write(draft_file, output)
|
|
359
|
-
|
|
368
|
+
# Deliver via notification system
|
|
369
|
+
notify_cron_output(job, output, agent_name: job[:agent])
|
|
370
|
+
LOG.info "[Cron] Script output delivered via notification (#{output.length} chars)"
|
|
360
371
|
elsif !output.empty?
|
|
361
372
|
LOG.info "[Cron] Script output: #{output[0..200]}..."
|
|
362
373
|
else
|
|
@@ -369,15 +380,22 @@ def build_cron_prompt(job, project)
|
|
|
369
380
|
prompt = job[:prompt]
|
|
370
381
|
agent_name = job[:agent]
|
|
371
382
|
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
383
|
+
has_notify = job[:notify_target] || job[:discord_channel_id]
|
|
372
384
|
|
|
373
|
-
if
|
|
374
|
-
|
|
385
|
+
if has_notify
|
|
386
|
+
notify_dir = File.join(BRAINIAC_DIR, "tmp", "notify", "draft")
|
|
387
|
+
FileUtils.mkdir_p(notify_dir)
|
|
388
|
+
draft_file = File.join(notify_dir, "cron-#{timestamp}-#{agent_name}-#{job[:id]}.md")
|
|
375
389
|
meta_file = "#{draft_file}.meta.json"
|
|
376
|
-
FileUtils.mkdir_p(File.dirname(draft_file))
|
|
377
390
|
|
|
378
391
|
agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
392
|
+
notify_channel = (job[:notify_channel] || "discord").to_s
|
|
393
|
+
notify_target = job[:notify_target] || job[:discord_channel_id]
|
|
394
|
+
|
|
379
395
|
meta = {
|
|
380
|
-
|
|
396
|
+
notify_channel: notify_channel,
|
|
397
|
+
notify_target: notify_target,
|
|
398
|
+
channel_id: notify_target,
|
|
381
399
|
agent_key: agent_key,
|
|
382
400
|
agent_name: agent_name,
|
|
383
401
|
cron_job_id: job[:id],
|
|
@@ -388,8 +406,8 @@ def build_cron_prompt(job, project)
|
|
|
388
406
|
File.write(meta_file, JSON.pretty_generate(meta))
|
|
389
407
|
|
|
390
408
|
full_prompt = <<~PROMPT
|
|
391
|
-
## Scheduled Task (
|
|
392
|
-
This is a scheduled cron job
|
|
409
|
+
## Scheduled Task (Notification Posting)
|
|
410
|
+
This is a scheduled cron job. Output will be posted to #{notify_channel} (#{notify_target}).
|
|
393
411
|
|
|
394
412
|
You were asked to: "#{prompt}"
|
|
395
413
|
|
|
@@ -397,7 +415,7 @@ def build_cron_prompt(job, project)
|
|
|
397
415
|
Source directory: #{project["repo_path"]}
|
|
398
416
|
|
|
399
417
|
**IMPORTANT: Write your response to #{draft_file}. Do NOT reply via stdout.**
|
|
400
|
-
Your response will be automatically posted
|
|
418
|
+
Your response will be automatically posted.
|
|
401
419
|
|
|
402
420
|
#{prompt}
|
|
403
421
|
PROMPT
|
|
@@ -428,16 +446,13 @@ def handle_cron_completion(job, project, agent_name, agent_config_name, log_file
|
|
|
428
446
|
cron_exit_status = $CHILD_STATUS.exitstatus
|
|
429
447
|
LOG.info "[Cron] Job #{job[:id]} finished (exit: #{cron_exit_status})"
|
|
430
448
|
|
|
431
|
-
if cron_exit_status && cron_exit_status != 0 && job[:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
project_config: project
|
|
439
|
-
)
|
|
440
|
-
end
|
|
449
|
+
if cron_exit_status && cron_exit_status != 0 && job[:notify_target]
|
|
450
|
+
notify_agent_crash(
|
|
451
|
+
exit_status: cron_exit_status, log_file: log_file,
|
|
452
|
+
agent_name: agent_name, source: :cron,
|
|
453
|
+
source_context: { job: job },
|
|
454
|
+
project_config: project
|
|
455
|
+
)
|
|
441
456
|
end
|
|
442
457
|
|
|
443
458
|
extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file)
|
|
@@ -249,44 +249,19 @@ def close_uat_cards_after_deploy(project_key, project_config)
|
|
|
249
249
|
end
|
|
250
250
|
|
|
251
251
|
def send_deploy_notification(project_key, closed_cards)
|
|
252
|
-
channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
|
|
253
|
-
return unless channel_id
|
|
254
|
-
|
|
255
|
-
token = discord_bot_tokens.values.first
|
|
256
|
-
return unless token
|
|
257
|
-
|
|
258
252
|
card_lines = closed_cards.map { |c| "• [##{c[:number]} — #{c[:title]}](#{c[:url]})" }.join("\n")
|
|
259
253
|
message = "🚀 **#{project_key.capitalize}** deployed to production\nClosed UAT cards:\n#{card_lines}"
|
|
260
|
-
|
|
261
|
-
send_discord_message(channel_id, message, token: token)
|
|
262
|
-
rescue StandardError => e
|
|
263
|
-
LOG.warn "Failed to send deploy notification: #{e.message}"
|
|
254
|
+
send_notification(:deploy, message, metadata_project: project_key)
|
|
264
255
|
end
|
|
265
256
|
|
|
266
257
|
def send_uat_deploy_notification(project_key)
|
|
267
|
-
channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
|
|
268
|
-
return unless channel_id
|
|
269
|
-
|
|
270
|
-
token = discord_bot_tokens.values.first
|
|
271
|
-
return unless token
|
|
272
|
-
|
|
273
258
|
message = "✅ **#{project_key.capitalize}** deployed to UAT successfully"
|
|
274
|
-
|
|
275
|
-
rescue StandardError => e
|
|
276
|
-
LOG.warn "Failed to send UAT deploy notification: #{e.message}"
|
|
259
|
+
send_notification(:deploy, message, metadata_project: project_key)
|
|
277
260
|
end
|
|
278
261
|
|
|
279
262
|
def send_workflow_failure_notification(project_key, workflow_name, run_url)
|
|
280
|
-
channel_id = DISCORD_CONFIG["deploy_notification_channel_id"]
|
|
281
|
-
return unless channel_id
|
|
282
|
-
|
|
283
|
-
token = discord_bot_tokens.values.first
|
|
284
|
-
return unless token
|
|
285
|
-
|
|
286
263
|
message = "❌ **#{project_key.capitalize}** — #{workflow_name} failed\n[View run](#{run_url})"
|
|
287
|
-
|
|
288
|
-
rescue StandardError => e
|
|
289
|
-
LOG.warn "Failed to send workflow failure notification: #{e.message}"
|
|
264
|
+
send_notification(:ci_failure, message, metadata_project: project_key)
|
|
290
265
|
end
|
|
291
266
|
|
|
292
267
|
def handle_github_issue_opened(payload)
|
|
@@ -164,27 +164,21 @@ def format_zoho_notification(email, rule)
|
|
|
164
164
|
parts.join("\n")
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
-
# Send the notification to the configured
|
|
167
|
+
# Send the notification to the configured channel.
|
|
168
168
|
def notify_zoho_match(email, rule)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
target = rule["notify_target"] || rule["discord_channel_id"] || ZOHO_CONFIG["default_notify_target"] || ZOHO_CONFIG["default_discord_channel_id"]
|
|
170
|
+
channel = rule["notify_channel"] || ZOHO_CONFIG["notify_channel"] || "discord"
|
|
171
|
+
|
|
172
|
+
unless target
|
|
173
|
+
LOG.warn "[Zoho] No notify_target configured for rule '#{rule["label"]}' and no default set"
|
|
172
174
|
return
|
|
173
175
|
end
|
|
174
176
|
|
|
175
177
|
message = format_zoho_notification(email, rule)
|
|
178
|
+
agent = rule["notify_as"] || ZOHO_CONFIG["notify_as"]
|
|
176
179
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
token = tokens[bot_name&.downcase] || tokens.values.first
|
|
180
|
-
|
|
181
|
-
unless token
|
|
182
|
-
LOG.warn "[Zoho] No Discord bot token available to send notification"
|
|
183
|
-
return
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
LOG.info "[Zoho] Sending notification to channel #{channel_id} (bot: #{bot_name})"
|
|
187
|
-
send_discord_message(channel_id, message, token: token)
|
|
180
|
+
LOG.info "[Zoho] Sending notification via #{channel} to #{target}"
|
|
181
|
+
send_notification(:zoho_email, message, channel: channel, target: target, agent: agent)
|
|
188
182
|
end
|
|
189
183
|
|
|
190
184
|
# ---------------------------------------------------------------------------
|
|
@@ -360,20 +354,20 @@ end
|
|
|
360
354
|
|
|
361
355
|
# Act on the triage decision
|
|
362
356
|
def execute_zoho_triage_decision(decision, email, rule)
|
|
363
|
-
channel_id = rule["
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
357
|
+
channel_id = rule["notify_target"] || rule["discord_channel_id"] ||
|
|
358
|
+
ZOHO_CONFIG["default_notify_target"] || ZOHO_CONFIG["default_discord_channel_id"]
|
|
359
|
+
notify_channel = rule["notify_channel"] || ZOHO_CONFIG["notify_channel"] || "discord"
|
|
360
|
+
bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"]
|
|
367
361
|
|
|
368
362
|
case decision["decision"]
|
|
369
363
|
when "create_card"
|
|
370
|
-
create_zoho_triage_card(decision, email, channel_id,
|
|
364
|
+
create_zoho_triage_card(decision, email, channel_id, notify_channel, bot_name)
|
|
371
365
|
when "skip"
|
|
372
366
|
msg = "📧 **Support Email — No Card Needed**\n"
|
|
373
367
|
msg += "**Subject:** #{email["subject"]}\n"
|
|
374
368
|
msg += "**From:** #{email["fromAddress"]}\n"
|
|
375
369
|
msg += "**Reason:** #{decision["reason"]}"
|
|
376
|
-
|
|
370
|
+
send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
|
|
377
371
|
LOG.info "[Zoho:Triage] Skipped card: #{decision["reason"]}"
|
|
378
372
|
when "borderline"
|
|
379
373
|
msg = "⚠️ **Support Email — Needs Human Decision**\n"
|
|
@@ -382,8 +376,8 @@ def execute_zoho_triage_decision(decision, email, rule)
|
|
|
382
376
|
msg += "**Why borderline:** #{decision["reason"]}\n"
|
|
383
377
|
summary = (email["summary"] || "").to_s[0..300]
|
|
384
378
|
msg += "```\n#{summary}\n```" unless summary.empty?
|
|
385
|
-
|
|
386
|
-
LOG.info "[Zoho:Triage] Borderline — posted
|
|
379
|
+
send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
|
|
380
|
+
LOG.info "[Zoho:Triage] Borderline — posted for human decision"
|
|
387
381
|
else
|
|
388
382
|
LOG.warn "[Zoho:Triage] Unknown decision: #{decision["decision"]}"
|
|
389
383
|
notify_zoho_match(email, rule)
|
|
@@ -391,7 +385,7 @@ def execute_zoho_triage_decision(decision, email, rule)
|
|
|
391
385
|
end
|
|
392
386
|
|
|
393
387
|
# Create a card from the triage decision
|
|
394
|
-
def create_zoho_triage_card(decision, email, channel_id,
|
|
388
|
+
def create_zoho_triage_card(decision, email, channel_id, notify_channel, bot_name)
|
|
395
389
|
board_id = ZOHO_CONFIG["triage_board_id"]
|
|
396
390
|
unless board_id
|
|
397
391
|
LOG.error "[Zoho:Triage] No triage_board_id configured in zoho.json"
|
|
@@ -418,15 +412,13 @@ def create_zoho_triage_card(decision, email, channel_id, token)
|
|
|
418
412
|
card_url = card_info[:url]
|
|
419
413
|
LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
|
|
420
414
|
|
|
421
|
-
# Notify
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
send_discord_message(channel_id, msg, token: token)
|
|
429
|
-
end
|
|
415
|
+
# Notify configured channel
|
|
416
|
+
msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
|
|
417
|
+
msg += "**Title:** #{title}\n"
|
|
418
|
+
msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
|
|
419
|
+
msg += "**Tags:** #{tags.join(", ")}\n"
|
|
420
|
+
msg += "**From:** #{email["fromAddress"]}"
|
|
421
|
+
send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
|
|
430
422
|
else
|
|
431
423
|
LOG.warn "[Zoho:Triage] No work item plugin handled card creation"
|
|
432
424
|
notify_zoho_match(email, { "label" => "Support Email (no card plugin)", "emoji" => "⚠️" }.merge(rule_defaults(nil)))
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -186,17 +186,17 @@ def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_con
|
|
|
186
186
|
snippet = extract_crash_snippet(log_file)
|
|
187
187
|
snippet_block = snippet ? "\n```\n#{snippet[-1500..]}\n```" : ""
|
|
188
188
|
|
|
189
|
-
#
|
|
189
|
+
# Emit to plugins — they handle their own channel-specific delivery
|
|
190
190
|
handled = Brainiac.emit(:agent_crashed,
|
|
191
191
|
exit_status: exit_status, log_file: log_file, agent_name: agent_display,
|
|
192
192
|
source: source, source_context: source_context, project_config: project_config,
|
|
193
193
|
snippet: snippet)
|
|
194
194
|
|
|
195
|
-
# If a plugin handled it
|
|
196
|
-
return if handled.any?
|
|
195
|
+
# If a plugin handled it, we're done
|
|
196
|
+
return if handled.any?
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
# Built-in: GitHub crash comment (doesn't need a plugin)
|
|
199
|
+
if source == :github
|
|
200
200
|
pr_number = source_context[:pr_number]
|
|
201
201
|
repo_name = source_context[:repo_name]
|
|
202
202
|
return unless pr_number && repo_name
|
|
@@ -209,16 +209,6 @@ def notify_agent_crash(exit_status:, log_file:, agent_name:, source:, source_con
|
|
|
209
209
|
rescue StandardError => e
|
|
210
210
|
LOG.error "[CrashNotify] Failed to post GitHub crash comment: #{e.message}"
|
|
211
211
|
end
|
|
212
|
-
|
|
213
|
-
when :discord
|
|
214
|
-
channel_id = source_context[:channel_id]
|
|
215
|
-
message_id = source_context[:message_id]
|
|
216
|
-
bot_token = source_context[:bot_token]
|
|
217
|
-
return unless channel_id && bot_token
|
|
218
|
-
|
|
219
|
-
message = "💥 **#{agent_display} crashed** (exit code #{exit_status})\nLog: `#{log_file}`#{snippet_block}"
|
|
220
|
-
send_discord_message(channel_id, message, token: bot_token, reply_to: message_id)
|
|
221
|
-
LOG.info "[CrashNotify] Posted crash message to Discord channel #{channel_id}"
|
|
222
212
|
end
|
|
223
213
|
rescue StandardError => e
|
|
224
214
|
LOG.error "[CrashNotify] Unexpected error: #{e.message}"
|
|
@@ -358,7 +348,7 @@ def handle_agent_completion(**ctx)
|
|
|
358
348
|
end
|
|
359
349
|
|
|
360
350
|
brain_push(message: "#{ctx[:agent_config_name] || "agent"}: #{ctx[:log_name]}")
|
|
361
|
-
check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
|
|
351
|
+
# check_brainiac_restart(ctx[:head_before], ctx[:status_before], ctx[:chdir], ctx[:project_key_for_restart], ctx[:agent_config_name])
|
|
362
352
|
end
|
|
363
353
|
|
|
364
354
|
def check_brainiac_restart(head_before, status_before, chdir, project_key_for_restart, agent_config_name)
|