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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/agents/c3po-operator.md +58 -0
  3. data/lib/openlanes/adapters/base.rb +7 -0
  4. data/lib/openlanes/adapters/claude.rb +21 -2
  5. data/lib/openlanes/adapters.rb +19 -2
  6. data/lib/openlanes/cli/run/run.rb +47 -15
  7. data/lib/openlanes/cli/start/start.rb +1 -14
  8. data/lib/openlanes/cli/web/web.rb +11 -1
  9. data/lib/openlanes/engine/dispatcher.rb +62 -7
  10. data/lib/openlanes/engine/narrator.rb +107 -0
  11. data/lib/openlanes/engine/progress_log.rb +50 -9
  12. data/lib/openlanes/execution/prompt.rb +13 -0
  13. data/lib/openlanes/lifecycle/creator.rb +24 -0
  14. data/lib/openlanes/live/chat.rb +616 -0
  15. data/lib/openlanes/live/commands.rb +4 -1
  16. data/lib/openlanes/live/copilot/brief.rb +128 -0
  17. data/lib/openlanes/live/copilot/cards.rb +226 -0
  18. data/lib/openlanes/live/copilot/operator.rb +259 -0
  19. data/lib/openlanes/live/copilot.rb +256 -0
  20. data/lib/openlanes/live/keys.rb +58 -0
  21. data/lib/openlanes/live/memory_store.rb +24 -0
  22. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  23. data/lib/openlanes/live/session.rb +7 -1
  24. data/lib/openlanes/live/stations.rb +9 -5
  25. data/lib/openlanes/live/store.rb +18 -0
  26. data/lib/openlanes/live/subscriber.rb +73 -0
  27. data/lib/openlanes/live.rb +5 -0
  28. data/lib/openlanes/presentation/reporter.rb +2 -2
  29. data/lib/openlanes/support/ui.rb +75 -1
  30. data/lib/openlanes/vcs/publisher.rb +62 -0
  31. data/lib/openlanes/vcs/worktree.rb +44 -9
  32. data/lib/openlanes/version.rb +1 -1
  33. data/lib/openlanes/web/app.rb +100 -1
  34. data/lib/openlanes/web/context.rb +20 -2
  35. data/lib/openlanes/web/demo.rb +10 -0
  36. data/lib/openlanes/web/events.rb +51 -2
  37. data/lib/openlanes/web/launcher.rb +1 -1
  38. data/lib/openlanes/web/server.rb +2 -0
  39. data/lib/openlanes/web/stores.rb +11 -0
  40. data/lib/openlanes/web.rb +13 -0
  41. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  42. data/web/dist/assets/{index-BdR34cXC.js → index-6MZkmh3k.js} +2 -2
  43. data/web/dist/assets/{index-uM88Njdj.js → index-B5CKJPm-.js} +2 -2
  44. data/web/dist/assets/{index-DB4QwuYn.js → index-BF6QYroq.js} +2 -2
  45. data/web/dist/assets/{index-Bp7W-ZTq.js → index-BFPI-zXI.js} +2 -2
  46. data/web/dist/assets/{index-CI74IkWP.js → index-BYJCWlk0.js} +49 -49
  47. data/web/dist/assets/{index-popk9wbW.js → index-C2jwoWVV.js} +2 -2
  48. data/web/dist/assets/{index-CYxcbpRM.js → index-C3zc4gFe.js} +2 -2
  49. data/web/dist/assets/index-CjInBi5X.css +1 -0
  50. data/web/dist/assets/{index-2xPC-eHt.js → index-CnJfB0l2.js} +2 -2
  51. data/web/dist/assets/{index-P3vSkiU-.js → index-CnvkZu5Y.js} +2 -2
  52. data/web/dist/assets/{index-OR-odsl9.js → index-DH0gDlee.js} +2 -2
  53. data/web/dist/assets/{index-D0rmJN3h.js → index-DXgeajd6.js} +2 -2
  54. data/web/dist/assets/{index-C0VmkrO-.js → index-DaSjQ-H4.js} +2 -2
  55. data/web/dist/assets/{index-B7-O9ujQ.js → index-DbueqOK5.js} +2 -2
  56. data/web/dist/assets/{index-DDI7zoAS.js → index-Dbxvk-EC.js} +2 -2
  57. data/web/dist/assets/{index-8dNhRmiO.js → index-DtA46i32.js} +2 -2
  58. data/web/dist/assets/{index-DGLUuEXi.js → index-IqD8mBos.js} +2 -2
  59. data/web/dist/assets/{index-Byyxbh_z.js → index-bpsFIpXE.js} +2 -2
  60. data/web/dist/assets/{index-mRV4XhJZ.js → index-pvJi-JJO.js} +2 -2
  61. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  62. data/web/dist/index.html +3 -3
  63. metadata +31 -23
  64. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  65. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  66. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  67. 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
- # `bin/setup-worktree`, which copies in the ignored-but-required files a new
24
- # checkout does not get. Shelling out rather than reimplementing the rules
25
- # here keeps one answer to "what does a worktree need", and keeps that answer
26
- # usable from a shell on a machine that has never run `bundle install`.
27
- SEEDER = File.expand_path("../../../bin/setup-worktree", __dir__)
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
- def initialize(root:, dir: nil, user: ENV["USER"] || "agent")
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?(SEEDER)
122
- UI.log("worktree: #{SEEDER} is missing, so #{path} has no .env or credential keys")
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(SEEDER, "--quiet", path, out: File::NULL, err: File::NULL)
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
@@ -6,5 +6,5 @@
6
6
  #
7
7
  # © 2026 Konstantin Gredeskoul
8
8
  module OpenLanes
9
- VERSION = "3.0.0"
9
+ VERSION = "3.0.1"
10
10
  end
@@ -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
@@ -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]
@@ -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, and a `: ping` comment so proxies and the
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) = File.join(Dir.tmpdir, "openlanes-web-#{File.basename(root)}.log")
140
+ def default_log(root) = Engine::ProgressLog.default_path(root, kind: "web")
141
141
  end
142
142
  end
143
143
  end
@@ -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
@@ -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)