brute_rack 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b7e3f20b9ac6c79ca75305edcd49c5e73f407a083a26e00dc02065413aa0d96
4
+ data.tar.gz: f3a6c8efd975fe580c3608d5568930d250d8f983cbf541a20eefc8dfa895c5ec
5
+ SHA512:
6
+ metadata.gz: f286b4d40dce441de9d2055509e72606a601bbd8a34355bfeb6c2038ae67271a942cdaed4f5747e75d03f660a2795a0f9bfa1d5fc765f42255ed6ef038be93a8
7
+ data.tar.gz: d47fcedfff3ce7845ae555586ba4b35bda21bc5833698fff0af9f34dba176d8fecaeee851c7c4eea519f27122d0fe0352dbf929fd8eff33152aaa8c6d925fdcb
data/exe/brute-client ADDED
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # CLI client for the Brute HTTP server.
5
+ #
6
+ # Usage:
7
+ # brute-client health
8
+ # brute-client prompt "Fix the failing tests"
9
+ # brute-client prompt --session abc123 "Continue working"
10
+ # brute-client stream "Refactor the auth module"
11
+ # brute-client sessions
12
+ # brute-client sessions create --title "Bug fix"
13
+ # brute-client sessions delete <id>
14
+ # brute-client files list .
15
+ # brute-client files read app.rb
16
+ # brute-client files search "def initialize"
17
+ # brute-client files status
18
+ # brute-client tools
19
+ # brute-client config
20
+ # brute-client vcs
21
+ # brute-client shell "ls -la"
22
+
23
+ require "net/http"
24
+ require "json"
25
+ require "uri"
26
+ require "optparse"
27
+
28
+ class BruteClient
29
+ def initialize(base_url)
30
+ @base = URI.parse(base_url)
31
+ end
32
+
33
+ def get(path, query: nil)
34
+ uri = URI.join(@base, path)
35
+ uri.query = URI.encode_www_form(query) if query
36
+ Net::HTTP.get_response(uri).then { |r| handle(r) }
37
+ end
38
+
39
+ def post(path, body: {})
40
+ uri = URI.join(@base, path)
41
+ Net::HTTP.post(uri, JSON.generate(body), "Content-Type" => "application/json").then { |r| handle(r) }
42
+ end
43
+
44
+ def delete(path)
45
+ uri = URI.join(@base, path)
46
+ req = Net::HTTP::Delete.new(uri)
47
+ Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }.then { |r| handle(r) }
48
+ end
49
+
50
+ def stream(path, body: {})
51
+ uri = URI.join(@base, path)
52
+ req = Net::HTTP::Post.new(uri)
53
+ req["Content-Type"] = "application/json"
54
+ req["Accept"] = "text/event-stream"
55
+ req.body = JSON.generate(body)
56
+
57
+ Net::HTTP.start(uri.host, uri.port) do |http|
58
+ http.request(req) do |response|
59
+ response.read_body do |chunk|
60
+ chunk.lines.each do |line|
61
+ case line
62
+ when /\Aevent: (.+)/
63
+ @current_event = $1.strip
64
+ when /\Adata: (.+)/
65
+ handle_sse_event(@current_event, $1.strip)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def handle(response)
76
+ case response.code.to_i
77
+ when 200..299
78
+ begin
79
+ JSON.parse(response.body).then { |data| pp data }
80
+ rescue JSON::ParserError
81
+ puts response.body
82
+ end
83
+ when 204
84
+ puts "(no content)"
85
+ else
86
+ $stderr.puts "Error #{response.code}: #{response.body}"
87
+ exit 1
88
+ end
89
+ end
90
+
91
+ def handle_sse_event(type, data)
92
+ parsed = JSON.parse(data) rescue { "raw" => data }
93
+ case type
94
+ when "content"
95
+ print parsed["text"]
96
+ when "tool_call"
97
+ $stderr.puts "\n--- [#{parsed["name"]}] ---"
98
+ when "tool_result"
99
+ $stderr.puts " [#{parsed["success"] ? "ok" : "FAILED"}]"
100
+ when "done"
101
+ puts
102
+ $stderr.puts "Tools: #{parsed["tools_called"]&.join(", ")}" if parsed["tools_called"]&.any?
103
+ when "error"
104
+ $stderr.puts "Error: #{parsed["message"]}"
105
+ when "server.connected"
106
+ $stderr.puts "Connected to brute-server #{parsed["version"]}"
107
+ else
108
+ $stderr.puts "[#{type}] #{data}"
109
+ end
110
+ end
111
+ end
112
+
113
+ # --- CLI ---
114
+
115
+ base_url = ENV["BRUTE_SERVER"] || "http://127.0.0.1:9292"
116
+ client = BruteClient.new(base_url)
117
+
118
+ command = ARGV.shift
119
+
120
+ case command
121
+ when "health"
122
+ client.get("/global/health")
123
+
124
+ when "prompt"
125
+ session_id = nil
126
+ parser = OptionParser.new do |opts|
127
+ opts.on("-s", "--session ID") { |id| session_id = id }
128
+ end
129
+ parser.order!(ARGV)
130
+ message = ARGV.join(" ")
131
+ abort "Usage: brute-client prompt \"your message\"" if message.empty?
132
+
133
+ session_id ||= SecureRandom.uuid
134
+ client.post("/session/#{session_id}/message", body: {
135
+ parts: [{ type: "text", text: message }],
136
+ })
137
+
138
+ when "stream"
139
+ message = ARGV.join(" ")
140
+ abort "Usage: brute-client stream \"your message\"" if message.empty?
141
+
142
+ session_id = SecureRandom.uuid
143
+ client.post("/session", body: { title: message[0..50] })
144
+ # Use prompt_async + event stream
145
+ # For now, use blocking message which returns the full response
146
+ client.post("/session/#{session_id}/message", body: {
147
+ parts: [{ type: "text", text: message }],
148
+ })
149
+
150
+ when "sessions"
151
+ sub = ARGV.shift
152
+ case sub
153
+ when "create"
154
+ title = nil
155
+ OptionParser.new { |o| o.on("-t", "--title T") { |t| title = t } }.order!(ARGV)
156
+ client.post("/session", body: { title: title })
157
+ when "delete"
158
+ id = ARGV.shift
159
+ abort "Usage: brute-client sessions delete <id>" unless id
160
+ client.delete("/session/#{id}")
161
+ when "status"
162
+ client.get("/session/status")
163
+ when nil
164
+ client.get("/session")
165
+ else
166
+ # Treat as session ID — get details
167
+ client.get("/session/#{sub}")
168
+ end
169
+
170
+ when "messages"
171
+ id = ARGV.shift
172
+ abort "Usage: brute-client messages <session-id>" unless id
173
+ msg_id = ARGV.shift
174
+ if msg_id
175
+ client.get("/session/#{id}/message/#{msg_id}")
176
+ else
177
+ client.get("/session/#{id}/message")
178
+ end
179
+
180
+ when "files"
181
+ sub = ARGV.shift
182
+ case sub
183
+ when "list"
184
+ path = ARGV.shift || "."
185
+ client.get("/file", query: { path: path })
186
+ when "read"
187
+ path = ARGV.shift
188
+ abort "Usage: brute-client files read <path>" unless path
189
+ client.get("/file/content", query: { path: path })
190
+ when "search"
191
+ pattern = ARGV.join(" ")
192
+ abort "Usage: brute-client files search <pattern>" if pattern.empty?
193
+ client.get("/find", query: { pattern: pattern })
194
+ when "find"
195
+ query = ARGV.join(" ")
196
+ abort "Usage: brute-client files find <query>" if query.empty?
197
+ client.get("/find/file", query: { query: query })
198
+ when "status"
199
+ client.get("/file/status")
200
+ else
201
+ abort "Usage: brute-client files {list|read|search|find|status}"
202
+ end
203
+
204
+ when "tools"
205
+ sub = ARGV.shift
206
+ case sub
207
+ when "ids", nil
208
+ client.get("/experimental/tool/ids")
209
+ when "list"
210
+ client.get("/experimental/tool")
211
+ else
212
+ abort "Usage: brute-client tools {ids|list}"
213
+ end
214
+
215
+ when "config"
216
+ sub = ARGV.shift
217
+ case sub
218
+ when "providers"
219
+ client.get("/config/providers")
220
+ when nil
221
+ client.get("/config")
222
+ end
223
+
224
+ when "provider"
225
+ client.get("/provider")
226
+
227
+ when "vcs"
228
+ client.get("/vcs")
229
+
230
+ when "path"
231
+ client.get("/path")
232
+
233
+ when "shell"
234
+ command_str = ARGV.join(" ")
235
+ abort "Usage: brute-client shell \"command\"" if command_str.empty?
236
+ session_id = SecureRandom.uuid
237
+ client.post("/session/#{session_id}/shell", body: { command: command_str })
238
+
239
+ when "todo"
240
+ id = ARGV.shift
241
+ abort "Usage: brute-client todo <session-id>" unless id
242
+ client.get("/session/#{id}/todo")
243
+
244
+ when "abort"
245
+ id = ARGV.shift
246
+ abort "Usage: brute-client abort <session-id>" unless id
247
+ client.post("/session/#{id}/abort")
248
+
249
+ when "log"
250
+ message = ARGV.join(" ")
251
+ abort "Usage: brute-client log \"message\"" if message.empty?
252
+ client.post("/log", body: { service: "cli", level: "info", message: message })
253
+
254
+ when nil, "help", "--help", "-h"
255
+ puts <<~HELP
256
+ Usage: brute-client <command> [args]
257
+
258
+ Commands:
259
+ health Server health check
260
+ prompt [-s session] "message" Send a prompt (blocking)
261
+ stream "message" Send a prompt (streaming)
262
+ sessions List sessions
263
+ sessions create [-t title] Create a session
264
+ sessions delete <id> Delete a session
265
+ sessions status Session statuses
266
+ messages <session-id> [msg-id] List/get messages
267
+ files list [path] List directory
268
+ files read <path> Read file content
269
+ files search <pattern> Search file contents
270
+ files find <query> Find files by name
271
+ files status Git file status
272
+ tools [ids|list] List available tools
273
+ config [providers] Show configuration
274
+ provider List providers
275
+ vcs Git info
276
+ path Current directory
277
+ shell "command" Execute shell command
278
+ todo <session-id> Session todo list
279
+ abort <session-id> Abort running session
280
+ log "message" Write server log entry
281
+
282
+ Environment:
283
+ BRUTE_SERVER Server URL (default: http://127.0.0.1:9292)
284
+ HELP
285
+
286
+ else
287
+ $stderr.puts "Unknown command: #{command}"
288
+ $stderr.puts "Run 'brute-client help' for usage."
289
+ exit 1
290
+ end
data/exe/brute-server ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Start the Brute HTTP server.
5
+ #
6
+ # brute-server # Run ./service.rb in the current directory
7
+ # brute-server /path/to/service.rb # Run a specific service config
8
+ #
9
+ # Requires async-service and falcon.
10
+
11
+ config = ARGV.shift || File.join(Dir.pwd, "service.rb")
12
+
13
+ abort "brute-server: #{config} not found" unless File.exist?(config)
14
+
15
+ $stderr.puts "brute-server: #{config}"
16
+ exec("async-service", config, *ARGV)
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run one of the bundled example services.
5
+ #
6
+ # brute-server-demo # Run the default example
7
+ # brute-server-demo default # Same as above
8
+ # brute-server-demo restricted # Read-only research agent
9
+ # brute-server-demo multi-agent # Two agents on different ports
10
+
11
+ EXAMPLES_DIR = File.expand_path("../examples", __dir__)
12
+ AVAILABLE = Dir.children(EXAMPLES_DIR)
13
+ .select { |d| File.exist?(File.join(EXAMPLES_DIR, d, "service.rb")) }
14
+ .sort
15
+
16
+ name = ARGV.shift || "default"
17
+
18
+ if %w[-h --help help].include?(name)
19
+ $stderr.puts "Usage: brute-server-demo [example]"
20
+ $stderr.puts
21
+ $stderr.puts "Available examples:"
22
+ AVAILABLE.each { |e| $stderr.puts " #{e}" }
23
+ exit
24
+ end
25
+
26
+ service_rb = File.join(EXAMPLES_DIR, name, "service.rb")
27
+
28
+ unless File.exist?(service_rb)
29
+ $stderr.puts "Unknown example: #{name}"
30
+ $stderr.puts "Available: #{AVAILABLE.join(", ")}"
31
+ exit 1
32
+ end
33
+
34
+ brute_server = File.expand_path("brute-server", __dir__)
35
+ exec(brute_server, service_rb, *ARGV)
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module BruteRack
6
+ # Rack application implementing the OpenCode-compatible server API.
7
+ # Routes requests by method + path to endpoint modules.
8
+ #
9
+ # require "brute_rack"
10
+ # BruteRack::App.new
11
+ #
12
+ class App
13
+ def self.not_found
14
+ [404, {"content-type" => "application/json"}, ['{"error":"not found"}']]
15
+ end
16
+
17
+ def self.method_not_allowed
18
+ [405, {"content-type" => "application/json"}, ['{"error":"method not allowed"}']]
19
+ end
20
+
21
+ def initialize(cwd: Dir.pwd, agent_options: {})
22
+ @cwd = cwd
23
+ @logger = Logger.new($stderr, level: Logger::INFO)
24
+ @event_bus = EventBus.new
25
+ @registry = SessionRegistry.new(event_bus: @event_bus, cwd: cwd, agent_options: agent_options)
26
+ end
27
+
28
+ def call(env)
29
+ method = env["REQUEST_METHOD"]
30
+ path = env["PATH_INFO"]
31
+
32
+ case method
33
+ when "GET" then route_get(path, env)
34
+ when "POST" then route_post(path, env)
35
+ when "PATCH" then route_patch(path, env)
36
+ when "DELETE" then route_delete(path, env)
37
+ else self.class.method_not_allowed
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def ctx
44
+ { cwd: @cwd, event_bus: @event_bus, registry: @registry, logger: @logger }
45
+ end
46
+
47
+ # ----------------------------------------------------------------
48
+ # GET
49
+ # ----------------------------------------------------------------
50
+ def route_get(path, env)
51
+ case path
52
+ # Global
53
+ when "/global/health" then Endpoints::Global.health(env, **ctx)
54
+ when "/global/event" then Endpoints::Global.event(env, **ctx)
55
+ when "/event" then Endpoints::Global.event(env, **ctx)
56
+ # Sessions
57
+ when "/session" then Endpoints::Sessions.list(env, **ctx)
58
+ when "/session/status" then Endpoints::Sessions.status(env, **ctx)
59
+ when %r{\A/session/([^/]+)/message/(.+)\z}
60
+ Endpoints::Messages.get_message(env, id: $1, message_id: $2, **ctx)
61
+ when %r{\A/session/([^/]+)/message\z}
62
+ Endpoints::Messages.list(env, id: $1, **ctx)
63
+ when %r{\A/session/([^/]+)/todo\z}
64
+ Endpoints::Sessions.todo(env, id: $1, **ctx)
65
+ when %r{\A/session/([^/]+)\z}
66
+ Endpoints::Sessions.get(env, id: $1, **ctx)
67
+ # Files
68
+ when "/find" then Endpoints::Files.find(env, **ctx)
69
+ when "/find/file" then Endpoints::Files.find_file(env, **ctx)
70
+ when "/file/content" then Endpoints::Files.content(env, **ctx)
71
+ when "/file/status" then Endpoints::Files.status(env, **ctx)
72
+ when "/file" then Endpoints::Files.list(env, **ctx)
73
+ # Tools
74
+ when "/experimental/tool/ids" then Endpoints::Tools.ids(env, **ctx)
75
+ when "/experimental/tool" then Endpoints::Tools.list(env, **ctx)
76
+ # Config & Provider
77
+ when "/config/providers" then Endpoints::Config.providers(env, **ctx)
78
+ when "/config" then Endpoints::Config.get(env, **ctx)
79
+ when "/provider" then Endpoints::Provider.list(env, **ctx)
80
+ # Path & VCS
81
+ when "/path" then Endpoints::PathVcs.path(env, **ctx)
82
+ when "/vcs" then Endpoints::PathVcs.vcs(env, **ctx)
83
+ else self.class.not_found
84
+ end
85
+ end
86
+
87
+ # ----------------------------------------------------------------
88
+ # POST
89
+ # ----------------------------------------------------------------
90
+ def route_post(path, env)
91
+ case path
92
+ # Sessions
93
+ when "/session"
94
+ Endpoints::Sessions.create(env, **ctx)
95
+ when %r{\A/session/([^/]+)/message\z}
96
+ Endpoints::Messages.send_message(env, id: $1, **ctx)
97
+ when %r{\A/session/([^/]+)/prompt_async\z}
98
+ Endpoints::Messages.prompt_async(env, id: $1, **ctx)
99
+ when %r{\A/session/([^/]+)/shell\z}
100
+ Endpoints::Messages.shell(env, id: $1, **ctx)
101
+ when %r{\A/session/([^/]+)/abort\z}
102
+ Endpoints::Sessions.abort(env, id: $1, **ctx)
103
+ when %r{\A/session/([^/]+)/fork\z}
104
+ Endpoints::Sessions.fork(env, id: $1, **ctx)
105
+ when %r{\A/session/([^/]+)/summarize\z}
106
+ Endpoints::Sessions.summarize(env, id: $1, **ctx)
107
+ # Flow (brute-specific)
108
+ when "/flow"
109
+ Endpoints::Flow.call(env, **ctx)
110
+ # Logging
111
+ when "/log"
112
+ Endpoints::Logging.create(env, **ctx)
113
+ else self.class.not_found
114
+ end
115
+ end
116
+
117
+ # ----------------------------------------------------------------
118
+ # PATCH
119
+ # ----------------------------------------------------------------
120
+ def route_patch(path, env)
121
+ case path
122
+ when %r{\A/session/([^/]+)\z}
123
+ Endpoints::Sessions.update(env, id: $1, **ctx)
124
+ else self.class.not_found
125
+ end
126
+ end
127
+
128
+ # ----------------------------------------------------------------
129
+ # DELETE
130
+ # ----------------------------------------------------------------
131
+ def route_delete(path, env)
132
+ case path
133
+ when %r{\A/session/([^/]+)\z}
134
+ Endpoints::Sessions.delete(env, id: $1, **ctx)
135
+ else self.class.not_found
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteRack
4
+ module Endpoints
5
+ # GET /config → agent configuration
6
+ # GET /config/providers → available providers and models
7
+ module Config
8
+ PROVIDER_MAP = {
9
+ "ANTHROPIC_API_KEY" => { id: "anthropic", name: "Anthropic", models: ["claude-sonnet-4-20250514", "claude-haiku-4-20250414"] },
10
+ "OPENAI_API_KEY" => { id: "openai", name: "OpenAI", models: ["gpt-4o", "gpt-4o-mini", "o3-mini"] },
11
+ "GOOGLE_API_KEY" => { id: "google", name: "Google", models: ["gemini-2.0-flash", "gemini-2.5-pro"] },
12
+ }.freeze
13
+
14
+ def self.get(_env, cwd:, **)
15
+ {
16
+ version: Brute::VERSION,
17
+ cwd: cwd,
18
+ tools: LLM::Function.registry.map(&:name),
19
+ tool_count: LLM::Function.registry.size,
20
+ providers: available_providers.map { |p| p[:id] },
21
+ }.then { |config| [200, HEADERS_JSON, [JSON.generate(config)]] }
22
+ end
23
+
24
+ def self.providers(_env, **)
25
+ providers = available_providers
26
+ defaults = providers.each_with_object({}) { |p, h| h[p[:id]] = p[:models].first }
27
+ [200, HEADERS_JSON, [JSON.generate(providers: providers, default: defaults)]]
28
+ end
29
+
30
+ def self.available_providers
31
+ PROVIDER_MAP.filter_map do |env_key, info|
32
+ info if ENV[env_key]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module BruteRack
6
+ module Endpoints
7
+ # GET /find?pattern=<pat> → search file contents (ripgrep)
8
+ # GET /find/file?query=<q> → find files by name
9
+ # GET /file?path=<path> → list directory
10
+ # GET /file/content?path=<p> → read file content
11
+ # GET /file/status → git status
12
+ module Files
13
+ def self.find(env, cwd:, **)
14
+ params = parse_query(env)
15
+ pattern = params["pattern"]
16
+ return [400, HEADERS_JSON, [JSON.generate(error: "pattern required")]] unless pattern
17
+
18
+ Brute::Tools::FSSearch.new.call(pattern: pattern, path: cwd).then do |result|
19
+ matches = result[:results].to_s.lines.filter_map do |line|
20
+ if line =~ /\A(.+?):(\d+):(.*)/
21
+ { path: $1, line_number: $2.to_i, lines: $3.strip }
22
+ end
23
+ end
24
+ [200, HEADERS_JSON, [JSON.generate(matches)]]
25
+ end
26
+ end
27
+
28
+ def self.find_file(env, cwd:, **)
29
+ params = parse_query(env)
30
+ query = params["query"]
31
+ return [400, HEADERS_JSON, [JSON.generate(error: "query required")]] unless query
32
+
33
+ limit = (params["limit"] || 50).to_i.clamp(1, 200)
34
+ type_filter = params["type"] # "file" or "directory"
35
+
36
+ cmd = ["find", cwd, "-maxdepth", "8", "-iname", "*#{query}*"]
37
+ cmd += ["-type", "f"] if type_filter == "file"
38
+ cmd += ["-type", "d"] if type_filter == "directory"
39
+
40
+ Open3.capture3(*cmd).then do |stdout, _, _|
41
+ paths = stdout.lines.map(&:strip).reject(&:empty?).first(limit)
42
+ [200, HEADERS_JSON, [JSON.generate(paths)]]
43
+ end
44
+ end
45
+
46
+ def self.list(env, cwd:, **)
47
+ params = parse_query(env)
48
+ path = File.expand_path(params["path"] || ".", cwd)
49
+
50
+ return [404, HEADERS_JSON, [JSON.generate(error: "not found")]] unless File.directory?(path)
51
+
52
+ Dir.entries(path).reject { |f| f.start_with?(".") }.sort.map do |name|
53
+ full = File.join(path, name)
54
+ {
55
+ name: name,
56
+ path: full,
57
+ type: File.directory?(full) ? "directory" : "file",
58
+ size: File.file?(full) ? File.size(full) : nil,
59
+ }
60
+ end.then do |nodes|
61
+ [200, HEADERS_JSON, [JSON.generate(nodes)]]
62
+ end
63
+ end
64
+
65
+ def self.content(env, cwd:, **)
66
+ params = parse_query(env)
67
+ path = params["path"]
68
+ return [400, HEADERS_JSON, [JSON.generate(error: "path required")]] unless path
69
+
70
+ full = File.expand_path(path, cwd)
71
+ return [404, HEADERS_JSON, [JSON.generate(error: "not found")]] unless File.exist?(full)
72
+ return [400, HEADERS_JSON, [JSON.generate(error: "not a file")]] unless File.file?(full)
73
+
74
+ {
75
+ path: full,
76
+ content: File.read(full, encoding: "UTF-8"),
77
+ size: File.size(full),
78
+ lines: File.readlines(full).size,
79
+ }.then do |result|
80
+ [200, HEADERS_JSON, [JSON.generate(result)]]
81
+ end
82
+ rescue Encoding::InvalidByteSequenceError
83
+ [400, HEADERS_JSON, [JSON.generate(error: "binary file")]]
84
+ end
85
+
86
+ def self.status(_env, cwd:, **)
87
+ Open3.capture3("git", "status", "--porcelain", chdir: cwd).then do |stdout, _, st|
88
+ if st.success?
89
+ files = stdout.lines.map { |l|
90
+ { status: l[0..1].strip, path: l[3..].strip }
91
+ }
92
+ [200, HEADERS_JSON, [JSON.generate(files)]]
93
+ else
94
+ [200, HEADERS_JSON, [JSON.generate([])]]
95
+ end
96
+ end
97
+ end
98
+
99
+ def self.parse_query(env)
100
+ Rack::Utils.parse_query(env["QUERY_STRING"] || "")
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteRack
4
+ module Endpoints
5
+ # POST /flow — run a BPMN multi-agent flow.
6
+ #
7
+ # Request: { "message": "...", "cwd": "..." }
8
+ # Response: { "result": {...} }
9
+ #
10
+ # Requires the brute_flow gem to be installed.
11
+ #
12
+ module Flow
13
+ def self.call(env, cwd:)
14
+ require "brute_flow"
15
+
16
+ JSON.parse(env["rack.input"].read).then do |body|
17
+ Brute.flow(cwd: body["cwd"] || cwd, variables: { user_message: body["message"] }) do
18
+ service :router, type: "Brute::Flow::Services::RouterService"
19
+ exclusive_gateway :mode, default: :simple_path do
20
+ branch :fibre_path, condition: '=agent_mode = "fibre"' do
21
+ parallel do
22
+ service :tools, type: "Brute::Flow::Services::ToolSuggestService"
23
+ service :memory, type: "Brute::Flow::Services::MemoryRecallService"
24
+ end
25
+ service :agent, type: "Brute::Flow::Services::AgentService"
26
+ end
27
+ branch :simple_path do
28
+ service :agent, type: "Brute::Flow::Services::AgentService"
29
+ end
30
+ end
31
+ end.then do |runner|
32
+ runner.run.then do |result|
33
+ [200, {"content-type" => "application/json"}, [JSON.generate(result: result)]]
34
+ end
35
+ end
36
+ end
37
+ rescue LoadError
38
+ [501, {"content-type" => "application/json"},
39
+ [JSON.generate(error: "brute_flow gem not installed")]]
40
+ rescue => e
41
+ [500, {"content-type" => "application/json"},
42
+ [JSON.generate(error: e.message)]]
43
+ end
44
+ end
45
+ end
46
+ end