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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/docs/deploy-architecture.md +619 -0
  4. data/lib/clacky/agent/llm_caller.rb +14 -2
  5. data/lib/clacky/agent/message_compressor.rb +24 -6
  6. data/lib/clacky/agent/message_compressor_helper.rb +17 -10
  7. data/lib/clacky/agent/session_serializer.rb +69 -0
  8. data/lib/clacky/agent/skill_manager.rb +2 -2
  9. data/lib/clacky/agent.rb +3 -0
  10. data/lib/clacky/brand_config.rb +29 -3
  11. data/lib/clacky/clacky_auth_client.rb +152 -0
  12. data/lib/clacky/clacky_cloud_config.rb +123 -0
  13. data/lib/clacky/cli.rb +13 -0
  14. data/lib/clacky/client.rb +21 -7
  15. data/lib/clacky/cloud_project_client.rb +169 -0
  16. data/lib/clacky/default_agents/base_prompt.md +1 -0
  17. data/lib/clacky/default_parsers/doc_parser.rb +9 -9
  18. data/lib/clacky/default_skills/browser-setup/SKILL.md +9 -0
  19. data/lib/clacky/default_skills/channel-setup/SKILL.md +21 -4
  20. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +8 -2
  21. data/lib/clacky/default_skills/deploy/SKILL.md +96 -5
  22. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1268 -274
  23. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +341 -0
  24. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +72 -147
  25. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +60 -50
  26. data/lib/clacky/default_skills/deploy/tools/list_services.rb +47 -60
  27. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +147 -96
  28. data/lib/clacky/default_skills/new/SKILL.md +117 -5
  29. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +74 -0
  30. data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +32 -0
  31. data/lib/clacky/deploy_api_client.rb +484 -0
  32. data/lib/clacky/json_ui_controller.rb +16 -10
  33. data/lib/clacky/message_format/bedrock.rb +3 -2
  34. data/lib/clacky/message_history.rb +8 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -6
  36. data/lib/clacky/providers.rb +23 -4
  37. data/lib/clacky/server/browser_manager.rb +3 -1
  38. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +2 -1
  39. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +3 -1
  40. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +5 -5
  41. data/lib/clacky/server/http_server.rb +12 -2
  42. data/lib/clacky/server/server_master.rb +43 -7
  43. data/lib/clacky/server/web_ui_controller.rb +17 -9
  44. data/lib/clacky/skill.rb +6 -2
  45. data/lib/clacky/tools/run_project.rb +4 -1
  46. data/lib/clacky/tools/shell.rb +7 -1
  47. data/lib/clacky/ui2/ui_controller.rb +1 -5
  48. data/lib/clacky/ui_interface.rb +5 -7
  49. data/lib/clacky/utils/arguments_parser.rb +22 -5
  50. data/lib/clacky/version.rb +1 -1
  51. data/lib/clacky/web/app.css +45 -5
  52. data/lib/clacky/web/app.js +126 -19
  53. data/lib/clacky/web/i18n.js +57 -0
  54. data/lib/clacky/web/sessions.js +108 -39
  55. data/lib/clacky/web/skills.js +8 -2
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+
6
+ module Clacky
7
+ module DeployTools
8
+ # Create a PostgreSQL database service on Railway and wait for it to be ready.
9
+ #
10
+ # This tool handles the known Railway CLI bug where `railway add` returns an
11
+ # error exit code when using project tokens, but actually succeeds in creating
12
+ # the service.
13
+ #
14
+ # Strategy:
15
+ # 1. Capture existing service IDs before creation
16
+ # 2. Execute `railway add --database postgres` (ignore exit code)
17
+ # 3. Poll for new service ID (max 15 seconds)
18
+ # 4. Wait for DATABASE_URL to be available on the new service (max 2 minutes)
19
+ #
20
+ # @example
21
+ # result = CreateDatabaseService.execute(
22
+ # platform_token: "railway-token-here"
23
+ # )
24
+ # if result[:success]
25
+ # puts "Database created: #{result[:service_name]}"
26
+ # puts "DATABASE_URL: #{result[:database_url]}"
27
+ # end
28
+ class CreateDatabaseService
29
+
30
+ # Maximum time to wait for new service to appear after creation command
31
+ DETECTION_TIMEOUT = 15 # seconds
32
+ DETECTION_INTERVAL = 3 # seconds
33
+
34
+ # Maximum time to wait for DATABASE_URL to be available
35
+ PROVISION_TIMEOUT = 120 # seconds (2 minutes)
36
+ PROVISION_INTERVAL = 5 # seconds
37
+
38
+ # Execute database creation
39
+ #
40
+ # @param platform_token [String] RAILWAY_TOKEN for authentication
41
+ # @return [Hash] {
42
+ # success: Boolean,
43
+ # service_id: String (if success),
44
+ # service_name: String (if success),
45
+ # database_url: String (if success),
46
+ # error: String (if failed)
47
+ # }
48
+ def self.execute(platform_token:)
49
+ new(platform_token).execute
50
+ end
51
+
52
+ def initialize(platform_token)
53
+ @platform_token = platform_token
54
+ @env = { "RAILWAY_TOKEN" => platform_token }
55
+ end
56
+
57
+ def execute
58
+ # Step 0: Check if database already exists
59
+ existing_db = find_existing_database
60
+
61
+ if existing_db
62
+ puts " ✅ Database already exists: #{existing_db[:name]}"
63
+
64
+ # Return without DATABASE_URL - Railway automatically shares it across services
65
+ return {
66
+ success: true,
67
+ service_id: existing_db[:id],
68
+ service_name: existing_db[:name],
69
+ database_url: nil, # Don't fetch - Railway auto-injects
70
+ status: "existing"
71
+ }
72
+ end
73
+
74
+ # No database exists - create new one
75
+ puts " 📦 Creating new PostgreSQL database..."
76
+
77
+ # Step 1: Get existing service IDs
78
+ existing_service_ids = fetch_service_ids
79
+
80
+ unless existing_service_ids
81
+ return { success: false, error: "Failed to fetch existing services" }
82
+ end
83
+
84
+ # Step 2: Execute create command (ignore exit code due to known bug)
85
+ execute_create_command
86
+
87
+ # Step 3: Detect new service
88
+ new_service = detect_new_service(existing_service_ids)
89
+
90
+ unless new_service
91
+ return { success: false, error: "Database service not detected after #{DETECTION_TIMEOUT}s" }
92
+ end
93
+
94
+ # Step 4: Wait for DATABASE_URL
95
+ database_url = wait_for_database_url(new_service[:name])
96
+
97
+ unless database_url
98
+ return {
99
+ success: false,
100
+ error: "DATABASE_URL not available after #{PROVISION_TIMEOUT}s for service #{new_service[:name]}"
101
+ }
102
+ end
103
+
104
+ {
105
+ success: true,
106
+ service_id: new_service[:id],
107
+ service_name: new_service[:name],
108
+ database_url: database_url,
109
+ status: "created"
110
+ }
111
+
112
+ rescue => e
113
+ {
114
+ success: false,
115
+ error: "Unexpected error: #{e.message}"
116
+ }
117
+ end
118
+
119
+ # Find existing Postgres database service
120
+ # @return [Hash, nil] { id:, name: } or nil if not found
121
+ private def find_existing_database
122
+ Tempfile.create('railway_status') do |tmpfile|
123
+ success = system(
124
+ @env,
125
+ "railway", "status", "--json",
126
+ in: :close,
127
+ out: tmpfile,
128
+ err: File::NULL
129
+ )
130
+
131
+ return nil unless success
132
+
133
+ tmpfile.rewind
134
+ output = tmpfile.read.strip
135
+ return nil if output.empty?
136
+
137
+ begin
138
+ data = JSON.parse(output)
139
+
140
+ # Navigate to production environment's service instances
141
+ edges = data.dig("environments", "edges")
142
+ return nil unless edges&.any?
143
+
144
+ prod_env = edges.find { |e| e.dig("node", "name") == "production" }
145
+ return nil unless prod_env
146
+
147
+ service_edges = prod_env.dig("node", "serviceInstances", "edges")
148
+ return nil unless service_edges
149
+
150
+ # Find Postgres service (by name pattern or image source)
151
+ postgres_edge = service_edges.find do |edge|
152
+ node = edge["node"]
153
+ next false unless node
154
+
155
+ service_name = node["serviceName"].to_s
156
+ source_image = node.dig("source", "image").to_s
157
+
158
+ # Match: name contains "Postgres" or "postgres", or source is postgres image
159
+ service_name.match?(/postgres/i) || source_image.include?("postgres")
160
+ end
161
+
162
+ return nil unless postgres_edge
163
+
164
+ node = postgres_edge["node"]
165
+ { id: node["serviceId"], name: node["serviceName"] }
166
+ rescue JSON::ParserError
167
+ nil
168
+ end
169
+ end
170
+ end
171
+
172
+ # Fetch all existing service IDs from railway status
173
+ # @return [Array<String>, nil] Array of service IDs, or nil on failure
174
+ private def fetch_service_ids
175
+ Tempfile.create('railway_status') do |tmpfile|
176
+ success = system(
177
+ @env,
178
+ "railway", "status", "--json",
179
+ in: :close,
180
+ out: tmpfile,
181
+ err: File::NULL
182
+ )
183
+
184
+ return nil unless success
185
+
186
+ tmpfile.rewind
187
+ output = tmpfile.read.strip
188
+ return [] if output.empty?
189
+
190
+ begin
191
+ data = JSON.parse(output)
192
+
193
+ # Extract service IDs from environments.edges[0].node.serviceInstances.edges
194
+ edges = data.dig("environments", "edges")
195
+ return [] unless edges&.any?
196
+
197
+ service_instances = edges[0].dig("node", "serviceInstances", "edges")
198
+ return [] unless service_instances
199
+
200
+ service_instances.map { |edge| edge.dig("node", "serviceId") }.compact
201
+ rescue JSON::ParserError
202
+ nil
203
+ end
204
+ end
205
+ end
206
+
207
+ # Execute the railway add command
208
+ # NOTE: We ignore the exit code because Railway CLI has a known bug
209
+ # where it returns error with project tokens but still succeeds
210
+ private def execute_create_command
211
+ system(
212
+ @env,
213
+ "railway", "add", "--database", "postgres",
214
+ in: :close,
215
+ out: File::NULL,
216
+ err: File::NULL
217
+ )
218
+ # Exit code intentionally ignored
219
+ end
220
+
221
+ # Detect newly created service by comparing service IDs
222
+ # @param existing_ids [Array<String>] Service IDs before creation
223
+ # @return [Hash, nil] { id: String, name: String } or nil if not found
224
+ private def detect_new_service(existing_ids)
225
+ max_attempts = DETECTION_TIMEOUT / DETECTION_INTERVAL
226
+
227
+ max_attempts.times do
228
+ sleep DETECTION_INTERVAL
229
+
230
+ current_ids = fetch_service_ids
231
+ next unless current_ids
232
+
233
+ new_ids = current_ids - existing_ids
234
+
235
+ if new_ids.any?
236
+ # Fetch full service info to get the name
237
+ return fetch_service_info(new_ids.first)
238
+ end
239
+ end
240
+
241
+ nil
242
+ end
243
+
244
+ # Fetch service name for a given service ID
245
+ # @param service_id [String]
246
+ # @return [Hash, nil] { id: String, name: String }
247
+ private def fetch_service_info(service_id)
248
+ Tempfile.create('railway_status') do |tmpfile|
249
+ success = system(
250
+ @env,
251
+ "railway", "status", "--json",
252
+ in: :close,
253
+ out: tmpfile,
254
+ err: File::NULL
255
+ )
256
+
257
+ return nil unless success
258
+
259
+ tmpfile.rewind
260
+ output = tmpfile.read.strip
261
+ return nil if output.empty?
262
+
263
+ begin
264
+ data = JSON.parse(output)
265
+ edges = data.dig("environments", "edges")
266
+ return nil unless edges&.any?
267
+
268
+ service_instances = edges[0].dig("node", "serviceInstances", "edges")
269
+ return nil unless service_instances
270
+
271
+ # Find the service with matching ID
272
+ service_node = service_instances.find do |edge|
273
+ edge.dig("node", "serviceId") == service_id
274
+ end
275
+
276
+ return nil unless service_node
277
+
278
+ {
279
+ id: service_id,
280
+ name: service_node.dig("node", "serviceName")
281
+ }
282
+ rescue JSON::ParserError
283
+ nil
284
+ end
285
+ end
286
+ end
287
+
288
+ # Wait for DATABASE_URL to be available on the service
289
+ # @param service_name [String]
290
+ # @return [String, nil] DATABASE_URL or nil if timeout
291
+ private def wait_for_database_url(service_name)
292
+ max_attempts = PROVISION_TIMEOUT / PROVISION_INTERVAL
293
+
294
+ max_attempts.times do
295
+ sleep PROVISION_INTERVAL
296
+
297
+ database_url = fetch_database_url(service_name)
298
+ return database_url if database_url
299
+ end
300
+
301
+ nil
302
+ end
303
+
304
+ # Fetch DATABASE_URL from service variables
305
+ # @param service_name [String]
306
+ # @return [String, nil] DATABASE_URL value or nil
307
+ private def fetch_database_url(service_name)
308
+ tmpfile = Tempfile.new(['railway_vars', '.json'])
309
+
310
+ begin
311
+ success = system(
312
+ @env,
313
+ "railway", "variables", "--service", service_name, "--json",
314
+ in: :close,
315
+ out: tmpfile,
316
+ err: File::NULL
317
+ )
318
+
319
+ return nil unless success
320
+
321
+ tmpfile.rewind
322
+ output = tmpfile.read.strip
323
+ return nil if output.empty?
324
+
325
+ begin
326
+ # Output should be a JSON hash of variables
327
+ vars = JSON.parse(output)
328
+
329
+ # Prefer DATABASE_URL, fall back to DATABASE_PUBLIC_URL
330
+ vars["DATABASE_URL"] || vars["DATABASE_PUBLIC_URL"]
331
+ rescue JSON::ParserError
332
+ nil
333
+ end
334
+ ensure
335
+ tmpfile.close
336
+ tmpfile.unlink
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
@@ -1,173 +1,98 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
-
5
3
  module Clacky
