samagotchi 0.124.0 → 0.126.0
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/CHANGELOG.md +53 -1
- data/docs/architecture.md +4 -2
- data/docs/cli.md +20 -8
- data/docs/guardrails.md +22 -0
- data/docs/internals/participants.md +96 -3
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/internals/web-push.md +10 -6
- data/docs/sessions.md +5 -2
- data/lib/samagotchi/bridge.rb +4 -3
- data/lib/samagotchi/bridge_client.rb +8 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +3 -0
- data/lib/samagotchi/children_status.rb +6 -1
- data/lib/samagotchi/engine.rb +43 -7
- data/lib/samagotchi/guardrail_wiring.rb +10 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/process_group.rb +10 -1
- data/lib/samagotchi/reminder_carry.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +49 -0
- data/lib/samagotchi/session.rb +40 -3
- data/lib/samagotchi/session_inbox.rb +14 -8
- data/lib/samagotchi/session_manager.rb +39 -8
- data/lib/samagotchi/session_participants.rb +10 -2
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
- data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
- data/lib/samagotchi/verdict.rb +20 -0
- data/lib/samagotchi/verify_anchor.rb +28 -0
- data/lib/samagotchi/verify_fresh.rb +275 -0
- data/lib/samagotchi/verify_fresh_origin.rb +71 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +10 -3
- data/lib/samagotchi/web/participant_access.rb +15 -4
- data/lib/samagotchi/web/participant_review.rb +7 -1
- data/lib/samagotchi/web/participant_view.rb +46 -5
- data/lib/samagotchi/web/participants_admin.rb +15 -4
- data/lib/samagotchi/web/public/annotate_bar.css +8 -4
- data/lib/samagotchi/web/public/annotate_bar.js +43 -7
- data/lib/samagotchi/web/public/app.js +134 -8
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- data/lib/samagotchi/web/public/data.js +2 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/notify.js +13 -2
- data/lib/samagotchi/web/public/participant.html +3 -1
- data/lib/samagotchi/web/public/participant.js +113 -9
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- data/lib/samagotchi/web/public/send_turn.js +6 -1
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/tokens.css +2 -1
- data/lib/samagotchi/web/public/verdict_card.js +65 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
- data/lib/samagotchi/web/public/verify_card.css +17 -0
- data/lib/samagotchi/web/public/verify_preset.js +18 -0
- data/lib/samagotchi/web/push/attention.rb +25 -5
- data/lib/samagotchi/web/push/notifier.rb +4 -3
- data/lib/samagotchi/web/session_control.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +25 -1
- data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
- data/lib/samagotchi/worker.rb +125 -12
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +19 -1
|
@@ -22,8 +22,10 @@ module Samagotchi
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# Reads +session+'s sidecar again.
|
|
25
|
+
# A Verify fresh child reads its parent's (Session#participants_source_id):
|
|
26
|
+
# its requester's grant is there.
|
|
25
27
|
def refresh!(session)
|
|
26
|
-
@session_id = session
|
|
28
|
+
@session_id = source_id(session)
|
|
27
29
|
@entries = read
|
|
28
30
|
self
|
|
29
31
|
end
|
|
@@ -44,7 +46,7 @@ module Samagotchi
|
|
|
44
46
|
def entry_for(author, session: nil)
|
|
45
47
|
return nil unless Steer.participant_author?(author)
|
|
46
48
|
|
|
47
|
-
refresh!(session) if session && session
|
|
49
|
+
refresh!(session) if session && source_id(session) != @session_id
|
|
48
50
|
id = Steer.participant_id(author)
|
|
49
51
|
@entries.find { |e| e.id == id } || (@entries = read).find { |e| e.id == id }
|
|
50
52
|
end
|
|
@@ -62,6 +64,12 @@ module Samagotchi
|
|
|
62
64
|
|
|
63
65
|
private
|
|
64
66
|
|
|
67
|
+
def source_id(session)
|
|
68
|
+
return nil unless session
|
|
69
|
+
|
|
70
|
+
session.respond_to?(:participants_source_id) ? session.participants_source_id : session.id
|
|
71
|
+
end
|
|
72
|
+
|
|
65
73
|
def read
|
|
66
74
|
return [] if @session_id.to_s.empty?
|
|
67
75
|
|
|
@@ -34,7 +34,7 @@ module Samagotchi
|
|
|
34
34
|
[--scope=all]: every project's sessions; by default only this git project's (all outside a repo)
|
|
35
35
|
--archived: archived sessions too (marked [archived]; json: archived: true)
|
|
36
36
|
stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one
|
|
37
|
-
restart ID... # hand each session to a new worker on the newest chi installed; refused while a turn
|
|
37
|
+
restart ID... # hand each session to a new worker on the newest chi installed; keeps its reminders; refused while a turn or question would be lost
|
|
38
38
|
archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
|
|
39
39
|
delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
|
|
40
40
|
prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../composer_route"
|
|
4
|
+
require_relative "../../prompt_history"
|
|
5
|
+
require_relative "../../session_commands"
|
|
6
|
+
require_relative "../exit_lines"
|
|
7
|
+
require_relative "../reline_seam"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
class TerminalUI
|
|
11
|
+
class AttachedLoop
|
|
12
|
+
# Input composition and Ctrl-C handling for the attached loop.
|
|
13
|
+
# Reads @client, @clock, @command_registry, @continue_offer, @exit_ask,
|
|
14
|
+
# @gaps, @link, @prefilled, @question, @queue, @queued_ids, @reader,
|
|
15
|
+
# @running, @screen; writes @prefilled, @last_idle_interrupt_at.
|
|
16
|
+
# Calls the loop helpers #answer_continue, #answer_exit_ask, #answer_question,
|
|
17
|
+
# #archive_after_turn, #command_registry, #detach, #echo_answer, #exit_and_archive,
|
|
18
|
+
# #exit_and_delete, #exit_worker, #gap_divider, #participant_commands, #persist_recent_history,
|
|
19
|
+
# #queue_input_prefill, #read_prompt_line, #send_command, #send_prompt,
|
|
20
|
+
# #show_recap, #show_stats, #submit_delivery, #unqueue_newest, #via, and
|
|
21
|
+
# #worker_down.
|
|
22
|
+
module Composer
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# A divider above a typed line's echo (RelineSeam.echo_above, on the
|
|
26
|
+
# reader thread): a prompt typed after a quiet gap, at an idle prompt.
|
|
27
|
+
# It marks the activity, so the turn's start marks no second one.
|
|
28
|
+
def gap_above_echo(text)
|
|
29
|
+
return nil if @running
|
|
30
|
+
|
|
31
|
+
route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
|
|
32
|
+
return nil unless %i[prompt deliver].include?(route.kind)
|
|
33
|
+
|
|
34
|
+
line = @gaps.divider
|
|
35
|
+
line && gap_divider(line)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Symbol, nil] :detach to end the loop
|
|
39
|
+
def submit(line)
|
|
40
|
+
if @exit_ask
|
|
41
|
+
# The exit ask is open: this line answers it. Ctrl-D and /detach
|
|
42
|
+
# detach without stopping anything, the way its 1 does.
|
|
43
|
+
return detach(ExitLines.kept_line(@exit_ask, via: via, session_id: @client.session_id)) if line.nil? ||
|
|
44
|
+
command_registry.lookup_local(line.strip)&.id == :detach
|
|
45
|
+
|
|
46
|
+
return answer_exit_ask(line)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
|
|
50
|
+
|
|
51
|
+
text = line.strip
|
|
52
|
+
if @question
|
|
53
|
+
# /detach leaves the question open, as Ctrl-D does; any other
|
|
54
|
+
# line (/exit too) is read as the answer.
|
|
55
|
+
return submit(nil) if command_registry.lookup_local(text)&.id == :detach
|
|
56
|
+
|
|
57
|
+
return answer_question(text)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
text, kept = split_prefilled(text)
|
|
61
|
+
submitted = submit_line(text)
|
|
62
|
+
# The removed text the command was typed after, back in the input
|
|
63
|
+
# (unless an /unqueue put its own there, or the loop ends). Set
|
|
64
|
+
# before the read restarts with it: the read's echo reads it.
|
|
65
|
+
if kept && submitted != :detach && @prefilled.nil?
|
|
66
|
+
@prefilled = kept
|
|
67
|
+
@prefilled = nil unless @reader&.prefill(kept)
|
|
68
|
+
end
|
|
69
|
+
submitted
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A line typed after an /unqueue's text (#unqueued) without clearing
|
|
73
|
+
# it, the text untouched and then a command ("fix it/unqueue"): the
|
|
74
|
+
# command alone, and the text to put back. Any other line as is.
|
|
75
|
+
# @return [Array(String, String|nil)]
|
|
76
|
+
def split_prefilled(text)
|
|
77
|
+
kept = @prefilled
|
|
78
|
+
@prefilled = nil
|
|
79
|
+
rest = command_after(text, kept)
|
|
80
|
+
rest ? [rest, kept] : [text, nil]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The command typed after +kept+ (/unqueue's text) in +text+, or nil.
|
|
84
|
+
# @return [String, nil]
|
|
85
|
+
def command_after(text, kept)
|
|
86
|
+
return nil unless kept && text.length > kept.length && text.start_with?(kept)
|
|
87
|
+
|
|
88
|
+
rest = text[kept.length..].strip
|
|
89
|
+
route = ComposerRoute.call(rest, running: @running, registry: command_registry)
|
|
90
|
+
rest if route.choice || %i[local command usage].include?(route.kind)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The read's echo (RelineSeam.echo_as, on the reader thread): the
|
|
94
|
+
# command alone when one is typed after /unqueue's text, as
|
|
95
|
+
# #split_prefilled runs it; nil echoes the line as typed.
|
|
96
|
+
def prefilled_echo(typed) = command_after(typed.strip, @prefilled)
|
|
97
|
+
|
|
98
|
+
# @return [Symbol, nil] :detach to end the loop
|
|
99
|
+
def submit_line(text)
|
|
100
|
+
# /cut TEXT and /queue TEXT are this UI's own deliveries, read before
|
|
101
|
+
# every command check (ComposerRoute; SessionCommands registers them
|
|
102
|
+
# for Tab and /help only). A plain line sends no delivery: it goes in
|
|
103
|
+
# at the running turn's next step, and never cuts.
|
|
104
|
+
route = ComposerRoute.call(text, running: @running, registry: command_registry)
|
|
105
|
+
return submit_delivery(route) if route.choice
|
|
106
|
+
|
|
107
|
+
# The terminal's own commands (SessionCommands registers them, the
|
|
108
|
+
# REPL reads the same words). Before the continue offer: the
|
|
109
|
+
# leaving ones are no answer to it.
|
|
110
|
+
local = route.local
|
|
111
|
+
return submit(nil) if local == :detach
|
|
112
|
+
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
113
|
+
return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
|
|
114
|
+
return exit_and_archive if local == :archive
|
|
115
|
+
|
|
116
|
+
if @continue_offer
|
|
117
|
+
return answer_continue(text) unless local || route.kind == :command
|
|
118
|
+
|
|
119
|
+
# Not an answer: the read left no echo, so show what ran.
|
|
120
|
+
echo_answer(text)
|
|
121
|
+
end
|
|
122
|
+
return if route.kind == :empty
|
|
123
|
+
|
|
124
|
+
if local == :stats
|
|
125
|
+
show_stats
|
|
126
|
+
elsif local == :recap
|
|
127
|
+
show_recap
|
|
128
|
+
elsif local == :unqueue
|
|
129
|
+
unqueue_newest
|
|
130
|
+
elsif %i[share participants].include?(local)
|
|
131
|
+
@screen.commit(participant_commands.run(text))
|
|
132
|
+
elsif route.kind == :command
|
|
133
|
+
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
134
|
+
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
135
|
+
send_command(text)
|
|
136
|
+
elsif route.kind == :typo
|
|
137
|
+
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
138
|
+
@screen.commit(route.reply)
|
|
139
|
+
else
|
|
140
|
+
send_prompt(text)
|
|
141
|
+
end
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# One read on the reader thread: the REPL's multiline read with Tab
|
|
146
|
+
# completion at the main prompt, a plain line for a question's choice
|
|
147
|
+
# or the continue offer's answer.
|
|
148
|
+
def read_input_line(prompt, prefill)
|
|
149
|
+
return $stdin.gets&.chomp unless $stdin.tty?
|
|
150
|
+
# A question's answer leaves only its summary line.
|
|
151
|
+
return RelineSeam.without_echo { Reline.readline(prompt, true) } if @question || @continue_offer
|
|
152
|
+
|
|
153
|
+
queue_input_prefill(prefill) if prefill
|
|
154
|
+
read_prompt_line(prompt)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The attached TUI's commands, for Tab.
|
|
158
|
+
def slash_commands = command_registry.completions(:attached)
|
|
159
|
+
|
|
160
|
+
# @path and #memory candidates, as the link offers them
|
|
161
|
+
# (AttachLink#completions).
|
|
162
|
+
def build_project_path_completion_candidates(path_fragment)
|
|
163
|
+
@link.completions.paths ? super : []
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_memory_completion_candidates(memory_fragment)
|
|
167
|
+
@link.completions.memories ? super : []
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The session's commands as its worker's snapshot names them (its
|
|
171
|
+
# plugins' too); the built-ins until one arrives, or from a worker too
|
|
172
|
+
# old to name them. An unknown /foo is a prompt, as in the REPL.
|
|
173
|
+
def command_registry = @command_registry || SessionCommands.builtin_registry
|
|
174
|
+
|
|
175
|
+
# Ctrl-C cancels the running turn (whoever started it). At an idle
|
|
176
|
+
# prompt it clears the line; on an empty one it says how to detach, and
|
|
177
|
+
# a second press within DETACH_WINDOW detaches (D4).
|
|
178
|
+
# @param pressed [Hash, nil] {text:, at:} from the read the press ended
|
|
179
|
+
# (nil: pressed with no read open)
|
|
180
|
+
# @return [Symbol, nil] :detach
|
|
181
|
+
def interrupt(pressed = nil)
|
|
182
|
+
typed = pressed&.fetch(:text, nil).to_s
|
|
183
|
+
if @running
|
|
184
|
+
begin
|
|
185
|
+
@client.cancel(reason: "ctrl_c")
|
|
186
|
+
rescue SystemCallError, IOError => e
|
|
187
|
+
@screen.commit("could not cancel the turn (#{worker_down(e)})")
|
|
188
|
+
end
|
|
189
|
+
return nil
|
|
190
|
+
end
|
|
191
|
+
unless typed.strip.empty?
|
|
192
|
+
@last_idle_interrupt_at = nil
|
|
193
|
+
return nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
now = pressed&.fetch(:at, nil) || @clock.call
|
|
197
|
+
return submit(nil) if @last_idle_interrupt_at && now - @last_idle_interrupt_at <= DETACH_WINDOW
|
|
198
|
+
|
|
199
|
+
@last_idle_interrupt_at = now
|
|
200
|
+
@screen.commit(DETACH_HINT)
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# On the reader thread, from Reline's Ctrl-C: what the line held, and
|
|
205
|
+
# when. During a turn the press goes to the loop (it cancels the turn)
|
|
206
|
+
# and the read goes on with the typed text still in it, as in the REPL;
|
|
207
|
+
# otherwise it is noted for the Interrupt the LineReader queues next.
|
|
208
|
+
# @return [Boolean] true: handled, the read goes on; false: Reline ends
|
|
209
|
+
# the read with Interrupt
|
|
210
|
+
def note_interrupted_line
|
|
211
|
+
pressed = { text: Reline.line_buffer.to_s, at: @clock.call }
|
|
212
|
+
if @running
|
|
213
|
+
@queue << [:interrupt, pressed]
|
|
214
|
+
return true
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
Thread.current[:interrupted_line] = pressed
|
|
218
|
+
false
|
|
219
|
+
rescue StandardError
|
|
220
|
+
false
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../exit_lines"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class TerminalUI
|
|
7
|
+
class AttachedLoop
|
|
8
|
+
# Exit and archive commands for the attached loop.
|
|
9
|
+
# Reads @client, @client_id, @exit_ask, @link, @queued_ids, @running,
|
|
10
|
+
# @screen, @view; writes @archive_after_turn and @exit_ask.
|
|
11
|
+
# Calls the loop helpers #via and #detach.
|
|
12
|
+
module ExitCommands
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# @return [Symbol] :detach
|
|
16
|
+
def detach(line)
|
|
17
|
+
@view.finish_thinking_spinner
|
|
18
|
+
@screen.commit(line)
|
|
19
|
+
:detach
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Detach, and ask the worker to exit now. The worker decides: it stays
|
|
23
|
+
# up while anything still needs it (a turn, queued input, a continue
|
|
24
|
+
# offer, another UI, reminders), and the line says which. When the only
|
|
25
|
+
# holds are ones the user can stop, it asks what to do with them instead
|
|
26
|
+
# of detaching (#answer_exit_ask).
|
|
27
|
+
# @return [Symbol, nil] :detach, or nil to stay at the prompt
|
|
28
|
+
def exit_worker
|
|
29
|
+
reply = @client.request_exit(client_id: @client_id)
|
|
30
|
+
holds = reply.status == 409 ? reply.json&.fetch("holds", nil) : nil
|
|
31
|
+
return detach(ExitLines.exit_line(reply, via: via, session_id: @client.session_id)) unless holds.is_a?(Array) && !holds.empty?
|
|
32
|
+
|
|
33
|
+
@exit_ask = holds
|
|
34
|
+
@screen.commit(ExitLines.ask_lines(holds))
|
|
35
|
+
nil
|
|
36
|
+
rescue SystemCallError, IOError => e
|
|
37
|
+
detach(ExitLines.exit_failed_line(e.message, via: via, session_id: @client.session_id))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The answer to the exit ask.
|
|
41
|
+
# @return [Symbol, nil] :detach for 1 and 2, nil for 3 or a line the
|
|
42
|
+
# ask doesn't understand
|
|
43
|
+
def answer_exit_ask(line)
|
|
44
|
+
choice = ExitLines::EXIT_ANSWERS[line.to_s.strip.downcase]
|
|
45
|
+
unless choice
|
|
46
|
+
@screen.commit("Say 1 (keep running), 2 (stop them and exit) or 3 (back to the session).")
|
|
47
|
+
return nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
holds = @exit_ask
|
|
51
|
+
@exit_ask = nil
|
|
52
|
+
case choice
|
|
53
|
+
when :keep then detach(ExitLines.kept_line(holds, via: via, session_id: @client.session_id))
|
|
54
|
+
when :stop then stop_and_exit
|
|
55
|
+
else
|
|
56
|
+
@screen.commit("(nothing stopped; the worker keeps running)")
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 2: ask the worker to stop them all and exit.
|
|
62
|
+
# @return [Symbol] :detach
|
|
63
|
+
def stop_and_exit
|
|
64
|
+
reply = @client.request_exit(client_id: @client_id, stop: true)
|
|
65
|
+
detach(ExitLines.exit_line(reply, via: via, session_id: @client.session_id))
|
|
66
|
+
rescue SystemCallError, IOError => e
|
|
67
|
+
detach(ExitLines.exit_failed_line(e.message, via: via, session_id: @client.session_id))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# /exit --delete: the worker exits as for /exit, then the session is
|
|
71
|
+
# deleted. When the worker stays up (another UI, queued input, ...),
|
|
72
|
+
# nothing is deleted.
|
|
73
|
+
def exit_and_delete
|
|
74
|
+
reply = @client.request_exit(client_id: @client_id, delete: true)
|
|
75
|
+
id = @client.session_id
|
|
76
|
+
unless reply.status == 200
|
|
77
|
+
line = ExitLines.exit_line(reply, via: via, session_id: id)
|
|
78
|
+
return detach(line.sub("Detached; the session keeps running", "Detached; not deleted: the session keeps running")) if reply.status == 409
|
|
79
|
+
|
|
80
|
+
return detach("#{line} Not deleted.")
|
|
81
|
+
end
|
|
82
|
+
# An empty session: the worker deletes it as it leaves.
|
|
83
|
+
return detach(ExitLines::EMPTY_EXIT) if ExitLines.discards?(reply)
|
|
84
|
+
|
|
85
|
+
@link.delete_session
|
|
86
|
+
detach("Detached; deleted session #{id}.")
|
|
87
|
+
rescue SystemCallError, IOError => e
|
|
88
|
+
detach("#{ExitLines.exit_failed_line(e.message, via: via, session_id: id)} Not deleted.")
|
|
89
|
+
rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError => e
|
|
90
|
+
detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): " \
|
|
91
|
+
"#{via.command("sessions delete #{id}")}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# /archive during a turn (D2): it waits for the turn's end and the
|
|
95
|
+
# commands queued behind it (#archive_due?), then runs.
|
|
96
|
+
# @return [nil]
|
|
97
|
+
def archive_after_turn
|
|
98
|
+
@archive_after_turn = true
|
|
99
|
+
@screen.commit("(archives after this turn)")
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def archive_due? = @archive_after_turn && !@running && @queued_ids.empty?
|
|
104
|
+
|
|
105
|
+
# @return [Symbol] :detached
|
|
106
|
+
def archive_now
|
|
107
|
+
@archive_after_turn = false
|
|
108
|
+
exit_and_archive
|
|
109
|
+
:detached
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# /archive: the worker is asked to exit as for /exit, then the session
|
|
113
|
+
# is archived. A worker staying up for another UI (or reminders) is
|
|
114
|
+
# stopped by the archive, as the web's archive stops it; a turn
|
|
115
|
+
# running, a queued prompt or an open continue offer refuses it
|
|
116
|
+
# (SessionManager.archive_session). An empty session: the worker
|
|
117
|
+
# deletes it as it leaves.
|
|
118
|
+
def exit_and_archive
|
|
119
|
+
# /archive can't ask about what still runs: a child the archive will
|
|
120
|
+
# stop is not worth a question here.
|
|
121
|
+
reply = @client.request_exit(client_id: @client_id, ask: false)
|
|
122
|
+
id = @client.session_id
|
|
123
|
+
return detach(ExitLines::EMPTY_EXIT) if reply.status == 200 && ExitLines.discards?(reply)
|
|
124
|
+
return detach("#{ExitLines.exit_line(reply, via: via, session_id: id)} Not archived.") unless [200, 409].include?(reply.status)
|
|
125
|
+
|
|
126
|
+
result = @link.archive_session
|
|
127
|
+
return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
|
|
128
|
+
|
|
129
|
+
detach("Detached; archived session #{id}. #{via.command("sessions list --archived")} finds it.")
|
|
130
|
+
rescue SystemCallError, IOError => e
|
|
131
|
+
detach("#{ExitLines.exit_failed_line(e.message, via: via, session_id: id)} Not archived.")
|
|
132
|
+
rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
|
|
133
|
+
detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: #{via.attach(id)}")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../client_id"
|
|
4
|
+
require_relative "../exit_lines"
|
|
5
|
+
require_relative "../../session_commands"
|
|
6
|
+
require_relative "../question_prompt"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
class TerminalUI
|
|
10
|
+
class AttachedLoop
|
|
11
|
+
# Question and continue prompts for the attached loop.
|
|
12
|
+
# Reads @as_parent, @answered_ids, @client, @continue_offer, @offer_asked,
|
|
13
|
+
# @question, @reader, @screen, @set_aside, @view; writes @continue_answer,
|
|
14
|
+
# @continue_offer, @offer_asked, @previewed_id, @question, @question_pending,
|
|
15
|
+
# @set_aside, @unanswered.
|
|
16
|
+
# Calls the loop helpers #client_label, #detach, #own?, #paint, #send_command,
|
|
17
|
+
# #stale_worker, #sync_prompt, #via, and #worker_down.
|
|
18
|
+
module Questions
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# The worker says what became of it (:continue_resolved); an invalid
|
|
22
|
+
# answer gets its error and the offer stays.
|
|
23
|
+
def answer_continue(text)
|
|
24
|
+
echo_answer(text) if TurnFlow.continue_decision(text).first == :invalid
|
|
25
|
+
@continue_answer = text.empty? ? "yes" : text
|
|
26
|
+
send_command(continue_line(text))
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A bare answer at the continue prompt, as the REPL reads it (an empty
|
|
31
|
+
# one is yes).
|
|
32
|
+
def continue_line(text)
|
|
33
|
+
text.empty? ? SessionCommands::CONTINUE_COMMAND : "#{SessionCommands::CONTINUE_COMMAND} #{text}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def offer_continue(event)
|
|
37
|
+
@continue_offer = { context: event[:context], no_interrupt: event[:no_interrupt] }
|
|
38
|
+
sync_prompt
|
|
39
|
+
sync_continue_slot
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
CONTINUE_DECISIONS = { "resume" => "yes", "abort" => "no", "abort_with_reason" => "no, with a reason",
|
|
43
|
+
"refused" => "not continued: the participant may no longer run turns" }.freeze
|
|
44
|
+
|
|
45
|
+
# The offer's choices go; one line says what became of it. Another
|
|
46
|
+
# UI's answer shows as its command line (web> /continue yes) instead.
|
|
47
|
+
def continue_resolved(event)
|
|
48
|
+
@continue_offer = nil
|
|
49
|
+
# Asked as the step-limit question: its line says what became of it.
|
|
50
|
+
asked = @offer_asked
|
|
51
|
+
@offer_asked = false
|
|
52
|
+
sync_continue_slot
|
|
53
|
+
who = client_label(event[:client_id], "another UI")
|
|
54
|
+
outcome = if event[:decision] == "dropped"
|
|
55
|
+
"(dropped: #{own?(event[:client_id]) ? "you" : who} sent a new prompt)"
|
|
56
|
+
elsif event[:decision] == "refused"
|
|
57
|
+
CONTINUE_DECISIONS["refused"]
|
|
58
|
+
elsif own?(event[:client_id])
|
|
59
|
+
@continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
|
|
60
|
+
end
|
|
61
|
+
@continue_answer = nil
|
|
62
|
+
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
|
|
63
|
+
# Back to the > prompt: with the text a question set aside meanwhile
|
|
64
|
+
# (#close_question), else what the ? read holds.
|
|
65
|
+
set_aside = @set_aside
|
|
66
|
+
@set_aside = nil
|
|
67
|
+
set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The notes slot shows the offer's choices while it waits (a question
|
|
71
|
+
# has the slot while it is open).
|
|
72
|
+
def sync_continue_slot
|
|
73
|
+
return if @question || @offer_asked
|
|
74
|
+
|
|
75
|
+
if @continue_offer
|
|
76
|
+
@screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
|
|
77
|
+
else
|
|
78
|
+
@screen.clear_slot(:notes)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def unanswered_question(pending)
|
|
83
|
+
@unanswered = pending
|
|
84
|
+
detach("A question waits for an answer: #{via.attach(@client.session_id)}")
|
|
85
|
+
:unanswered
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def prompt_text
|
|
89
|
+
return paint(QUESTION_PROMPT, 33) if @question || @continue_offer
|
|
90
|
+
|
|
91
|
+
paint(PROMPT, 92)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Switch the open prompt to ? for the answer and show the choices in
|
|
95
|
+
# the notes slot, fitted to the terminal. Once the question closes,
|
|
96
|
+
# one line (the question and what became of it) stays.
|
|
97
|
+
def ask(pending)
|
|
98
|
+
return unless pending
|
|
99
|
+
|
|
100
|
+
@view.finish_thinking_spinner
|
|
101
|
+
# What was typed at the prompt waits for the question to close.
|
|
102
|
+
# Not over text already set aside (a question that closed while a
|
|
103
|
+
# continue offer waits): the ? read holds an answer then, if anything.
|
|
104
|
+
@set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
|
|
105
|
+
@question = QuestionPrompt.new(pending)
|
|
106
|
+
@question_pending = pending
|
|
107
|
+
# The step-limit question stands for the continue offer's own slot
|
|
108
|
+
# until the offer is resolved.
|
|
109
|
+
@offer_asked = true if @question.continue?
|
|
110
|
+
# An edit's diff goes above, into the scrollback, once per question
|
|
111
|
+
# (a join gets it from the snapshot and may get the event too).
|
|
112
|
+
unless @previewed_id == @question.id
|
|
113
|
+
@previewed_id = @question.id
|
|
114
|
+
preview = @question.preview_lines(paint: method(:paint))
|
|
115
|
+
@screen.commit(preview.join("\n")) unless preview.empty?
|
|
116
|
+
end
|
|
117
|
+
# The prompt first, so the choices never show under the typed text.
|
|
118
|
+
sync_prompt(keep_text: false)
|
|
119
|
+
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def answer_question(text)
|
|
123
|
+
# Enter alone at the step-limit question continues (it can't be
|
|
124
|
+
# dismissed), as at the continue prompt.
|
|
125
|
+
return dismiss_question if text.empty? && !@question.continue?
|
|
126
|
+
|
|
127
|
+
answer = @question.parse(text)
|
|
128
|
+
@screen.commit(answer.note) if answer.note
|
|
129
|
+
unless answer.ok?
|
|
130
|
+
echo_answer(text)
|
|
131
|
+
return @screen.commit(answer.error)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
135
|
+
reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
136
|
+
case reply.status
|
|
137
|
+
when 200
|
|
138
|
+
@answered_ids << @question.id
|
|
139
|
+
close_question(@question.answer_text(answer))
|
|
140
|
+
when 409 then close_question("(already answered in another UI)")
|
|
141
|
+
when 408 then @screen.commit("could not answer (#{ExitLines::LATE})")
|
|
142
|
+
else
|
|
143
|
+
detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
|
|
144
|
+
@screen.commit("could not answer (#{[reply.status, detail].compact.join(" ")})")
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
rescue SystemCallError, IOError => e
|
|
148
|
+
@screen.commit("could not answer (#{worker_down(e)})")
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# An empty answer dismisses the question, as in the REPL: the tool
|
|
153
|
+
# returns unanswered (an approval: denied) and the turn goes on. The
|
|
154
|
+
# :question_cancelled every UI gets finds it already closed here.
|
|
155
|
+
def dismiss_question
|
|
156
|
+
reply = @client.dismiss_question(id: @question.id)
|
|
157
|
+
case reply.status
|
|
158
|
+
when 200 then close_question(@question.approval? ? "(denied)" : "(cancelled)")
|
|
159
|
+
when 409 then close_question("(question already closed in another UI)")
|
|
160
|
+
when 404 then @screen.commit("#{stale_worker("dismiss questions")}; Ctrl-C cancels the turn")
|
|
161
|
+
when 408 then @screen.commit("could not dismiss the question (#{ExitLines::LATE}); Ctrl-C cancels the turn")
|
|
162
|
+
else
|
|
163
|
+
# The question stays open (after a 404 too).
|
|
164
|
+
detail = reply.json&.fetch("error", nil)
|
|
165
|
+
@screen.commit("could not dismiss the question (#{[reply.status, detail].compact.join(" ")}); Ctrl-C cancels the turn")
|
|
166
|
+
end
|
|
167
|
+
nil
|
|
168
|
+
rescue SystemCallError, IOError => e
|
|
169
|
+
@screen.commit("could not dismiss the question (#{worker_down(e)}); Ctrl-C cancels the turn")
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A line read at ? that isn't a (valid) answer: the read left no echo,
|
|
174
|
+
# so it shows above what it got.
|
|
175
|
+
def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
|
|
176
|
+
|
|
177
|
+
def question_relayed(event)
|
|
178
|
+
return unless @question && @question.id == event[:id].to_s
|
|
179
|
+
|
|
180
|
+
@question.relayed_to = event[:relayed_to]
|
|
181
|
+
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def question_answered(event)
|
|
185
|
+
return unless @question
|
|
186
|
+
return close_question(nil) if @answered_ids.include?(event[:id])
|
|
187
|
+
|
|
188
|
+
answer = event[:answer] || {}
|
|
189
|
+
picked = [*Array(answer[:selected]), answer[:freeform]].compact.join(", ")
|
|
190
|
+
close_question("#{picked.empty? ? "(answered)" : picked} (in another UI)")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @param outcome [String, nil] what became of it, for the line that
|
|
194
|
+
# stays in the scrollback (nil: none, it was closed already)
|
|
195
|
+
def close_question(outcome)
|
|
196
|
+
@screen.clear_slot(:notes)
|
|
197
|
+
@screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
|
|
198
|
+
@question = nil
|
|
199
|
+
# A continue offer still waiting keeps the ? prompt (the step-limit
|
|
200
|
+
# question closes before its offer resolves): the text set aside
|
|
201
|
+
# waits for the > prompt (#continue_resolved).
|
|
202
|
+
return sync_continue_slot if @continue_offer
|
|
203
|
+
|
|
204
|
+
set_aside = @set_aside
|
|
205
|
+
@set_aside = nil
|
|
206
|
+
sync_prompt(keep_text: false, prefill: set_aside)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Restart the open read when its prompt no longer fits (a question or a
|
|
210
|
+
# continue offer opened or closed), first erasing the prompt Reline drew.
|
|
211
|
+
# What is typed there goes along (+keep_text+), or +prefill+ goes in.
|
|
212
|
+
def sync_prompt(keep_text: true, prefill: nil)
|
|
213
|
+
return unless @reader
|
|
214
|
+
|
|
215
|
+
@screen.synchronize do
|
|
216
|
+
next if @reader.current == prompt_text
|
|
217
|
+
|
|
218
|
+
@screen.clear_slot(:editor)
|
|
219
|
+
@reader.reprompt(keep_text: keep_text, prefill: prefill)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|