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,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: idea.split.first(8)).either(
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
  )