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.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+
5
+ module BruteRack
6
+ module Endpoints
7
+ # GET /global/health → { healthy: true, version: "..." }
8
+ # GET /global/event → SSE stream of all bus events
9
+ module Global
10
+ def self.health(_env, **)
11
+ [200, HEADERS_JSON,
12
+ [JSON.generate(healthy: true, version: Brute::VERSION)]]
13
+ end
14
+
15
+ def self.event(_env, event_bus:, **)
16
+ sse = BruteRack::SSE.new
17
+
18
+ subscriber = event_bus.subscribe do |event|
19
+ sse.event(event[:type], **(event[:data] || {}), session_id: event[:session_id])
20
+ end
21
+
22
+ # Send initial connected event
23
+ sse.event("server.connected", version: Brute::VERSION)
24
+
25
+ # Keep the stream open until the client disconnects.
26
+ # Falcon will detect the closed connection and raise Async::Stop
27
+ # which tears down the task, at which point we unsubscribe.
28
+ Async do
29
+ sleep # block forever — events are pushed by the bus
30
+ rescue
31
+ # Client disconnected or server shutting down
32
+ ensure
33
+ event_bus.unsubscribe(subscriber)
34
+ sse.close
35
+ end
36
+
37
+ [200, {"content-type" => "text/event-stream", "cache-control" => "no-cache"}, sse.body]
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteRack
4
+ module Endpoints
5
+ # POST /log → { service, level, message, extra? }
6
+ module Logging
7
+ LEVELS = %w[debug info warn error fatal].freeze
8
+
9
+ def self.create(env, logger:, **)
10
+ input = env["rack.input"].read
11
+ body = input.empty? ? {} : JSON.parse(input)
12
+
13
+ level = body["level"] || "info"
14
+ level = "info" unless LEVELS.include?(level)
15
+ message = "[#{body["service"] || "client"}] #{body["message"]}"
16
+
17
+ logger.send(level.to_sym, message)
18
+
19
+ [200, HEADERS_JSON, [JSON.generate(true)]]
20
+ rescue => e
21
+ [500, HEADERS_JSON, [JSON.generate(error: e.message)]]
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "securerandom"
5
+
6
+ module BruteRack
7
+ module Endpoints
8
+ # GET /session/:id/message → list messages
9
+ # POST /session/:id/message → send message (blocking)
10
+ # GET /session/:id/message/:messageID → get specific message
11
+ # POST /session/:id/prompt_async → send message (fire-and-forget)
12
+ # POST /session/:id/shell → execute shell command
13
+ module Messages
14
+ def self.list(_env, id:, registry:, **)
15
+ orch = registry.get(id)
16
+ return [404, HEADERS_JSON, [JSON.generate(error: "session not found")]] unless orch
17
+
18
+ orch.context.messages.to_a.compact.each_with_index.map do |msg, i|
19
+ {
20
+ id: i,
21
+ role: msg.respond_to?(:role) ? msg.role.to_s : "unknown",
22
+ content: msg.respond_to?(:content) ? msg.content.to_s[0..10_000] : nil,
23
+ has_tool_calls: msg.respond_to?(:functions) && msg.functions&.any?,
24
+ }
25
+ end.then do |messages|
26
+ [200, HEADERS_JSON, [JSON.generate(messages)]]
27
+ end
28
+ end
29
+
30
+ def self.send_message(env, id:, registry:, cwd:, **)
31
+ parse_body(env).then do |body|
32
+ parts = body["parts"]
33
+ text = if parts.is_a?(Array)
34
+ parts.filter_map { |p| p["text"] if p["type"] == "text" }.join("\n")
35
+ else
36
+ body["message"] || body.dig("parts", 0, "text") || ""
37
+ end
38
+
39
+ registry.run(id, text, cwd: body["cwd"] || cwd).then do |response|
40
+ message_id = SecureRandom.uuid
41
+ {
42
+ info: { id: message_id, role: "assistant", session_id: id },
43
+ parts: [{ type: "text", text: response&.content }],
44
+ }.then do |result|
45
+ [200, HEADERS_JSON, [JSON.generate(result)]]
46
+ end
47
+ end
48
+ end
49
+ rescue => e
50
+ [500, HEADERS_JSON, [JSON.generate(error: e.message)]]
51
+ end
52
+
53
+ def self.get_message(_env, id:, message_id:, registry:, **)
54
+ orch = registry.get(id)
55
+ return [404, HEADERS_JSON, [JSON.generate(error: "session not found")]] unless orch
56
+
57
+ idx = message_id.match?(/\A\d+\z/) ? message_id.to_i : nil
58
+ msg = idx ? orch.context.messages.to_a.compact[idx] : nil
59
+
60
+ if msg
61
+ {
62
+ info: { id: message_id, role: msg.respond_to?(:role) ? msg.role.to_s : "unknown", session_id: id },
63
+ parts: [{ type: "text", text: msg.respond_to?(:content) ? msg.content.to_s : nil }],
64
+ }.then { |result| [200, HEADERS_JSON, [JSON.generate(result)]] }
65
+ else
66
+ [404, HEADERS_JSON, [JSON.generate(error: "message not found")]]
67
+ end
68
+ end
69
+
70
+ def self.prompt_async(env, id:, registry:, cwd:, event_bus:, **)
71
+ parse_body(env).then do |body|
72
+ text = body["message"] || body.dig("parts", 0, "text") || ""
73
+
74
+ Async do
75
+ registry.run(id, text, cwd: body["cwd"] || cwd)
76
+ rescue => e
77
+ event_bus.publish(type: "message.error", session_id: id, data: { error: e.message })
78
+ end
79
+
80
+ [204, {}, []]
81
+ end
82
+ end
83
+
84
+ def self.shell(env, id:, registry:, cwd:, **)
85
+ parse_body(env).then do |body|
86
+ Brute::Tools::Shell.new.call(
87
+ command: body["command"],
88
+ cwd: body["cwd"] || cwd,
89
+ ).then do |result|
90
+ message_id = SecureRandom.uuid
91
+ {
92
+ info: { id: message_id, role: "tool", session_id: id },
93
+ parts: [{ type: "tool-result", name: "shell", result: result }],
94
+ }.then { |r| [200, HEADERS_JSON, [JSON.generate(r)]] }
95
+ end
96
+ end
97
+ rescue => e
98
+ [500, HEADERS_JSON, [JSON.generate(error: e.message)]]
99
+ end
100
+
101
+ def self.parse_body(env)
102
+ input = env["rack.input"].read
103
+ input.empty? ? {} : JSON.parse(input)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module BruteRack
6
+ module Endpoints
7
+ # GET /path → { cwd: "..." }
8
+ # GET /vcs → { branch, remote, dirty, ... }
9
+ module PathVcs
10
+ def self.path(_env, cwd:, **)
11
+ [200, HEADERS_JSON, [JSON.generate(cwd: cwd)]]
12
+ end
13
+
14
+ def self.vcs(_env, cwd:, **)
15
+ branch, _ = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD", chdir: cwd)
16
+ remote, _ = Open3.capture2("git", "remote", "get-url", "origin", chdir: cwd)
17
+ status, _ = Open3.capture2("git", "status", "--porcelain", chdir: cwd)
18
+ sha, _ = Open3.capture2("git", "rev-parse", "--short", "HEAD", chdir: cwd)
19
+
20
+ {
21
+ branch: branch.strip,
22
+ remote: remote.strip,
23
+ sha: sha.strip,
24
+ dirty: !status.strip.empty?,
25
+ changed_files: status.lines.size,
26
+ }.then { |info| [200, HEADERS_JSON, [JSON.generate(info)]] }
27
+ rescue => e
28
+ [200, HEADERS_JSON, [JSON.generate(error: "not a git repository", message: e.message)]]
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteRack
4
+ module Endpoints
5
+ # GET /provider → { all: [...], default: {...}, connected: [...] }
6
+ module Provider
7
+ def self.list(_env, **)
8
+ all_providers = Endpoints::Config.available_providers
9
+ connected = all_providers.map { |p| p[:id] }
10
+ defaults = all_providers.each_with_object({}) { |p, h| h[p[:id]] = p[:models].first }
11
+
12
+ [200, HEADERS_JSON, [JSON.generate(all: all_providers, default: defaults, connected: connected)]]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteRack
4
+ module Endpoints
5
+ # GET /session → list sessions
6
+ # POST /session → create session { parentID?, title? }
7
+ # GET /session/status → { session_id: status, ... }
8
+ # GET /session/:id → session details
9
+ # PATCH /session/:id → update title { title }
10
+ # DELETE /session/:id → delete session
11
+ # POST /session/:id/abort → abort running session
12
+ # POST /session/:id/fork → fork session { messageID? }
13
+ # POST /session/:id/summarize → compact session { providerID, modelID }
14
+ # GET /session/:id/todo → todo list
15
+ module Sessions
16
+ def self.list(_env, **)
17
+ Brute::Session.list.then do |sessions|
18
+ [200, HEADERS_JSON, [JSON.generate(sessions)]]
19
+ end
20
+ end
21
+
22
+ def self.create(env, registry:, **)
23
+ parse_body(env).then do |body|
24
+ id = SecureRandom.uuid
25
+ # Don't eagerly create orchestrator — that requires an API key.
26
+ # The orchestrator is created lazily when a message is sent.
27
+ Brute::Session.new(id: id)
28
+ [200, HEADERS_JSON, [JSON.generate(id: id, title: body["title"])]]
29
+ end
30
+ end
31
+
32
+ def self.status(_env, registry:, **)
33
+ [200, HEADERS_JSON, [JSON.generate(registry.all_statuses)]]
34
+ end
35
+
36
+ def self.get(_env, id:, **)
37
+ Brute::Session.list.then do |sessions|
38
+ sessions.find { |s| s[:id] == id }.then do |found|
39
+ if found
40
+ [200, HEADERS_JSON, [JSON.generate(found)]]
41
+ else
42
+ [404, HEADERS_JSON, [JSON.generate(error: "session not found")]]
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def self.update(env, id:, **)
49
+ parse_body(env).then do |body|
50
+ # We store title in the metadata sidecar
51
+ meta_dir = File.join(Dir.home, ".forge", "sessions")
52
+ meta_path = File.join(meta_dir, "#{id}.meta.json")
53
+ if File.exist?(meta_path)
54
+ JSON.parse(File.read(meta_path)).then do |meta|
55
+ meta["title"] = body["title"] if body["title"]
56
+ File.write(meta_path, JSON.generate(meta))
57
+ [200, HEADERS_JSON, [JSON.generate(meta)]]
58
+ end
59
+ else
60
+ [404, HEADERS_JSON, [JSON.generate(error: "session not found")]]
61
+ end
62
+ end
63
+ end
64
+
65
+ def self.delete(_env, id:, registry:, **)
66
+ registry.remove(id)
67
+ Brute::Session.new(id: id).delete
68
+ [200, HEADERS_JSON, [JSON.generate(true)]]
69
+ end
70
+
71
+ def self.abort(_env, id:, registry:, **)
72
+ registry.abort(id)
73
+ [200, HEADERS_JSON, [JSON.generate(true)]]
74
+ end
75
+
76
+ def self.fork(env, id:, registry:, **)
77
+ parse_body(env).then do |body|
78
+ new_id = SecureRandom.uuid
79
+ # Create a new session, optionally copying from source
80
+ registry.get_or_create(new_id)
81
+ source_session = registry.session(id)
82
+ if source_session && File.exist?(source_session.path)
83
+ new_session = registry.session(new_id)
84
+ FileUtils.cp(source_session.path, new_session.path)
85
+ meta_src = source_session.path.sub(/\.json$/, ".meta.json")
86
+ if File.exist?(meta_src)
87
+ meta_dst = new_session.path.sub(/\.json$/, ".meta.json")
88
+ FileUtils.cp(meta_src, meta_dst)
89
+ end
90
+ end
91
+ [200, HEADERS_JSON, [JSON.generate(id: new_id, forked_from: id)]]
92
+ end
93
+ end
94
+
95
+ def self.summarize(env, id:, registry:, **)
96
+ orch = registry.get(id)
97
+ return [404, HEADERS_JSON, [JSON.generate(error: "session not found")]] unless orch
98
+
99
+ compactor = Brute::Compactor.new(Brute.provider)
100
+ messages = orch.context.messages.to_a.compact
101
+ compactor.compact(messages).then do |result|
102
+ if result
103
+ [200, HEADERS_JSON, [JSON.generate(true)]]
104
+ else
105
+ [200, HEADERS_JSON, [JSON.generate(false)]]
106
+ end
107
+ end
108
+ end
109
+
110
+ def self.todo(_env, id:, **)
111
+ [200, HEADERS_JSON, [JSON.generate(Brute::TodoStore.all)]]
112
+ end
113
+
114
+ def self.parse_body(env)
115
+ input = env["rack.input"].read
116
+ input.empty? ? {} : JSON.parse(input)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteRack
4
+ module Endpoints
5
+ # GET /experimental/tool/ids → list tool names
6
+ # GET /experimental/tool?provider=&model= → list tools with schemas
7
+ module Tools
8
+ def self.ids(_env, **)
9
+ names = LLM::Function.registry.map(&:name)
10
+ [200, HEADERS_JSON, [JSON.generate(names)]]
11
+ end
12
+
13
+ def self.list(_env, **)
14
+ tools = LLM::Function.registry.map do |fn|
15
+ schema = if fn.params
16
+ { type: "object", properties: serialize_params(fn.params) }
17
+ else
18
+ { type: "object", properties: {} }
19
+ end
20
+
21
+ {
22
+ name: fn.name,
23
+ description: fn.description,
24
+ input_schema: schema,
25
+ }
26
+ end
27
+ [200, HEADERS_JSON, [JSON.generate(tools)]]
28
+ end
29
+
30
+ def self.serialize_params(params)
31
+ return {} unless params.respond_to?(:properties)
32
+ params.properties.transform_values do |prop|
33
+ {
34
+ type: prop.class.name.split("::").last.downcase,
35
+ description: prop.respond_to?(:description) ? prop.description : nil,
36
+ required: prop.respond_to?(:required) ? prop.required : nil,
37
+ }.compact
38
+ end
39
+ rescue
40
+ {}
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteRack
4
+ # In-memory pub/sub event bus. Endpoints publish events, SSE streams subscribe.
5
+ #
6
+ # bus = EventBus.new
7
+ # bus.subscribe { |event| puts event }
8
+ # bus.publish(type: "content.delta", session_id: "abc", data: { text: "hi" })
9
+ #
10
+ class EventBus
11
+ def initialize
12
+ @subscribers = []
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def publish(event)
17
+ @mutex.synchronize { @subscribers.each { |cb| cb.call(event) } }
18
+ end
19
+
20
+ def subscribe(&block)
21
+ @mutex.synchronize { @subscribers << block }
22
+ block
23
+ end
24
+
25
+ def unsubscribe(block)
26
+ @mutex.synchronize { @subscribers.delete(block) }
27
+ end
28
+
29
+ def subscriber_count
30
+ @mutex.synchronize { @subscribers.size }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteRack
4
+ # Tracks active orchestrators per session. Enables abort, status queries,
5
+ # and multi-session management without creating throwaway orchestrators.
6
+ #
7
+ # registry = SessionRegistry.new(event_bus: bus, cwd: "/project")
8
+ # orch = registry.get_or_create("session-id")
9
+ # registry.status("session-id") # => :idle
10
+ #
11
+ class SessionRegistry
12
+ STATUSES = %i[idle running completed errored].freeze
13
+
14
+ def initialize(event_bus:, cwd: Dir.pwd, agent_options: {})
15
+ @event_bus = event_bus
16
+ @cwd = cwd
17
+ @agent_options = agent_options
18
+ @sessions = {} # id => { orchestrator:, status:, session: }
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def get_or_create(id, cwd: nil)
23
+ @mutex.synchronize do
24
+ @sessions[id] ||= build_entry(id, cwd || @cwd)
25
+ @sessions[id][:orchestrator]
26
+ end
27
+ end
28
+
29
+ def get(id)
30
+ @mutex.synchronize { @sessions.dig(id, :orchestrator) }
31
+ end
32
+
33
+ def session(id)
34
+ @mutex.synchronize { @sessions.dig(id, :session) }
35
+ end
36
+
37
+ def status(id)
38
+ @mutex.synchronize { @sessions.dig(id, :status) || :unknown }
39
+ end
40
+
41
+ def set_status(id, status)
42
+ @mutex.synchronize do
43
+ @sessions[id][:status] = status if @sessions[id]
44
+ end
45
+ @event_bus.publish(type: "session.status", session_id: id, data: { status: status })
46
+ end
47
+
48
+ def all_statuses
49
+ @mutex.synchronize do
50
+ @sessions.transform_values { |v| v[:status] }
51
+ end
52
+ end
53
+
54
+ def ids
55
+ @mutex.synchronize { @sessions.keys }
56
+ end
57
+
58
+ def remove(id)
59
+ @mutex.synchronize { @sessions.delete(id) }
60
+ end
61
+
62
+ def abort(id)
63
+ @mutex.synchronize { @sessions.dig(id, :orchestrator) }&.abort!
64
+ set_status(id, :idle)
65
+ true
66
+ end
67
+
68
+ # Run a message through a session's orchestrator with event publishing.
69
+ def run(id, message, cwd: nil)
70
+ orch = get_or_create(id, cwd: cwd)
71
+ set_status(id, :running)
72
+ @event_bus.publish(type: "message.start", session_id: id, data: { message: message })
73
+
74
+ orch.run(message).then do |response|
75
+ set_status(id, :idle)
76
+ @event_bus.publish(type: "message.complete", session_id: id, data: {})
77
+ response
78
+ end
79
+ rescue => e
80
+ set_status(id, :errored)
81
+ @event_bus.publish(type: "message.error", session_id: id, data: { error: e.message })
82
+ raise
83
+ end
84
+
85
+ private
86
+
87
+ def build_entry(id, cwd)
88
+ session = Brute::Session.new(id: id, dir: @agent_options[:session_dir])
89
+ orch = Brute.agent(
90
+ cwd: cwd,
91
+ session: session,
92
+ **@agent_options.except(:session_dir),
93
+ on_content: ->(text) {
94
+ @event_bus.publish(type: "content.delta", session_id: id, data: { text: text }) if text
95
+ },
96
+ on_tool_call: ->(name, args) {
97
+ @event_bus.publish(type: "tool.call", session_id: id, data: { name: name, args: args.is_a?(Hash) ? args : {} })
98
+ },
99
+ on_tool_result: ->(name, result) {
100
+ success = !(result.is_a?(Hash) && result[:error])
101
+ @event_bus.publish(type: "tool.result", session_id: id, data: { name: name, success: success })
102
+ },
103
+ )
104
+ { orchestrator: orch, session: session, status: :idle }
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/http/body/writable"
4
+ require "json"
5
+
6
+ module BruteRack
7
+ # Server-Sent Events helper.
8
+ #
9
+ # Wraps Async::HTTP::Body::Writable to produce SSE-formatted chunks.
10
+ # Falcon streams each write to the client immediately — no buffering.
11
+ #
12
+ # sse = BruteRack::SSE.new
13
+ # sse.event("content", text: "Hello") # writes "event: content\ndata: {...}\n\n"
14
+ # sse.close # signals end of stream
15
+ # sse.body # the streamable body for Rack response
16
+ #
17
+ class SSE
18
+ attr_reader :body
19
+
20
+ def initialize
21
+ @body = Async::HTTP::Body::Writable.new
22
+ end
23
+
24
+ def event(type, **data)
25
+ @body.write("event: #{type}\ndata: #{JSON.generate(data)}\n\n")
26
+ end
27
+
28
+ def close
29
+ @body.close
30
+ end
31
+ end
32
+ end
data/lib/brute_rack.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brute"
4
+ require "json"
5
+ require "rack"
6
+
7
+ module BruteRack
8
+ VERSION = "0.1.0"
9
+
10
+ # Not frozen — WEBrick mutates response headers.
11
+ HEADERS_JSON = {"content-type" => "application/json"}
12
+
13
+ module Endpoints
14
+ HEADERS_JSON = BruteRack::HEADERS_JSON
15
+ end
16
+ end
17
+
18
+ # Infrastructure
19
+ require_relative "brute_rack/sse"
20
+ require_relative "brute_rack/event_bus"
21
+ require_relative "brute_rack/session_registry"
22
+
23
+ # Endpoints (OpenCode-compatible API)
24
+ require_relative "brute_rack/endpoints/global"
25
+ require_relative "brute_rack/endpoints/sessions"
26
+ require_relative "brute_rack/endpoints/messages"
27
+ require_relative "brute_rack/endpoints/files"
28
+ require_relative "brute_rack/endpoints/tools"
29
+ require_relative "brute_rack/endpoints/config"
30
+ require_relative "brute_rack/endpoints/provider"
31
+ require_relative "brute_rack/endpoints/path_vcs"
32
+ require_relative "brute_rack/endpoints/logging"
33
+ require_relative "brute_rack/endpoints/flow"
34
+
35
+ # Rack app (router)
36
+ require_relative "brute_rack/app"