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 +7 -0
- data/exe/brute-client +290 -0
- data/exe/brute-server +16 -0
- data/exe/brute-server-demo +35 -0
- data/lib/brute_rack/app.rb +139 -0
- data/lib/brute_rack/endpoints/config.rb +37 -0
- data/lib/brute_rack/endpoints/files.rb +104 -0
- data/lib/brute_rack/endpoints/flow.rb +46 -0
- data/lib/brute_rack/endpoints/global.rb +41 -0
- data/lib/brute_rack/endpoints/logging.rb +25 -0
- data/lib/brute_rack/endpoints/messages.rb +107 -0
- data/lib/brute_rack/endpoints/path_vcs.rb +32 -0
- data/lib/brute_rack/endpoints/provider.rb +16 -0
- data/lib/brute_rack/endpoints/sessions.rb +120 -0
- data/lib/brute_rack/endpoints/tools.rb +44 -0
- data/lib/brute_rack/event_bus.rb +33 -0
- data/lib/brute_rack/session_registry.rb +107 -0
- data/lib/brute_rack/sse.rb +32 -0
- data/lib/brute_rack.rb +36 -0
- metadata +101 -0
|
@@ -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"
|