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
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module OpenLanes
7
+ module Live
8
+ # The conversation with the run's copilot, c3po-operator (W17): one Redis
9
+ # stream per node ({Keys#copilot}) holding every turn, the person's and
10
+ # the copilot's, in the order they were said. The page reads it once with
11
+ # {#history} and then follows it on the event stream it already has
12
+ # (`event: copilot`), so the copilot needs no transport of its own.
13
+ #
14
+ # Who answers is the responder's business, not this class's: the API
15
+ # server hands it {Operator}, which runs c3po-operator, and `--demo` a
16
+ # canned line. Whatever answers writes {Turn}s, and {Turn.check} refuses
17
+ # a card that lacks what the page needs to draw it, whoever wrote it.
18
+ class Copilot
19
+ # A message the person may not send as written.
20
+ class Invalid < Error; end
21
+
22
+ # A message nobody is there to answer.
23
+ class Unanswered < Error; end
24
+
25
+ # Who spoke a turn.
26
+ SPEAKERS = %w[you copilot].freeze
27
+
28
+ # What a turn is: words, a red card asking to confirm a command that
29
+ # cannot be undone, or a quiet card holding a drafted answer.
30
+ KINDS = %w[text confirm draft].freeze
31
+
32
+ # The fields each card cannot be drawn without.
33
+ FIELDS = {
34
+ "text" => [],
35
+ "confirm" => %w[command confirm done],
36
+ "draft" => %w[question draft]
37
+ }.freeze
38
+
39
+ # Characters one message may carry. A longer one is a paste gone
40
+ # wrong, and the agent would pay for every token of it.
41
+ LIMIT = 4000
42
+
43
+ # Tokens and model before the copilot has said anything that counts them.
44
+ NO_USAGE = { model: nil, up: 0, down: 0 }.freeze
45
+
46
+ # @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
47
+ # @param keys [OpenLanes::Live::Keys]
48
+ # @param responder [#call, nil] the person's {Turn} in, the copilot's
49
+ # {Turn}s out; nil for a reader, which {#say} refuses to write through
50
+ def initialize(store:, keys:, responder: nil)
51
+ @store = store
52
+ @keys = keys
53
+ @responder = responder
54
+ end
55
+
56
+ # Record the person's message, and nothing more. Answering is
57
+ # {#answer}'s, run apart from the request that brought the message: a
58
+ # real responder is a `claude -p` call, and a POST held open for the
59
+ # whole of it would keep the page from showing the message, or that
60
+ # the copilot is reading, until the answer was already written.
61
+ #
62
+ # @param input [String, Hash] `CopilotMessage`: `{id, text}`
63
+ # @return [OpenLanes::Live::Copilot::Turn] the person's turn
64
+ # @raise [OpenLanes::Live::Copilot::Invalid] for the API server's 422
65
+ # @raise [OpenLanes::Live::Copilot::Unanswered] when nobody would answer
66
+ def say(input)
67
+ turn = message(input)
68
+ # Refused before it is stored: a message kept where nothing will ever
69
+ # answer it reads, on the page, as a copilot that is merely slow.
70
+ raise Unanswered, "the copilot is not running in this server" unless @responder
71
+
72
+ turn.tap { append(it) }
73
+ end
74
+
75
+ # Store whatever the responder answers to +turn+. A responder that
76
+ # fails is answered for, under the reply id the page waits for, so the
77
+ # failure shows in the conversation rather than as a page reading
78
+ # forever.
79
+ #
80
+ # @param turn [OpenLanes::Live::Copilot::Turn] the person's, from {#say}
81
+ # @return [Array<OpenLanes::Live::Copilot::Turn>] what was stored
82
+ def answer(turn)
83
+ replies = begin
84
+ @responder.call(turn)
85
+ rescue StandardError => e
86
+ [Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: "c3po-operator could not answer: #{e.message}")]
87
+ end
88
+ replies.each { append(it) }
89
+ end
90
+
91
+ # The conversation as the page draws it on load: the newest turns,
92
+ # oldest first, and the copilot's latest model and tokens. The usage is
93
+ # looked for in the whole retained stream when the newest turns carry
94
+ # none, so a run of unmetered turns does not reset the header to zero.
95
+ #
96
+ # @param count [Integer]
97
+ # @return [Hash] `CopilotLog`: `{turns:, usage:}`
98
+ def history(count: 100)
99
+ turns = newest_turns(count)
100
+ usage = latest_usage(turns)
101
+ usage ||= latest_usage(newest_turns(Keys::COPILOT_LENGTH)) if turns.size == count
102
+ { turns:, usage: usage || NO_USAGE }
103
+ end
104
+
105
+ # Turns written after +after+, oldest first, with their stream ids.
106
+ #
107
+ # @param after [String] a stream id
108
+ # @return [Array<Array(String, Hash)>]
109
+ def since(after)
110
+ @store.xread(@keys.copilot, id: after).map { |entry| [entry.id, JSON.parse(entry.fields["json"])] }
111
+ end
112
+
113
+ # @return [String] the newest turn's stream id, `0-0` when there is none
114
+ def newest = @store.xrevrange(@keys.copilot, count: 1).first&.id || "0-0"
115
+
116
+ private
117
+
118
+ # @return [OpenLanes::Live::Copilot::Turn]
119
+ def message(input)
120
+ data = input.is_a?(String) ? JSON.parse(input) : input
121
+ raise Invalid, "a message is a JSON object" unless data.is_a?(Hash)
122
+
123
+ id = data["id"].to_s
124
+ text = data["text"].to_s.strip
125
+ raise Invalid, "id must be 1 to 64 letters, digits, - or _" unless id.match?(Command::ID)
126
+ raise Invalid, "a message needs text" if text.empty?
127
+ raise Invalid, "a message is at most #{LIMIT} characters" if text.length > LIMIT
128
+
129
+ Turn.new(id:, from: "you", kind: "text", text:)
130
+ rescue JSON::ParserError => e
131
+ raise Invalid, "not a message: #{e.message.lines.first.to_s.strip}"
132
+ end
133
+
134
+ # @return [Array<Hash>] up to +count+ turns, oldest first
135
+ def newest_turns(count)
136
+ @store.xrevrange(@keys.copilot, count:).reverse.map { JSON.parse(it.fields["json"]) }
137
+ end
138
+
139
+ # @return [Hash, nil]
140
+ def latest_usage(turns) = turns.reverse_each.map { it["usage"] }.find(&:itself)
141
+
142
+ # @return [void]
143
+ def append(turn)
144
+ @store.xadd(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH)
145
+ end
146
+ end
147
+
148
+ class Copilot
149
+ # One thing said in the conversation: `CopilotTurn` in `types.ts`.
150
+ #
151
+ # @!attribute [r] id
152
+ # @return [String] the person's message id; a reply's is that id, a
153
+ # dot and its place among the replies, so a reply names what it answers
154
+ # @!attribute [r] from
155
+ # @return [String] one of {SPEAKERS}
156
+ # @!attribute [r] kind
157
+ # @return [String] one of {KINDS}
158
+ # @!attribute [r] text
159
+ # @return [String]
160
+ # @!attribute [r] at
161
+ # @return [Time]
162
+ # @!attribute [r] usage
163
+ # @return [Hash, nil] `{model, up, down}`, the copilot's running total
164
+ # after this turn; nil on the person's turns and on answers no model
165
+ # wrote
166
+ # @!attribute [r] fields
167
+ # @return [Hash{String => Object}] what a card needs, per {FIELDS}
168
+ Turn = Data.define(:id, :from, :kind, :text, :at, :usage, :fields) do
169
+ def initialize(at: Time.now, usage: nil, fields: {}, **rest)
170
+ super
171
+ Turn.check(self)
172
+ end
173
+
174
+ # @return [Hash] as the wire carries it
175
+ def wire = { id:, from:, kind:, text:, at: at.utc.iso8601, usage: }.merge(fields.transform_keys(&:to_sym))
176
+
177
+ # @return [String]
178
+ def to_json(*) = JSON.generate(wire)
179
+ end
180
+
181
+ class Turn
182
+ # The keys every turn has; anything else belongs to {FIELDS}.
183
+ BASE = %w[id from kind text at usage].freeze
184
+
185
+ # @param data [Hash] a turn as the wire carries it
186
+ # @return [OpenLanes::Live::Copilot::Turn]
187
+ # @raise [OpenLanes::Live::Copilot::Invalid]
188
+ def self.parse(data)
189
+ data = data.transform_keys(&:to_s)
190
+ new(id: data["id"].to_s,
191
+ from: data["from"].to_s,
192
+ kind: data["kind"].to_s,
193
+ text: data["text"].to_s,
194
+ at: data["at"] ? Time.iso8601(data["at"]) : Time.now,
195
+ usage: data["usage"],
196
+ fields: data.except(*BASE))
197
+ rescue ArgumentError => e
198
+ raise Invalid, "not a turn: #{e.message}"
199
+ end
200
+
201
+ # @param turn [OpenLanes::Live::Copilot::Turn]
202
+ # @return [void]
203
+ # @raise [OpenLanes::Live::Copilot::Invalid] naming what is wrong
204
+ def self.check(turn)
205
+ raise Invalid, "unknown speaker #{turn.from.inspect}" unless SPEAKERS.include?(turn.from)
206
+ raise Invalid, "unknown kind #{turn.kind.inspect}; known: #{KINDS.join(", ")}" unless KINDS.include?(turn.kind)
207
+ raise Invalid, "only the copilot draws cards" if turn.from == "you" && turn.kind != "text"
208
+
209
+ fields = turn.fields.transform_keys(&:to_s)
210
+ check_names(turn.kind, fields.keys)
211
+ check_text(turn.kind, fields)
212
+ check_command(fields["command"]) if turn.kind == "confirm"
213
+ end
214
+
215
+ # A card holds exactly its own fields: a missing one cannot be drawn,
216
+ # and an extra one could shadow a base key such as `id` on the wire.
217
+ #
218
+ # @return [void]
219
+ def self.check_names(kind, names)
220
+ wanted = FIELDS.fetch(kind)
221
+ missing = wanted - names
222
+ raise Invalid, "a #{kind} card needs #{missing.join(" and ")}" unless missing.empty?
223
+
224
+ extra = names - wanted
225
+ raise Invalid, "a #{kind} card has no #{extra.join(" or ")}" unless extra.empty?
226
+ end
227
+
228
+ # Every field but a command is drawn as text, so it has to be some:
229
+ # a blank one would draw an unnamed button or an empty draft.
230
+ #
231
+ # @return [void]
232
+ def self.check_text(kind, fields)
233
+ wrong = (FIELDS.fetch(kind) - %w[command]).reject { fields[it].is_a?(String) && !fields[it].strip.empty? }
234
+ raise Invalid, "a #{kind} card's #{wrong.join(" and ")} must be text" unless wrong.empty?
235
+ end
236
+
237
+ # A confirm card is sent through the same gate as the page's own
238
+ # buttons (W13), so it has to be a command that gate would take. It
239
+ # carries no id: the page mints one when the person presses, and an id
240
+ # supplied here would replace it and could repeat one already done.
241
+ #
242
+ # @return [void]
243
+ def self.check_command(command)
244
+ raise Invalid, "a confirm card's command is a JSON object" unless command.is_a?(Hash)
245
+
246
+ command = command.transform_keys(&:to_s)
247
+ raise Invalid, "a confirm card's command carries no id; the page mints one" if command.key?("id")
248
+
249
+ Command.parse(command.merge("id" => "card"))
250
+ rescue Command::Invalid => e
251
+ raise Invalid, "a confirm card's command is not one: #{e.message}"
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -62,6 +62,60 @@ module OpenLanes
62
62
  # @return [String]
