ask-coding-harness 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/CHANGELOG.md +42 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/bin/ach +7 -0
- data/bin/ask-coding-harness +7 -0
- data/lib/ask/coding_harness/agent_runner.rb +260 -0
- data/lib/ask/coding_harness/cli.rb +151 -0
- data/lib/ask/coding_harness/config.rb +119 -0
- data/lib/ask/coding_harness/demo_adapter.rb +253 -0
- data/lib/ask/coding_harness/event_translator.rb +115 -0
- data/lib/ask/coding_harness/runner.rb +109 -0
- data/lib/ask/coding_harness/server.rb +319 -0
- data/lib/ask/coding_harness/store.rb +126 -0
- data/lib/ask/coding_harness/version.rb +7 -0
- data/lib/ask-coding-harness.rb +62 -0
- data/public/assets/index-Cs2ZprmP.js +2113 -0
- data/public/assets/index-D2px4HJ9.css +1 -0
- data/public/icon.svg +5 -0
- data/public/index.html +25 -0
- data/public/manifest.json +17 -0
- data/public/sw.js +49 -0
- metadata +308 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "roda"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module CodingHarness
|
|
9
|
+
# HTTP API + static file server for the coding harness.
|
|
10
|
+
#
|
|
11
|
+
# Endpoints:
|
|
12
|
+
# GET /api/config — models, adapter, workspace, features
|
|
13
|
+
# GET /api/workspace — workspace name, root, git branch
|
|
14
|
+
# GET /api/conversations — list conversations (archived filter)
|
|
15
|
+
# GET /api/conversations/:id — conversation with messages
|
|
16
|
+
# PATCH /api/conversations/:id — rename
|
|
17
|
+
# DELETE /api/conversations/:id — delete
|
|
18
|
+
# POST /api/conversations/:id/archive — toggle archive
|
|
19
|
+
# PATCH/DELETE /api/conversations/:id/messages/:index — edit/trim
|
|
20
|
+
# POST /api/chat — send a message, stream events via SSE
|
|
21
|
+
# POST /api/conversations/:id/approvals/:actionId/approve|reject
|
|
22
|
+
# POST /api/conversations/:id/approvals/approve-all
|
|
23
|
+
# POST /api/conversations/:id/plan/approve|reject
|
|
24
|
+
# POST /api/conversations/:id/abort
|
|
25
|
+
# GET /* — static frontend (PWA), SPA fallback
|
|
26
|
+
class Server < Roda
|
|
27
|
+
# Built frontend (PWA). Shipped inside the gem.
|
|
28
|
+
def self.public_dir
|
|
29
|
+
File.expand_path("../../../public", __dir__)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
plugin :default_headers, {
|
|
33
|
+
"Access-Control-Allow-Origin" => "*",
|
|
34
|
+
"Access-Control-Allow-Methods" => "GET, POST, PATCH, DELETE, OPTIONS",
|
|
35
|
+
"Access-Control-Allow-Headers" => "Content-Type"
|
|
36
|
+
}
|
|
37
|
+
plugin :error_handler do |e|
|
|
38
|
+
if env["PATH_INFO"]&.start_with?("/api/")
|
|
39
|
+
response.status = 500
|
|
40
|
+
{ error: e.message }.to_json
|
|
41
|
+
else
|
|
42
|
+
raise e
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
plugin :public, root: public_dir
|
|
46
|
+
plugin :streaming
|
|
47
|
+
|
|
48
|
+
# Build a server instance wired to the given components. Tests build
|
|
49
|
+
# servers with fakes; the CLI builds with the real store + runner.
|
|
50
|
+
#
|
|
51
|
+
# @param config [Config]
|
|
52
|
+
# @param store [Store]
|
|
53
|
+
# @param runner [AgentRunner]
|
|
54
|
+
def self.build(config:, store:, runner:)
|
|
55
|
+
Class.new(self) do
|
|
56
|
+
define_method(:harness_config) { config }
|
|
57
|
+
define_method(:harness_store) { store }
|
|
58
|
+
define_method(:harness_runner) { runner }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
route do |r|
|
|
63
|
+
# CORS preflight
|
|
64
|
+
if r.request_method == "OPTIONS"
|
|
65
|
+
response.status = 204
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
r.on "api" do
|
|
70
|
+
# GET /api/config — models, adapter, workspace, features
|
|
71
|
+
r.get "config" do
|
|
72
|
+
cfg = harness_config
|
|
73
|
+
{
|
|
74
|
+
models: models,
|
|
75
|
+
defaultModel: cfg.model,
|
|
76
|
+
currentAdapter: cfg.adapter,
|
|
77
|
+
workspace: workspace_info,
|
|
78
|
+
features: {
|
|
79
|
+
approvals: cfg.approvals?,
|
|
80
|
+
planMode: cfg.plan_mode,
|
|
81
|
+
todos: cfg.todos
|
|
82
|
+
}
|
|
83
|
+
}.to_json
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# GET /api/workspace — name, root, git branch
|
|
87
|
+
r.get "workspace" do
|
|
88
|
+
workspace_info.to_json
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# POST /api/chat — streaming turn
|
|
92
|
+
r.post "chat" do
|
|
93
|
+
body = JSON.parse(r.body.read)
|
|
94
|
+
input = body["message"].to_s.strip
|
|
95
|
+
conversation_id = body["conversation_id"]
|
|
96
|
+
model = body["model"]
|
|
97
|
+
|
|
98
|
+
if input.empty?
|
|
99
|
+
response.status = 400
|
|
100
|
+
next { error: "message is required" }.to_json
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
store = harness_store
|
|
104
|
+
existing = conversation_id && store.load(conversation_id)
|
|
105
|
+
conversation = existing || store.build(directory: harness_config.workspace)
|
|
106
|
+
new_conversation = existing.nil?
|
|
107
|
+
conversation = store.save(conversation) if new_conversation
|
|
108
|
+
|
|
109
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
110
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
111
|
+
response.headers["Connection"] = "keep-alive"
|
|
112
|
+
|
|
113
|
+
stream do |out|
|
|
114
|
+
out << "event: conversation.created\ndata: #{conversation["id"]}\n\n" if new_conversation
|
|
115
|
+
|
|
116
|
+
out << "event: turn.started\ndata: {}\n\n"
|
|
117
|
+
harness_runner.start_turn(conversation, input, model: model) do |ev|
|
|
118
|
+
begin
|
|
119
|
+
out << "event: #{ev[:type]}\ndata: #{JSON.generate(ev[:data])}\n\n"
|
|
120
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
121
|
+
# Client disconnected — the turn keeps running and the
|
|
122
|
+
# outcome is persisted by the runner.
|
|
123
|
+
end
|
|
124
|
+
end.join
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# GET /api/conversations
|
|
129
|
+
r.get "conversations" do
|
|
130
|
+
harness_store.list(archived: r.params["archived"] == "true").to_json
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
r.on "conversations", String do |id|
|
|
134
|
+
store = harness_store
|
|
135
|
+
|
|
136
|
+
# GET /api/conversations/:id
|
|
137
|
+
r.get do
|
|
138
|
+
conv = store.load(id)
|
|
139
|
+
if conv
|
|
140
|
+
conv.to_json
|
|
141
|
+
else
|
|
142
|
+
response.status = 404
|
|
143
|
+
{ error: "Conversation not found" }.to_json
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# PATCH /api/conversations/:id — rename
|
|
148
|
+
r.is method: :patch do
|
|
149
|
+
body = JSON.parse(r.body.read)
|
|
150
|
+
title = body["title"].to_s.strip
|
|
151
|
+
if title.empty?
|
|
152
|
+
response.status = 400
|
|
153
|
+
next { error: "title is required" }.to_json
|
|
154
|
+
end
|
|
155
|
+
conv = store.load(id)
|
|
156
|
+
unless conv
|
|
157
|
+
response.status = 404
|
|
158
|
+
next { error: "Conversation not found" }.to_json
|
|
159
|
+
end
|
|
160
|
+
conv["title"] = title
|
|
161
|
+
store.save(conv)
|
|
162
|
+
{ id: id, title: title }.to_json
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# DELETE /api/conversations/:id
|
|
166
|
+
r.is method: :delete do
|
|
167
|
+
store.delete(id)
|
|
168
|
+
{ deleted: true }.to_json
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# POST /api/conversations/:id/archive
|
|
172
|
+
r.post "archive" do
|
|
173
|
+
conv = store.load(id)
|
|
174
|
+
unless conv
|
|
175
|
+
response.status = 404
|
|
176
|
+
next { error: "Conversation not found" }.to_json
|
|
177
|
+
end
|
|
178
|
+
conv["archived"] = !conv["archived"]
|
|
179
|
+
store.save(conv)
|
|
180
|
+
{ id: id, archived: conv["archived"] }.to_json
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# POST /api/conversations/:id/abort
|
|
184
|
+
r.post "abort" do
|
|
185
|
+
harness_runner.abort(id)
|
|
186
|
+
{ aborted: true }.to_json
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# ── Approvals ──
|
|
190
|
+
r.on "approvals" do
|
|
191
|
+
# POST /api/conversations/:id/approvals/approve-all
|
|
192
|
+
r.post "approve-all" do
|
|
193
|
+
harness_runner.approve_all(id)
|
|
194
|
+
{ approved: true }.to_json
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
r.on Integer do |action_id|
|
|
198
|
+
r.post "approve" do
|
|
199
|
+
harness_runner.approve(id, action_id)
|
|
200
|
+
{ approved: true }.to_json
|
|
201
|
+
end
|
|
202
|
+
r.post "reject" do
|
|
203
|
+
harness_runner.reject(id, action_id)
|
|
204
|
+
{ rejected: true }.to_json
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ── Plan ──
|
|
210
|
+
r.on "plan" do
|
|
211
|
+
r.post "approve" do
|
|
212
|
+
harness_runner.approve_plan(id)
|
|
213
|
+
{ approved: true }.to_json
|
|
214
|
+
end
|
|
215
|
+
r.post "reject" do
|
|
216
|
+
harness_runner.reject_plan(id)
|
|
217
|
+
{ rejected: true }.to_json
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ── Messages ──
|
|
222
|
+
r.on "messages", Integer do |index|
|
|
223
|
+
# PATCH /api/conversations/:id/messages/:index — edit a user message
|
|
224
|
+
r.on method: :patch do
|
|
225
|
+
body = JSON.parse(r.body.read)
|
|
226
|
+
content = body["content"].to_s.strip
|
|
227
|
+
if content.empty?
|
|
228
|
+
response.status = 400
|
|
229
|
+
next { error: "content is required" }.to_json
|
|
230
|
+
end
|
|
231
|
+
conv = store.load(id)
|
|
232
|
+
unless conv
|
|
233
|
+
response.status = 404
|
|
234
|
+
next { error: "Conversation not found" }.to_json
|
|
235
|
+
end
|
|
236
|
+
msg = conv["messages"][index]
|
|
237
|
+
unless msg
|
|
238
|
+
response.status = 404
|
|
239
|
+
next { error: "Message not found" }.to_json
|
|
240
|
+
end
|
|
241
|
+
if msg["role"] != "user"
|
|
242
|
+
response.status = 400
|
|
243
|
+
next { error: "Only user messages can be edited" }.to_json
|
|
244
|
+
end
|
|
245
|
+
msg["content"] = content
|
|
246
|
+
store.save(conv)
|
|
247
|
+
conv.to_json
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# DELETE /api/conversations/:id/messages/:index — trim from here
|
|
251
|
+
r.on method: :delete do
|
|
252
|
+
conv = store.load(id)
|
|
253
|
+
unless conv
|
|
254
|
+
response.status = 404
|
|
255
|
+
next { error: "Conversation not found" }.to_json
|
|
256
|
+
end
|
|
257
|
+
if index >= conv["messages"].length
|
|
258
|
+
response.status = 404
|
|
259
|
+
next { error: "Message not found" }.to_json
|
|
260
|
+
end
|
|
261
|
+
conv["messages"] = conv["messages"][0...index]
|
|
262
|
+
store.save(conv)
|
|
263
|
+
conv.to_json
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# ── Static files (PWA frontend) ──
|
|
270
|
+
r.public
|
|
271
|
+
# SPA fallback: serve index.html for unmatched GET requests
|
|
272
|
+
r.get do
|
|
273
|
+
file = File.join(self.class.public_dir, "index.html")
|
|
274
|
+
if File.exist?(file)
|
|
275
|
+
response["Content-Type"] = "text/html"
|
|
276
|
+
File.read(file)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def harness_config
|
|
284
|
+
raise NotImplementedError, "built via Server.build"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def harness_store
|
|
288
|
+
raise NotImplementedError, "built via Server.build"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def harness_runner
|
|
292
|
+
raise NotImplementedError, "built via Server.build"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def workspace_info
|
|
296
|
+
cfg = harness_config
|
|
297
|
+
root = cfg.workspace
|
|
298
|
+
branch = nil
|
|
299
|
+
head = File.join(root, ".git", "HEAD")
|
|
300
|
+
if File.file?(head)
|
|
301
|
+
ref = File.read(head).strip
|
|
302
|
+
branch = ref.split("/").last if ref.start_with?("ref:")
|
|
303
|
+
end
|
|
304
|
+
{ name: File.basename(root), root: root, gitBranch: branch }
|
|
305
|
+
rescue SystemCallError
|
|
306
|
+
{ name: File.basename(root), root: root, gitBranch: nil }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def models
|
|
310
|
+
env_models = ENV["ACH_MODELS"]
|
|
311
|
+
if env_models&.length&.positive?
|
|
312
|
+
env_models.split(",").map(&:strip)
|
|
313
|
+
else
|
|
314
|
+
["deepseek-v4-flash", "claude-sonnet-4", "gpt-4o", "o3-mini"]
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module CodingHarness
|
|
8
|
+
# Persistence for conversations, backed by ask-state-providers (SQLite).
|
|
9
|
+
#
|
|
10
|
+
# A conversation is a plain Hash with string keys:
|
|
11
|
+
# id, title, directory, archived, messages, created_at, updated_at
|
|
12
|
+
#
|
|
13
|
+
# Messages are { "role" => "user"|"assistant", "content" => String,
|
|
14
|
+
# "created_at" => String } hashes. The store is the single owner of
|
|
15
|
+
# conversation records; the server and runner only read/write through it.
|
|
16
|
+
class Store
|
|
17
|
+
CONVERSATIONS_KEY = "__conversations__"
|
|
18
|
+
MAX_CONVERSATIONS = 500
|
|
19
|
+
|
|
20
|
+
# @param db_path [String] path to the SQLite database file
|
|
21
|
+
def initialize(db_path:)
|
|
22
|
+
@db_path = db_path
|
|
23
|
+
dir = File.dirname(db_path)
|
|
24
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A fresh conversation record. Not persisted until {#save}.
|
|
28
|
+
def build(directory: nil, title: "New conversation")
|
|
29
|
+
now = Time.now.iso8601
|
|
30
|
+
{
|
|
31
|
+
"id" => SecureRandom.uuid,
|
|
32
|
+
"title" => title,
|
|
33
|
+
"directory" => directory,
|
|
34
|
+
"archived" => false,
|
|
35
|
+
"messages" => [],
|
|
36
|
+
"created_at" => now,
|
|
37
|
+
"updated_at" => now
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Persist a conversation, bumping updated_at and guessing a title when
|
|
42
|
+
# the conversation is still untitled and has messages.
|
|
43
|
+
def save(conv)
|
|
44
|
+
conv["updated_at"] = Time.now.iso8601
|
|
45
|
+
if conv["title"] == "New conversation" && conv["messages"].length >= 1
|
|
46
|
+
conv["title"] = guess_title(conv["messages"])
|
|
47
|
+
end
|
|
48
|
+
db.set("conv:#{conv["id"]}", conv)
|
|
49
|
+
index_conversation(conv["id"])
|
|
50
|
+
conv
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Load a conversation by id, or nil when missing.
|
|
54
|
+
def load(id)
|
|
55
|
+
data = db.get("conv:#{id}")
|
|
56
|
+
data&.transform_keys(&:to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def delete(id)
|
|
60
|
+
db.delete("conv:#{id}")
|
|
61
|
+
db.list_remove(CONVERSATIONS_KEY, id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Conversation summaries (no messages), sorted by most recent first.
|
|
65
|
+
def list(archived: false)
|
|
66
|
+
ids = db.list_range(CONVERSATIONS_KEY, 0, -1)
|
|
67
|
+
summaries = ids.filter_map do |id|
|
|
68
|
+
data = db.get("conv:#{id}")
|
|
69
|
+
next unless data
|
|
70
|
+
next if data["archived"] == true && !archived
|
|
71
|
+
summary(data)
|
|
72
|
+
end
|
|
73
|
+
summaries.sort_by { |c| c["updated_at"].to_s }.reverse
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Summaries for one workspace directory, most recent first.
|
|
77
|
+
def list_for_directory(directory, archived: false)
|
|
78
|
+
list(archived: archived).select { |c| c["directory"] == directory }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Workspace directories that have conversations, with counts.
|
|
82
|
+
def projects(archived: false)
|
|
83
|
+
list(archived: archived).each_with_object(Hash.new(0)) do |c, counts|
|
|
84
|
+
dir = c["directory"]
|
|
85
|
+
counts[dir] += 1 if dir
|
|
86
|
+
end.map { |dir, count| { "directory" => dir, "name" => File.basename(dir), "conversation_count" => count } }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def close
|
|
90
|
+
@db&.close
|
|
91
|
+
@db = nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def db
|
|
97
|
+
@db ||= Ask::State::Providers::SQLite.new(path: @db_path)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def summary(data)
|
|
101
|
+
{
|
|
102
|
+
"id" => data["id"],
|
|
103
|
+
"title" => data["title"] || "New conversation",
|
|
104
|
+
"directory" => data["directory"],
|
|
105
|
+
"archived" => data["archived"] == true,
|
|
106
|
+
"message_count" => data["messages"]&.length || 0,
|
|
107
|
+
"created_at" => data["created_at"],
|
|
108
|
+
"updated_at" => data["updated_at"]
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def guess_title(messages)
|
|
113
|
+
first = messages.find { |m| m["role"] == "user" }
|
|
114
|
+
text = (first && first["content"]).to_s
|
|
115
|
+
text.length > 40 ? text[0, 40] + "…" : text
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Append to the conversation index, skipping duplicates (saves happen
|
|
119
|
+
# on every message).
|
|
120
|
+
def index_conversation(id)
|
|
121
|
+
existing = db.list_range(CONVERSATIONS_KEY, 0, -1)
|
|
122
|
+
db.list_append(CONVERSATIONS_KEY, id, max_length: MAX_CONVERSATIONS) unless existing.include?(id)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ask/coding_harness/version"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
# Ask Coding Harness — a general-purpose coding agent in the browser.
|
|
7
|
+
#
|
|
8
|
+
# Self-hosted web coding agent for the ask-rb ecosystem. Runs
|
|
9
|
+
# Ask::Agent::Session against any workspace, streams every event to a
|
|
10
|
+
# mobile-first PWA over SSE, and comes with an `ach` utility CLI for
|
|
11
|
+
# headless runs.
|
|
12
|
+
#
|
|
13
|
+
# Ask::CodingHarness.configure do |c|
|
|
14
|
+
# c.workspace = "/path/to/project"
|
|
15
|
+
# c.model = "deepseek-v4-flash"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Ask::CodingHarness.run_server
|
|
19
|
+
module CodingHarness
|
|
20
|
+
class << self
|
|
21
|
+
# Global configuration. See Config for available settings.
|
|
22
|
+
def config
|
|
23
|
+
@config ||= Config.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Configure the harness. Yields the global Config instance.
|
|
27
|
+
def configure
|
|
28
|
+
yield config if block_given?
|
|
29
|
+
config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Run the web server (blocking). See CLI for the non-blocking path.
|
|
33
|
+
def run_server(host: nil, port: nil)
|
|
34
|
+
require_relative "ask/coding_harness/server"
|
|
35
|
+
require "rackup"
|
|
36
|
+
|
|
37
|
+
store = Store.new(db_path: config.db_path)
|
|
38
|
+
runner = AgentRunner.new(config: config, store: store)
|
|
39
|
+
app = Server.build(config: config, store: store, runner: runner).freeze.app
|
|
40
|
+
Rackup::Server.start(app: app, Host: host || config.host, Port: port || config.port)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Run a prompt headlessly against the workspace and return the result.
|
|
44
|
+
#
|
|
45
|
+
# @param prompt [String] the task to run
|
|
46
|
+
# @param workspace [String, nil] overrides config.workspace
|
|
47
|
+
# @return [Ask::CodingHarness::Runner::Result]
|
|
48
|
+
def run(prompt, workspace: nil, **opts)
|
|
49
|
+
require_relative "ask/coding_harness/runner"
|
|
50
|
+
Runner.new(config: config).run(prompt, workspace: workspace, **opts)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
require "ask-tools-shell"
|
|
57
|
+
require "ask-state-providers"
|
|
58
|
+
require "ask/coding_harness/config"
|
|
59
|
+
require "ask/coding_harness/store"
|
|
60
|
+
require "ask/coding_harness/event_translator"
|
|
61
|
+
require "ask/coding_harness/agent_runner"
|
|
62
|
+
require "ask/coding_harness/runner"
|