openclacky 0.9.28 → 0.9.30
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/CHANGELOG.md +39 -0
- data/docs/deploy-architecture.md +619 -0
- data/lib/clacky/agent/llm_caller.rb +14 -2
- data/lib/clacky/agent/message_compressor.rb +24 -6
- data/lib/clacky/agent/message_compressor_helper.rb +17 -10
- data/lib/clacky/agent/session_serializer.rb +69 -0
- data/lib/clacky/agent/skill_manager.rb +2 -2
- data/lib/clacky/agent.rb +3 -0
- data/lib/clacky/brand_config.rb +29 -3
- data/lib/clacky/clacky_auth_client.rb +152 -0
- data/lib/clacky/clacky_cloud_config.rb +123 -0
- data/lib/clacky/cli.rb +13 -0
- data/lib/clacky/client.rb +21 -7
- data/lib/clacky/cloud_project_client.rb +169 -0
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_parsers/doc_parser.rb +9 -9
- data/lib/clacky/default_skills/browser-setup/SKILL.md +9 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +21 -4
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +8 -2
- data/lib/clacky/default_skills/deploy/SKILL.md +96 -5
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1268 -274
- data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +341 -0
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +72 -147
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +60 -50
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +47 -60
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +147 -96
- data/lib/clacky/default_skills/new/SKILL.md +117 -5
- data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +74 -0
- data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +32 -0
- data/lib/clacky/deploy_api_client.rb +484 -0
- data/lib/clacky/json_ui_controller.rb +16 -10
- data/lib/clacky/message_format/bedrock.rb +3 -2
- data/lib/clacky/message_history.rb +8 -0
- data/lib/clacky/plain_ui_controller.rb +1 -6
- data/lib/clacky/providers.rb +23 -4
- data/lib/clacky/server/browser_manager.rb +3 -1
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +2 -1
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +3 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +5 -5
- data/lib/clacky/server/http_server.rb +12 -2
- data/lib/clacky/server/server_master.rb +43 -7
- data/lib/clacky/server/web_ui_controller.rb +17 -9
- data/lib/clacky/skill.rb +6 -2
- data/lib/clacky/tools/run_project.rb +4 -1
- data/lib/clacky/tools/shell.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +1 -5
- data/lib/clacky/ui_interface.rb +5 -7
- data/lib/clacky/utils/arguments_parser.rb +22 -5
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +45 -5
- data/lib/clacky/web/app.js +126 -19
- data/lib/clacky/web/i18n.js +57 -0
- data/lib/clacky/web/sessions.js +108 -39
- data/lib/clacky/web/skills.js +8 -2
- data/lib/clacky.rb +3 -0
- metadata +8 -1
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
# DeployApiClient - Encapsulates all Deploy API calls for the Railway deployment flow.
|
|
8
|
+
#
|
|
9
|
+
# All endpoints use Workspace API Key (clacky_ak_*) authentication, as the backend
|
|
10
|
+
# supports both clacky_ak_* and clacky_dk_* for all deploy endpoints.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# client = DeployApiClient.new("clacky_ak_xxx", base_url: "https://api.clacky.ai")
|
|
14
|
+
#
|
|
15
|
+
# # Check payment status
|
|
16
|
+
# result = client.payment_status(project_id: "proj_abc")
|
|
17
|
+
# # => { success: true, is_paid: true }
|
|
18
|
+
#
|
|
19
|
+
# # Create a deploy task
|
|
20
|
+
# result = client.create_task(project_id: "proj_abc")
|
|
21
|
+
# # => { success: true, deploy_task_id: "...", platform_token: "...", ... }
|
|
22
|
+
#
|
|
23
|
+
# # Poll services until DB is ready
|
|
24
|
+
# result = client.services(deploy_task_id: "task_abc")
|
|
25
|
+
# # => { success: true, services: [...], domain_name: "..." }
|
|
26
|
+
#
|
|
27
|
+
# # Poll deploy status
|
|
28
|
+
# result = client.deploy_status(deploy_task_id: "task_abc")
|
|
29
|
+
# # => { success: true, status: "SUCCESS", url: "https://..." }
|
|
30
|
+
#
|
|
31
|
+
# # Bind domain
|
|
32
|
+
# result = client.bind_domain(deploy_task_id: "task_abc")
|
|
33
|
+
# # => { success: true, domain: "my-app.example.com" }
|
|
34
|
+
#
|
|
35
|
+
# # Notify backend of deploy outcome
|
|
36
|
+
# client.notify(project_id: "...", deploy_task_id: "...", status: "success")
|
|
37
|
+
class DeployApiClient
|
|
38
|
+
BASE_PATH = "/openclacky/v1"
|
|
39
|
+
REQUEST_TIMEOUT = 30 # seconds for normal requests
|
|
40
|
+
OPEN_TIMEOUT = 10 # seconds for connection
|
|
41
|
+
|
|
42
|
+
def initialize(workspace_key, base_url:)
|
|
43
|
+
@workspace_key = workspace_key.to_s.strip
|
|
44
|
+
@base_url = base_url.to_s.strip.sub(%r{/+$}, "")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# -------------------------------------------------------------------------
|
|
48
|
+
# Payment
|
|
49
|
+
# -------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
# Query whether the project has an active paid subscription.
|
|
52
|
+
#
|
|
53
|
+
# @param project_id [String]
|
|
54
|
+
# @return [Hash] { success: true, is_paid: Boolean } or { success: false, error: String }
|
|
55
|
+
def payment_status(project_id:)
|
|
56
|
+
response = connection.get("#{BASE_PATH}/deploy/payment") do |req|
|
|
57
|
+
req.params["project_id"] = project_id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return http_error(response) unless response.status == 200
|
|
61
|
+
|
|
62
|
+
body = parse_body(response)
|
|
63
|
+
return body_error(body) unless success_code?(body)
|
|
64
|
+
|
|
65
|
+
data = body["data"] || {}
|
|
66
|
+
{ success: true, is_paid: data["is_paid"] == true }
|
|
67
|
+
rescue Faraday::Error => e
|
|
68
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
69
|
+
rescue => e
|
|
70
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# -------------------------------------------------------------------------
|
|
74
|
+
# Regions
|
|
75
|
+
# -------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
# Fetch the list of supported deployment regions.
|
|
78
|
+
#
|
|
79
|
+
# @param project_id [String] Required by the backend to scope region availability.
|
|
80
|
+
# @return [Hash] {
|
|
81
|
+
# success: true,
|
|
82
|
+
# regions: Array<Hash> # e.g. [{ "id" => "us-west", "name" => "US West", "label" => "US West (Oregon)" }, ...]
|
|
83
|
+
# } or { success: false, error: String }
|
|
84
|
+
def regions(project_id:)
|
|
85
|
+
response = connection.get("#{BASE_PATH}/deploy/regions") do |req|
|
|
86
|
+
req.params["project_id"] = project_id
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
return http_error(response) unless response.status == 200
|
|
90
|
+
|
|
91
|
+
body = parse_body(response)
|
|
92
|
+
return body_error(body) unless success_code?(body)
|
|
93
|
+
|
|
94
|
+
data = body["data"] || {}
|
|
95
|
+
list = data["regions"] || data || []
|
|
96
|
+
list = list.values if list.is_a?(Hash)
|
|
97
|
+
{ success: true, regions: Array(list) }
|
|
98
|
+
rescue Faraday::Error => e
|
|
99
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
100
|
+
rescue => e
|
|
101
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# -------------------------------------------------------------------------
|
|
105
|
+
# Create Deploy Task
|
|
106
|
+
# -------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
# Create a new deployment task on the backend. Returns Railway credentials.
|
|
109
|
+
#
|
|
110
|
+
# @param project_id [String]
|
|
111
|
+
# @param backup_db [Boolean] default false
|
|
112
|
+
# @param env_vars [Hash] extra env vars to pass at task creation time
|
|
113
|
+
# @param region [String] optional Railway region slug
|
|
114
|
+
# @return [Hash] {
|
|
115
|
+
# success: true,
|
|
116
|
+
# deploy_task_id: String,
|
|
117
|
+
# deploy_service_id: String,
|
|
118
|
+
# platform_token: String, # RAILWAY_TOKEN
|
|
119
|
+
# platform_project_id: String,
|
|
120
|
+
# platform_environment_id: String
|
|
121
|
+
# }
|
|
122
|
+
def create_task(project_id:, backup_db: false, env_vars: {}, region: nil)
|
|
123
|
+
body_params = { project_id: project_id, backup_db: backup_db }
|
|
124
|
+
body_params[:env_vars] = env_vars unless env_vars.empty?
|
|
125
|
+
body_params[:region] = region if region
|
|
126
|
+
|
|
127
|
+
response = connection.post("#{BASE_PATH}/deploy/create-task") do |req|
|
|
128
|
+
req.headers["Content-Type"] = "application/json"
|
|
129
|
+
req.body = JSON.generate(body_params)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return http_error(response) unless response.status == 200
|
|
133
|
+
|
|
134
|
+
body = parse_body(response)
|
|
135
|
+
return body_error(body) unless success_code?(body)
|
|
136
|
+
|
|
137
|
+
data = body["data"] || {}
|
|
138
|
+
{
|
|
139
|
+
success: true,
|
|
140
|
+
deploy_task_id: data["deploy_task_id"],
|
|
141
|
+
deploy_service_id: data["deploy_service_id"],
|
|
142
|
+
platform_token: data["platform_token"],
|
|
143
|
+
platform_project_id: data["platform_project_id"],
|
|
144
|
+
platform_environment_id: data["platform_environment_id"]
|
|
145
|
+
}
|
|
146
|
+
rescue Faraday::Error => e
|
|
147
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
148
|
+
rescue => e
|
|
149
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# -------------------------------------------------------------------------
|
|
153
|
+
# Services (poll for middleware readiness)
|
|
154
|
+
# -------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# Query all services under a deploy task.
|
|
157
|
+
# Used to wait for the PostgreSQL middleware to reach status SUCCESS
|
|
158
|
+
# before injecting the DATABASE_URL reference.
|
|
159
|
+
#
|
|
160
|
+
# @param deploy_task_id [String]
|
|
161
|
+
# @return [Hash] {
|
|
162
|
+
# success: true,
|
|
163
|
+
# services: Array<Hash>, # full service objects from API
|
|
164
|
+
# domain_name: String, # assigned domain (may be empty on first call)
|
|
165
|
+
# db_service: Hash | nil # first middleware service with status SUCCESS
|
|
166
|
+
# }
|
|
167
|
+
def services(deploy_task_id:)
|
|
168
|
+
url = "#{BASE_PATH}/deploy/services?deploy_task_id=#{deploy_task_id}"
|
|
169
|
+
puts " [DEBUG API] GET #{@base_url}#{url}"
|
|
170
|
+
|
|
171
|
+
response = connection.get("#{BASE_PATH}/deploy/services") do |req|
|
|
172
|
+
req.params["deploy_task_id"] = deploy_task_id
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
puts " [DEBUG API] Response status: #{response.status}"
|
|
176
|
+
|
|
177
|
+
return http_error(response) unless response.status == 200
|
|
178
|
+
|
|
179
|
+
body = parse_body(response)
|
|
180
|
+
puts " [DEBUG API] Response body: #{body.inspect[0..500]}..." if body
|
|
181
|
+
|
|
182
|
+
return body_error(body) unless success_code?(body)
|
|
183
|
+
|
|
184
|
+
data = body["data"] || {}
|
|
185
|
+
svcs = data["services"] || []
|
|
186
|
+
domain = data["domain_name"].to_s
|
|
187
|
+
|
|
188
|
+
# Debug: print detailed service info
|
|
189
|
+
puts " [DEBUG] Total services returned: #{svcs.size}"
|
|
190
|
+
svcs.each_with_index do |s, idx|
|
|
191
|
+
puts " [DEBUG] Service[#{idx}]: name=#{s['service_name']}, type=#{s['type']}, status=#{s['status']}"
|
|
192
|
+
if s["type"] == "middleware"
|
|
193
|
+
env_vars = s["env_vars"] || {}
|
|
194
|
+
puts " [DEBUG] - env_vars keys: #{env_vars.keys.join(', ')}"
|
|
195
|
+
puts " [DEBUG] - has DATABASE_URL: #{env_vars.key?('DATABASE_URL')}"
|
|
196
|
+
puts " [DEBUG] - has DATABASE_PUBLIC_URL: #{env_vars.key?('DATABASE_PUBLIC_URL')}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Find first middleware (DB) that is fully provisioned
|
|
201
|
+
db_svc = svcs.find do |s|
|
|
202
|
+
s["type"] == "middleware" && s["status"]&.upcase == "SUCCESS"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
puts " [DEBUG] db_svc found: #{!db_svc.nil?}"
|
|
206
|
+
if db_svc
|
|
207
|
+
puts " [DEBUG] - db_svc name: #{db_svc['service_name']}"
|
|
208
|
+
puts " [DEBUG] - db_svc status: #{db_svc['status']}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# middleware_support: { supported: Boolean, supported_types: Array }
|
|
212
|
+
# When supported == false, no DB middleware will be provisioned by Clacky.
|
|
213
|
+
# The deploy script uses this to skip the DB polling loop entirely.
|
|
214
|
+
middleware_support = data["middleware_support"] || {}
|
|
215
|
+
puts " [DEBUG] middleware_support: #{middleware_support.inspect}"
|
|
216
|
+
|
|
217
|
+
# platform_bucket_credentials contains S3-compatible storage credentials.
|
|
218
|
+
# Passed through so the deploy script can inject STORAGE_BUCKET_* env vars.
|
|
219
|
+
bucket_credentials = data["platform_bucket_credentials"]
|
|
220
|
+
bucket_name = data["platform_bucket_name"].to_s
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
success: true,
|
|
224
|
+
services: svcs,
|
|
225
|
+
domain_name: domain,
|
|
226
|
+
db_service: db_svc,
|
|
227
|
+
middleware_support: middleware_support,
|
|
228
|
+
bucket_credentials: bucket_credentials,
|
|
229
|
+
bucket_name: bucket_name
|
|
230
|
+
}
|
|
231
|
+
rescue Faraday::Error => e
|
|
232
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
233
|
+
rescue => e
|
|
234
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# -------------------------------------------------------------------------
|
|
238
|
+
# Deploy Status
|
|
239
|
+
# -------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
# Query the real-time deployment status for a task.
|
|
242
|
+
#
|
|
243
|
+
# @param deploy_task_id [String]
|
|
244
|
+
# @return [Hash] {
|
|
245
|
+
# success: true,
|
|
246
|
+
# status: String, # SUCCESS / FAILED / CRASHED / DEPLOYING / WAITING
|
|
247
|
+
# url: String
|
|
248
|
+
# }
|
|
249
|
+
def deploy_status(deploy_task_id:)
|
|
250
|
+
response = connection.get("#{BASE_PATH}/deploy/status") do |req|
|
|
251
|
+
req.params["deploy_task_id"] = deploy_task_id
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
return http_error(response) unless response.status == 200
|
|
255
|
+
|
|
256
|
+
body = parse_body(response)
|
|
257
|
+
return body_error(body) unless success_code?(body)
|
|
258
|
+
|
|
259
|
+
data = body["data"] || {}
|
|
260
|
+
{
|
|
261
|
+
success: true,
|
|
262
|
+
status: data["status"].to_s.upcase,
|
|
263
|
+
url: data["url"].to_s,
|
|
264
|
+
deploy_service_id: data["deploy_service_id"].to_s
|
|
265
|
+
}
|
|
266
|
+
rescue Faraday::Error => e
|
|
267
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
268
|
+
rescue => e
|
|
269
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# -------------------------------------------------------------------------
|
|
273
|
+
# Bind Domain
|
|
274
|
+
# -------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
# Bind a custom domain to the deploy task.
|
|
277
|
+
#
|
|
278
|
+
# @param deploy_task_id [String]
|
|
279
|
+
# @return [Hash] { success: true, domain: String } or { success: false, error: String }
|
|
280
|
+
def bind_domain(deploy_task_id:)
|
|
281
|
+
response = connection.post("#{BASE_PATH}/deploy/bind-domain") do |req|
|
|
282
|
+
req.headers["Content-Type"] = "application/json"
|
|
283
|
+
req.body = JSON.generate({ deploy_task_id: deploy_task_id })
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
return http_error(response) unless response.status == 200
|
|
287
|
+
|
|
288
|
+
body = parse_body(response)
|
|
289
|
+
return body_error(body) unless success_code?(body)
|
|
290
|
+
|
|
291
|
+
data = body["data"] || {}
|
|
292
|
+
{ success: true, domain: data["domain"].to_s }
|
|
293
|
+
rescue Faraday::Error => e
|
|
294
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
295
|
+
rescue => e
|
|
296
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# -------------------------------------------------------------------------
|
|
300
|
+
# Build Logs
|
|
301
|
+
# -------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
# Fetch build logs for a deploy task (synchronous, not SSE).
|
|
304
|
+
#
|
|
305
|
+
# @param deploy_task_id [String]
|
|
306
|
+
# @param service_id [String, nil] optional service ID filter
|
|
307
|
+
# @param level [String] log level filter ("INFO", "ERROR", "WARN", etc.)
|
|
308
|
+
# @param lines [Integer] maximum number of lines to return (default: 100)
|
|
309
|
+
# @return [Hash] {
|
|
310
|
+
# success: true,
|
|
311
|
+
# logs: Array<Hash> # [{ "timestamp" => ..., "level" => "INFO", "message" => "..." }, ...]
|
|
312
|
+
# } or { success: false, error: String }
|
|
313
|
+
def build_logs(deploy_task_id:, service_id: nil, level: "INFO", lines: 100)
|
|
314
|
+
body_params = {
|
|
315
|
+
deploy_task_id: deploy_task_id,
|
|
316
|
+
level: level,
|
|
317
|
+
lines: lines
|
|
318
|
+
}
|
|
319
|
+
body_params[:service_id] = service_id if service_id
|
|
320
|
+
|
|
321
|
+
response = connection.post("#{BASE_PATH}/tasks/logs") do |req|
|
|
322
|
+
req.headers["Content-Type"] = "application/json"
|
|
323
|
+
req.body = JSON.generate(body_params)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
return http_error(response) unless response.status == 200
|
|
327
|
+
|
|
328
|
+
body = parse_body(response)
|
|
329
|
+
return body_error(body) unless success_code?(body)
|
|
330
|
+
|
|
331
|
+
data = body["data"] || {}
|
|
332
|
+
logs = data["logs"] || []
|
|
333
|
+
{ success: true, logs: logs }
|
|
334
|
+
rescue Faraday::Error => e
|
|
335
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
336
|
+
rescue => e
|
|
337
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Stream build logs using SSE (Server-Sent Events).
|
|
341
|
+
# This method yields each log line as it arrives.
|
|
342
|
+
#
|
|
343
|
+
# @param deploy_task_id [String]
|
|
344
|
+
# @param service_id [String, nil] optional service ID filter
|
|
345
|
+
# @param level [String] log level filter ("INFO", "ERROR", "WARN", etc.)
|
|
346
|
+
# @yield [Hash] each log event: { "type" => "log", "timestamp" => ..., "message" => "..." }
|
|
347
|
+
# @return [Hash] { success: true } or { success: false, error: String }
|
|
348
|
+
def stream_build_logs(deploy_task_id:, service_id: nil, level: "INFO", &block)
|
|
349
|
+
require "net/http"
|
|
350
|
+
require "openssl"
|
|
351
|
+
|
|
352
|
+
body_params = {
|
|
353
|
+
deploy_task_id: deploy_task_id,
|
|
354
|
+
level: level
|
|
355
|
+
}
|
|
356
|
+
body_params[:service_id] = service_id if service_id
|
|
357
|
+
|
|
358
|
+
url = "#{@base_url}#{BASE_PATH}/tasks/stream/build-logs"
|
|
359
|
+
uri = URI.parse(url)
|
|
360
|
+
|
|
361
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
|
|
362
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
363
|
+
request["Authorization"] = "Bearer #{@workspace_key}"
|
|
364
|
+
request["Accept"] = "text/event-stream"
|
|
365
|
+
request["Content-Type"] = "application/json"
|
|
366
|
+
request.body = JSON.generate(body_params)
|
|
367
|
+
|
|
368
|
+
http.request(request) do |response|
|
|
369
|
+
return { success: false, error: "HTTP #{response.code}: #{response.message}" } unless response.code.to_i == 200
|
|
370
|
+
|
|
371
|
+
buffer = ""
|
|
372
|
+
response.read_body do |chunk|
|
|
373
|
+
buffer << chunk
|
|
374
|
+
while (line_end = buffer.index("\n"))
|
|
375
|
+
line = buffer.slice!(0..line_end).strip
|
|
376
|
+
next if line.empty? || !line.start_with?("data:")
|
|
377
|
+
|
|
378
|
+
json_str = line.sub(/^data:\s*/, "")
|
|
379
|
+
begin
|
|
380
|
+
event = JSON.parse(json_str)
|
|
381
|
+
block.call(event) if block
|
|
382
|
+
rescue JSON::ParserError
|
|
383
|
+
# Ignore malformed JSON
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
{ success: true }
|
|
391
|
+
rescue => e
|
|
392
|
+
{ success: false, error: "Stream error: #{e.message}" }
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# -------------------------------------------------------------------------
|
|
396
|
+
# Notify
|
|
397
|
+
# -------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
# Notify the backend of the current deployment outcome.
|
|
400
|
+
# Fire-and-forget — failures are logged but do not raise.
|
|
401
|
+
#
|
|
402
|
+
# @param project_id [String]
|
|
403
|
+
# @param deploy_task_id [String]
|
|
404
|
+
# @param deploy_service_id [String] optional
|
|
405
|
+
# @param status [String] "deploying" | "success" | "failed"
|
|
406
|
+
# @param message [String] optional description
|
|
407
|
+
# @param target_port [Integer] default 3000
|
|
408
|
+
# @return [Hash] { success: true } or { success: false, error: String }
|
|
409
|
+
def notify(project_id:, deploy_task_id:, status:,
|
|
410
|
+
deploy_service_id: nil, message: nil, target_port: nil)
|
|
411
|
+
payload = {
|
|
412
|
+
project_id: project_id,
|
|
413
|
+
deploy_task_id: deploy_task_id,
|
|
414
|
+
status: status
|
|
415
|
+
}
|
|
416
|
+
payload[:deploy_service_id] = deploy_service_id if deploy_service_id
|
|
417
|
+
payload[:message] = message if message
|
|
418
|
+
payload[:target_port] = target_port if target_port
|
|
419
|
+
|
|
420
|
+
response = connection.post("#{BASE_PATH}/deploy/notify") do |req|
|
|
421
|
+
req.headers["Content-Type"] = "application/json"
|
|
422
|
+
req.body = JSON.generate(payload)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
return http_error(response) unless response.status == 200
|
|
426
|
+
|
|
427
|
+
body = parse_body(response)
|
|
428
|
+
return body_error(body) unless success_code?(body)
|
|
429
|
+
|
|
430
|
+
{ success: true }
|
|
431
|
+
rescue => e
|
|
432
|
+
# Notify failures are non-fatal — log and move on
|
|
433
|
+
warn "[deploy_api] notify failed: #{e.message}"
|
|
434
|
+
{ success: false, error: e.message }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# -------------------------------------------------------------------------
|
|
438
|
+
# Private helpers
|
|
439
|
+
# -------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
private def connection
|
|
442
|
+
@connection ||= Faraday.new(url: @base_url) do |f|
|
|
443
|
+
f.options.timeout = REQUEST_TIMEOUT
|
|
444
|
+
f.options.open_timeout = OPEN_TIMEOUT
|
|
445
|
+
f.headers["Authorization"] = "Bearer #{@workspace_key}"
|
|
446
|
+
f.headers["Accept"] = "application/json"
|
|
447
|
+
# Disable SSL verification to avoid OpenSSL certificate path issues
|
|
448
|
+
# on some macOS environments with system Ruby
|
|
449
|
+
f.ssl.verify = false
|
|
450
|
+
f.adapter Faraday.default_adapter
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
private def parse_body(response)
|
|
455
|
+
JSON.parse(response.body)
|
|
456
|
+
rescue JSON::ParserError
|
|
457
|
+
nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
private def success_code?(body)
|
|
461
|
+
return false if body.nil?
|
|
462
|
+
|
|
463
|
+
code = body["code"].to_i
|
|
464
|
+
code == 0 || code == 200
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
private def http_error(response)
|
|
468
|
+
msg = begin
|
|
469
|
+
parsed = JSON.parse(response.body)
|
|
470
|
+
parsed["message"] || parsed["msg"] || response.body.to_s[0, 200]
|
|
471
|
+
rescue
|
|
472
|
+
response.body.to_s[0, 200]
|
|
473
|
+
end
|
|
474
|
+
{ success: false, error: "HTTP #{response.status}: #{msg}" }
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
private def body_error(body)
|
|
478
|
+
return { success: false, error: "Invalid JSON response from API" } if body.nil?
|
|
479
|
+
|
|
480
|
+
msg = body["message"] || body["msg"] || "Unknown API error (code: #{body["code"]})"
|
|
481
|
+
{ success: false, error: msg }
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
@@ -97,10 +97,6 @@ module Clacky
|
|
|
97
97
|
emit("info", message: message)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
def show_idle_status(phase:, message:)
|
|
101
|
-
emit("idle_status", phase: phase.to_s, message: message)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
100
|
def show_warning(message)
|
|
105
101
|
emit("warning", message: message)
|
|
106
102
|
end
|
|
@@ -119,15 +115,25 @@ module Clacky
|
|
|
119
115
|
|
|
120
116
|
# === Progress ===
|
|
121
117
|
|
|
122
|
-
def show_progress(message = nil, prefix_newline: true,
|
|
123
|
-
@progress_start_time = Time.now
|
|
124
|
-
|
|
118
|
+
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
|
|
119
|
+
@progress_start_time = Time.now if phase == "active"
|
|
120
|
+
|
|
121
|
+
data = {
|
|
122
|
+
message: message,
|
|
123
|
+
progress_type: progress_type,
|
|
124
|
+
phase: phase,
|
|
125
|
+
status: phase == "active" ? "start" : "stop" # backward compat
|
|
126
|
+
}
|
|
127
|
+
data[:metadata] = metadata unless metadata.empty?
|
|
128
|
+
data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
|
|
129
|
+
|
|
130
|
+
emit("progress", **data)
|
|
131
|
+
|
|
132
|
+
@progress_start_time = nil if phase == "done"
|
|
125
133
|
end
|
|
126
134
|
|
|
127
135
|
def clear_progress
|
|
128
|
-
|
|
129
|
-
@progress_start_time = nil
|
|
130
|
-
emit("progress", status: "stop", elapsed: elapsed)
|
|
136
|
+
show_progress(progress_type: "thinking", phase: "done")
|
|
131
137
|
end
|
|
132
138
|
|
|
133
139
|
# === State updates ===
|
|
@@ -17,11 +17,12 @@ module Clacky
|
|
|
17
17
|
# This module converts canonical format ↔ Bedrock Converse API format.
|
|
18
18
|
module Bedrock
|
|
19
19
|
# Detect if the request should use the Bedrock Converse API.
|
|
20
|
-
# Matches
|
|
20
|
+
# Matches any of:
|
|
21
21
|
# - API key with "ABSK" prefix (native AWS Bedrock)
|
|
22
|
+
# - API key with "clacky-" prefix (Clacky workspace key, proxied via Bedrock Converse)
|
|
22
23
|
# - Model ID with "abs-" prefix (Clacky AI proxy that speaks Bedrock Converse)
|
|
23
24
|
def self.bedrock_api_key?(api_key, model)
|
|
24
|
-
api_key.to_s.start_with?("ABSK") || model.to_s.start_with?("abs-")
|
|
25
|
+
api_key.to_s.start_with?("ABSK", "clacky-") || model.to_s.start_with?("abs-")
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
module_function
|
|
@@ -12,6 +12,7 @@ module Clacky
|
|
|
12
12
|
task_id created_at system_injected session_context memory_update
|
|
13
13
|
subagent_instructions subagent_result token_usage
|
|
14
14
|
compressed_summary chunk_path truncated transient
|
|
15
|
+
chunk_index chunk_count
|
|
15
16
|
].freeze
|
|
16
17
|
|
|
17
18
|
def initialize(messages = [])
|
|
@@ -123,6 +124,13 @@ module Clacky
|
|
|
123
124
|
msg&.dig(:session_date)
|
|
124
125
|
end
|
|
125
126
|
|
|
127
|
+
# Return the chunk_count from the most recently injected chunk index message.
|
|
128
|
+
# Used by inject_chunk_index_if_needed to avoid re-injecting when nothing changed.
|
|
129
|
+
def last_injected_chunk_count
|
|
130
|
+
msg = @messages.reverse.find { |m| m[:chunk_index] }
|
|
131
|
+
msg&.dig(:chunk_count) || 0
|
|
132
|
+
end
|
|
133
|
+
|
|
126
134
|
# Return only real (non-system-injected) user messages.
|
|
127
135
|
def real_user_messages
|
|
128
136
|
@messages.select { |m| m[:role] == "user" && !m[:system_injected] }
|
|
@@ -87,11 +87,6 @@ module Clacky
|
|
|
87
87
|
puts_line("[info] #{message}")
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
def show_idle_status(phase:, message:)
|
|
91
|
-
# In plain mode, just print the final state
|
|
92
|
-
puts_line("[info] #{message}") if phase.to_s == "end"
|
|
93
|
-
end
|
|
94
|
-
|
|
95
90
|
def show_warning(message)
|
|
96
91
|
puts_line("[warn] #{message}")
|
|
97
92
|
end
|
|
@@ -111,7 +106,7 @@ module Clacky
|
|
|
111
106
|
|
|
112
107
|
# === Progress (no-ops — no spinner in plain mode) ===
|
|
113
108
|
|
|
114
|
-
def show_progress(message = nil, prefix_newline: true,
|
|
109
|
+
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
|
|
115
110
|
def clear_progress; end
|
|
116
111
|
|
|
117
112
|
# === State updates (no-ops) ===
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -11,13 +11,31 @@ module Clacky
|
|
|
11
11
|
# - api: API type (anthropic-messages, openai-responses, openai-completions)
|
|
12
12
|
# - default_model: Recommended default model
|
|
13
13
|
PRESETS = {
|
|
14
|
+
"openclacky" => {
|
|
15
|
+
"name" => "OpenClacky",
|
|
16
|
+
"base_url" => "https://api.openclacky.com",
|
|
17
|
+
"api" => "bedrock",
|
|
18
|
+
"default_model" => "abs-claude-sonnet-4-5",
|
|
19
|
+
"lite_model" => "abs-claude-haiku-4-5",
|
|
20
|
+
"models" => [
|
|
21
|
+
"abs-claude-opus-4-6",
|
|
22
|
+
"abs-claude-sonnet-4-6",
|
|
23
|
+
"abs-claude-sonnet-4-5",
|
|
24
|
+
"abs-claude-haiku-4-5"
|
|
25
|
+
],
|
|
26
|
+
# Fallback chain: if a model is unavailable, try the next one in order.
|
|
27
|
+
# Keys are primary model names; values are the fallback model to use instead.
|
|
28
|
+
"fallback_models" => {
|
|
29
|
+
"abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
|
|
30
|
+
},
|
|
31
|
+
"website_url" => "https://www.openclacky.com/ai-keys"
|
|
32
|
+
}.freeze,
|
|
14
33
|
|
|
15
34
|
"openrouter" => {
|
|
16
35
|
"name" => "OpenRouter",
|
|
17
36
|
"base_url" => "https://openrouter.ai/api/v1",
|
|
18
37
|
"api" => "openai-responses",
|
|
19
38
|
"default_model" => "anthropic/claude-sonnet-4-6",
|
|
20
|
-
"lite_model" => "anthropic/claude-haiku-4-5",
|
|
21
39
|
"models" => [], # Dynamic - fetched from API
|
|
22
40
|
"website_url" => "https://openrouter.ai/keys"
|
|
23
41
|
}.freeze,
|
|
@@ -49,15 +67,16 @@ module Clacky
|
|
|
49
67
|
"website_url" => "https://console.anthropic.com/settings/keys"
|
|
50
68
|
}.freeze,
|
|
51
69
|
|
|
52
|
-
"clackyai" => {
|
|
53
|
-
"name" => "ClackyAI",
|
|
70
|
+
"clackyai-sea" => {
|
|
71
|
+
"name" => "ClackyAI( Sea )",
|
|
54
72
|
"base_url" => "https://api.clacky.ai",
|
|
55
73
|
"api" => "bedrock",
|
|
56
|
-
"default_model" => "abs-claude-sonnet-4-
|
|
74
|
+
"default_model" => "abs-claude-sonnet-4-5",
|
|
57
75
|
"lite_model" => "abs-claude-haiku-4-5",
|
|
58
76
|
"models" => [
|
|
59
77
|
"abs-claude-opus-4-6",
|
|
60
78
|
"abs-claude-sonnet-4-6",
|
|
79
|
+
"abs-claude-sonnet-4-5",
|
|
61
80
|
"abs-claude-haiku-4-5"
|
|
62
81
|
],
|
|
63
82
|
# Fallback chain: if a model is unavailable, try the next one in order.
|
|
@@ -237,7 +237,9 @@ module Clacky
|
|
|
237
237
|
cmd = build_mcp_command(detected)
|
|
238
238
|
Clacky::Logger.info("[BrowserManager] Starting MCP daemon: #{cmd.join(' ')}")
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
# close_others: true prevents inheriting the server's listening socket (port 7070).
|
|
241
|
+
# The MCP daemon is an independent external process and should not hold server fds.
|
|
242
|
+
stdin, stdout, stderr_io, wait_thr = Open3.popen3(*cmd, close_others: true)
|
|
241
243
|
Thread.new { stderr_io.read rescue nil }
|
|
242
244
|
|
|
243
245
|
# MCP handshake
|
|
@@ -130,7 +130,8 @@ module Clacky
|
|
|
130
130
|
end
|
|
131
131
|
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
|
|
132
132
|
Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
|
|
133
|
-
|
|
133
|
+
# Let the exception bubble up to start() where it will log and sleep before retry
|
|
134
|
+
raise
|
|
134
135
|
ensure
|
|
135
136
|
@ws_open = false
|
|
136
137
|
@ws_socket = nil
|
|
@@ -361,7 +361,9 @@ module Clacky
|
|
|
361
361
|
|
|
362
362
|
send_frame(cmd: cmd, req_id: req_id, body: body)
|
|
363
363
|
|
|
364
|
-
|
|
364
|
+
timeout_thread = Thread.new { sleep 30; queue.push(nil) }
|
|
365
|
+
result = queue.pop
|
|
366
|
+
timeout_thread.kill
|
|
365
367
|
raise "Timeout waiting for ack (req_id=#{req_id}, cmd=#{cmd})" if result.nil?
|
|
366
368
|
|
|
367
369
|
errcode = result["errcode"] || result.dig("body", "errcode")
|