63
63
  def run(of = node) = "#{base}:#{of}:run"
64
64
 
65
+ # Stream: `{json}`, one `CopilotTurn`: the conversation with this
66
+ # node's copilot (W17), the person's messages and its answers in the
67
+ # order they were said. {Copilot} writes and reads it.
68
+ #
69
+ # @param of [String]
70
+ # @return [String]
71
+ def copilot(of = node) = "#{base}:#{of}:copilot"
72
+
73
+ # Hash: `session`, `model`, `up` and `down`, the copilot's `claude`
74
+ # session id and what it has spent in it. Kept beside {#copilot} so a
75
+ # reloaded page or a restarted server carries on the same conversation
76
+ # (W17: one session, resumed on each message).
77
+ #
78
+ # @param of [String]
79
+ # @return [String]
80
+ def copilot_session(of = node) = "#{base}:#{of}:copilot-session"
81
+
82
+ # Pub/sub channel: one chat's messages, joins, leaves and idle notices
83
+ # (plan 006). Redis delivers a `PUBLISH` to every database's
84
+ # subscribers, so the database is part of every chat channel's name: a
85
+ # chat between nodes on database 13 never reaches a page on database 0.
86
+ #
87
+ # @param id [String] the chat's UUID
88
+ # @param db [Integer] the Redis database the nodes share
89
+ # @return [String]
90
+ def chat_room(id, db:) = "#{chat_base(db)}:room:#{id}"
91
+
92
+ # Pub/sub pattern matching every {#chat_room} on +db+, which each API
93
+ # server subscribes to once, so joining a chat needs no new subscription.
94
+ #
95
+ # @param db [Integer]
96
+ # @return [String]
97
+ def chat_rooms(db:) = "#{chat_base(db).gsub(/[*?\[\]\\]/) { "\\#{it}" }}:room:*"
98
+
99
+ # Pub/sub channel: one machine's chat inbox, where invites to it and
100
+ # their answers arrive. Every API server for that machine listens, so
101
+ # an invite reaches every page open there (D9).
102
+ #
103
+ # @param machine [String] a node id, 24 hex digits of `user@host`
104
+ # @param db [Integer]
105
+ # @return [String]
106
+ def chat_inbox(machine, db:) = "#{chat_base(db)}:machine:#{machine}"
107
+
108
+ # String: how an invite was settled (`accepted`, `declined`, `expired`),
109
+ # `SET NX EX`, so the first answer from any page wins (D9).
110
+ #
111
+ # @param invite [String] the invite's UUID
112
+ # @return [String]
113
+ def chat_answer(invite) = "#{base}:chat:invite:#{invite}"
114
+
115
+ # @param db [Integer]
116
+ # @return [String]
117
+ def chat_base(db) = "#{base}:chat:#{db}"
118
+
65
119
  # String: the node name holding a plan, `SET NX EX`.
