language-operator 0.1.70 → 0.1.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/components/agent/Gemfile +1 -1
- data/components/tool/Gemfile +1 -1
- data/docs/persona-driven-system-prompts.md +346 -0
- data/examples/basic_agent_with_default_chat.rb +99 -0
- data/examples/chat_endpoint_agent.rb +7 -19
- data/examples/identity_aware_chat_agent.rb +83 -0
- data/examples/ux_helpers_demo.rb +0 -0
- data/lib/language_operator/agent/base.rb +12 -7
- data/lib/language_operator/agent/execution_state.rb +92 -0
- data/lib/language_operator/agent/metadata_collector.rb +222 -0
- data/lib/language_operator/agent/prompt_builder.rb +282 -0
- data/lib/language_operator/agent/web_server.rb +610 -21
- data/lib/language_operator/agent.rb +34 -25
- data/lib/language_operator/dsl/agent_definition.rb +3 -53
- data/lib/language_operator/dsl/chat_endpoint_definition.rb +112 -2
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +1 -1
- data/lib/language_operator/version.rb +1 -1
- data/synth/003/Makefile +6 -0
- metadata +9 -3
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
require 'rack'
|
|
4
4
|
require 'rackup'
|
|
5
5
|
require 'mcp'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'securerandom'
|
|
6
8
|
require_relative 'executor'
|
|
9
|
+
require_relative 'prompt_builder'
|
|
7
10
|
|
|
8
11
|
module LanguageOperator
|
|
9
12
|
module Agent
|
|
@@ -29,6 +32,7 @@ module LanguageOperator
|
|
|
29
32
|
@routes = {}
|
|
30
33
|
@mcp_server = nil
|
|
31
34
|
@mcp_transport = nil
|
|
35
|
+
@execution_state = nil # Initialized when register_execute_endpoint called
|
|
32
36
|
|
|
33
37
|
# Initialize executor pool to prevent MCP connection leaks
|
|
34
38
|
@executor_pool_size = ENV.fetch('EXECUTOR_POOL_SIZE', '4').to_i
|
|
@@ -110,16 +114,22 @@ module LanguageOperator
|
|
|
110
114
|
|
|
111
115
|
# Register chat completion endpoint
|
|
112
116
|
#
|
|
113
|
-
# Sets up OpenAI-compatible chat completion endpoint.
|
|
114
|
-
#
|
|
117
|
+
# Sets up OpenAI-compatible chat completion endpoint for all agents.
|
|
118
|
+
# Every agent automatically gets identity-aware chat capabilities.
|
|
115
119
|
#
|
|
116
|
-
# @param chat_endpoint_def [LanguageOperator::Dsl::ChatEndpointDefinition] Chat endpoint definition
|
|
117
120
|
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
118
121
|
# @return [void]
|
|
119
|
-
def register_chat_endpoint(
|
|
120
|
-
@chat_endpoint = chat_endpoint_def
|
|
122
|
+
def register_chat_endpoint(agent)
|
|
121
123
|
@chat_agent = agent
|
|
122
124
|
|
|
125
|
+
# Create simple chat configuration (identity awareness always enabled)
|
|
126
|
+
@chat_config = {
|
|
127
|
+
model_name: ENV.fetch('AGENT_NAME', agent.config&.dig('agent', 'name') || 'agent'),
|
|
128
|
+
system_prompt: build_default_system_prompt(agent),
|
|
129
|
+
temperature: 0.7,
|
|
130
|
+
max_tokens: 2000
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
# Register OpenAI-compatible endpoint
|
|
124
134
|
register_route('/v1/chat/completions', method: :post) do |context|
|
|
125
135
|
handle_chat_completion(context)
|
|
@@ -131,19 +141,78 @@ module LanguageOperator
|
|
|
131
141
|
object: 'list',
|
|
132
142
|
data: [
|
|
133
143
|
{
|
|
134
|
-
id:
|
|
144
|
+
id: @chat_config[:model_name],
|
|
135
145
|
object: 'model',
|
|
136
146
|
created: Time.now.to_i,
|
|
137
147
|
owned_by: 'language-operator',
|
|
138
148
|
permission: [],
|
|
139
|
-
root:
|
|
149
|
+
root: @chat_config[:model_name],
|
|
140
150
|
parent: nil
|
|
141
151
|
}
|
|
142
152
|
]
|
|
143
153
|
}
|
|
144
154
|
end
|
|
145
155
|
|
|
146
|
-
puts "Registered chat
|
|
156
|
+
puts "Registered identity-aware chat endpoint as model: #{@chat_config[:model_name]}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Register execution trigger endpoint
|
|
160
|
+
#
|
|
161
|
+
# Enables scheduled/reactive agents to execute tasks via HTTP POST.
|
|
162
|
+
# Prevents concurrent executions via ExecutionState.
|
|
163
|
+
#
|
|
164
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
165
|
+
# @param agent_def [LanguageOperator::Dsl::AgentDefinition, nil] Optional agent definition
|
|
166
|
+
# @return [void]
|
|
167
|
+
def register_execute_endpoint(agent, agent_def = nil)
|
|
168
|
+
require_relative 'execution_state'
|
|
169
|
+
|
|
170
|
+
@execute_agent = agent
|
|
171
|
+
@execute_agent_def = agent_def
|
|
172
|
+
@execution_state = LanguageOperator::Agent::ExecutionState.new
|
|
173
|
+
|
|
174
|
+
register_route('/api/v1/execute', method: :post) do |context|
|
|
175
|
+
handle_execute_request(context)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
puts 'Registered /api/v1/execute endpoint for triggered execution'
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Register workspace file management endpoints
|
|
182
|
+
#
|
|
183
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
184
|
+
def register_workspace_endpoints(agent)
|
|
185
|
+
return unless agent.workspace_available?
|
|
186
|
+
|
|
187
|
+
@workspace_agent = agent
|
|
188
|
+
@workspace_path = agent.workspace_path
|
|
189
|
+
|
|
190
|
+
# List directory contents
|
|
191
|
+
register_route('/api/v1/workspace/files', method: :get) do |context|
|
|
192
|
+
handle_workspace_list(context)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# View file contents
|
|
196
|
+
register_route('/api/v1/workspace/files/view', method: :get) do |context|
|
|
197
|
+
handle_workspace_view(context)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Upload file
|
|
201
|
+
register_route('/api/v1/workspace/files', method: :post) do |context|
|
|
202
|
+
handle_workspace_upload(context)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Delete file
|
|
206
|
+
register_route('/api/v1/workspace/files', method: :delete) do |context|
|
|
207
|
+
handle_workspace_delete(context)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Download file/directory
|
|
211
|
+
register_route('/api/v1/workspace/files/download', method: :get) do |context|
|
|
212
|
+
handle_workspace_download(context)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
puts 'Registered /api/v1/workspace/* endpoints for file management'
|
|
147
216
|
end
|
|
148
217
|
|
|
149
218
|
# Handle incoming HTTP request
|
|
@@ -193,6 +262,163 @@ module LanguageOperator
|
|
|
193
262
|
|
|
194
263
|
private
|
|
195
264
|
|
|
265
|
+
# Build default system prompt for agent
|
|
266
|
+
#
|
|
267
|
+
# Creates a basic system prompt based on agent description
|
|
268
|
+
#
|
|
269
|
+
# @param agent [LanguageOperator::Agent::Base] The agent instance
|
|
270
|
+
# @return [String] Default system prompt
|
|
271
|
+
def build_default_system_prompt(agent)
|
|
272
|
+
description = agent.config&.dig('agent', 'instructions') ||
|
|
273
|
+
agent.config&.dig('agent', 'description') ||
|
|
274
|
+
'AI assistant'
|
|
275
|
+
|
|
276
|
+
if description.downcase.start_with?('you are')
|
|
277
|
+
description
|
|
278
|
+
else
|
|
279
|
+
"You are #{description.downcase}. Provide helpful assistance based on your capabilities."
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Handle POST /api/v1/execute request
|
|
284
|
+
#
|
|
285
|
+
# @param context [Hash] Request context with :body, :headers, :request
|
|
286
|
+
# @return [Hash] Response data
|
|
287
|
+
def handle_execute_request(context)
|
|
288
|
+
puts "Received execute request: #{context[:body]}"
|
|
289
|
+
|
|
290
|
+
# Check for concurrent execution
|
|
291
|
+
if @execution_state.running?
|
|
292
|
+
info = @execution_state.current_info
|
|
293
|
+
puts "Execution already running: #{info}"
|
|
294
|
+
return {
|
|
295
|
+
status: 409,
|
|
296
|
+
body: {
|
|
297
|
+
error: 'ExecutionInProgress',
|
|
298
|
+
message: 'Agent is currently executing a task',
|
|
299
|
+
current_execution: info
|
|
300
|
+
}.to_json,
|
|
301
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Parse request
|
|
306
|
+
request_data = JSON.parse(context[:body] || '{}')
|
|
307
|
+
puts "Parsed request data: #{request_data.inspect}"
|
|
308
|
+
|
|
309
|
+
execution_id = "exec-#{SecureRandom.hex(8)}"
|
|
310
|
+
puts "Generated execution ID: #{execution_id}"
|
|
311
|
+
|
|
312
|
+
wait_for_completion = request_data.fetch('wait', true)
|
|
313
|
+
puts "Execution details - ID: #{execution_id}, wait: #{wait_for_completion}"
|
|
314
|
+
|
|
315
|
+
# Start execution
|
|
316
|
+
puts 'About to start execution state...'
|
|
317
|
+
@execution_state.start_execution(execution_id)
|
|
318
|
+
puts "Started execution state for #{execution_id}"
|
|
319
|
+
|
|
320
|
+
if wait_for_completion
|
|
321
|
+
execute_sync(execution_id, request_data['context'])
|
|
322
|
+
else
|
|
323
|
+
execute_async(execution_id, request_data['context'])
|
|
324
|
+
end
|
|
325
|
+
rescue JSON::ParserError
|
|
326
|
+
execute_error_response(400, 'InvalidJSON', 'Request body must be valid JSON')
|
|
327
|
+
rescue LanguageOperator::Agent::ExecutionInProgressError => e
|
|
328
|
+
execute_error_response(409, 'ExecutionInProgress', e.message)
|
|
329
|
+
rescue StandardError => e
|
|
330
|
+
puts "Exception in handle_execute_request: #{e.class}: #{e.message}"
|
|
331
|
+
puts "Exception backtrace: #{e.backtrace.first(5).join('\n')}"
|
|
332
|
+
@execution_state&.fail_execution(e)
|
|
333
|
+
execute_error_response(500, 'ExecutionError', e.message)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Execute task synchronously
|
|
337
|
+
#
|
|
338
|
+
# @param execution_id [String] Execution identifier
|
|
339
|
+
# @param context_data [Hash] Additional context data
|
|
340
|
+
# @return [Hash] Response data
|
|
341
|
+
def execute_sync(execution_id, _context_data)
|
|
342
|
+
start_time = Time.now
|
|
343
|
+
puts "Starting execution #{execution_id}"
|
|
344
|
+
|
|
345
|
+
# Execute agent main block (convention: all DSL agents have main blocks)
|
|
346
|
+
puts 'About to execute main block...'
|
|
347
|
+
$stdout.flush
|
|
348
|
+
|
|
349
|
+
# Test logging capture during execution
|
|
350
|
+
original_stdout = $stdout
|
|
351
|
+
original_stderr = $stderr
|
|
352
|
+
puts "Original stdout: #{original_stdout.class}"
|
|
353
|
+
puts "Original stderr: #{original_stderr.class}"
|
|
354
|
+
$stdout.flush
|
|
355
|
+
|
|
356
|
+
result = LanguageOperator::Agent.execute_main_block(@execute_agent, @execute_agent_def)
|
|
357
|
+
|
|
358
|
+
puts "Main block execution returned: #{result.inspect}"
|
|
359
|
+
puts "Stdout after execution: #{$stdout.class}"
|
|
360
|
+
puts "Stderr after execution: #{$stderr.class}"
|
|
361
|
+
$stdout.flush
|
|
362
|
+
|
|
363
|
+
puts "Execution #{execution_id} completed with result: #{result.inspect}"
|
|
364
|
+
@execution_state.complete_execution(result)
|
|
365
|
+
|
|
366
|
+
{
|
|
367
|
+
status: 200,
|
|
368
|
+
body: {
|
|
369
|
+
status: 'completed',
|
|
370
|
+
result: result,
|
|
371
|
+
execution_id: execution_id,
|
|
372
|
+
started_at: start_time.iso8601,
|
|
373
|
+
completed_at: Time.now.iso8601,
|
|
374
|
+
duration_seconds: (Time.now - start_time).round(2)
|
|
375
|
+
}.to_json,
|
|
376
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
377
|
+
}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Execute task asynchronously
|
|
381
|
+
#
|
|
382
|
+
# @param execution_id [String] Execution identifier
|
|
383
|
+
# @param context_data [Hash] Additional context data
|
|
384
|
+
# @return [Hash] Response data
|
|
385
|
+
def execute_async(execution_id, _context_data)
|
|
386
|
+
Thread.new do
|
|
387
|
+
result = LanguageOperator::Agent.execute_main_block(@execute_agent, @execute_agent_def)
|
|
388
|
+
@execution_state.complete_execution(result)
|
|
389
|
+
rescue StandardError => e
|
|
390
|
+
@execution_state.fail_execution(e)
|
|
391
|
+
logger.error('Async execution failed', error: e.message, execution_id: execution_id)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
{
|
|
395
|
+
status: 202,
|
|
396
|
+
body: {
|
|
397
|
+
status: 'accepted',
|
|
398
|
+
execution_id: execution_id,
|
|
399
|
+
message: 'Execution started asynchronously'
|
|
400
|
+
}.to_json,
|
|
401
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
402
|
+
}
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Generate error response for execute endpoint
|
|
406
|
+
#
|
|
407
|
+
# @param status [Integer] HTTP status code
|
|
408
|
+
# @param error_type [String] Error type identifier
|
|
409
|
+
# @param message [String] Error message
|
|
410
|
+
# @return [Hash] Error response data
|
|
411
|
+
def execute_error_response(status, error_type, message)
|
|
412
|
+
{
|
|
413
|
+
status: status,
|
|
414
|
+
body: {
|
|
415
|
+
error: error_type,
|
|
416
|
+
message: message
|
|
417
|
+
}.to_json,
|
|
418
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
419
|
+
}
|
|
420
|
+
end
|
|
421
|
+
|
|
196
422
|
# Setup executor pool for connection reuse
|
|
197
423
|
#
|
|
198
424
|
# Creates a thread-safe queue pre-populated with executor instances
|
|
@@ -406,7 +632,7 @@ module LanguageOperator
|
|
|
406
632
|
# @param context [Hash] Request context
|
|
407
633
|
# @return [Array, Hash] Rack response or hash for streaming
|
|
408
634
|
def handle_chat_completion(context)
|
|
409
|
-
return error_response(StandardError.new('Chat endpoint not configured')) unless @
|
|
635
|
+
return error_response(StandardError.new('Chat endpoint not configured')) unless @chat_config
|
|
410
636
|
|
|
411
637
|
# Parse request body
|
|
412
638
|
request_data = JSON.parse(context[:body])
|
|
@@ -444,7 +670,7 @@ module LanguageOperator
|
|
|
444
670
|
id: "chatcmpl-#{SecureRandom.hex(12)}",
|
|
445
671
|
object: 'chat.completion',
|
|
446
672
|
created: Time.now.to_i,
|
|
447
|
-
model: @
|
|
673
|
+
model: @chat_config[:model_name],
|
|
448
674
|
choices: [
|
|
449
675
|
{
|
|
450
676
|
index: 0,
|
|
@@ -480,41 +706,79 @@ module LanguageOperator
|
|
|
480
706
|
'Cache-Control' => 'no-cache',
|
|
481
707
|
'Connection' => 'keep-alive'
|
|
482
708
|
},
|
|
483
|
-
StreamingBody.new(@chat_agent, prompt, @
|
|
709
|
+
StreamingBody.new(@chat_agent, prompt, @chat_config[:model_name])
|
|
484
710
|
]
|
|
485
711
|
end
|
|
486
712
|
|
|
487
|
-
# Build prompt from OpenAI message format
|
|
713
|
+
# Build prompt from OpenAI message format with identity awareness
|
|
488
714
|
#
|
|
489
715
|
# @param messages [Array<Hash>] Array of message objects
|
|
490
|
-
# @return [String] Combined prompt
|
|
716
|
+
# @return [String] Combined prompt with agent identity context
|
|
491
717
|
def build_prompt_from_messages(messages)
|
|
492
|
-
# Combine all messages into a single prompt
|
|
493
|
-
# System messages become instructions
|
|
494
|
-
# User/assistant messages become conversation
|
|
495
718
|
prompt_parts = []
|
|
496
719
|
|
|
497
|
-
#
|
|
498
|
-
|
|
720
|
+
# Build identity-aware system prompt (always enabled)
|
|
721
|
+
system_prompt = build_identity_aware_system_prompt
|
|
722
|
+
prompt_parts << "System: #{system_prompt}" if system_prompt
|
|
723
|
+
|
|
724
|
+
# Add conversation context (always enabled)
|
|
725
|
+
conversation_context = build_conversation_context
|
|
726
|
+
prompt_parts << conversation_context if conversation_context
|
|
499
727
|
|
|
500
|
-
# Add conversation history
|
|
728
|
+
# Add conversation history (skip system messages from original array since we handle them above)
|
|
501
729
|
messages.each do |msg|
|
|
502
730
|
role = msg['role']
|
|
503
731
|
content = msg['content']
|
|
504
732
|
|
|
505
733
|
case role
|
|
506
|
-
when 'system'
|
|
507
|
-
prompt_parts << "System: #{content}"
|
|
508
734
|
when 'user'
|
|
509
735
|
prompt_parts << "User: #{content}"
|
|
510
736
|
when 'assistant'
|
|
511
737
|
prompt_parts << "Assistant: #{content}"
|
|
738
|
+
# Skip system messages - we handle them via PromptBuilder
|
|
512
739
|
end
|
|
513
740
|
end
|
|
514
741
|
|
|
515
742
|
prompt_parts.join("\n\n")
|
|
516
743
|
end
|
|
517
744
|
|
|
745
|
+
# Build identity-aware system prompt using PromptBuilder
|
|
746
|
+
#
|
|
747
|
+
# @return [String] Dynamic system prompt with agent identity
|
|
748
|
+
def build_identity_aware_system_prompt
|
|
749
|
+
# Create prompt builder with identity awareness always enabled
|
|
750
|
+
builder = PromptBuilder.new(
|
|
751
|
+
@chat_agent,
|
|
752
|
+
nil, # No chat config needed
|
|
753
|
+
template: :standard, # Good default
|
|
754
|
+
enable_identity_awareness: true
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
builder.build_system_prompt
|
|
758
|
+
rescue StandardError => e
|
|
759
|
+
# Log error and fall back to static prompt
|
|
760
|
+
puts "Warning: Failed to build identity-aware system prompt: #{e.message}"
|
|
761
|
+
@chat_config[:system_prompt]
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# Build conversation context for ongoing chats
|
|
765
|
+
#
|
|
766
|
+
# @return [String, nil] Conversation context
|
|
767
|
+
def build_conversation_context
|
|
768
|
+
builder = PromptBuilder.new(
|
|
769
|
+
@chat_agent,
|
|
770
|
+
nil, # No chat config needed
|
|
771
|
+
enable_identity_awareness: true
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
context = builder.build_conversation_context
|
|
775
|
+
context ? "Context: #{context}" : nil
|
|
776
|
+
rescue StandardError => e
|
|
777
|
+
# Log error and continue without context
|
|
778
|
+
puts "Warning: Failed to build conversation context: #{e.message}"
|
|
779
|
+
nil
|
|
780
|
+
end
|
|
781
|
+
|
|
518
782
|
# Estimate token count (rough approximation)
|
|
519
783
|
#
|
|
520
784
|
# @param text [String] Text to estimate
|
|
@@ -701,6 +965,331 @@ module LanguageOperator
|
|
|
701
965
|
ensure
|
|
702
966
|
stream.close
|
|
703
967
|
end
|
|
968
|
+
|
|
969
|
+
# Workspace file management handlers
|
|
970
|
+
|
|
971
|
+
# Handle GET /api/v1/workspace/files - list directory contents
|
|
972
|
+
def handle_workspace_list(context)
|
|
973
|
+
request = context[:request]
|
|
974
|
+
path = request.params['path'] || '/'
|
|
975
|
+
|
|
976
|
+
# Sanitize path to prevent directory traversal
|
|
977
|
+
safe_path = sanitize_workspace_path(path)
|
|
978
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
979
|
+
|
|
980
|
+
return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
|
|
981
|
+
|
|
982
|
+
if File.directory?(full_path)
|
|
983
|
+
list_directory_contents(full_path, path)
|
|
984
|
+
else
|
|
985
|
+
# If it's a file, return file info
|
|
986
|
+
get_file_info(full_path, path)
|
|
987
|
+
end
|
|
988
|
+
rescue StandardError => e
|
|
989
|
+
workspace_error_response(500, 'InternalError', e.message)
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# Handle GET /api/v1/workspace/files/view - view file contents
|
|
993
|
+
def handle_workspace_view(context)
|
|
994
|
+
request = context[:request]
|
|
995
|
+
path = request.params['path']
|
|
996
|
+
|
|
997
|
+
return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
|
|
998
|
+
|
|
999
|
+
# Sanitize path to prevent directory traversal
|
|
1000
|
+
safe_path = sanitize_workspace_path(path)
|
|
1001
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
1002
|
+
|
|
1003
|
+
return workspace_error_response(404, 'NotFound', "File not found: #{path}") unless File.exist?(full_path)
|
|
1004
|
+
|
|
1005
|
+
return workspace_error_response(400, 'BadRequest', "Path is not a file: #{path}") unless File.file?(full_path)
|
|
1006
|
+
|
|
1007
|
+
# Check file size (limit to 10MB for API responses)
|
|
1008
|
+
file_size = File.size(full_path)
|
|
1009
|
+
return workspace_error_response(413, 'FileTooLarge', 'File too large for viewing (max 10MB)') if file_size > 10 * 1024 * 1024
|
|
1010
|
+
|
|
1011
|
+
# Read file contents
|
|
1012
|
+
contents = File.read(full_path)
|
|
1013
|
+
|
|
1014
|
+
{
|
|
1015
|
+
status: 200,
|
|
1016
|
+
body: {
|
|
1017
|
+
path: path,
|
|
1018
|
+
size: file_size,
|
|
1019
|
+
modified: File.mtime(full_path).iso8601,
|
|
1020
|
+
content: contents,
|
|
1021
|
+
content_type: detect_content_type(full_path)
|
|
1022
|
+
}.to_json,
|
|
1023
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1024
|
+
}
|
|
1025
|
+
rescue StandardError => e
|
|
1026
|
+
workspace_error_response(500, 'InternalError', e.message)
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
# Handle POST /api/v1/workspace/files - upload file
|
|
1030
|
+
def handle_workspace_upload(context)
|
|
1031
|
+
request = context[:request]
|
|
1032
|
+
|
|
1033
|
+
# Check for multipart form data
|
|
1034
|
+
content_type = request.env['CONTENT_TYPE']
|
|
1035
|
+
unless content_type&.start_with?('multipart/form-data')
|
|
1036
|
+
return workspace_error_response(400, 'BadRequest', 'Content-Type must be multipart/form-data')
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Parse multipart data
|
|
1040
|
+
begin
|
|
1041
|
+
# Get file from params (Rack automatically parses multipart data)
|
|
1042
|
+
file_param = request.params['file']
|
|
1043
|
+
path_param = request.params['path']
|
|
1044
|
+
|
|
1045
|
+
return workspace_error_response(400, 'BadRequest', 'file parameter required') unless file_param
|
|
1046
|
+
|
|
1047
|
+
return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path_param
|
|
1048
|
+
|
|
1049
|
+
# Handle file upload object
|
|
1050
|
+
return workspace_error_response(400, 'BadRequest', 'Invalid file upload format') unless file_param.is_a?(Hash) && file_param[:tempfile]
|
|
1051
|
+
|
|
1052
|
+
# Rack file upload format
|
|
1053
|
+
tempfile = file_param[:tempfile]
|
|
1054
|
+
original_filename = file_param[:filename]
|
|
1055
|
+
content_type = file_param[:type]
|
|
1056
|
+
|
|
1057
|
+
# Sanitize target path
|
|
1058
|
+
safe_path = sanitize_workspace_path(path_param)
|
|
1059
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
1060
|
+
|
|
1061
|
+
# Ensure target directory exists
|
|
1062
|
+
target_dir = File.dirname(full_path)
|
|
1063
|
+
FileUtils.mkdir_p(target_dir) unless File.directory?(target_dir)
|
|
1064
|
+
|
|
1065
|
+
# Check file size (limit to 100MB)
|
|
1066
|
+
file_size = tempfile.size
|
|
1067
|
+
return workspace_error_response(413, 'FileTooLarge', 'File too large for upload (max 100MB)') if file_size > 100 * 1024 * 1024
|
|
1068
|
+
|
|
1069
|
+
# Copy uploaded file to workspace
|
|
1070
|
+
tempfile.rewind
|
|
1071
|
+
File.open(full_path, 'wb') do |dest_file|
|
|
1072
|
+
IO.copy_stream(tempfile, dest_file)
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# Set reasonable permissions
|
|
1076
|
+
File.chmod(0o644, full_path)
|
|
1077
|
+
|
|
1078
|
+
{
|
|
1079
|
+
status: 201,
|
|
1080
|
+
body: {
|
|
1081
|
+
path: path_param,
|
|
1082
|
+
size: file_size,
|
|
1083
|
+
original_filename: original_filename,
|
|
1084
|
+
content_type: content_type,
|
|
1085
|
+
message: 'File uploaded successfully'
|
|
1086
|
+
}.to_json,
|
|
1087
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1088
|
+
}
|
|
1089
|
+
rescue StandardError => e
|
|
1090
|
+
workspace_error_response(500, 'UploadError', "Upload failed: #{e.message}")
|
|
1091
|
+
ensure
|
|
1092
|
+
# Clean up temporary file
|
|
1093
|
+
tempfile&.close if tempfile.respond_to?(:close)
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
# Handle DELETE /api/v1/workspace/files - delete file
|
|
1098
|
+
def handle_workspace_delete(context)
|
|
1099
|
+
request = context[:request]
|
|
1100
|
+
path = request.params['path']
|
|
1101
|
+
|
|
1102
|
+
return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
|
|
1103
|
+
|
|
1104
|
+
# Sanitize path to prevent directory traversal
|
|
1105
|
+
safe_path = sanitize_workspace_path(path)
|
|
1106
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
1107
|
+
|
|
1108
|
+
return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
|
|
1109
|
+
|
|
1110
|
+
begin
|
|
1111
|
+
deleted_info = {
|
|
1112
|
+
path: path,
|
|
1113
|
+
type: File.directory?(full_path) ? 'directory' : 'file',
|
|
1114
|
+
size: File.directory?(full_path) ? nil : File.size(full_path)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if File.directory?(full_path)
|
|
1118
|
+
# Delete directory and all contents
|
|
1119
|
+
FileUtils.rm_rf(full_path)
|
|
1120
|
+
deleted_info[:message] = 'Directory deleted successfully'
|
|
1121
|
+
else
|
|
1122
|
+
# Delete single file
|
|
1123
|
+
File.delete(full_path)
|
|
1124
|
+
deleted_info[:message] = 'File deleted successfully'
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
{
|
|
1128
|
+
status: 200,
|
|
1129
|
+
body: deleted_info.to_json,
|
|
1130
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1131
|
+
}
|
|
1132
|
+
rescue StandardError => e
|
|
1133
|
+
workspace_error_response(500, 'DeleteError', "Delete failed: #{e.message}")
|
|
1134
|
+
end
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
# Handle GET /api/v1/workspace/files/download - download file/archive
|
|
1138
|
+
def handle_workspace_download(context)
|
|
1139
|
+
request = context[:request]
|
|
1140
|
+
path = request.params['path']
|
|
1141
|
+
|
|
1142
|
+
return workspace_error_response(400, 'BadRequest', 'path parameter required') unless path
|
|
1143
|
+
|
|
1144
|
+
# Sanitize path to prevent directory traversal
|
|
1145
|
+
safe_path = sanitize_workspace_path(path)
|
|
1146
|
+
full_path = File.join(@workspace_path, safe_path)
|
|
1147
|
+
|
|
1148
|
+
return workspace_error_response(404, 'NotFound', "Path not found: #{path}") unless File.exist?(full_path)
|
|
1149
|
+
|
|
1150
|
+
begin
|
|
1151
|
+
if File.directory?(full_path)
|
|
1152
|
+
# Create temporary tar.gz archive for directory
|
|
1153
|
+
archive_name = "#{File.basename(safe_path.empty? ? 'workspace' : safe_path)}.tar.gz"
|
|
1154
|
+
temp_archive = "/tmp/#{SecureRandom.hex(8)}_#{archive_name}"
|
|
1155
|
+
|
|
1156
|
+
# Create tar archive
|
|
1157
|
+
system('tar', '-czf', temp_archive, '-C', File.dirname(full_path), File.basename(full_path))
|
|
1158
|
+
|
|
1159
|
+
return workspace_error_response(500, 'ArchiveError', 'Failed to create archive') unless File.exist?(temp_archive)
|
|
1160
|
+
|
|
1161
|
+
# Read archive contents
|
|
1162
|
+
archive_data = File.binread(temp_archive)
|
|
1163
|
+
|
|
1164
|
+
# Clean up temporary file
|
|
1165
|
+
File.delete(temp_archive)
|
|
1166
|
+
|
|
1167
|
+
{
|
|
1168
|
+
status: 200,
|
|
1169
|
+
body: archive_data,
|
|
1170
|
+
headers: {
|
|
1171
|
+
'Content-Type' => 'application/gzip',
|
|
1172
|
+
'Content-Disposition' => "attachment; filename=\"#{archive_name}\"",
|
|
1173
|
+
'Content-Length' => archive_data.length.to_s
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
else
|
|
1177
|
+
# Direct file download
|
|
1178
|
+
file_data = File.binread(full_path)
|
|
1179
|
+
filename = File.basename(safe_path)
|
|
1180
|
+
content_type = detect_content_type(full_path)
|
|
1181
|
+
|
|
1182
|
+
# For binary files, use application/octet-stream
|
|
1183
|
+
content_disposition = if content_type == 'application/octet-stream'
|
|
1184
|
+
"attachment; filename=\"#{filename}\""
|
|
1185
|
+
else
|
|
1186
|
+
# For text files, allow inline viewing
|
|
1187
|
+
"inline; filename=\"#{filename}\""
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
{
|
|
1191
|
+
status: 200,
|
|
1192
|
+
body: file_data,
|
|
1193
|
+
headers: {
|
|
1194
|
+
'Content-Type' => content_type,
|
|
1195
|
+
'Content-Disposition' => content_disposition,
|
|
1196
|
+
'Content-Length' => file_data.length.to_s
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
end
|
|
1200
|
+
rescue StandardError => e
|
|
1201
|
+
workspace_error_response(500, 'DownloadError', "Download failed: #{e.message}")
|
|
1202
|
+
end
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
private
|
|
1206
|
+
|
|
1207
|
+
# Sanitize workspace path to prevent directory traversal attacks
|
|
1208
|
+
def sanitize_workspace_path(path)
|
|
1209
|
+
# Remove leading slash and resolve relative paths
|
|
1210
|
+
clean_path = path.sub(%r{^/+}, '')
|
|
1211
|
+
|
|
1212
|
+
# Resolve and normalize the path
|
|
1213
|
+
normalized = File.expand_path(clean_path, '/')
|
|
1214
|
+
|
|
1215
|
+
# Ensure it doesn't escape the root
|
|
1216
|
+
return '' if normalized == '/' || !normalized.start_with?('/')
|
|
1217
|
+
|
|
1218
|
+
# Remove leading slash for joining with workspace_path
|
|
1219
|
+
normalized[1..]
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
# List directory contents
|
|
1223
|
+
def list_directory_contents(full_path, requested_path)
|
|
1224
|
+
entries = Dir.entries(full_path).reject { |entry| entry.start_with?('.') }
|
|
1225
|
+
|
|
1226
|
+
files = entries.map do |entry|
|
|
1227
|
+
entry_path = File.join(full_path, entry)
|
|
1228
|
+
relative_path = File.join(requested_path == '/' ? '' : requested_path, entry)
|
|
1229
|
+
|
|
1230
|
+
{
|
|
1231
|
+
name: entry,
|
|
1232
|
+
path: "/#{relative_path}".gsub(%r{/+}, '/'),
|
|
1233
|
+
type: File.directory?(entry_path) ? 'directory' : 'file',
|
|
1234
|
+
size: File.directory?(entry_path) ? nil : File.size(entry_path),
|
|
1235
|
+
modified: File.mtime(entry_path).iso8601
|
|
1236
|
+
}
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
files.sort_by! { |f| [f[:type] == 'directory' ? 0 : 1, f[:name]] }
|
|
1240
|
+
|
|
1241
|
+
{
|
|
1242
|
+
status: 200,
|
|
1243
|
+
body: {
|
|
1244
|
+
path: requested_path,
|
|
1245
|
+
files: files,
|
|
1246
|
+
total: files.length
|
|
1247
|
+
}.to_json,
|
|
1248
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1249
|
+
}
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# Get file info for a single file
|
|
1253
|
+
def get_file_info(full_path, requested_path)
|
|
1254
|
+
{
|
|
1255
|
+
status: 200,
|
|
1256
|
+
body: {
|
|
1257
|
+
path: requested_path,
|
|
1258
|
+
type: 'file',
|
|
1259
|
+
size: File.size(full_path),
|
|
1260
|
+
modified: File.mtime(full_path).iso8601
|
|
1261
|
+
}.to_json,
|
|
1262
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1263
|
+
}
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
# Detect content type based on file extension
|
|
1267
|
+
def detect_content_type(file_path)
|
|
1268
|
+
case File.extname(file_path).downcase
|
|
1269
|
+
when '.txt', '.log' then 'text/plain'
|
|
1270
|
+
when '.json' then 'application/json'
|
|
1271
|
+
when '.yml', '.yaml' then 'application/x-yaml'
|
|
1272
|
+
when '.md' then 'text/markdown'
|
|
1273
|
+
when '.html' then 'text/html'
|
|
1274
|
+
when '.css' then 'text/css'
|
|
1275
|
+
when '.js' then 'application/javascript'
|
|
1276
|
+
when '.py' then 'text/x-python'
|
|
1277
|
+
when '.rb' then 'text/x-ruby'
|
|
1278
|
+
else 'application/octet-stream'
|
|
1279
|
+
end
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
# Generate workspace API error response
|
|
1283
|
+
def workspace_error_response(status, error_type, message)
|
|
1284
|
+
{
|
|
1285
|
+
status: status,
|
|
1286
|
+
body: {
|
|
1287
|
+
error: error_type,
|
|
1288
|
+
message: message
|
|
1289
|
+
}.to_json,
|
|
1290
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
1291
|
+
}
|
|
1292
|
+
end
|
|
704
1293
|
end
|
|
705
1294
|
end
|
|
706
1295
|
end
|