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,616 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module OpenLanes
|
|
9
|
+
module Live
|
|
10
|
+
# Chat between machines (plan 006): one per API server, relaying Redis
|
|
11
|
+
# pub/sub to the pages it serves. Nothing said is stored (D2): a message
|
|
12
|
+
# is published, every API server with a page in that chat hears it, and
|
|
13
|
+
# each hands it to its pages over the event stream they already hold
|
|
14
|
+
# (`event: chat`). The page sends by POST, as it does everything else.
|
|
15
|
+
#
|
|
16
|
+
# Two kinds of channel, named by {Keys}: a room per chat
|
|
17
|
+
# ({Keys#chat_room}), which every server takes through one pattern, and an
|
|
18
|
+
# inbox per machine ({Keys#chat_inbox}), where invites and their answers
|
|
19
|
+
# arrive. Both carry the Redis database in their name, because pub/sub
|
|
20
|
+
# does not.
|
|
21
|
+
#
|
|
22
|
+
# A participant is one page, named by the id the page keeps in its
|
|
23
|
+
# session storage, so a reload is the same participant. The roster is not
|
|
24
|
+
# stored either: a joiner announces itself, everyone already there answers
|
|
25
|
+
# `here`, and every server repeats `here` for its own participants each
|
|
26
|
+
# {HEARTBEAT}, which is also how one whose server died is noticed.
|
|
27
|
+
#
|
|
28
|
+
# The listening thread calls {#step}; the suite calls it by hand.
|
|
29
|
+
class Chat
|
|
30
|
+
# A request the page may not make as written: the API server's 422.
|
|
31
|
+
class Invalid < Error; end
|
|
32
|
+
|
|
33
|
+
# An invite already answered, expired or never seen: the API server's 409.
|
|
34
|
+
class Settled < Error; end
|
|
35
|
+
|
|
36
|
+
# Characters one message may carry (D10).
|
|
37
|
+
LIMIT = 4000
|
|
38
|
+
|
|
39
|
+
# Characters a name may carry.
|
|
40
|
+
NAME_LIMIT = 60
|
|
41
|
+
|
|
42
|
+
# Seconds an invite waits for an answer (D9).
|
|
43
|
+
INVITE_TTL = 60
|
|
44
|
+
|
|
45
|
+
# Seconds a page may be without its event stream before it counts as
|
|
46
|
+
# having left (D6): long enough for a reload or a dropped connection.
|
|
47
|
+
GRACE = 10
|
|
48
|
+
|
|
49
|
+
# Seconds between a server's `here` for each of its participants.
|
|
50
|
+
HEARTBEAT = 20
|
|
51
|
+
|
|
52
|
+
# Seconds without a word from a participant before it is taken as gone.
|
|
53
|
+
FORGET_AFTER = HEARTBEAT * 3
|
|
54
|
+
|
|
55
|
+
# Events kept for a page that is not reading them.
|
|
56
|
+
BACKLOG = 200
|
|
57
|
+
|
|
58
|
+
# Seconds the listening thread waits for a message before it looks
|
|
59
|
+
# after expiries and heartbeats.
|
|
60
|
+
POLL = 0.25
|
|
61
|
+
|
|
62
|
+
# A page id: what the page mints for itself.
|
|
63
|
+
PAGE = /\A[A-Za-z0-9_-]{8,64}\z/
|
|
64
|
+
|
|
65
|
+
# A chat or invite id.
|
|
66
|
+
UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
67
|
+
|
|
68
|
+
# A node id, 24 hex digits.
|
|
69
|
+
MACHINE = /\A\h{24}\z/
|
|
70
|
+
|
|
71
|
+
# What a page tells the room it is doing.
|
|
72
|
+
ROOM_KINDS = %w[join here say idle back leave].freeze
|
|
73
|
+
|
|
74
|
+
# One page this server serves.
|
|
75
|
+
Page = Struct.new(:id, :events, :streams, :gone_at, :chat, :name, :idle)
|
|
76
|
+
|
|
77
|
+
# @param stores [#current] a store per thread, as {Web::Stores} gives
|
|
78
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
79
|
+
# @param machine [String] this node's id
|
|
80
|
+
# @param label [String] `user@host`, what other pages show for it
|
|
81
|
+
# @param clock [Proc] seconds, monotonic
|
|
82
|
+
# @param wall [Proc] the time, for what the page shows
|
|
83
|
+
def initialize(stores:, keys:, machine:, label:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
84
|
+
wall: -> { Time.now })
|
|
85
|
+
@stores = stores
|
|
86
|
+
@keys = keys
|
|
87
|
+
@machine = machine
|
|
88
|
+
@label = label
|
|
89
|
+
@clock = clock
|
|
90
|
+
@wall = wall
|
|
91
|
+
@lock = Monitor.new
|
|
92
|
+
@pages = {}
|
|
93
|
+
@rooms = Hash.new { |h, k| h[k] = {} }
|
|
94
|
+
@incoming = {}
|
|
95
|
+
@outgoing = {}
|
|
96
|
+
@beat = nil
|
|
97
|
+
@running = false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [String]
|
|
101
|
+
attr_reader :machine
|
|
102
|
+
|
|
103
|
+
# ---- the listening thread ---------------------------------------------
|
|
104
|
+
|
|
105
|
+
# Listen on a thread of its own until {#stop}.
|
|
106
|
+
#
|
|
107
|
+
# @return [self]
|
|
108
|
+
def start
|
|
109
|
+
@running = true
|
|
110
|
+
@thread = Thread.new do
|
|
111
|
+
while @running
|
|
112
|
+
begin
|
|
113
|
+
step(POLL)
|
|
114
|
+
rescue Unavailable
|
|
115
|
+
close_subscriber
|
|
116
|
+
sleep 1
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Say goodbye for every page here, then stop listening.
|
|
124
|
+
#
|
|
125
|
+
# @return [void]
|
|
126
|
+
def stop
|
|
127
|
+
@running = false
|
|
128
|
+
@thread&.join(POLL * 4)
|
|
129
|
+
pages = @lock.synchronize { @pages.values.select(&:chat) }
|
|
130
|
+
pages.each { leave(page: it.id) }
|
|
131
|
+
rescue Unavailable
|
|
132
|
+
nil
|
|
133
|
+
ensure
|
|
134
|
+
close_subscriber
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Handle whatever has arrived, waiting up to +timeout+ for the first,
|
|
138
|
+
# then expire, prune and heartbeat.
|
|
139
|
+
#
|
|
140
|
+
# @param timeout [Float]
|
|
141
|
+
# @return [void]
|
|
142
|
+
def step(timeout = 0)
|
|
143
|
+
sub = subscriber
|
|
144
|
+
message = sub.next_message(timeout)
|
|
145
|
+
while message
|
|
146
|
+
receive(*message)
|
|
147
|
+
message = sub.next_message(0)
|
|
148
|
+
end
|
|
149
|
+
housekeep
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ---- the event stream -------------------------------------------------
|
|
153
|
+
|
|
154
|
+
# A page's event stream opened.
|
|
155
|
+
#
|
|
156
|
+
# @param id [String]
|
|
157
|
+
# @return [void]
|
|
158
|
+
def attach(id)
|
|
159
|
+
@lock.synchronize do
|
|
160
|
+
page = page!(id)
|
|
161
|
+
page.streams += 1
|
|
162
|
+
page.gone_at = nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# A page's event stream closed. It has {GRACE} to come back.
|
|
167
|
+
#
|
|
168
|
+
# @param id [String]
|
|
169
|
+
# @return [void]
|
|
170
|
+
def detach(id)
|
|
171
|
+
@lock.synchronize do
|
|
172
|
+
page = @pages[id] or return
|
|
173
|
+
page.streams = [page.streams - 1, 0].max
|
|
174
|
+
page.gone_at = now if page.streams.zero?
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Every event for +id+ since the last call, oldest first.
|
|
179
|
+
#
|
|
180
|
+
# @param id [String]
|
|
181
|
+
# @return [Array<Hash>] `ChatEvent`s
|
|
182
|
+
def drain(id) = @lock.synchronize { @pages[id]&.events&.slice!(0..) || [] }
|
|
183
|
+
|
|
184
|
+
# ---- what the page asks -----------------------------------------------
|
|
185
|
+
|
|
186
|
+
# `ChatState`: the chat this page is in, the invites open to this
|
|
187
|
+
# machine, and the ones this page is waiting on.
|
|
188
|
+
#
|
|
189
|
+
# @param page [String]
|
|
190
|
+
# @return [Hash]
|
|
191
|
+
def state(page:)
|
|
192
|
+
@lock.synchronize do
|
|
193
|
+
me = page!(page)
|
|
194
|
+
chat = me.chat && { id: me.chat, me: participant(me), participants: roster(me.chat) }
|
|
195
|
+
{ chat:,
|
|
196
|
+
invites: @incoming.values.map { it[:invite] },
|
|
197
|
+
outgoing: @outgoing.values.select { it[:page] == page }.map { it.slice(:id, :machine, :label) } }
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Start a chat and invite +machine+ to it, leaving any chat the page was in.
|
|
202
|
+
#
|
|
203
|
+
# @param page [String]
|
|
204
|
+
# @param name [String]
|
|
205
|
+
# @param machine [String]
|
|
206
|
+
# @param label [String] the machine's `user@host`
|
|
207
|
+
# @return [Hash] `{chat:, invite:}`
|
|
208
|
+
def start_chat(page:, name:, machine:, label:)
|
|
209
|
+
name = name!(name)
|
|
210
|
+
target!(machine)
|
|
211
|
+
chat = SecureRandom.uuid
|
|
212
|
+
enter(page, chat, name)
|
|
213
|
+
{ chat:, invite: invite(page:, machine:, label:)[:id] }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Invite +machine+ into the chat this page is in (D5).
|
|
217
|
+
#
|
|
218
|
+
# @return [Hash] `{id, machine, label}`
|
|
219
|
+
def invite(page:, machine:, label:)
|
|
220
|
+
target!(machine)
|
|
221
|
+
me, chat = @lock.synchronize do
|
|
222
|
+
me = page!(page)
|
|
223
|
+
raise Invalid, "start a chat before inviting anyone" unless me.chat
|
|
224
|
+
|
|
225
|
+
[participant(me), me.chat]
|
|
226
|
+
end
|
|
227
|
+
id = SecureRandom.uuid
|
|
228
|
+
wire = { id:, chat:, from: me, machine:, label:, expires_at: (@wall.call + INVITE_TTL).utc.iso8601 }
|
|
229
|
+
# The receivers stay unknown until PUBLISH says: an `absent` heard
|
|
230
|
+
# before then is counted, never compared against a count not yet set.
|
|
231
|
+
sent = { id:, page:, machine:, label:, deadline: now + INVITE_TTL, receivers: nil, absent: 0 }
|
|
232
|
+
@lock.synchronize { @outgoing[id] = sent }
|
|
233
|
+
receivers = publish(@keys.chat_inbox(machine, db:), kind: "invite", invite: wire)
|
|
234
|
+
nobody = @lock.synchronize do
|
|
235
|
+
sent[:receivers] = receivers
|
|
236
|
+
sent[:absent] >= receivers
|
|
237
|
+
end
|
|
238
|
+
expire(id) if nobody
|
|
239
|
+
sent.slice(:id, :machine, :label)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Accept an invite open to this machine and join its chat (D4: leaving
|
|
243
|
+
# the one the page was in).
|
|
244
|
+
#
|
|
245
|
+
# @return [Hash] `{chat:}`
|
|
246
|
+
def accept(page:, invite:, name:)
|
|
247
|
+
name = name!(name)
|
|
248
|
+
open = @lock.synchronize { @incoming[invite] } or raise Settled, "that invite is no longer open"
|
|
249
|
+
settle(open[:invite], "accepted")
|
|
250
|
+
enter(page, open[:invite][:chat], name)
|
|
251
|
+
{ chat: open[:invite][:chat] }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# @return [void]
|
|
255
|
+
def decline(page:, invite:)
|
|
256
|
+
page!(page)
|
|
257
|
+
open = @lock.synchronize { @incoming[invite] } or raise Settled, "that invite is no longer open"
|
|
258
|
+
settle(open[:invite], "declined")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Stop Chat (D6). Nothing happens for a page in no chat.
|
|
262
|
+
#
|
|
263
|
+
# @return [void]
|
|
264
|
+
def leave(page:)
|
|
265
|
+
me = @lock.synchronize do
|
|
266
|
+
me = @pages[page]
|
|
267
|
+
next nil unless me&.chat
|
|
268
|
+
|
|
269
|
+
gone = [participant(me), me.chat]
|
|
270
|
+
@rooms[me.chat].delete(me.id)
|
|
271
|
+
@rooms.delete(me.chat) if local(me.chat).one?
|
|
272
|
+
me.chat = nil
|
|
273
|
+
me.idle = false
|
|
274
|
+
gone
|
|
275
|
+
end
|
|
276
|
+
publish(@keys.chat_room(me.last, db:), kind: "leave", from: me.first) if me
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# @return [Hash] `ChatMessage` as sent
|
|
280
|
+
def say(page:, id:, text:)
|
|
281
|
+
raise Invalid, "id must be 1 to 64 letters, digits, - or _" unless id.to_s.match?(Command::ID)
|
|
282
|
+
|
|
283
|
+
text = text.to_s.strip
|
|
284
|
+
raise Invalid, "a message needs text" if text.empty?
|
|
285
|
+
raise Invalid, "a message is at most #{LIMIT} characters" if text.length > LIMIT
|
|
286
|
+
|
|
287
|
+
me, chat = in_chat(page)
|
|
288
|
+
said = { id:, text:, at: @wall.call.utc.iso8601 }
|
|
289
|
+
publish(@keys.chat_room(chat, db:), kind: "say", from: me, **said)
|
|
290
|
+
said.merge(who: me)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Idle or back (D7). Only a change is announced.
|
|
294
|
+
#
|
|
295
|
+
# @return [void]
|
|
296
|
+
def presence(page:, idle:)
|
|
297
|
+
idle = idle == true
|
|
298
|
+
changed = @lock.synchronize do
|
|
299
|
+
me = page!(page)
|
|
300
|
+
next nil if !me.chat || me.idle == idle
|
|
301
|
+
|
|
302
|
+
me.idle = idle
|
|
303
|
+
[participant(me), me.chat]
|
|
304
|
+
end
|
|
305
|
+
publish(@keys.chat_room(changed.last, db:), kind: idle ? "idle" : "back", from: changed.first) if changed
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
private
|
|
309
|
+
|
|
310
|
+
# ---- receiving --------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
# @return [void]
|
|
313
|
+
def receive(channel, payload)
|
|
314
|
+
data = JSON.parse(payload)
|
|
315
|
+
return unless data.is_a?(Hash)
|
|
316
|
+
|
|
317
|
+
if channel == inbox then mail(data)
|
|
318
|
+
elsif (chat = channel.delete_prefix(room_prefix)) != channel then room(chat, data)
|
|
319
|
+
end
|
|
320
|
+
rescue JSON::ParserError
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# @return [void]
|
|
325
|
+
def room(chat, data)
|
|
326
|
+
kind = data["kind"]
|
|
327
|
+
who = person(data["from"])
|
|
328
|
+
return unless ROOM_KINDS.include?(kind) && who
|
|
329
|
+
|
|
330
|
+
answer = @lock.synchronize do
|
|
331
|
+
next nil unless @rooms.key?(chat) && local(chat).any?
|
|
332
|
+
|
|
333
|
+
case kind
|
|
334
|
+
when "leave"
|
|
335
|
+
@rooms[chat].delete(who[:id])
|
|
336
|
+
post(chat, kind: "left", chat:, who:, participants: roster(chat))
|
|
337
|
+
when "say"
|
|
338
|
+
seen(chat, who)
|
|
339
|
+
post(chat, kind: "said", chat:, who:, id: data["id"].to_s, text: data["text"].to_s.slice(0, LIMIT), at: data["at"].to_s)
|
|
340
|
+
when "here"
|
|
341
|
+
fresh = seen(chat, who)
|
|
342
|
+
post(chat, kind: "roster", chat:, participants: roster(chat)) if fresh
|
|
343
|
+
else
|
|
344
|
+
seen(chat, who)
|
|
345
|
+
post(chat, kind: kind == "join" ? "joined" : kind, chat:, who:, participants: roster(chat))
|
|
346
|
+
end
|
|
347
|
+
kind == "join" ? local(chat).reject { it.id == who[:id] }.map { participant(it) } : []
|
|
348
|
+
end
|
|
349
|
+
Array(answer).each { publish(@keys.chat_room(chat, db:), kind: "here", from: it) }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# @return [void]
|
|
353
|
+
def mail(data)
|
|
354
|
+
case data["kind"]
|
|
355
|
+
when "invite" then invited(data["invite"])
|
|
356
|
+
when "absent" then absent(data["invite"].to_s)
|
|
357
|
+
when "settled" then settled(data["invite"].to_s, data["answer"].to_s)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @return [void]
|
|
362
|
+
def invited(invite)
|
|
363
|
+
invite = invitation(invite) or return
|
|
364
|
+
reply = @lock.synchronize do
|
|
365
|
+
next invite[:from][:machine] if @pages.values.none? { it.streams.positive? }
|
|
366
|
+
|
|
367
|
+
@incoming[invite[:id]] = { invite:, deadline: now + INVITE_TTL + GRACE }
|
|
368
|
+
@pages.each_value { push(it, kind: "invited", invite:) }
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
publish(@keys.chat_inbox(reply, db:), kind: "absent", invite: invite[:id]) if reply
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# A server on the invited machine with no page open. When every one
|
|
375
|
+
# that heard the invite says so, nobody is there to answer (D9).
|
|
376
|
+
#
|
|
377
|
+
# @return [void]
|
|
378
|
+
def absent(id)
|
|
379
|
+
nobody = @lock.synchronize do
|
|
380
|
+
sent = @outgoing[id] or next false
|
|
381
|
+
sent[:absent] += 1
|
|
382
|
+
sent[:receivers] && sent[:absent] >= sent[:receivers]
|
|
383
|
+
end
|
|
384
|
+
expire(id) if nobody
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# @return [void]
|
|
388
|
+
def settled(id, answer)
|
|
389
|
+
@lock.synchronize do
|
|
390
|
+
if @incoming.delete(id)
|
|
391
|
+
@pages.each_value { push(it, kind: "invite_closed", invite: id, answer:) }
|
|
392
|
+
end
|
|
393
|
+
sent = @outgoing.delete(id)
|
|
394
|
+
told(sent, answer) if sent
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# The inviting page hears a decline or a silence; an accept it hears as a join.
|
|
399
|
+
#
|
|
400
|
+
# @return [void]
|
|
401
|
+
def told(sent, answer)
|
|
402
|
+
kind = { "declined" => "declined", "expired" => "unanswered" }[answer] or return
|
|
403
|
+
page = sent && @pages[sent[:page]] or return
|
|
404
|
+
push(page, kind:, invite: sent[:id], machine: sent[:machine], label: sent[:label])
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# ---- settling invites -------------------------------------------------
|
|
408
|
+
|
|
409
|
+
# The first answer wins, from whichever page or server gives it.
|
|
410
|
+
#
|
|
411
|
+
# @return [void]
|
|
412
|
+
# @raise [Settled] when another answer came first
|
|
413
|
+
def settle(invite, answer)
|
|
414
|
+
unless store.set(@keys.chat_answer(invite[:id]), answer, ex: INVITE_TTL * 2, nx: true)
|
|
415
|
+
@lock.synchronize { @incoming.delete(invite[:id]) }
|
|
416
|
+
raise Settled, "that invite was already answered"
|
|
417
|
+
end
|
|
418
|
+
publish(inbox, kind: "settled", invite: invite[:id], answer:)
|
|
419
|
+
publish(@keys.chat_inbox(invite[:from][:machine], db:), kind: "settled", invite: invite[:id], answer:)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Nobody answered in time, or nobody could. The invited machine's
|
|
423
|
+
# pages close their modals; this page hears "did not answer".
|
|
424
|
+
#
|
|
425
|
+
# @return [void]
|
|
426
|
+
def expire(id)
|
|
427
|
+
sent = @lock.synchronize { @outgoing[id] } or return
|
|
428
|
+
if store.set(@keys.chat_answer(id), "expired", ex: INVITE_TTL * 2, nx: true)
|
|
429
|
+
publish(@keys.chat_inbox(sent[:machine], db:), kind: "settled", invite: id, answer: "expired")
|
|
430
|
+
@lock.synchronize { told(@outgoing.delete(id), "expired") }
|
|
431
|
+
else
|
|
432
|
+
# Answered after all: the answer's own message says how.
|
|
433
|
+
answer = store.get(@keys.chat_answer(id)).to_s
|
|
434
|
+
@lock.synchronize { told(@outgoing.delete(id), answer) if @outgoing.key?(id) }
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# ---- housekeeping -----------------------------------------------------
|
|
439
|
+
|
|
440
|
+
# @return [void]
|
|
441
|
+
def housekeep
|
|
442
|
+
t = now
|
|
443
|
+
gone = @lock.synchronize { @pages.values.select { it.gone_at && t - it.gone_at >= GRACE } }
|
|
444
|
+
gone.each do |page|
|
|
445
|
+
leave(page: page.id)
|
|
446
|
+
@lock.synchronize { @pages.delete(page.id) if page.streams.zero? }
|
|
447
|
+
end
|
|
448
|
+
@lock.synchronize { @outgoing.values.select { t >= it[:deadline] }.map { it[:id] } }.each { expire(it) }
|
|
449
|
+
forget_stale_invites(t)
|
|
450
|
+
prune(t)
|
|
451
|
+
heartbeat(t)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# An invite whose inviter's server went away never hears its expiry.
|
|
455
|
+
#
|
|
456
|
+
# @return [void]
|
|
457
|
+
def forget_stale_invites(at)
|
|
458
|
+
@lock.synchronize do
|
|
459
|
+
@incoming.select { |_, open| at >= open[:deadline] }.each_key do |id|
|
|
460
|
+
@incoming.delete(id)
|
|
461
|
+
@pages.each_value { push(it, kind: "invite_closed", invite: id, answer: "expired") }
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# A participant on another server that has stopped saying `here`.
|
|
467
|
+
#
|
|
468
|
+
# @return [void]
|
|
469
|
+
def prune(at)
|
|
470
|
+
@lock.synchronize do
|
|
471
|
+
@rooms.each do |chat, people|
|
|
472
|
+
mine = local(chat).map(&:id)
|
|
473
|
+
stale = people.reject { |id, p| mine.include?(id) || at - p[:seen] < FORGET_AFTER }
|
|
474
|
+
stale.each_key { people.delete(it) }
|
|
475
|
+
stale.each_value { post(chat, kind: "left", chat:, who: it.except(:seen), participants: roster(chat)) }
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# @return [void]
|
|
481
|
+
def heartbeat(at)
|
|
482
|
+
return if @beat && at - @beat < HEARTBEAT
|
|
483
|
+
|
|
484
|
+
@beat = at
|
|
485
|
+
mine = @lock.synchronize { @pages.values.select(&:chat).map { [participant(it), it.chat] } }
|
|
486
|
+
mine.each { |who, chat| publish(@keys.chat_room(chat, db:), kind: "here", from: who) }
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# ---- helpers ----------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
# @return [void]
|
|
492
|
+
def enter(page, chat, name)
|
|
493
|
+
leave(page:)
|
|
494
|
+
who = @lock.synchronize do
|
|
495
|
+
me = page!(page)
|
|
496
|
+
me.chat = chat
|
|
497
|
+
me.name = name
|
|
498
|
+
me.idle = false
|
|
499
|
+
seen(chat, participant(me))
|
|
500
|
+
participant(me)
|
|
501
|
+
end
|
|
502
|
+
publish(@keys.chat_room(chat, db:), kind: "join", from: who)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# @return [Array(Hash, String)] the page as a participant, and its chat
|
|
506
|
+
# @raise [Invalid] when it is in none
|
|
507
|
+
def in_chat(page)
|
|
508
|
+
@lock.synchronize do
|
|
509
|
+
me = page!(page)
|
|
510
|
+
raise Invalid, "this page is not in a chat" unless me.chat
|
|
511
|
+
|
|
512
|
+
[participant(me), me.chat]
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# @return [Boolean] whether the participant is new to the roster
|
|
517
|
+
def seen(chat, who)
|
|
518
|
+
fresh = !@rooms[chat].key?(who[:id])
|
|
519
|
+
@rooms[chat][who[:id]] = who.merge(seen: now)
|
|
520
|
+
fresh
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# @return [Array<Hash>] `ChatParticipant`s, in the order they arrived
|
|
524
|
+
def roster(chat) = @rooms.fetch(chat, {}).values.map { it.except(:seen) }
|
|
525
|
+
|
|
526
|
+
# @return [Array<Page>] this server's pages in +chat+
|
|
527
|
+
def local(chat) = @pages.values.select { it.chat == chat }
|
|
528
|
+
|
|
529
|
+
# @return [void]
|
|
530
|
+
def post(chat, event) = local(chat).each { push(it, event) }
|
|
531
|
+
|
|
532
|
+
# @return [void]
|
|
533
|
+
def push(page, event)
|
|
534
|
+
page.events << event
|
|
535
|
+
page.events.shift(page.events.size - BACKLOG) if page.events.size > BACKLOG
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# @return [Hash] `ChatParticipant`
|
|
539
|
+
def participant(page) = { id: page.id, name: page.name, machine: @machine, label: @label, idle: page.idle }
|
|
540
|
+
|
|
541
|
+
# @return [Page]
|
|
542
|
+
# @raise [Invalid]
|
|
543
|
+
def page!(id)
|
|
544
|
+
raise Invalid, "page must be 8 to 64 letters, digits, - or _" unless id.to_s.match?(PAGE)
|
|
545
|
+
|
|
546
|
+
@lock.synchronize { @pages[id] ||= Page.new(id:, events: [], streams: 0, gone_at: now, chat: nil, name: nil, idle: false) }
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# @return [String]
|
|
550
|
+
# @raise [Invalid]
|
|
551
|
+
def name!(name)
|
|
552
|
+
name = name.to_s.gsub(/[[:cntrl:]]/, " ").strip
|
|
553
|
+
raise Invalid, "a name is needed" if name.empty?
|
|
554
|
+
raise Invalid, "a name is at most #{NAME_LIMIT} characters" if name.length > NAME_LIMIT
|
|
555
|
+
|
|
556
|
+
name
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# @return [void]
|
|
560
|
+
# @raise [Invalid]
|
|
561
|
+
def target!(machine)
|
|
562
|
+
raise Invalid, "not a machine id" unless machine.to_s.match?(MACHINE)
|
|
563
|
+
raise Invalid, "this machine cannot invite itself" if machine == @machine
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# A participant as another server sent it, or nil when it is not one.
|
|
567
|
+
#
|
|
568
|
+
# @return [Hash, nil]
|
|
569
|
+
def person(data)
|
|
570
|
+
return nil unless data.is_a?(Hash) && data["id"].to_s.match?(PAGE)
|
|
571
|
+
|
|
572
|
+
{ id: data["id"].to_s, name: data["name"].to_s.slice(0, NAME_LIMIT), machine: data["machine"].to_s,
|
|
573
|
+
label: data["label"].to_s, idle: data["idle"] == true }
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# An invite as another server sent it, for this machine, or nil.
|
|
577
|
+
#
|
|
578
|
+
# @return [Hash, nil]
|
|
579
|
+
def invitation(data)
|
|
580
|
+
return nil unless data.is_a?(Hash) && data["machine"] == @machine
|
|
581
|
+
return nil unless data["id"].to_s.match?(UUID) && data["chat"].to_s.match?(UUID)
|
|
582
|
+
|
|
583
|
+
from = person(data["from"]) or return nil
|
|
584
|
+
{ id: data["id"], chat: data["chat"], from:, machine: @machine, label: data["label"].to_s,
|
|
585
|
+
expires_at: data["expires_at"].to_s }
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# @return [Integer] receivers
|
|
589
|
+
def publish(channel, data) = store.publish(channel, JSON.generate(data))
|
|
590
|
+
|
|
591
|
+
# @return [OpenLanes::Live::Subscriber, OpenLanes::Live::MemorySubscriber]
|
|
592
|
+
def subscriber = @subscriber ||= store.subscriber(channels: [inbox], patterns: [@keys.chat_rooms(db:)])
|
|
593
|
+
|
|
594
|
+
# @return [void]
|
|
595
|
+
def close_subscriber
|
|
596
|
+
@subscriber&.close
|
|
597
|
+
@subscriber = nil
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# @return [String]
|
|
601
|
+
def inbox = @keys.chat_inbox(@machine, db:)
|
|
602
|
+
|
|
603
|
+
# @return [String]
|
|
604
|
+
def room_prefix = @keys.chat_room("", db:)
|
|
605
|
+
|
|
606
|
+
# @return [Integer]
|
|
607
|
+
def db = @db ||= store.db
|
|
608
|
+
|
|
609
|
+
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
610
|
+
def store = @stores.current
|
|
611
|
+
|
|
612
|
+
# @return [Float]
|
|
613
|
+
def now = @clock.call
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|
|
@@ -131,6 +131,9 @@ module OpenLanes
|
|
|
131
131
|
return answer_again(entry, first)
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
+
# Said on the terminal and in the log: what a person asked for from
|
|
135
|
+
# the page is part of the run's story, beside what the agents did.
|
|
136
|
+
UI.log("from the page: #{command.kind.tr("_", " ")}", **{ plan: command.plan, agent: command.agent }.compact)
|
|
134
137
|
status, reason = apply(command, dispatcher)
|
|
135
138
|
return reply(entry, command.id, status, reason) unless status == :pending
|
|
136
139
|
|
|
@@ -424,7 +427,7 @@ module OpenLanes
|
|
|
424
427
|
return created(made.feature, dispatcher)
|
|
425
428
|
end
|
|
426
429
|
|
|
427
|
-
path = Lifecycle.create(dir: tree.dir, words:
|
|
430
|
+
path = Lifecycle.create(dir: tree.dir, words: Lifecycle::Creator.topic(idea)).either(
|
|
428
431
|
->(created) { created },
|
|
429
432
|
->(problem) { raise Refusal, "could not create the plan: #{problem}" }
|
|
430
433
|
)
|