66
120
  #
67
121
  # @param ordinal [#to_s] e.g. "004.00"
@@ -80,6 +134,10 @@ module OpenLanes
80
134
  BOARD_LENGTH = 100
81
135
  REPLIES_LENGTH = 1000
82
136
 
137
+ # Length of {#copilot}, kept approximately: a conversation the page
138
+ # can scroll back through, not an archive.
139
+ COPILOT_LENGTH = 500
140
+
83
141
  # Seconds a snapshot outlives its last refresh.
84
142
  SNAPSHOT_TTL = 30
85
143
 
@@ -22,6 +22,7 @@ module OpenLanes
22
22
  @groups = Hash.new { |h, k| h[k] = {} }
23
23
  @last_ms = 0
24
24
  @last_seq = 0
25
+ @subscribers = []
25
26
  end
26
27
 
27
28
  # @return [String] what {Live.mask} shows for it
@@ -193,6 +194,29 @@ module OpenLanes
193
194
  # @return [Array<String>]
194
195
  def scan(prefix) = locked { @data.keys.select { |k| k.start_with?(prefix) && !live(k).nil? }.sort }
195
196
 
197
+ # ---- pub/sub ----------------------------------------------------------
198
+
199
+ # @return [Integer] how many subscriptions received it
200
+ def publish(channel, message)
201
+ receivers = locked { @subscribers.dup }
202
+ receivers.sum do |subscriber|
203
+ subscriber.deliver(channel, message.to_s)
204
+ subscriber.matches(channel)
205
+ end
206
+ end
207
+
208
+ # @return [OpenLanes::Live::MemorySubscriber]
209
+ def subscriber(channels: [], patterns: [])
210
+ MemorySubscriber.new(store: self, channels:, patterns:).tap { |s| locked { @subscribers << s } }
211
+ end
212
+
213
+ # @param subscriber [OpenLanes::Live::MemorySubscriber]
214
+ # @return [void]
215
+ def unsubscribe(subscriber) = locked { @subscribers.delete(subscriber) }
216
+
217
+ # @return [Integer] always 0: one process, one keyspace
218
+ def db = 0
219
+
196
220
  # ---- transactions -----------------------------------------------------