6
4
  module DeployTools
7
- # Execute deployment and monitor until completion
5
+ # Trigger a Railway deployment via `railway up` (blocking, with live logs).
6
+ # Uses RAILWAY_TOKEN passed through environment — no clackycli wrapper needed.
8
7
  class ExecuteDeployment
9
- MAX_WAIT_TIME = 600 # 10 minutes
10
- POLL_INTERVAL = 5 # 5 seconds
11
8
 
12
- # Execute deployment for a service
9
+ # Trigger deployment for a service (blocking - waits for completion).
13
10
  #
14
- # @param service_name [String] Service to deploy
15
- # @return [Hash] Result of the deployment
16
- def self.execute(service_name:)
17
- if service_name.nil? || service_name.empty?
18
- return {
19
- error: "Service name is required",
20
- details: "Please provide a valid service name"
21
- }
11
+ # @param service_name [String] Railway service name (from railway.toml or service list)
12
+ # @param platform_token [String] RAILWAY_TOKEN for this deploy task
13
+ # @return [Hash] { success: true, url: String } or { success: false, error: String }
14
+ def self.execute(service_name:, platform_token:)
15
+ if service_name.nil? || service_name.strip.empty?
16
+ return { success: false, error: "service_name is required" }
22
17
  end
23
18
 
