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
|
@@ -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
|
data/lib/openlanes/live/keys.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
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
|
data/lib/openlanes/live/store.rb
CHANGED
|
@@ -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
|
data/lib/openlanes/live.rb
CHANGED
|
@@ -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
|
|
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}
|
|
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?
|
data/lib/openlanes/support/ui.rb
CHANGED
|
@@ -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
|
-
|
|
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
|