197
221
 
198
222
  # @yieldparam tx [Store::Transaction]
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ # {Subscriber}'s methods over {MemoryStore#publish}: a queue in this
6
+ # process, matched by exact channel or by Redis-style glob.
7
+ class MemorySubscriber
8
+ # @param store [OpenLanes::Live::MemoryStore]
9
+ # @param channels [Array<String>]
10
+ # @param patterns [Array<String>]
11
+ def initialize(store:, channels: [], patterns: [])
12
+ @store = store
13
+ @channels = channels
14
+ @patterns = patterns
15
+ @queue = Thread::Queue.new
16
+ end
17
+
18
+ # @param channel [String]
19
+ # @return [Integer] how many of its subscriptions match, as Redis counts receivers
20
+ def matches(channel)
21
+ @channels.count(channel) + @patterns.count { File.fnmatch?(it, channel) }
22
+ end
23
+
24
+ # @param channel [String]
25
+ # @param message [String]
26
+ # @return [void]
27
+ def deliver(channel, message) = matches(channel).times { @queue << [channel, message] }
28
+
29
+ # @param timeout [Float]
30
+ # @return [Array(String, String), nil]
31
+ def next_message(timeout) = @queue.pop(timeout: [timeout, 0].max)
32
+
33
+ # @return [void]
34
+ def close
35
+ @store.unsubscribe(self)
36
+ @queue.close
37
+ end
38
+ end
39
+ end
40
+ end
@@ -101,7 +101,13 @@ module OpenLanes
101
101
  end