24
- puts "🚀 Starting deployment for service: #{service_name}"
25
-
26
- # Trigger deployment
27
- command = "clackycli up -s #{shell_escape(service_name)} -d"
28
- output = `#{command} 2>&1`
29
- exit_code = $?.exitstatus
30
-
31
- if exit_code != 0
32
- return {
33
- error: "Failed to trigger deployment",
34
- details: output,
35
- exit_code: exit_code,
36
- service: service_name
37
- }
19
+ if platform_token.nil? || platform_token.strip.empty?
20
+ return { success: false, error: "platform_token is required" }
38
21
  end
39
22
 
40
- puts " Deployment triggered successfully"
41
- puts " Monitoring deployment progress..."
42
-
43
- # Monitor deployment status
44
- result = monitor_deployment(service_name)
45
- result[:service] = service_name
46
- result
47
- end
48
-
49
- # Monitor deployment status until completion or timeout
50
- #
51
- # @param service_name [String] Service name
52
- # @return [Hash] Deployment result
53
- def self.monitor_deployment(service_name)
54
- start_time = Time.now
55
- last_status = nil
23
+ puts "🚀 Deploying service: #{service_name}"
24
+ puts " (This may take several minutes - you'll see live build logs below)"
25
+ puts ""
56
26
 
57
- loop do
58
- elapsed = Time.now - start_time
27
+ env = { "RAILWAY_TOKEN" => platform_token }
28
+
29
+ # Use railway up without --detach to block and show live logs
30
+ success = system(
31
+ env,
32
+ "railway", "up", "--service", service_name,
33
+ in: :close # Don't redirect stdout/stderr - let user see live logs
34
+ )
35
+
36
+ if success
37
+ puts ""
38
+ puts "✅ Deployment completed successfully"
59
39
 
