openlanes 3.0.0 → 3.0.1
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/agents/c3po-operator.md +58 -0
- data/lib/openlanes/adapters/base.rb +7 -0
- data/lib/openlanes/adapters/claude.rb +21 -2
- data/lib/openlanes/adapters.rb +19 -2
- data/lib/openlanes/cli/run/run.rb +47 -15
- data/lib/openlanes/cli/start/start.rb +1 -14
- data/lib/openlanes/cli/web/web.rb +11 -1
- data/lib/openlanes/engine/dispatcher.rb +62 -7
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/progress_log.rb +50 -9
- data/lib/openlanes/execution/prompt.rb +13 -0
- data/lib/openlanes/lifecycle/creator.rb +24 -0
- data/lib/openlanes/live/chat.rb +616 -0
- data/lib/openlanes/live/commands.rb +4 -1
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot.rb +256 -0
- data/lib/openlanes/live/keys.rb +58 -0
- data/lib/openlanes/live/memory_store.rb +24 -0
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/session.rb +7 -1
- data/lib/openlanes/live/stations.rb +9 -5
- data/lib/openlanes/live/store.rb +18 -0
- data/lib/openlanes/live/subscriber.rb +73 -0
- data/lib/openlanes/live.rb +5 -0
- data/lib/openlanes/presentation/reporter.rb +2 -2
- data/lib/openlanes/support/ui.rb +75 -1
- data/lib/openlanes/vcs/publisher.rb +62 -0
- data/lib/openlanes/vcs/worktree.rb +44 -9
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/app.rb +100 -1
- data/lib/openlanes/web/context.rb +20 -2
- data/lib/openlanes/web/demo.rb +10 -0
- data/lib/openlanes/web/events.rb +51 -2
- data/lib/openlanes/web/launcher.rb +1 -1
- data/lib/openlanes/web/server.rb +2 -0
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +13 -0
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-BdR34cXC.js → index-6MZkmh3k.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-B5CKJPm-.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-BF6QYroq.js} +2 -2
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-BFPI-zXI.js} +2 -2
- data/web/dist/assets/{index-CI74IkWP.js → index-BYJCWlk0.js} +49 -49
- data/web/dist/assets/{index-popk9wbW.js → index-C2jwoWVV.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-C3zc4gFe.js} +2 -2
- data/web/dist/assets/index-CjInBi5X.css +1 -0
- data/web/dist/assets/{index-2xPC-eHt.js → index-CnJfB0l2.js} +2 -2
- data/web/dist/assets/{index-P3vSkiU-.js → index-CnvkZu5Y.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-DH0gDlee.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-DXgeajd6.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-DaSjQ-H4.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-DbueqOK5.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-Dbxvk-EC.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-DtA46i32.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-IqD8mBos.js} +2 -2
- data/web/dist/assets/{index-Byyxbh_z.js → index-bpsFIpXE.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-pvJi-JJO.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- metadata +31 -23
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "tempfile"
|
|
4
5
|
require "tty/command"
|
|
5
6
|
|
|
@@ -93,6 +94,7 @@ module OpenLanes
|
|
|
93
94
|
mismatch = open && head_mismatch(open, checkout)
|
|
94
95
|
return refuse(subject, checkout, title, mismatch) if mismatch
|
|
95
96
|
|
|
97
|
+
carry_plan(checkout, subject)
|
|
96
98
|
commit(checkout, title)
|
|
97
99
|
push(checkout)
|
|
98
100
|
url = open ? open.url : create_pull_request(checkout, subject, title)
|
|
@@ -132,8 +134,68 @@ module OpenLanes
|
|
|
132
134
|
# @return [String]
|
|
133
135
|
def auto_letter(subject) = LETTERS.fetch(subject.pull_requests.size, LETTERS.last)
|
|
134
136
|
|
|
137
|
+
# Left out of the copy: the half-written temporaries an atomic write
|
|
138
|
+
# renames into place (`state.json.<pid>.<thread>.tmp`).
|
|
139
|
+
CARRY_EXCLUDED = ["*.tmp"].freeze
|
|
140
|
+
|
|
141
|
+
# Copy the plan's folder from the main checkout into the worktree, so
|
|
142
|
+
# the pull request carries its spec, plan and state next to the code.
|
|
143
|
+
#
|
|
144
|
+
# The main checkout stays the only writer. The engine reads, signs and
|
|
145
|
+
# renames the plan there and nowhere else; the worktree gets a snapshot,
|
|
146
|
+
# refreshed on every publish. Every copy of the same plan number is
|
|
147
|
+
# removed first, so a folder renamed since the last publish (⭐️ to 🟢,
|
|
148
|
+
# say) does not leave its old name behind in the branch, and a file
|
|
149
|
+
# deleted in the main checkout is deleted in the branch too.
|
|
150
|
+
#
|
|
151
|
+
# Nothing is copied when the worktree is the main checkout itself, which
|
|
152
|
+
# would delete the source, or when the plans live outside the
|
|
153
|
+
# repository, where the branch has no place for them.
|
|
154
|
+
#
|
|
155
|
+
# @param checkout [OpenLanes::Vcs::Worktree::Checkout]
|
|
156
|
+
# @param subject [OpenLanes::Plans::Subject] the plan in the main checkout
|
|
157
|
+
# @return [String, nil] the copy's path, nil when nothing was copied
|
|
158
|
+
def carry_plan(checkout, subject)
|
|
159
|
+
root = real(@root)
|
|
160
|
+
source = real(subject.feature.path)
|
|
161
|
+
return nil if real(checkout.path) == root || !source.start_with?("#{root}/")
|
|
162
|
+
|
|
163
|
+
parent = File.join(checkout.path, File.dirname(source.delete_prefix("#{root}/")))
|
|
164
|
+
FileUtils.mkdir_p(parent)
|
|
165
|
+
Dir.children(parent).each do |name|
|
|
166
|
+
next unless Plans::Ordinal.from_dirname(name) == subject.feature.ordinal
|
|
167
|
+
|
|
168
|
+
FileUtils.rm_rf(File.join(parent, name))
|
|
169
|
+
end
|
|
170
|
+
copy_tree(source, File.join(parent, File.basename(source)))
|
|
171
|
+
end
|
|
172
|
+
|
|
135
173
|
private
|
|
136
174
|
|
|
175
|
+
# @param path [String]
|
|
176
|
+
# @return [String] symlinks resolved, so /var and /private/var compare equal
|
|
177
|
+
def real(path)
|
|
178
|
+
File.realpath(path)
|
|
179
|
+
rescue Errno::ENOENT
|
|
180
|
+
File.expand_path(path)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @param source [String]
|
|
184
|
+
# @param target [String]
|
|
185
|
+
# @return [String] target
|
|
186
|
+
def copy_tree(source, target)
|
|
187
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: source).each do |relative|
|
|
188
|
+
next if relative == "." || CARRY_EXCLUDED.any? { File.fnmatch?(it, File.basename(relative)) }
|
|
189
|
+
|
|
190
|
+
from = File.join(source, relative)
|
|
191
|
+
to = File.join(target, relative)
|
|
192
|
+
FileUtils.mkdir_p(File.directory?(from) ? to : File.dirname(to))
|
|
193
|
+
FileUtils.cp(from, to) if File.file?(from)
|
|
194
|
+
end
|
|
195
|
+
FileUtils.mkdir_p(target)
|
|
196
|
+
target
|
|
197
|
+
end
|
|
198
|
+
|
|
137
199
|
# Why the open pull request cannot receive this checkout's push, if it
|
|
138
200
|
# cannot.
|
|
139
201
|
#
|
|
@@ -20,11 +20,43 @@ module OpenLanes
|
|
|
20
20
|
# carries itself from worktree creation through to a merged pull request with
|
|
21
21
|
# nobody having to remember it.
|
|
22
22
|
class Worktree
|
|
23
|
-
# `
|
|
24
|
-
# checkout does not get.
|
|
25
|
-
#
|
|
26
|
-
# usable from a shell on a machine that
|
|
27
|
-
|
|
23
|
+
# `setup-worktree`, which copies in the ignored-but-required files a new
|
|
24
|
+
# checkout does not get. It is the user's own script, not this gem's:
|
|
25
|
+
# shelling out rather than reimplementing the rules here keeps one answer
|
|
26
|
+
# to "what does a worktree need", usable from a shell on a machine that
|
|
27
|
+
# has never run `bundle install`.
|
|
28
|
+
SEEDER_NAME = "setup-worktree"
|
|
29
|
+
|
|
30
|
+
# Names the seeder outright, ahead of every place {.seeder} searches.
|
|
31
|
+
SEEDER_ENV = "OPENLANES_SETUP_WORKTREE"
|
|
32
|
+
|
|
33
|
+
# Searched after `PATH`, where the user's own tooling keeps it.
|
|
34
|
+
SEEDER_HOMES = %w[~/.claude/bin ~/.agents/bin].freeze
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# The seeder to run, or nil when none is installed.
|
|
38
|
+
#
|
|
39
|
+
# It used to be `bin/setup-worktree` beside this file, which the gem
|
|
40
|
+
# never shipped, so every installed run seeded nothing and said so
|
|
41
|
+
# once per agent.
|
|
42
|
+
#
|
|
43
|
+
# @param env [Hash] the environment, for `PATH` and {SEEDER_ENV}
|
|
44
|
+
# @return [String, nil] an executable path
|
|
45
|
+
def seeder(env = ENV) = seeder_candidates(env).find { File.file?(it) && File.executable?(it) }
|
|
46
|
+
|
|
47
|
+
# Every place {.seeder} looks, in order.
|
|
48
|
+
#
|
|
49
|
+
# @param env [Hash]
|
|
50
|
+
# @return [Array<String>]
|
|
51
|
+
def seeder_candidates(env = ENV)
|
|
52
|
+
on_path = env.fetch("PATH", "").split(File::PATH_SEPARATOR).reject(&:empty?).map { File.join(it, SEEDER_NAME) }
|
|
53
|
+
homes = SEEDER_HOMES.map { File.join(File.expand_path(it), SEEDER_NAME) }
|
|
54
|
+
[env[SEEDER_ENV].to_s, *on_path, *homes].reject(&:empty?).uniq
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [String] where {.seeder} looks, for a person
|
|
58
|
+
def seeder_search = "$#{SEEDER_ENV}, #{SEEDER_NAME} on PATH, #{SEEDER_HOMES.join(", ")}"
|
|
59
|
+
end
|
|
28
60
|
|
|
29
61
|
# One plan's isolated checkout.
|
|
30
62
|
#
|
|
@@ -50,9 +82,11 @@ module OpenLanes
|
|
|
50
82
|
# @param root [String] the main repository
|
|
51
83
|
# @param dir [String, nil] where worktrees go; defaults to a sibling
|
|
52
84
|
# @param user [String] branch namespace
|
|
53
|
-
|
|
85
|
+
# @param seeder [String, nil] the seeder to run; nil runs none
|
|
86
|
+
def initialize(root:, dir: nil, user: ENV["USER"] || "agent", seeder: self.class.seeder)
|
|
54
87
|
@root = File.expand_path(root)
|
|
55
88
|
@user = user
|
|
89
|
+
@seeder = seeder
|
|
56
90
|
@dir = File.expand_path(dir || default_dir)
|
|
57
91
|
end
|
|
58
92
|
|
|
@@ -118,12 +152,13 @@ module OpenLanes
|
|
|
118
152
|
# @param path [String] the worktree to seed
|
|
119
153
|
# @return [Boolean] whether the seeder ran and succeeded
|
|
120
154
|
def seed(path)
|
|
121
|
-
unless File.executable?(
|
|
122
|
-
UI.log("worktree: #{
|
|
155
|
+
unless @seeder && File.executable?(@seeder)
|
|
156
|
+
UI.log("worktree: no #{SEEDER_NAME} found (searched #{self.class.seeder_search}), " \
|
|
157
|
+
"so #{path} has no .env or credential keys")
|
|
123
158
|
return false
|
|
124
159
|
end
|
|
125
160
|
|
|
126
|
-
return true if system(
|
|
161
|
+
return true if system(@seeder, "--quiet", path, out: File::NULL, err: File::NULL)
|
|
127
162
|
|
|
128
163
|
UI.log("worktree: could not seed #{path}; its suite may fail on a missing key")
|
|
129
164
|
false
|
data/lib/openlanes/version.rb
CHANGED
data/lib/openlanes/web/app.rb
CHANGED
|
@@ -63,6 +63,45 @@ module OpenLanes
|
|
|
63
63
|
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
64
64
|
def store = context.store
|
|
65
65
|
|
|
66
|
+
# @return [OpenLanes::Live::Copilot]
|
|
67
|
+
def copilot = Live.copilot(store:, keys: context.keys, responder: context.copilot)
|
|
68
|
+
|
|
69
|
+
# Answer +turn+ apart from this request, on a store of its own,
|
|
70
|
+
# since a Redis connection is never shared between threads.
|
|
71
|
+
#
|
|
72
|
+
# @param turn [OpenLanes::Live::Copilot::Turn]
|
|
73
|
+
# @return [void]
|
|
74
|
+
def answer_later(turn)
|
|
75
|
+
context.background.call do
|
|
76
|
+
Live.copilot(store: context.stores.current, keys: context.keys, responder: context.copilot).answer(turn)
|
|
77
|
+
ensure
|
|
78
|
+
context.stores.release
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [OpenLanes::Live::Chat]
|
|
83
|
+
def chat = context.chat || halt(404, json(error: "this server has no chat"))
|
|
84
|
+
|
|
85
|
+
# The request's JSON object, or a 422.
|
|
86
|
+
#
|
|
87
|
+
# @return [Hash]
|
|
88
|
+
def body_json
|
|
89
|
+
data = JSON.parse(request.body.read)
|
|
90
|
+
data.is_a?(Hash) ? data : halt(422, json(error: "the body is a JSON object"))
|
|
91
|
+
rescue JSON::ParserError => e
|
|
92
|
+
halt 422, json(error: "not JSON: #{e.message.lines.first.to_s.strip}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Another machine on the board, as `user@host`, so an invite names
|
|
96
|
+
# who it went to and is only sent to a machine that is there.
|
|
97
|
+
#
|
|
98
|
+
# @param id [String] a node id
|
|
99
|
+
# @return [String]
|
|
100
|
+
def machine_label(id)
|
|
101
|
+
node = Live.board(store:, keys: context.keys)[:others].map { it["node"] }.find { it["id"] == id }
|
|
102
|
+
node ? "#{node["user"]}@#{node["host"]}" : halt(422, json(error: "no machine #{id} is on this Redis now"))
|
|
103
|
+
end
|
|
104
|
+
|
|
66
105
|
# @param data [Object]
|
|
67
106
|
# @return [String]
|
|
68
107
|
def json(data)
|
|
@@ -120,7 +159,7 @@ module OpenLanes
|
|
|
120
159
|
get "/api/events" do
|
|
121
160
|
content_type "text/event-stream"
|
|
122
161
|
headers "Cache-Control" => "no-cache, no-transform", "X-Accel-Buffering" => "no"
|
|
123
|
-
events = Events.new(context:, store:, last_id: request.env["HTTP_LAST_EVENT_ID"])
|
|
162
|
+
events = Events.new(context:, store:, last_id: request.env["HTTP_LAST_EVENT_ID"], page: params[:page])
|
|
124
163
|
stream { |out| events.run(out) }
|
|
125
164
|
end
|
|
126
165
|
|
|
@@ -136,6 +175,66 @@ module OpenLanes
|
|
|
136
175
|
json(Live::Reply.new(id: id_in(body), status: "refused", reason: e.message).wire)
|
|
137
176
|
end
|
|
138
177
|
|
|
178
|
+
get "/api/copilot" do
|
|
179
|
+
json(copilot.history)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The person's message to the copilot (W17). It is stored and accepted
|
|
183
|
+
# at once; the answer is written afterwards, apart from this request,
|
|
184
|
+
# and comes on the event stream as `event: copilot`, like a command's
|
|
185
|
+
# replies do. The answer is c3po-operator's ({Context#copilot}).
|
|
186
|
+
post "/api/copilot" do
|
|
187
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
188
|
+
|
|
189
|
+
turn = copilot.say(request.body.read)
|
|
190
|
+
answer_later(turn)
|
|
191
|
+
status 202
|
|
192
|
+
json(turn.wire)
|
|
193
|
+
rescue Live::Copilot::Invalid => e
|
|
194
|
+
status 422
|
|
195
|
+
json(error: e.message)
|
|
196
|
+
rescue Live::Copilot::Unanswered => e
|
|
197
|
+
status 503
|
|
198
|
+
json(error: e.message)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The chat between machines (plan 006): this page's chat, the invites
|
|
202
|
+
# open to this machine, and the ones this page waits on.
|
|
203
|
+
get "/api/chat" do
|
|
204
|
+
json(chat.state(page: params[:page]))
|
|
205
|
+
rescue Live::Chat::Invalid => e
|
|
206
|
+
status 422
|
|
207
|
+
json(error: e.message)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# What a page does in a chat. Everyone hears of it as `event: chat`
|
|
211
|
+
# on their own stream, this page included.
|
|
212
|
+
post "/api/chat/:action" do
|
|
213
|
+
halt 403, json(error: "X-CSRF-Token does not match; fetch /api/session again") unless csrf?
|
|
214
|
+
|
|
215
|
+
data = body_json
|
|
216
|
+
page = data["page"]
|
|
217
|
+
machine = data["machine"].to_s
|
|
218
|
+
answer = case params[:action]
|
|
219
|
+
when "start" then chat.start_chat(page:, name: data["name"], machine:, label: machine_label(machine))
|
|
220
|
+
when "invite" then chat.invite(page:, machine:, label: machine_label(machine))
|
|
221
|
+
when "accept" then chat.accept(page:, invite: data["invite"].to_s, name: data["name"])
|
|
222
|
+
when "decline" then chat.decline(page:, invite: data["invite"].to_s).then { {} }
|
|
223
|
+
when "leave" then chat.leave(page:).then { {} }
|
|
224
|
+
when "say" then chat.say(page:, id: data["id"], text: data["text"])
|
|
225
|
+
when "presence" then chat.presence(page:, idle: data["idle"]).then { {} }
|
|
226
|
+
else halt 404, json(error: "no such chat action: #{params[:action]}")
|
|
227
|
+
end
|
|
228
|
+
status 202
|
|
229
|
+
json(answer)
|
|
230
|
+
rescue Live::Chat::Invalid => e
|
|
231
|
+
status 422
|
|
232
|
+
json(error: e.message)
|
|
233
|
+
rescue Live::Chat::Settled => e
|
|
234
|
+
status 409
|
|
235
|
+
json(error: e.message)
|
|
236
|
+
end
|
|
237
|
+
|
|
139
238
|
# Demo only: the board back to the fixture, for a test that wants a
|
|
140
239
|
# known start. A real engine's run has no such thing.
|
|
141
240
|
post "/api/demo/reset" do
|
|
@@ -33,6 +33,16 @@ module OpenLanes
|
|
|
33
33
|
# @!attribute [r] demo
|
|
34
34
|
# @return [OpenLanes::Web::Demo, nil] the demo engine, under `--demo`,
|
|
35
35
|
# which `POST /api/demo/reset` puts back to the start
|
|
36
|
+
# @!attribute [r] copilot
|
|
37
|
+
# @return [#call, nil] who answers the copilot's messages (W17):
|
|
38
|
+
# {Live::Copilot::Operator} beside a real run, {Demo::COPILOT} under
|
|
39
|
+
# `--demo`, nil where nothing does, and `POST /api/copilot` says so
|
|
40
|
+
# @!attribute [r] background
|
|
41
|
+
# @return [#call] runs the block it is given apart from the request, a
|
|
42
|
+
# thread by default; the suite passes one that runs it when told
|
|
43
|
+
# @!attribute [r] chat
|
|
44
|
+
# @return [OpenLanes::Live::Chat, nil] the chat between machines (plan 006),
|
|
45
|
+
# relayed to this server's pages; nil where nothing serves it
|
|
36
46
|
# @!attribute [r] stopping
|
|
37
47
|
# @return [Concurrent::AtomicBoolean] set when the server shuts down, so
|
|
38
48
|
# open streams end within a tick
|
|
@@ -47,9 +57,12 @@ module OpenLanes
|
|
|
47
57
|
:ping_every,
|
|
48
58
|
:lifetime,
|
|
49
59
|
:demo,
|
|
60
|
+
:copilot,
|
|
61
|
+
:background,
|
|
62
|
+
:chat,
|
|
50
63
|
:stopping) do
|
|
51
|
-
def initialize(dist: DIST, node: nil, tick: 0.25, ping_every: 15.0, lifetime: nil, demo: nil,
|
|
52
|
-
stopping: Concurrent::AtomicBoolean.new, **rest)
|
|
64
|
+
def initialize(dist: DIST, node: nil, tick: 0.25, ping_every: 15.0, lifetime: nil, demo: nil, copilot: nil, chat: nil,
|
|
65
|
+
background: Context::THREAD, stopping: Concurrent::AtomicBoolean.new, **rest)
|
|
53
66
|
super
|
|
54
67
|
end
|
|
55
68
|
|
|
@@ -62,5 +75,10 @@ module OpenLanes
|
|
|
62
75
|
# @return [Boolean]
|
|
63
76
|
def dist? = File.file?(index)
|
|
64
77
|
end
|
|
78
|
+
|
|
79
|
+
class Context
|
|
80
|
+
# Work started apart from a request, on a thread of its own.
|
|
81
|
+
THREAD = ->(&work) { Thread.new(&work) }
|
|
82
|
+
end
|
|
65
83
|
end
|
|
66
84
|
end
|
data/lib/openlanes/web/demo.rb
CHANGED
|
@@ -19,6 +19,16 @@ module OpenLanes
|
|
|
19
19
|
# The other machine's checkout id, fixed so its keys are stable.
|
|
20
20
|
OTHER_CHECKOUT = Live::Node.digest("openlanes-demo-other", Live::Node::CHECKOUT_LENGTH)
|
|
21
21
|
|
|
22
|
+
# What the copilot answers in the demo, where no agent runs and the
|
|
23
|
+
# board is made up: one plain line, so the panel can still be tried.
|
|
24
|
+
COPILOT_TEXT = "This is the demo run: c3po-operator does not run here, so nothing reads this or acts on it."
|
|
25
|
+
|
|
26
|
+
# The copilot's responder under `--demo` (W17), in place of
|
|
27
|
+
# {Live::Copilot::Operator}.
|
|
28
|
+
COPILOT = lambda do |turn|
|
|
29
|
+
[Live::Copilot::Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: COPILOT_TEXT)]
|
|
30
|
+
end
|
|
31
|
+
|
|
22
32
|
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
23
33
|
# @param keys [OpenLanes::Live::Keys] this node's
|
|
24
34
|
# @param board [OpenLanes::Web::DemoBoard]
|
data/lib/openlanes/web/events.rb
CHANGED
|
@@ -6,7 +6,8 @@ module OpenLanes
|
|
|
6
6
|
module Web
|
|
7
7
|
# One page's event stream (contract.md, `GET /api/events`): a `board`
|
|
8
8
|
# event whenever any node's snapshot `seq` changes, a `reply` event for
|
|
9
|
-
# each reply the engine writes,
|
|
9
|
+
# each reply the engine writes, a `copilot` event for each turn of the
|
|
10
|
+
# conversation with the copilot (W17), and a `: ping` comment so proxies and the
|
|
10
11
|
# browser keep the connection open.
|
|
11
12
|
#
|
|
12
13
|
# Polled rather than blocked on: a blocking read would hold the thread's
|
|
@@ -18,18 +19,40 @@ module OpenLanes
|
|
|
18
19
|
# opens with a `cursor` event carrying the id it starts from, so a
|
|
19
20
|
# browser that drops before the first reply still has one to send;
|
|
20
21
|
# without it, replies written during that gap were skipped.
|
|
22
|
+
#
|
|
23
|
+
# A `chat` event (plan 006) carries no `id:` either, and like the
|
|
24
|
+
# copilot's the stream opens with a `chat_cursor` when it carries a
|
|
25
|
+
# page's chat, so the page reads `GET /api/chat` only once relaying has
|
|
26
|
+
# begun. Nor is there a cursor id to carry: the chat between
|
|
27
|
+
# machines keeps no history (D2), so there is nothing to catch up on.
|
|
28
|
+
# The stream is how {Live::Chat} knows the page is still open; when it
|
|
29
|
+
# closes, the page has {Live::Chat::GRACE} to reconnect before it counts
|
|
30
|
+
# as having left (D6).
|
|
31
|
+
#
|
|
32
|
+
# A copilot turn carries no `id:`, so it never moves that cursor. The
|
|
33
|
+
# conversation has a boundary of its own instead: the stream samples the
|
|
34
|
+
# newest turn, says so in a `copilot_cursor` event, and from then on
|
|
35
|
+
# sends every turn after it. The page reads `GET /api/copilot` when that
|
|
36
|
+
# event arrives, never before it, so the read covers everything up to
|
|
37
|
+
# the boundary and the stream everything after. A turn written between a
|
|
38
|
+
# read the page started on its own and the stream opening fell in the
|
|
39
|
+
# gap between the two and was never shown.
|
|
21
40
|
class Events
|
|
22
41
|
# @param context [OpenLanes::Web::Context]
|
|
23
42
|
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
24
43
|
# @param last_id [String, nil] the `Last-Event-ID` the browser sent
|
|
44
|
+
# @param page [String, nil] the page's own id, `?page=` on the stream, for its chat
|
|
25
45
|
# @param clock [Proc] seconds, monotonic
|
|
26
46
|
# @param sleeper [Proc] waits the given seconds between ticks
|
|
27
|
-
def initialize(context:, store:, last_id: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
47
|
+
def initialize(context:, store:, last_id: nil, page: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
28
48
|
sleeper: ->(seconds) { sleep seconds })
|
|
29
49
|
@context = context
|
|
30
50
|
@store = store
|
|
31
51
|
@keys = context.keys
|
|
52
|
+
@copilot = Live.copilot(store:, keys: @keys)
|
|
32
53
|
@last_id = last_id.to_s.match?(/\A\d+-\d+\z/) ? last_id : nil
|
|
54
|
+
@chat = context.chat
|
|
55
|
+
@page = page.to_s.match?(Live::Chat::PAGE) ? page : nil
|
|
33
56
|
@clock = clock
|
|
34
57
|
@sleeper = sleeper
|
|
35
58
|
end
|
|
@@ -40,14 +63,22 @@ module OpenLanes
|
|
|
40
63
|
# @param out [#<<, #closed?]
|
|
41
64
|
# @return [void]
|
|
42
65
|
def run(out)
|
|
66
|
+
@chat&.attach(@page) if @page
|
|
43
67
|
started = @clock.call
|
|
44
68
|
pinged = started
|
|
45
69
|
cursor = @last_id || newest_reply
|
|
46
70
|
out << self.class.frame("cursor", { id: cursor }, id: cursor)
|
|
71
|
+
said = @copilot.newest
|
|
72
|
+
out << self.class.frame("copilot_cursor", { id: said }) unless out.closed?
|
|
73
|
+
# The page is attached, so every chat event from here on is queued for
|
|
74
|
+
# it: a `GET /api/chat` the page starts on this misses nothing.
|
|
75
|
+
out << self.class.frame("chat_cursor", {}) if @chat && @page && !out.closed?
|
|
47
76
|
seqs = nil
|
|
48
77
|
until over?(out, started)
|
|
49
78
|
seqs = board(out, seqs)
|
|
50
79
|
cursor = replies(out, cursor)
|
|
80
|
+
said = copilot(out, said)
|
|
81
|
+
chat(out)
|
|
51
82
|
pinged = ping(out) if @clock.call - pinged >= @context.ping_every
|
|
52
83
|
@sleeper.call(@context.tick)
|
|
53
84
|
end
|
|
@@ -55,6 +86,8 @@ module OpenLanes
|
|
|
55
86
|
# Ending the stream is the right answer: the page reconnects with
|
|
56
87
|
# backoff and shows itself as reconnecting meanwhile.
|
|
57
88
|
out << ": #{e.message.tr("\n", " ")}\n\n"
|
|
89
|
+
ensure
|
|
90
|
+
@chat&.detach(@page) if @page
|
|
58
91
|
end
|
|
59
92
|
|
|
60
93
|
# @param event [String]
|
|
@@ -87,6 +120,22 @@ module OpenLanes
|
|
|
87
120
|
cursor
|
|
88
121
|
end
|
|
89
122
|
|
|
123
|
+
# @return [String] the last copilot turn's stream id sent
|
|
124
|
+
def copilot(out, said)
|
|
125
|
+
@copilot.since(said).each do |id, turn|
|
|
126
|
+
out << self.class.frame("copilot", turn)
|
|
127
|
+
said = id
|
|
128
|
+
end
|
|
129
|
+
said
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [void]
|
|
133
|
+
def chat(out)
|
|
134
|
+
return unless @chat && @page
|
|
135
|
+
|
|
136
|
+
@chat.drain(@page).each { out << self.class.frame("chat", it) }
|
|
137
|
+
end
|
|
138
|
+
|
|
90
139
|
# @return [Float] when it pinged
|
|
91
140
|
def ping(out)
|
|
92
141
|
out << ": ping\n\n"
|
|
@@ -137,7 +137,7 @@ module OpenLanes
|
|
|
137
137
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
138
|
|
|
139
139
|
# @return [String]
|
|
140
|
-
def default_log(root) =
|
|
140
|
+
def default_log(root) = Engine::ProgressLog.default_path(root, kind: "web")
|
|
141
141
|
end
|
|
142
142
|
end
|
|
143
143
|
end
|
data/lib/openlanes/web/server.rb
CHANGED
|
@@ -50,6 +50,7 @@ module OpenLanes
|
|
|
50
50
|
log_writer: Puma::LogWriter.new(File.open(File::NULL, "w"), @log))
|
|
51
51
|
@puma.add_tcp_listener(@host, @port)
|
|
52
52
|
@thread = @puma.run
|
|
53
|
+
@context.chat&.start
|
|
53
54
|
self
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -69,6 +70,7 @@ module OpenLanes
|
|
|
69
70
|
# @return [void]
|
|
70
71
|
def stop
|
|
71
72
|
@context.stopping.make_true
|
|
73
|
+
@context.chat&.stop
|
|
72
74
|
@puma&.stop(true)
|
|
73
75
|
@context.stores.close
|
|
74
76
|
end
|
data/lib/openlanes/web/stores.rb
CHANGED
|
@@ -26,6 +26,17 @@ module OpenLanes
|
|
|
26
26
|
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
27
27
|
def current = @mutex.synchronize { @by_thread[Thread.current] ||= @factory.call }
|
|
28
28
|
|
|
29
|
+
# Drop and close this thread's store. A thread that is not one of
|
|
30
|
+
# Puma's pooled ones calls it when done, or the map would keep a
|
|
31
|
+
# connection for every such thread that ever ran. A shared store is
|
|
32
|
+
# dropped but not closed: the others still hold it.
|
|
33
|
+
#
|
|
34
|
+
# @return [void]
|
|
35
|
+
def release
|
|
36
|
+
store = @mutex.synchronize { @by_thread.delete(Thread.current) }
|
|
37
|
+
store&.close unless @mutex.synchronize { @by_thread.value?(store) }
|
|
38
|
+
end
|
|
39
|
+
|
|
29
40
|
# @return [void]
|
|
30
41
|
def close
|
|
31
42
|
stores = @mutex.synchronize { @by_thread.values.uniq.tap { @by_thread.clear } }
|
data/lib/openlanes/web.rb
CHANGED
|
@@ -38,9 +38,22 @@ module OpenLanes
|
|
|
38
38
|
# @param rest [Hash] {Context}'s other fields
|
|
39
39
|
# @return [OpenLanes::Web::Context]
|
|
40
40
|
def context(stores:, keys:, plans_dir:, url:, **rest)
|
|
41
|
+
rest[:chat] ||= chat(stores:, keys:, node: rest[:node])
|
|
41
42
|
Context.new(stores:, keys:, plans_dir:, url:, csrf: SecureRandom.hex(32), **rest)
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
# The chat between machines for one server (plan 006). Its machine is
|
|
46
|
+
# the node id in +keys+, so it answers for the same machine the board does.
|
|
47
|
+
#
|
|
48
|
+
# @param stores [OpenLanes::Web::Stores]
|
|
49
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
50
|
+
# @param node [OpenLanes::Live::Node, nil]
|
|
51
|
+
# @return [OpenLanes::Live::Chat]
|
|
52
|
+
def chat(stores:, keys:, node: nil, **)
|
|
53
|
+
machine = keys.node.split(":").first
|
|
54
|
+
Live::Chat.new(stores:, keys:, machine:, label: node&.label || machine, **)
|
|
55
|
+
end
|
|
56
|
+
|
|
44
57
|
# @param context [OpenLanes::Web::Context]
|
|
45
58
|
# @return [Class] a Rack application
|
|
46
59
|
def app(context) = App.serving(context)
|
|
Binary file
|