102
102
 
103
103
  # @return [String] where, with the password masked, and as whom
104
- def describe = "#{Live.mask(@store.url)} as #{@node.label} (#{@node.name})"
104
+ def describe = described.join(" ")
105
+
106
+ # The same, in the two parts a narrow box may break it between: the
107
+ # node id is long, and wraps mid-word when left to the box.
108
+ #
109
+ # @return [Array(String, String)] `"redis://… as kig@host"` and `"(<node id>)"`
110
+ def described = ["#{Live.mask(@store.url)} as #{@node.label}", "(#{@node.name})"]
105
111
  end
106
112
  end
107
113
  end
@@ -28,11 +28,14 @@ module OpenLanes
28
28
  # Key, label and the states folded into it, in spine order. ⭐️ Planned
29
29
  # sits with 🟡 Building and 🟢 Ready for Review with 👀 In Review,
30
30
  # because on the board each pair is one stop with the same agents.
31
+ # 🔴 Rejected sits at Building too: luke and rey rework it there, and
32
+ # placed by the spine alone it showed them at Building UI, a stop whose
33
+ # agent list leaves luke out.
31
34
  GROUPS = [
32
35
  [:new, "New", %i[new]],
33
36
  [:researched, "Researched", %i[researched retroactive]],
34
37
  [:ready_for_planning, "Planning", %i[ready_for_planning]],
35
- [:building, "Building", %i[planned building]],
38
+ [:building, "Building", %i[planned building rejected]],
36
39
  [:building_ui, "Building UI", %i[building_ui]],
37
40
  [:in_review, "Review", %i[ready_for_review in_review]],
38
41
  [:pr, "Pull request", %i[approved deployed]]
@@ -48,10 +51,11 @@ module OpenLanes
48
51
  end
49
52
  end
50
53
 
51
- # The stop a plan in +state+ is shown at. A state off the spine (🔴,
52
- # 💩, 😱) is shown where the spine takes it next; one with no way
53
- # forward (⭕️, 🅱️) where the plan was when it stopped, which only
54
- # its history knows.
54
+ # The stop a plan in +state+ is shown at. A state a {GROUPS} entry
55
+ # names is shown at that stop, which is how 🔴 Rejected lands at
56
+ # Building. Any other state off the spine (💩, 😱) is shown where the
57
+ # spine takes it next; one with no way forward (⭕️, 🅱️) where the
58
+ # plan was when it stopped, which only its history knows.
55
59
  #
56
60
  # @param state [Symbol]
57
61
  # @param before [Symbol, nil] the state the plan was worked in last
@@ -215,6 +215,24 @@ module OpenLanes
215
215
  found.uniq.sort
216
216
  end
217
217
 
218
+ # ---- pub/sub ----------------------------------------------------------
219
+
220
+ # @param channel [String]
221
+ # @param message [String]
222
+ # @return [Integer] how many subscriptions received it
223
+ def publish(channel, message) = call("PUBLISH", channel, message)
224
+
225
+ # A connection of its own, subscribed to +channels+ and +patterns+.
226
+ #
227
+ # @return [OpenLanes::Live::Subscriber]
228
+ def subscriber(channels: [], patterns: []) = Subscriber.new(url:, channels:, patterns:)
229
+
230
+ # The database in the URL. Pub/sub ignores databases, so whoever names
231
+ # a channel puts this in it (see {Keys#chat_room}).
232
+ #
233
+ # @return [Integer]
234
+ def db = @client.config.db.to_i
235
+
218
236
  # ---- transactions -----------------------------------------------------
219
237
 
220
238
  # The writes the block makes, applied atomically.
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis-client"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ # One Redis pub/sub connection, subscribed once to the channels and
8
+ # patterns it was made with (plan 006's chat). A subscribed connection
9
+ # can do nothing else, so it is its own connection, never the {Store}'s.
10
+ #
11
+ # Made by {Store#subscriber}; {MemorySubscriber} is the suite's.
12
+ class Subscriber
13
+ # Seconds the shortest wait lasts. redis-client reads a timeout of 0 as
14
+ # "wait for ever", which is the one thing a polling loop must not do.
15
+ SHORTEST = 0.01
16
+
17
+ # @param url [String]
18
+ # @param channels [Array<String>]
19
+ # @param patterns [Array<String>]
20
+ # @param timeout [Float] seconds to connect and to wait for each confirmation
21
+ # @raise [OpenLanes::Live::Unavailable]
22
+ def initialize(url:, channels: [], patterns: [], timeout: Store::TIMEOUT)
23
+ @url = url
24
+ guarded do
25
+ @pubsub = RedisClient.config(url:, timeout:).new_client.pubsub
26
+ @pubsub.call("SUBSCRIBE", *channels) unless channels.empty?
27
+ @pubsub.call("PSUBSCRIBE", *patterns) unless patterns.empty?
28
+ confirm(channels.size + patterns.size, timeout)
29
+ end
30
+ end
31
+
32
+ # The next message, waiting up to +timeout+ seconds for one.
33
+ #
34
+ # @param timeout [Float]
35
+ # @return [Array(String, String), nil] its channel and its payload
36
+ # @raise [OpenLanes::Live::Unavailable]
37
+ def next_message(timeout)
38
+ loop do
39
+ event = guarded { @pubsub.next_event([timeout, SHORTEST].max) } or return nil
40
+ case event.first
41
+ when "message" then return [event[1], event[2]]
42
+ when "pmessage" then return [event[2], event[3]]
43
+ end
44
+ end
45
+ end
46
+
47
+ # @return [void]
48
+ def close = @pubsub&.close
49
+
50
+ private
51
+
52
+ # Waits until Redis has confirmed every subscription, so a message
53
+ # published the moment this returns is one this connection receives.
54
+ #
55
+ # @return [void]
56
+ def confirm(expected, timeout)
57
+ confirmed = 0
58
+ while confirmed < expected
59
+ event = @pubsub.next_event(timeout) or raise Unavailable, "Redis at #{Live.mask(@url)} did not confirm a subscription"
60
+ confirmed += 1 if %w[subscribe psubscribe].include?(event.first)
61
+ end
62
+ end
63
+
64
+ # @return [Object] the block's value
65
+ # @raise [OpenLanes::Live::Unavailable]
66
+ def guarded
67
+ yield
68
+ rescue RedisClient::Error => e
69
+ raise Unavailable, "Redis at #{Live.mask(@url)} is unavailable: #{e.message.sub(/\s*\(redis:.*\)\z/, "")}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -57,6 +57,11 @@ module OpenLanes
57
57
  # @return [OpenLanes::Live::Session]
58
58
  def session(**) = Session.new(**)
59
59
 
60
+ # The conversation with the run's copilot (W17), on +store+ under +keys+.
61
+ #
62
+ # @return [OpenLanes::Live::Copilot]
63
+ def copilot(**) = Copilot.new(**)
64
+
60
65
  # This checkout's keys, for the API server beside its engine.
61
66
  #
62
67
  # @param root [String]
@@ -67,7 +67,7 @@ module OpenLanes
67
67
  #
68
68
  # @return [String]
69
69
  def row(flag, ordinal, emoji, label, title, tail)
70
- format("%s %s %s %s %s %s", flag, ordinal, emoji, label, title, tail)
70
+ format("%s %s %s %s %s %s", flag, ordinal, emoji, label, title, tail)
71
71
  end
72
72
 
73
73
  # Cell at which the pull requests start, so a plan's second and third pull
@@ -162,7 +162,7 @@ module OpenLanes
162
162
 
163
163
  # @return [String]
164
164
  def footer
165
- counts = totals.map { |key, n| "#{Plans::STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
165
+ counts = totals.map { |key, n| "#{Plans::STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
166
166
  lines = [" #{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}"]
167
167
 
168
168
  unless inconsistent.empty?
@@ -280,7 +280,10 @@ module OpenLanes
280
280
  log_taps.each { |tap| tap.call(message, fields) }
281
281
  path = log_path or return
282
282
 
283
- line = Engine::ProgressLog.render(message, **fields)
283
+ # Taps hear every field; the file takes only its own columns, so a
284
+ # field meant for the terminal (the state a plan moved to) adds
285
+ # nothing to the file's format.
286
+ line = Engine::ProgressLog.render(message, **fields.slice(*Engine::ProgressLog::FIELDS))
284
287
  (@log_mutex ||= Mutex.new).synchronize do
285
288
  FileUtils.mkdir_p(File.dirname(path))
286
289
  File.open(path, "a") { |f| f.puts(line) }
@@ -612,6 +615,77 @@ module OpenLanes
612
615
  # @return [void]
613
616
  def box(kind, message) = console.public_send(kind, message.to_s)
614
617
 
618
+ # Labelled lines for a box, the labels padded to one width:
619
+ #
620
+ # Progress: /var/folders/…/openlanes-minesweeper.log
621
+ # Live board: redis://host:6379 as kig@host
622
+ # (c016…:734c…)
623
+ #
624
+ # A value given as several parts is joined with spaces while it fits
625
+ # the box, and each part that does not starts a continuation line
626
+ # under the values, so a long id breaks where it was meant to rather
627
+ # than wherever the box's own wrap runs out of room.
628
+ #
629
+ # @param rows [Array<Array(String, String | Array<String>)>] label and value
630
+ # @param room [Integer] text columns inside the box
631
+ # @return [String]
632
+ def labelled(rows, room: box_room)
633
+ pad = rows.map { |label, _| display_width("#{label}:") }.max.to_i + 1
634
+ free = [room - pad, 1].max
635
+ rows.flat_map { |label, parts|
636
+ lines = Array(parts).map(&:to_s).each_with_object([]) do |part, acc|
637
+ if acc.any? && display_width("#{acc.last} #{part}") <= free
638
+ acc[-1] = "#{acc.last} #{part}"
639
+ else
640
+ acc.concat(broken(part, free))
641
+ end
642
+ end
643
+ lines.each_with_index.map { |text, i| (i.zero? ? fit("#{label}:", pad) : " " * pad) + text }
644
+ }.join("\n")
645
+ end
646
+
647
+ # A value wider than its column, cut into pieces that fit: after a
648
+ # `/` where it can be, so a path breaks between directories, and
649
+ # anywhere when one name is wider than the column on its own. Left
650
+ # to the box, a path wider than the room after its label wrapped
651
+ # onto the next line whole, under the label rather than beside it,
652
+ # and was then cut mid-name with no indent.
653
+ #
654
+ # @param text [String]
655
+ # @param free [Integer] columns
656
+ # @return [Array<String>] the pieces, each at most +free+ wide
657
+ def broken(text, free)
658
+ return [text] if display_width(text) <= free
659
+
660
+ text.split(%r{(?<=/)}).flat_map { cells(it, free) }.each_with_object([+""]) do |piece, acc|
661
+ acc.last.empty? || display_width(acc.last + piece) <= free ? acc.last << piece : acc << piece.dup
662
+ end
663
+ end
664
+
665
+ # A piece cut by terminal cells rather than characters, one grapheme
666
+ # cluster at a time, so a wide name such as `界界界` is never cut into
667
+ # a chunk wider than +free+ (nor a combining mark off its letter).
668
+ #
669
+ # @param text [String]
670
+ # @param free [Integer] columns
671
+ # @return [Array<String>]
672
+ def cells(text, free)
673
+ text.each_grapheme_cluster.with_object([+""]) do |grapheme, acc|
674
+ acc << +"" unless acc.last.empty? || display_width(acc.last + grapheme) <= free
675
+ acc.last << grapheme
676
+ end
677
+ end
678
+
679
+ # Text columns inside a box, as dry-cli-ui works them out: the box is
680
+ # {.width} or the terminal less two, whichever is narrower, and a
681
+ # border and two columns of padding go on either side.
682
+ #
683
+ # @return [Integer]
684
+ def box_room
685
+ terminal = tty? ? TTY::Screen.width : Dry::CLI::UI::Terminal::DEFAULT_WIDTH
686
+ [width, terminal - 2].min - 6
687
+ end
688
+
615
689
  # A framed panel centered on the screen, for the keyboard help. Unlike
616
690
  # {.box} it positions itself absolutely, so it overlays whatever the
617
691
  # spinners are drawing rather than scrolling in below them — the next