60
- if elapsed > MAX_WAIT_TIME
61
- return {
62
- success: false,
63
- error: "Deployment timeout",
64
- details: "Deployment exceeded maximum wait time of #{MAX_WAIT_TIME} seconds",
65
- elapsed: elapsed
66
- }
67
- end
68
-
69
- # Get deployment status
70
- status = get_deployment_status(service_name)
40
+ # Extract URL from railway status after successful deployment
41
+ url = extract_url(env, service_name)
71
42
 
72
- # Print status update if changed
73
- if status[:current_status] != last_status
74
- puts "📊 Status: #{status[:current_status]}"
75
- last_status = status[:current_status]
76
- end
77
-
78
- # Check if deployment completed
79
- if status[:completed]
80
- if status[:success]
81
- return {
82
- success: true,
83
- message: "Deployment completed successfully",
84
- elapsed: elapsed,
85
- final_status: status[:current_status]
86
- }
87
- else
88
- return {
89
- success: false,
90
- error: "Deployment failed",
91
- details: status[:error_message],
92
- elapsed: elapsed,
93
- final_status: status[:current_status]
94
- }
95
- end
96
- end
97
-
98
- # Wait before next poll
99
- sleep POLL_INTERVAL
43
+ return { success: true, url: url }
44
+ else
45
+ puts ""
46
+ puts "❌ Deployment failed"
47
+ return { success: false, error: "railway up exited with error code" }
100
48
  end
