zillacore 0.0.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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- metadata.gz.sig +0 -0
data/receiver.rb
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# ZillaCore — modular webhook receiver
|
|
4
|
+
#
|
|
5
|
+
# This is the thin entry point. All logic lives in lib/zillacore/*.
|
|
6
|
+
# Start with: ruby receiver.rb
|
|
7
|
+
|
|
8
|
+
require "sinatra"
|
|
9
|
+
require "json"
|
|
10
|
+
|
|
11
|
+
# Load all modules
|
|
12
|
+
require_relative "lib/zillacore/config"
|
|
13
|
+
require_relative "lib/zillacore/users"
|
|
14
|
+
require_relative "lib/zillacore/agents"
|
|
15
|
+
require_relative "lib/zillacore/brain"
|
|
16
|
+
require_relative "lib/zillacore/skills"
|
|
17
|
+
require_relative "lib/zillacore/sessions"
|
|
18
|
+
require_relative "lib/zillacore/prompts"
|
|
19
|
+
require_relative "lib/zillacore/planning"
|
|
20
|
+
require_relative "lib/zillacore/helpers"
|
|
21
|
+
require_relative "lib/zillacore/cron"
|
|
22
|
+
require_relative "lib/zillacore/handlers/fizzy"
|
|
23
|
+
require_relative "lib/zillacore/handlers/github"
|
|
24
|
+
require_relative "lib/zillacore/card_index"
|
|
25
|
+
require_relative "lib/zillacore/deployments"
|
|
26
|
+
|
|
27
|
+
# Reload hook registry — custom handlers register callbacks here
|
|
28
|
+
module ReloadHooks
|
|
29
|
+
@hooks = []
|
|
30
|
+
|
|
31
|
+
def self.register(name, &block)
|
|
32
|
+
@hooks << { name: name, block: block }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.run_all!
|
|
36
|
+
@hooks.each { |hook| hook[:block].call }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def register_reload_hook(name, &)
|
|
41
|
+
ReloadHooks.register(name, &)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Load custom handlers from ~/.zillacore/handlers/ (plugin system)
|
|
45
|
+
CUSTOM_HANDLERS_DIR = File.join(ZILLACORE_DIR, "handlers")
|
|
46
|
+
if Dir.exist?(CUSTOM_HANDLERS_DIR)
|
|
47
|
+
Dir.glob(File.join(CUSTOM_HANDLERS_DIR, "*.rb")).each do |handler|
|
|
48
|
+
LOG.info "[Handlers] Loading custom handler: #{File.basename(handler)}"
|
|
49
|
+
require handler
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if DISCORD_ENABLED
|
|
54
|
+
require_relative "lib/zillacore/handlers/discord"
|
|
55
|
+
require_relative "lib/zillacore/handlers/zoho"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# --- Sinatra config ---
|
|
59
|
+
set :host_authorization, { permit_all: true }
|
|
60
|
+
|
|
61
|
+
# Disable Sinatra's default logging (we use SelectiveLogger instead)
|
|
62
|
+
set :logging, false
|
|
63
|
+
|
|
64
|
+
# Suppress Sinatra/Puma startup banners — we log our own version line
|
|
65
|
+
disable :show_exceptions
|
|
66
|
+
set :quiet, true
|
|
67
|
+
set :server_settings, { Silent: true }
|
|
68
|
+
|
|
69
|
+
# Custom logger that filters polling endpoints unless LOG_LEVEL=debug
|
|
70
|
+
SILENT_POLL_PATHS = %w[/api/status /api/deployments].freeze
|
|
71
|
+
|
|
72
|
+
class SelectiveLogger < Rack::CommonLogger
|
|
73
|
+
def call(env)
|
|
74
|
+
if SILENT_POLL_PATHS.include?(env["PATH_INFO"]) && LOG.level > Logger::DEBUG
|
|
75
|
+
@app.call(env)
|
|
76
|
+
else
|
|
77
|
+
super
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
configure do
|
|
83
|
+
use SelectiveLogger, LOG
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
LOG.info "[ZillaCore] Starting v#{ZILLACORE_VERSION} on port #{settings.port} (#{settings.environment})"
|
|
87
|
+
|
|
88
|
+
# --- Dashboard authentication ---
|
|
89
|
+
|
|
90
|
+
helpers do
|
|
91
|
+
def authenticate_dashboard!
|
|
92
|
+
return unless DASHBOARD_TOKEN # No token configured = no auth (local-only mode)
|
|
93
|
+
|
|
94
|
+
provided = params["token"] || request.env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /i, "")
|
|
95
|
+
halt 401, "Unauthorized" unless provided == DASHBOARD_TOKEN
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def localhost_request?
|
|
99
|
+
host = request.env["HTTP_HOST"].to_s
|
|
100
|
+
host.include?("localhost") || host.include?("127.0.0.1")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
before "/dashboard" do
|
|
105
|
+
authenticate_dashboard!
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
before "/api/*" do
|
|
109
|
+
# Skip auth for all localhost requests (CLI, waybar, daemon, etc.)
|
|
110
|
+
pass if localhost_request?
|
|
111
|
+
# Skip auth for webhook-related routes that have their own verification
|
|
112
|
+
pass if request.path_info == "/api/discord"
|
|
113
|
+
authenticate_dashboard!
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# --- Fizzy webhook routes ---
|
|
117
|
+
|
|
118
|
+
post "/fizzy/?:board_key?" do
|
|
119
|
+
content_type :json
|
|
120
|
+
request.body.rewind
|
|
121
|
+
payload_body = request.body.read
|
|
122
|
+
board_key = params["board_key"]
|
|
123
|
+
|
|
124
|
+
verify_signature!(request, payload_body, board_key: board_key)
|
|
125
|
+
|
|
126
|
+
payload = JSON.parse(payload_body)
|
|
127
|
+
|
|
128
|
+
event_id = payload["id"]
|
|
129
|
+
action = payload["action"]
|
|
130
|
+
|
|
131
|
+
LOG.info "[Fizzy] Received event #{event_id}: action=#{action}"
|
|
132
|
+
|
|
133
|
+
if already_processed?(event_id)
|
|
134
|
+
LOG.info "Skipping duplicate event #{event_id}"
|
|
135
|
+
halt 200, { status: "duplicate" }.to_json
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
reload_projects!
|
|
139
|
+
reload_agent_registry!
|
|
140
|
+
reload_github_config!
|
|
141
|
+
|
|
142
|
+
case action
|
|
143
|
+
when "card_assigned"
|
|
144
|
+
status_code, body = handle_card_assigned(payload)
|
|
145
|
+
LOG.info "[Fizzy] #{action} response: #{status_code} - #{body}"
|
|
146
|
+
halt status_code, body
|
|
147
|
+
when "comment_created"
|
|
148
|
+
status_code, body = handle_comment(payload)
|
|
149
|
+
LOG.info "[Fizzy] comment_created response: #{status_code} - #{body}"
|
|
150
|
+
halt status_code, body
|
|
151
|
+
when "card_published", "card_triaged"
|
|
152
|
+
eventable = payload["eventable"] || {}
|
|
153
|
+
card_number = eventable["number"]&.to_s
|
|
154
|
+
|
|
155
|
+
# card_triaged never dispatches agents — only card_assigned and @mentions do that.
|
|
156
|
+
# Guards remain as defense-in-depth for any future column-based routing.
|
|
157
|
+
if action == "card_triaged" && card_number
|
|
158
|
+
if self_move_recent?(card_number)
|
|
159
|
+
LOG.info "[Fizzy] Ignoring card_triaged for ##{card_number} — self-move echo"
|
|
160
|
+
halt 200, { status: "ignored", reason: "self_move" }.to_json
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if card_merged?(card_number)
|
|
164
|
+
LOG.info "[Fizzy] Ignoring card_triaged for ##{card_number} — card already merged"
|
|
165
|
+
halt 200, { status: "ignored", reason: "card_merged" }.to_json
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
card_key = "card-#{card_number}"
|
|
169
|
+
if recently_completed?(card_key)
|
|
170
|
+
LOG.info "[Fizzy] Ignoring card_triaged for ##{card_number} — recently completed"
|
|
171
|
+
halt 200, { status: "ignored", reason: "recently_completed" }.to_json
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Only card_published does duplicate detection — card_triaged skips agent dispatch entirely
|
|
176
|
+
if action == "card_published"
|
|
177
|
+
assignees = eventable["assignees"] || []
|
|
178
|
+
if assignees.any? { |a| local_agent_names.include?(a["name"]) }
|
|
179
|
+
status_code, body = handle_card_assigned(payload)
|
|
180
|
+
LOG.info "[Fizzy] #{action} (with assignee) response: #{status_code} - #{body}"
|
|
181
|
+
halt status_code, body
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
status_code, body = handle_card_published(payload)
|
|
186
|
+
LOG.info "[Fizzy] #{action} response: #{status_code} - #{body}"
|
|
187
|
+
halt status_code, body
|
|
188
|
+
else
|
|
189
|
+
LOG.info "[Fizzy] Ignoring unknown action: #{action}"
|
|
190
|
+
halt 200, { status: "ignored", action: action }.to_json
|
|
191
|
+
end
|
|
192
|
+
rescue JSON::ParserError => e
|
|
193
|
+
LOG.error "Invalid JSON: #{e.message}"
|
|
194
|
+
halt 400, { error: "Invalid JSON" }.to_json
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
LOG.error "Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
197
|
+
halt 500, { error: e.message }.to_json
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
post "/github" do
|
|
201
|
+
content_type :json
|
|
202
|
+
request.body.rewind
|
|
203
|
+
payload_body = request.body.read
|
|
204
|
+
|
|
205
|
+
verify_github_signature!(request, payload_body)
|
|
206
|
+
|
|
207
|
+
payload = JSON.parse(payload_body)
|
|
208
|
+
event = request.env["HTTP_X_GITHUB_EVENT"]
|
|
209
|
+
|
|
210
|
+
reload_projects!
|
|
211
|
+
reload_agent_registry!
|
|
212
|
+
reload_github_config!
|
|
213
|
+
|
|
214
|
+
action = payload["action"]
|
|
215
|
+
|
|
216
|
+
case event
|
|
217
|
+
when "pull_request"
|
|
218
|
+
if action == "closed" && payload.dig("pull_request", "merged")
|
|
219
|
+
status_code, body = handle_github_pr_merged(payload)
|
|
220
|
+
halt status_code, body
|
|
221
|
+
elsif action == "opened"
|
|
222
|
+
track_pr_in_card_map(payload)
|
|
223
|
+
halt 200, { status: "processed", action: "pr_tracked" }.to_json
|
|
224
|
+
elsif action == "synchronize"
|
|
225
|
+
status_code, body = handle_github_pr_synchronized(payload)
|
|
226
|
+
halt status_code, body
|
|
227
|
+
else
|
|
228
|
+
halt 200, { status: "ignored", reason: "pull_request action: #{action}" }.to_json
|
|
229
|
+
end
|
|
230
|
+
when "pull_request_review"
|
|
231
|
+
if action == "submitted"
|
|
232
|
+
status_code, body = handle_github_pr_review_submitted(payload)
|
|
233
|
+
halt status_code, body
|
|
234
|
+
else
|
|
235
|
+
halt 200, { status: "ignored", reason: "pull_request_review action: #{action}" }.to_json
|
|
236
|
+
end
|
|
237
|
+
when "issue_comment"
|
|
238
|
+
if action == "created"
|
|
239
|
+
status_code, body = handle_github_issue_comment(payload)
|
|
240
|
+
halt status_code, body
|
|
241
|
+
else
|
|
242
|
+
halt 200, { status: "ignored", reason: "issue_comment action: #{action}" }.to_json
|
|
243
|
+
end
|
|
244
|
+
when "issues"
|
|
245
|
+
if action == "opened"
|
|
246
|
+
status_code, body = handle_github_issue_opened(payload)
|
|
247
|
+
halt status_code, body
|
|
248
|
+
else
|
|
249
|
+
halt 200, { status: "ignored", reason: "issues action: #{action}" }.to_json
|
|
250
|
+
end
|
|
251
|
+
when "workflow_run"
|
|
252
|
+
if action == "completed"
|
|
253
|
+
status_code, body = handle_github_workflow_run(payload)
|
|
254
|
+
halt status_code, body
|
|
255
|
+
else
|
|
256
|
+
halt 200, { status: "ignored", reason: "workflow_run action: #{action}" }.to_json
|
|
257
|
+
end
|
|
258
|
+
when "ping"
|
|
259
|
+
halt 200, { status: "pong" }.to_json
|
|
260
|
+
else
|
|
261
|
+
halt 200, { status: "ignored", event: event }.to_json
|
|
262
|
+
end
|
|
263
|
+
rescue JSON::ParserError => e
|
|
264
|
+
LOG.error "Invalid JSON: #{e.message}"
|
|
265
|
+
halt 400, { error: "Invalid JSON" }.to_json
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
LOG.error "Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
268
|
+
halt 500, { error: e.message }.to_json
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# --- Zoho Mail webhook route ---
|
|
272
|
+
|
|
273
|
+
if DISCORD_ENABLED
|
|
274
|
+
post "/zoho" do
|
|
275
|
+
content_type :json
|
|
276
|
+
request.body.rewind
|
|
277
|
+
payload_body = request.body.read
|
|
278
|
+
|
|
279
|
+
# Zoho sends X-Hook-Secret on the very first request — capture and store it
|
|
280
|
+
hook_secret = request.env["HTTP_X_HOOK_SECRET"]
|
|
281
|
+
if hook_secret
|
|
282
|
+
save_zoho_hook_secret(hook_secret)
|
|
283
|
+
LOG.info "[Zoho] Received and stored hook_secret from initial handshake"
|
|
284
|
+
halt 200, { status: "hook_secret_received" }.to_json
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
verify_zoho_signature!(request, payload_body)
|
|
288
|
+
|
|
289
|
+
email = JSON.parse(payload_body)
|
|
290
|
+
LOG.info "[Zoho] Received email: subject=#{email["subject"]}, from=#{email["fromAddress"]}, to=#{email["toAddress"]}"
|
|
291
|
+
LOG.info "[Zoho] Payload keys: #{email.keys.sort.join(", ")}"
|
|
292
|
+
LOG.info "[Zoho] summary=#{email["summary"].to_s[0..200].inspect}, html=#{email["html"].to_s[0..200].inspect}, content=#{email["content"].to_s[0..200].inspect}"
|
|
293
|
+
|
|
294
|
+
# Dump raw payload for debugging (last 5 kept)
|
|
295
|
+
zoho_debug_dir = File.join(ZILLACORE_DIR, "tmp", "zoho", "payloads")
|
|
296
|
+
FileUtils.mkdir_p(zoho_debug_dir)
|
|
297
|
+
File.write(File.join(zoho_debug_dir, "#{Time.now.strftime("%Y%m%d-%H%M%S")}.json"), JSON.pretty_generate(email))
|
|
298
|
+
|
|
299
|
+
reload_zoho_config!
|
|
300
|
+
rule = match_zoho_rule(email)
|
|
301
|
+
|
|
302
|
+
if rule
|
|
303
|
+
LOG.info "[Zoho] Matched rule: #{rule["label"]}"
|
|
304
|
+
if rule["dispatch_agent"]
|
|
305
|
+
dispatch_zoho_triage(email, rule)
|
|
306
|
+
halt 200, { status: "triage_dispatched", rule: rule["label"], agent: rule["dispatch_agent"] }.to_json
|
|
307
|
+
else
|
|
308
|
+
notify_zoho_match(email, rule)
|
|
309
|
+
halt 200, { status: "matched", rule: rule["label"] }.to_json
|
|
310
|
+
end
|
|
311
|
+
else
|
|
312
|
+
LOG.info "[Zoho] No rules matched"
|
|
313
|
+
halt 200, { status: "no_match" }.to_json
|
|
314
|
+
end
|
|
315
|
+
rescue JSON::ParserError => e
|
|
316
|
+
LOG.error "[Zoho] Invalid JSON: #{e.message}"
|
|
317
|
+
halt 400, { error: "Invalid JSON" }.to_json
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
LOG.error "[Zoho] Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
320
|
+
halt 500, { error: e.message }.to_json
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# --- Zoho OAuth routes ---
|
|
325
|
+
|
|
326
|
+
if DISCORD_ENABLED
|
|
327
|
+
ZOHO_AUTH_SCOPES = "ZohoMail.messages.READ,ZohoMail.accounts.READ,ZohoMail.folders.READ".freeze
|
|
328
|
+
|
|
329
|
+
get "/zoho/auth" do
|
|
330
|
+
reload_zoho_config!
|
|
331
|
+
api = ZOHO_CONFIG["api"] || {}
|
|
332
|
+
halt 500, "Missing client_id in zoho.json api section" unless api["client_id"]
|
|
333
|
+
|
|
334
|
+
redirect_uri = "#{request.base_url}/zoho/callback"
|
|
335
|
+
params = URI.encode_www_form(
|
|
336
|
+
scope: ZOHO_AUTH_SCOPES,
|
|
337
|
+
client_id: api["client_id"],
|
|
338
|
+
response_type: "code",
|
|
339
|
+
access_type: "offline",
|
|
340
|
+
redirect_uri: redirect_uri,
|
|
341
|
+
prompt: "consent"
|
|
342
|
+
)
|
|
343
|
+
redirect "https://accounts.zoho.com/oauth/v2/auth?#{params}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
get "/zoho/callback" do
|
|
347
|
+
content_type :html
|
|
348
|
+
code = params["code"]
|
|
349
|
+
halt 400, "No authorization code received" unless code
|
|
350
|
+
|
|
351
|
+
reload_zoho_config!
|
|
352
|
+
api = ZOHO_CONFIG["api"] || {}
|
|
353
|
+
redirect_uri = "#{request.base_url}/zoho/callback"
|
|
354
|
+
|
|
355
|
+
uri = URI(ZOHO_TOKEN_URL)
|
|
356
|
+
res = Net::HTTP.post_form(uri, {
|
|
357
|
+
"grant_type" => "authorization_code",
|
|
358
|
+
"client_id" => api["client_id"],
|
|
359
|
+
"client_secret" => api["client_secret"],
|
|
360
|
+
"code" => code,
|
|
361
|
+
"redirect_uri" => redirect_uri
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
data = JSON.parse(res.body)
|
|
365
|
+
if data["refresh_token"]
|
|
366
|
+
ZOHO_CONFIG["api"] ||= {}
|
|
367
|
+
ZOHO_CONFIG["api"]["refresh_token"] = data["refresh_token"]
|
|
368
|
+
@zoho_access_token = data["access_token"]
|
|
369
|
+
@zoho_token_expires_at = Time.now + 3300
|
|
370
|
+
File.write(ZOHO_CONFIG_FILE, JSON.pretty_generate(ZOHO_CONFIG))
|
|
371
|
+
LOG.info "[Zoho:OAuth] Stored refresh_token and access_token"
|
|
372
|
+
|
|
373
|
+
# Auto-fetch account_id if not set
|
|
374
|
+
unless ZOHO_CONFIG.dig("api", "account_id")
|
|
375
|
+
acct_uri = URI(ZOHO_MAIL_API_BASE.to_s)
|
|
376
|
+
http = Net::HTTP.new(acct_uri.host, acct_uri.port)
|
|
377
|
+
http.use_ssl = true
|
|
378
|
+
req = Net::HTTP::Get.new(acct_uri)
|
|
379
|
+
req["Authorization"] = "Zoho-oauthtoken #{@zoho_access_token}"
|
|
380
|
+
acct_res = http.request(req)
|
|
381
|
+
acct_data = JSON.parse(acct_res.body)
|
|
382
|
+
if (account_id = acct_data.dig("data", 0, "accountId"))
|
|
383
|
+
ZOHO_CONFIG["api"]["account_id"] = account_id
|
|
384
|
+
File.write(ZOHO_CONFIG_FILE, JSON.pretty_generate(ZOHO_CONFIG))
|
|
385
|
+
LOG.info "[Zoho:OAuth] Auto-fetched account_id: #{account_id}"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
"<h1>✅ Zoho OAuth Complete</h1><p>Refresh token and account_id saved to zoho.json. You can close this tab.</p>"
|
|
390
|
+
else
|
|
391
|
+
LOG.error "[Zoho:OAuth] Token exchange failed: #{data}"
|
|
392
|
+
"<h1>❌ OAuth Failed</h1><pre>#{data.to_json}</pre>"
|
|
393
|
+
end
|
|
394
|
+
rescue StandardError => e
|
|
395
|
+
LOG.error "[Zoho:OAuth] Error: #{e.message}"
|
|
396
|
+
"<h1>❌ Error</h1><pre>#{e.message}</pre>"
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# --- Admin API routes ---
|
|
401
|
+
|
|
402
|
+
get "/api/projects" do
|
|
403
|
+
content_type :json
|
|
404
|
+
reload_projects!
|
|
405
|
+
{ projects: PROJECTS }.to_json
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
get "/api/projects/:key" do
|
|
409
|
+
content_type :json
|
|
410
|
+
reload_projects!
|
|
411
|
+
project_key = params["key"]
|
|
412
|
+
if PROJECTS.key?(project_key)
|
|
413
|
+
{ project: PROJECTS[project_key] }.to_json
|
|
414
|
+
else
|
|
415
|
+
halt 404, { error: "Project not found" }.to_json
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
post "/api/reload" do
|
|
420
|
+
content_type :json
|
|
421
|
+
reload_projects!(force: true)
|
|
422
|
+
reload_agent_registry!(force: true)
|
|
423
|
+
reload_user_registry!(force: true)
|
|
424
|
+
reload_github_config!(force: true)
|
|
425
|
+
reload_deployments_config!(force: true)
|
|
426
|
+
ReloadHooks.run_all!
|
|
427
|
+
{ status: "reloaded", projects: PROJECTS.keys, agents: all_agent_names.to_a, registry: AGENT_REGISTRY.keys,
|
|
428
|
+
users: USER_REGISTRY["users"].size }.to_json
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
get "/api/agents" do
|
|
432
|
+
content_type :json
|
|
433
|
+
{ default: AI_AGENT_NAME, agents: discover_kiro_agents, all_known: all_agent_names.to_a, roster: agent_roster }.to_json
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
get "/api/users" do
|
|
437
|
+
content_type :json
|
|
438
|
+
reload_user_registry!
|
|
439
|
+
|
|
440
|
+
filter = params["filter"]
|
|
441
|
+
users = case filter
|
|
442
|
+
when "humans" then human_users
|
|
443
|
+
when "agents" then ai_agents
|
|
444
|
+
else USER_REGISTRY["users"]
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
{ users: users, total: USER_REGISTRY["users"].size, schema_version: USER_REGISTRY["schema_version"] }.to_json
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
get "/api/users/:identifier" do
|
|
451
|
+
content_type :json
|
|
452
|
+
reload_user_registry!
|
|
453
|
+
|
|
454
|
+
identifier = params["identifier"]
|
|
455
|
+
user = find_user(identifier)
|
|
456
|
+
|
|
457
|
+
if user
|
|
458
|
+
{ user: user }.to_json
|
|
459
|
+
else
|
|
460
|
+
halt 404, { error: "User not found", identifier: identifier }.to_json
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# --- Brain API routes ---
|
|
465
|
+
|
|
466
|
+
get "/api/brain" do
|
|
467
|
+
content_type :json
|
|
468
|
+
agent = params["agent"] || AI_AGENT_NAME
|
|
469
|
+
persona_dir = persona_dir_for(agent)
|
|
470
|
+
persona_col = persona_collection_for(agent)
|
|
471
|
+
|
|
472
|
+
knowledge_files = File.directory?(KNOWLEDGE_DIR) ? Dir.glob(File.join(KNOWLEDGE_DIR, "**", "*.md")).map { |f| f.sub("#{KNOWLEDGE_DIR}/", "") } : []
|
|
473
|
+
persona_files = File.directory?(persona_dir) ? Dir.glob(File.join(persona_dir, "**", "*.md")).map { |f| f.sub("#{persona_dir}/", "") } : []
|
|
474
|
+
|
|
475
|
+
{
|
|
476
|
+
agent: agent,
|
|
477
|
+
knowledge: { dir: KNOWLEDGE_DIR, collection: KNOWLEDGE_COLLECTION, files: knowledge_files },
|
|
478
|
+
persona: { dir: persona_dir, collection: persona_col, files: persona_files }
|
|
479
|
+
}.to_json
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
get "/api/brain/search" do
|
|
483
|
+
content_type :json
|
|
484
|
+
query = params["q"]
|
|
485
|
+
halt 400, { error: "Missing query parameter ?q=" }.to_json unless query && !query.empty?
|
|
486
|
+
|
|
487
|
+
agent = params["agent"] || AI_AGENT_NAME
|
|
488
|
+
scope = (params["scope"] || "knowledge").to_sym
|
|
489
|
+
scope = :knowledge unless %i[knowledge persona].include?(scope)
|
|
490
|
+
results = query_brain(query, agent_name: agent, scope: scope, max_results: (params["n"] || 5).to_i)
|
|
491
|
+
|
|
492
|
+
{ agent: agent, scope: scope, query: query, results: results }.to_json
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
get "/api/skills" do
|
|
496
|
+
content_type :json
|
|
497
|
+
skills = build_skill_index
|
|
498
|
+
{ total: skills.size, skills: skills }.to_json
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
post "/api/skills/curate" do
|
|
502
|
+
content_type :json
|
|
503
|
+
result = curate_skills
|
|
504
|
+
result.to_json
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
get "/api/card-index" do
|
|
508
|
+
content_type :json
|
|
509
|
+
query = params["q"]
|
|
510
|
+
if query && !query.empty?
|
|
511
|
+
similar = CARD_INDEX.find_similar_cards(query)
|
|
512
|
+
{ query: query, matches: similar, total_indexed: CARD_INDEX.size }.to_json
|
|
513
|
+
else
|
|
514
|
+
{ total: CARD_INDEX.size, cards: CARD_INDEX }.to_json
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
get "/api/dispatch-depth" do
|
|
519
|
+
content_type :json
|
|
520
|
+
{
|
|
521
|
+
max_depth: AGENT_DISPATCH_MAX_DEPTH,
|
|
522
|
+
window_seconds: AGENT_DISPATCH_WINDOW,
|
|
523
|
+
cards: AGENT_DISPATCH_DEPTH.transform_values do |v|
|
|
524
|
+
{ count: v[:count], last_human_at: v[:last_human_at]&.iso8601, blocked: v[:count] >= AGENT_DISPATCH_MAX_DEPTH }
|
|
525
|
+
end
|
|
526
|
+
}.to_json
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
get "/api/status" do
|
|
530
|
+
content_type :json
|
|
531
|
+
ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
532
|
+
# Clean up stale sessions first
|
|
533
|
+
ACTIVE_SESSIONS.delete_if do |card_key, info|
|
|
534
|
+
Process.kill(0, info[:pid])
|
|
535
|
+
false # Keep alive sessions
|
|
536
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
537
|
+
archive_session(card_key, info)
|
|
538
|
+
true # Remove dead sessions
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
sessions = ACTIVE_SESSIONS.map do |card_key, info|
|
|
542
|
+
# Use stored agent_name if available, otherwise try to extract from card_key
|
|
543
|
+
agent_name = if info[:agent_name]
|
|
544
|
+
info[:agent_name]
|
|
545
|
+
else
|
|
546
|
+
# Fallback: extract from card_key for backwards compatibility
|
|
547
|
+
# Formats: "discord-AGENT-CHANNEL-MESSAGE" or "card-ID"
|
|
548
|
+
parts = card_key.split("-")
|
|
549
|
+
agent_key = if parts[0] == "discord" && parts.size >= 4
|
|
550
|
+
parts[1] # Second part is agent name
|
|
551
|
+
else
|
|
552
|
+
"Unknown"
|
|
553
|
+
end
|
|
554
|
+
fizzy_display_name(agent_key)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
{
|
|
558
|
+
card_key: card_key,
|
|
559
|
+
agent: agent_name,
|
|
560
|
+
pid: info[:pid],
|
|
561
|
+
started_at: info[:started_at].iso8601,
|
|
562
|
+
elapsed_seconds: (Time.now - info[:started_at]).to_i,
|
|
563
|
+
log_file: info[:log_file],
|
|
564
|
+
alive: true,
|
|
565
|
+
children: child_processes_for(info[:pid])
|
|
566
|
+
}
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
recent = RECENT_SESSIONS.map do |s|
|
|
570
|
+
{
|
|
571
|
+
card_key: s[:card_key],
|
|
572
|
+
agent: s[:agent_name] || "Unknown",
|
|
573
|
+
log_file: s[:log_file],
|
|
574
|
+
started_at: s[:started_at]&.iso8601,
|
|
575
|
+
finished_at: s[:finished_at]&.iso8601
|
|
576
|
+
}
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
{ sessions: sessions, count: sessions.size, recent: recent, version: ZILLACORE_VERSION }.to_json
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Kill an entire agent session (parent process + all children)
|
|
584
|
+
post "/api/sessions/kill/:card_key" do
|
|
585
|
+
content_type :json
|
|
586
|
+
card_key = params[:card_key]
|
|
587
|
+
halt 400, { error: "missing card_key" }.to_json if card_key.to_s.empty?
|
|
588
|
+
|
|
589
|
+
killed = kill_session(card_key)
|
|
590
|
+
halt 404, { error: "session not found" }.to_json unless killed
|
|
591
|
+
|
|
592
|
+
LOG.info "Killed agent session #{card_key} via API"
|
|
593
|
+
{ killed: card_key }.to_json
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Kill a specific child process of an active agent session
|
|
597
|
+
post "/api/sessions/kill-process/:pid" do
|
|
598
|
+
content_type :json
|
|
599
|
+
target_pid = params[:pid].to_i
|
|
600
|
+
halt 400, { error: "invalid pid" }.to_json if target_pid <= 0
|
|
601
|
+
|
|
602
|
+
# Verify the target PID is actually a descendant of an active session
|
|
603
|
+
valid = ACTIVE_SESSIONS_MUTEX.synchronize do
|
|
604
|
+
ACTIVE_SESSIONS.any? do |_, info|
|
|
605
|
+
child_processes_for(info[:pid]).any? { |c| c[:pid] == target_pid }
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
halt 403, { error: "pid is not a child of any active agent session" }.to_json unless valid
|
|
609
|
+
|
|
610
|
+
begin
|
|
611
|
+
Process.kill("TERM", target_pid)
|
|
612
|
+
# Give it a moment, then force kill if still alive
|
|
613
|
+
Thread.new do
|
|
614
|
+
sleep 3
|
|
615
|
+
begin
|
|
616
|
+
Process.kill(0, target_pid)
|
|
617
|
+
Process.kill("KILL", target_pid)
|
|
618
|
+
rescue Errno::ESRCH, Errno::EPERM # rubocop:disable Lint/SuppressedException
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
LOG.info "Killed child process #{target_pid} (SIGTERM)"
|
|
622
|
+
{ killed: target_pid }.to_json
|
|
623
|
+
rescue Errno::ESRCH
|
|
624
|
+
halt 404, { error: "process not found" }.to_json
|
|
625
|
+
rescue Errno::EPERM
|
|
626
|
+
halt 403, { error: "permission denied" }.to_json
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# --- Dashboard ---
|
|
631
|
+
|
|
632
|
+
WAYBAR_CONFIG_PATH = File.expand_path("~/.zillacore/waybar.json")
|
|
633
|
+
|
|
634
|
+
def load_dashboard_agents
|
|
635
|
+
return {} unless File.exist?(WAYBAR_CONFIG_PATH)
|
|
636
|
+
|
|
637
|
+
config = JSON.parse(File.read(WAYBAR_CONFIG_PATH))
|
|
638
|
+
agents = {}
|
|
639
|
+
(config["agents"] || []).each { |a| agents[a["name"].downcase] = { emoji: a["emoji"], color: a["color"] } }
|
|
640
|
+
agents
|
|
641
|
+
rescue StandardError
|
|
642
|
+
{}
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
get "/dashboard" do
|
|
646
|
+
content_type :html
|
|
647
|
+
erb :dashboard, layout: false
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
get "/api/logs" do
|
|
651
|
+
content_type "text/plain"
|
|
652
|
+
log_file = params["file"]
|
|
653
|
+
lines = (params["lines"] || 200).to_i
|
|
654
|
+
|
|
655
|
+
halt 400, "Missing ?file= parameter" unless log_file && !log_file.empty?
|
|
656
|
+
halt 400, "Invalid path" if log_file.include?("..") || !log_file.start_with?("/")
|
|
657
|
+
halt 404, "File not found" unless File.exist?(log_file)
|
|
658
|
+
|
|
659
|
+
# Only allow reading log files from known project tmp dirs or zillacore tmp
|
|
660
|
+
allowed = PROJECTS.values.map { |p| File.join(p["repo_path"], "tmp") }
|
|
661
|
+
allowed << File.join(ZILLACORE_DIR, "tmp")
|
|
662
|
+
halt 403, "Forbidden" unless allowed.any? { |dir| log_file.start_with?(dir) }
|
|
663
|
+
|
|
664
|
+
# Read last N lines and strip ANSI escape codes
|
|
665
|
+
all_lines = File.readlines(log_file).last(lines)
|
|
666
|
+
all_lines.join.gsub(/\e\[[\d;]*[a-zA-Z]/, "").gsub(/\e\[\?[\d;]*[a-zA-Z]/, "")
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# --- Discord API + startup ---
|
|
670
|
+
|
|
671
|
+
if DISCORD_ENABLED
|
|
672
|
+
get "/api/discord" do
|
|
673
|
+
content_type :json
|
|
674
|
+
{
|
|
675
|
+
enabled: true,
|
|
676
|
+
bots: discord_bots_status,
|
|
677
|
+
config: {
|
|
678
|
+
default_project: DISCORD_CONFIG["default_project"],
|
|
679
|
+
channel_mappings: DISCORD_CONFIG["channel_mappings"]&.size || 0,
|
|
680
|
+
authorized_users: (DISCORD_CONFIG["authorized_user_ids"] || []).size,
|
|
681
|
+
authorized_roles: (DISCORD_CONFIG["authorized_role_ids"] || []).size
|
|
682
|
+
}
|
|
683
|
+
}.to_json
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
start_all_discord_gateways
|
|
687
|
+
start_discord_draft_poller
|
|
688
|
+
start_zillacore_restart_monitor
|
|
689
|
+
|
|
690
|
+
# Send "back online" notification after bots connect (if restarted)
|
|
691
|
+
Thread.new do
|
|
692
|
+
# Wait for at least one bot to be ready (up to 30s)
|
|
693
|
+
30.times do
|
|
694
|
+
sleep 1
|
|
695
|
+
ready = DISCORD_BOTS_MUTEX.synchronize { DISCORD_BOTS.any? { |_, info| info[:status] == "ready" } }
|
|
696
|
+
next unless ready
|
|
697
|
+
|
|
698
|
+
send_restart_notification("✅ Zillacore back online")
|
|
699
|
+
|
|
700
|
+
# Check if running an outdated version (skip in dev/foreground mode)
|
|
701
|
+
unless $stdout.tty?
|
|
702
|
+
version_info = check_zillacore_version
|
|
703
|
+
if version_info[:behind]
|
|
704
|
+
owner_id = owner_discord_id
|
|
705
|
+
mention = owner_id ? "<@#{owner_id}>" : "Someone"
|
|
706
|
+
channel_id = DISCORD_CONFIG["notification_channel_id"]
|
|
707
|
+
tokens = discord_bot_tokens
|
|
708
|
+
token = tokens.values.first
|
|
709
|
+
if channel_id && token
|
|
710
|
+
send_discord_message(channel_id,
|
|
711
|
+
"#{mention}: Zillacore was updated and needs to be pulled down (#{version_info[:commits_behind]} commit#{"s" if version_info[:commits_behind] != 1} behind, running #{version_info[:local_sha]} vs #{version_info[:remote_sha]})",
|
|
712
|
+
token: token)
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
break
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
else
|
|
721
|
+
get "/api/discord" do
|
|
722
|
+
content_type :json
|
|
723
|
+
{ enabled: false, reason: "websocket-client-simple gem not installed" }.to_json
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# --- Cron API + startup ---
|
|
728
|
+
|
|
729
|
+
get "/api/cron/script" do
|
|
730
|
+
content_type "text/plain"
|
|
731
|
+
path = params["path"]
|
|
732
|
+
halt 400, "Missing ?path= parameter" unless path && !path.empty?
|
|
733
|
+
halt 400, "Invalid path" if path.include?("..")
|
|
734
|
+
|
|
735
|
+
# Only allow reading scripts that are actually referenced by a cron job
|
|
736
|
+
reload_cron_jobs!
|
|
737
|
+
valid = CRON_JOBS.values.any? do |j|
|
|
738
|
+
j[:script] == path || j["script"] == path ||
|
|
739
|
+
(j[:prompt] || j["prompt"] || "").include?(path)
|
|
740
|
+
end
|
|
741
|
+
halt 403, "Not a registered cron script" unless valid
|
|
742
|
+
halt 404, "File not found" unless File.exist?(path)
|
|
743
|
+
|
|
744
|
+
File.read(path)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
get "/api/cron" do
|
|
748
|
+
content_type :json
|
|
749
|
+
reload_cron_jobs!
|
|
750
|
+
{
|
|
751
|
+
enabled: true,
|
|
752
|
+
jobs: CRON_JOBS,
|
|
753
|
+
thread_alive: CRON_THREAD[:ref]&.alive? || false
|
|
754
|
+
}.to_json
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
post "/api/cron/add" do
|
|
758
|
+
content_type :json
|
|
759
|
+
request.body.rewind
|
|
760
|
+
payload = JSON.parse(request.body.read)
|
|
761
|
+
|
|
762
|
+
result = add_cron_job(
|
|
763
|
+
id: payload["id"],
|
|
764
|
+
schedule: payload["schedule"],
|
|
765
|
+
agent: payload["agent"],
|
|
766
|
+
project: payload["project"],
|
|
767
|
+
prompt: payload["prompt"],
|
|
768
|
+
script: payload["script"],
|
|
769
|
+
model: payload["model"],
|
|
770
|
+
effort: payload["effort"],
|
|
771
|
+
discord_channel_id: payload["discord_channel_id"],
|
|
772
|
+
forum_title: payload["forum_title"],
|
|
773
|
+
forum_reply_to_latest: payload["forum_reply_to_latest"] || false,
|
|
774
|
+
repeat_count: payload["repeat_count"]
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
result.to_json
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
post "/api/cron/remove" do
|
|
781
|
+
content_type :json
|
|
782
|
+
request.body.rewind
|
|
783
|
+
payload = JSON.parse(request.body.read)
|
|
784
|
+
|
|
785
|
+
result = remove_cron_job(payload["id"])
|
|
786
|
+
result.to_json
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
post "/api/cron/toggle" do
|
|
790
|
+
content_type :json
|
|
791
|
+
request.body.rewind
|
|
792
|
+
payload = JSON.parse(request.body.read)
|
|
793
|
+
|
|
794
|
+
result = toggle_cron_job(payload["id"], payload["enabled"])
|
|
795
|
+
result.to_json
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
post "/api/cron/update" do
|
|
799
|
+
content_type :json
|
|
800
|
+
request.body.rewind
|
|
801
|
+
payload = JSON.parse(request.body.read)
|
|
802
|
+
|
|
803
|
+
result = update_cron_job(
|
|
804
|
+
payload["id"],
|
|
805
|
+
schedule: payload["schedule"],
|
|
806
|
+
discord_channel_id: payload["discord_channel_id"],
|
|
807
|
+
forum_title: payload["forum_title"],
|
|
808
|
+
forum_reply_to_latest: payload["forum_reply_to_latest"]
|
|
809
|
+
)
|
|
810
|
+
result.to_json
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
post "/api/cron/reload" do
|
|
814
|
+
content_type :json
|
|
815
|
+
reload_cron_jobs!
|
|
816
|
+
{ status: "reloaded", jobs: CRON_JOBS.size }.to_json
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
get "/api/cron/logs" do
|
|
820
|
+
content_type :json
|
|
821
|
+
job_id = params["id"]
|
|
822
|
+
halt 400, { error: "Missing ?id= parameter" }.to_json unless job_id && !job_id.empty?
|
|
823
|
+
|
|
824
|
+
logs = []
|
|
825
|
+
PROJECTS.each_value do |proj|
|
|
826
|
+
tmp_dir = File.join(proj["repo_path"], "tmp")
|
|
827
|
+
next unless Dir.exist?(tmp_dir)
|
|
828
|
+
|
|
829
|
+
Dir.glob(File.join(tmp_dir, "{agent-cron,cron-script}-#{job_id}-*.log")).each do |f|
|
|
830
|
+
logs << { file: f, size: File.size(f), modified: File.mtime(f).iso8601 }
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
logs.sort_by! { |l| l[:modified] }.reverse!
|
|
834
|
+
logs.first(20).to_json
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
get "/api/gif" do
|
|
838
|
+
content_type :json
|
|
839
|
+
query = params[:q].to_s.strip
|
|
840
|
+
halt 400, { error: "Missing ?q= parameter" }.to_json if query.empty?
|
|
841
|
+
|
|
842
|
+
api_key = DISCORD_CONFIG["giphy_api_key"]
|
|
843
|
+
halt 503, { error: "No giphy_api_key configured in discord.json" }.to_json unless api_key
|
|
844
|
+
|
|
845
|
+
begin
|
|
846
|
+
uri = URI("https://api.giphy.com/v1/gifs/search")
|
|
847
|
+
uri.query = URI.encode_www_form(api_key: api_key, q: query, limit: 5, rating: "pg-13")
|
|
848
|
+
response = Net::HTTP.get_response(uri)
|
|
849
|
+
|
|
850
|
+
if response.code.to_i == 200
|
|
851
|
+
results = JSON.parse(response.body)["data"] || []
|
|
852
|
+
gifs = results.map { |g| { url: g.dig("images", "original", "url") || g["url"], title: g["title"] } }
|
|
853
|
+
{ query: query, results: gifs }.to_json
|
|
854
|
+
else
|
|
855
|
+
LOG.warn "[GIF] Giphy API returned #{response.code}: #{response.body[0..200]}"
|
|
856
|
+
halt 502, { error: "Giphy API error: #{response.code}" }.to_json
|
|
857
|
+
end
|
|
858
|
+
rescue StandardError => e
|
|
859
|
+
LOG.error "[GIF] Search failed: #{e.message}"
|
|
860
|
+
halt 500, { error: e.message }.to_json
|
|
861
|
+
end
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# --- Deployment environment tracking ---
|
|
865
|
+
|
|
866
|
+
get "/api/deployments" do
|
|
867
|
+
content_type :json
|
|
868
|
+
reload_deployments_config!
|
|
869
|
+
reload_deployment_state!
|
|
870
|
+
{ deployments: deployment_status }.to_json
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
post "/api/deployments/:env" do
|
|
874
|
+
content_type :json
|
|
875
|
+
env_key = params["env"]
|
|
876
|
+
request.body.rewind
|
|
877
|
+
payload = JSON.parse(request.body.read)
|
|
878
|
+
|
|
879
|
+
result = deploy_to_environment(env_key, worktree_path: payload["worktree"], deployed_by: payload["deployed_by"])
|
|
880
|
+
if result[:error]
|
|
881
|
+
halt 404, result.to_json
|
|
882
|
+
else
|
|
883
|
+
{ status: "deployed", env: env_key, deployment: result }.to_json
|
|
884
|
+
end
|
|
885
|
+
rescue JSON::ParserError
|
|
886
|
+
halt 400, { error: "Invalid JSON" }.to_json
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
delete "/api/deployments/:env" do
|
|
890
|
+
content_type :json
|
|
891
|
+
env_key = params["env"]
|
|
892
|
+
state = load_deployment_state
|
|
893
|
+
if state.key?(env_key)
|
|
894
|
+
state[env_key] = { "status" => "available", "cleared_at" => Time.now.iso8601, "last_card" => state[env_key]["card_number"] }
|
|
895
|
+
save_deployment_state(state)
|
|
896
|
+
DEPLOYMENT_STATE.replace(state)
|
|
897
|
+
LOG.info "[Deploy] Manually cleared #{env_key}"
|
|
898
|
+
{ status: "cleared", env: env_key }.to_json
|
|
899
|
+
else
|
|
900
|
+
halt 404, { error: "Unknown environment: #{env_key}" }.to_json
|
|
901
|
+
end
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
post "/api/deployments/:env/deploying" do
|
|
905
|
+
content_type :json
|
|
906
|
+
env_key = params["env"]
|
|
907
|
+
config = DEPLOYMENTS_CONFIG["environments"] || {}
|
|
908
|
+
halt 404, { error: "Unknown environment: #{env_key}" }.to_json unless config.key?(env_key)
|
|
909
|
+
request.body.rewind
|
|
910
|
+
payload = begin
|
|
911
|
+
JSON.parse(request.body.read)
|
|
912
|
+
rescue StandardError
|
|
913
|
+
{}
|
|
914
|
+
end
|
|
915
|
+
mark_deploying(env_key, worktree_path: payload["worktree"] || "")
|
|
916
|
+
LOG.info "[Deploy] #{env_key} marked deploying via API"
|
|
917
|
+
{ status: "deploying", env: env_key }.to_json
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
LOG.info "[Cron] Starting cron thread..."
|
|
921
|
+
start_cron_thread
|
|
922
|
+
|
|
923
|
+
# Skill curator: runs daily, archives stale skills, logs consolidation candidates.
|
|
924
|
+
CURATOR_THREAD = Thread.new do
|
|
925
|
+
loop do
|
|
926
|
+
sleep(86_400) # Run once per day
|
|
927
|
+
LOG.info "[Curator] Running scheduled skill curation..."
|
|
928
|
+
curate_skills
|
|
929
|
+
rescue StandardError => e
|
|
930
|
+
LOG.warn "[Curator] Error: #{e.message}"
|
|
931
|
+
end
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
LOG.info "[CardIndex] Starting background backfill..."
|
|
935
|
+
CARD_INDEX.backfill
|
|
936
|
+
|
|
937
|
+
LOG.info "[Monitor] Starting daemon..."
|
|
938
|
+
daemon_path = File.join(__dir__, "monitor", "daemon.rb")
|
|
939
|
+
daemon_pid_file = "/tmp/zillacore-daemon.pid"
|
|
940
|
+
|
|
941
|
+
# Kill old daemon if it exists
|
|
942
|
+
if File.exist?(daemon_pid_file)
|
|
943
|
+
old_pid = File.read(daemon_pid_file).strip.to_i
|
|
944
|
+
begin
|
|
945
|
+
Process.kill("TERM", old_pid)
|
|
946
|
+
LOG.info "[Monitor] Killed old daemon (PID #{old_pid})"
|
|
947
|
+
rescue Errno::ESRCH
|
|
948
|
+
LOG.debug "[Monitor] Old daemon PID #{old_pid} not running"
|
|
949
|
+
end
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# Start new daemon
|
|
953
|
+
pid = spawn("ruby", daemon_path, chdir: __dir__, out: "/dev/null", err: "/dev/null")
|
|
954
|
+
File.write(daemon_pid_file, pid)
|
|
955
|
+
Process.detach(pid)
|
|
956
|
+
LOG.info "[Monitor] Daemon started (PID #{pid})"
|