49
+
50
+ rescue => e
51
+ { success: false, error: "Unexpected error: #{e.message}" }
101
52
  end
102
53
 
103
- # Get current deployment status
104
- #
105
- # @param service_name [String] Service name
106
- # @return [Hash] Status information
107
- def self.get_deployment_status(service_name)
108
- command = "clackycli service list --json"
109
- output = `#{command} 2>&1`
54
+ private_class_method def self.extract_url(env, service_name)
55
+ require "json"
56
+ require "tempfile"
110
57
 
111
- begin
112
- services = JSON.parse(output)
113
- service = services.find { |s| s['name'] == service_name }
58
+ Tempfile.create("railway_status") do |tmpfile|
59
+ system(
60
+ env,
61
+ "railway", "status", "--json",
62
+ in: :close,
63
+ out: tmpfile,
64
+ err: File::NULL
65
+ )
114
66
 
115
- if service.nil?
116
- return {
117
- completed: true,
118
- success: false,
119
- error_message: "Service not found: #{service_name}"
120
- }
121
- end
122
-
123
- # Get latest deployment
124
- deployments = service['deployments'] || []
125
- latest = deployments.first
67
+ tmpfile.rewind
68
+ output = tmpfile.read.strip
69
+ return nil if output.empty?
126
70
 
127
- if latest.nil?
128
- return {
129
- completed: false,
130
- current_status: 'waiting'
131
- }
132
- end
133
-
134
- status = latest['status']
71
+ status_data = JSON.parse(output)
72
+
73
+ # Navigate: environments.edges[].node (name="production").serviceInstances.edges[].node
74
+ env_edges = status_data.dig("environments", "edges")
75
+ return nil unless env_edges.is_a?(Array)
135
76
 
136
- case status
137
- when 'SUCCESS', 'ACTIVE'
138
- {
139
- completed: true,
140
- success: true,
141
- current_status: status
142
- }
143
- when 'FAILED', 'CRASHED'
144
- {
145
- completed: true,
146
- success: false,
147
- current_status: status,
148
- error_message: latest['error'] || 'Deployment failed'
149
- }
150
- else
151
- {
152
- completed: false,
153
- current_status: status
154
- }
77
+ prod_env = env_edges.find { |e| e.dig("node", "name") == "production" }
78
+ return nil unless prod_env
79
+
80
+ service_edges = prod_env.dig("node", "serviceInstances", "edges")
81
+ return nil unless service_edges.is_a?(Array)
82
+
83
+ service_edge = service_edges.find do |edge|
84
+ edge.dig("node", "serviceName") == service_name
155
85
  end
156
- rescue JSON::ParserError
157
- {
158
- completed: true,
159
- success: false,
160
- error_message: "Failed to parse deployment status"
161
- }
86
+ return nil unless service_edge
87
+
88
+ domains = service_edge.dig("node", "domains", "customDomains")
89
+ return nil unless domains.is_a?(Array) && !domains.empty?
90
+
91
+ domain = domains.first["domain"]
92
+ domain ? "https://#{domain}" : nil
162
93
  end
163
- end
164
-
165
- # Escape shell arguments
166
- #
167
- # @param str [String] String to escape
168
- # @return [String] Escaped string
169
- def self.shell_escape(str)
170
- "'#{str.gsub("'", "'\\\\''")}'"
94
+ rescue
95
+ nil
171
96
  end
172
97
  end
173
98
  end