samagotchi 0.116.0 → 0.117.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 +24 -1
- data/docs/cli.md +28 -1
- data/docs/configuration.md +6 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge.rb +88 -14
- data/lib/samagotchi/bridge_client.rb +13 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +19 -3
- data/lib/samagotchi/hold_stop.rb +89 -0
- data/lib/samagotchi/prompt_warmup.rb +8 -3
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/task_watch.rb +23 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +130 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -0
- data/lib/samagotchi/web/public/app.js +17 -1
- data/lib/samagotchi/web/public/composer_size.js +14 -7
- data/lib/samagotchi/web/public/data.js +14 -0
- data/lib/samagotchi/web/public/index.html +16 -0
- data/lib/samagotchi/web/public/session_actions.js +39 -6
- data/lib/samagotchi/web/public/stop_sheet.js +126 -0
- data/lib/samagotchi/web/session_access_routes.rb +9 -6
- data/lib/samagotchi/web/session_control.rb +40 -3
- data/lib/samagotchi/worker.rb +57 -5
- data/lib/samagotchi/worker_idle_exit.rb +111 -9
- metadata +3 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
require_relative "config"
|
|
4
6
|
require_relative "log"
|
|
5
7
|
require_relative "tools/task_runtime"
|
|
@@ -182,19 +184,35 @@ module Samagotchi
|
|
|
182
184
|
# @return [Array<Entry>]
|
|
183
185
|
def holding = entries.reject { |entry| entry.note_only || entry.failures >= MAX_FAILURES }
|
|
184
186
|
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
|
|
187
|
+
# A watched task still running, as /exit and the exit ask see it:
|
|
188
|
+
# +age_seconds+, the seconds since it started (nil when its record has
|
|
189
|
+
# no start time).
|
|
190
|
+
Running = Data.define(:id, :command, :age_seconds)
|
|
191
|
+
|
|
192
|
+
# The #holding tasks still running, for /exit (WorkerIdleExit's
|
|
193
|
+
# watched_running): one finished meanwhile is noted as the worker leaves.
|
|
194
|
+
# One `ps` per task.
|
|
195
|
+
# @return [Array<Running>]
|
|
189
196
|
def running
|
|
190
197
|
holding.filter_map do |entry|
|
|
191
198
|
record, = Tools::TaskRuntime.get_record(entry.id)
|
|
192
|
-
|
|
199
|
+
next unless record&.running?
|
|
200
|
+
|
|
201
|
+
Running.new(id: entry.id, command: record.command.to_s, age_seconds: age_seconds(record))
|
|
193
202
|
end
|
|
194
203
|
end
|
|
195
204
|
|
|
196
205
|
private
|
|
197
206
|
|
|
207
|
+
# Seconds since the task started, from its record's iso8601 +started_at+;
|
|
208
|
+
# nil when that can't be read.
|
|
209
|
+
def age_seconds(record)
|
|
210
|
+
started = Time.parse(record.started_at.to_s)
|
|
211
|
+
[(Time.now - started).round, 0].max
|
|
212
|
+
rescue ArgumentError, TypeError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
198
216
|
# Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
|
|
199
217
|
# was there. +log+: why a task leaves without a note, at +level+ (debug
|
|
200
218
|
# by default), with +error+ when there is one.
|
|
@@ -57,17 +57,35 @@ module Samagotchi
|
|
|
57
57
|
# An exit the worker agreed to in an empty session. Said before it has
|
|
58
58
|
# left: it checks again once it lets go, and a note or prompt arriving
|
|
59
59
|
# first keeps the session (SessionManager.discard_left_session).
|
|
60
|
-
|
|
60
|
+
EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
|
|
61
|
+
EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
|
|
61
62
|
# Why the worker stayed up after /exit, by the reason it gave.
|
|
62
63
|
HELD_REASONS = {
|
|
63
64
|
"turn_running" => "a turn is running", "input_queued" => "prompts are queued",
|
|
64
65
|
"continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
|
|
65
66
|
"reminders" => "reminders are set", "starting" => "the worker is still starting",
|
|
66
|
-
"background_tasks" => "background tasks it started are still running"
|
|
67
|
+
"background_tasks" => "background tasks it started are still running",
|
|
68
|
+
"delegates" => "delegates of this session are still running"
|
|
67
69
|
}.freeze
|
|
68
70
|
# How to end a background task that holds the worker up (an older
|
|
69
71
|
# worker's 409 has no hint of its own).
|
|
70
72
|
TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
|
|
73
|
+
# The /exit ask, when every hold is one the user can stop: what still
|
|
74
|
+
# runs, then the three answers. Each kind, singular and plural.
|
|
75
|
+
EXIT_HOLD_WORDS = {
|
|
76
|
+
"reminders" => %w[reminder reminders],
|
|
77
|
+
"background_tasks" => ["background task", "background tasks"],
|
|
78
|
+
"delegates" => %w[delegate delegates]
|
|
79
|
+
}.freeze
|
|
80
|
+
# The 200's +stopped+ keys, by the kind the ask lists them under.
|
|
81
|
+
STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
|
|
82
|
+
EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
|
|
83
|
+
# How the ask reads an answer.
|
|
84
|
+
EXIT_ANSWERS = {
|
|
85
|
+
"1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
|
|
86
|
+
"2" => :stop, "stop" => :stop, "exit" => :stop,
|
|
87
|
+
"3" => :back, "back" => :back, "stay" => :back
|
|
88
|
+
}.freeze
|
|
71
89
|
# How much of the last answer a join shows.
|
|
72
90
|
JOIN_ANSWER_LINES = 12
|
|
73
91
|
JOIN_ANSWER_CHARS = 1200
|
|
@@ -175,6 +193,8 @@ module Samagotchi
|
|
|
175
193
|
@own_failed = false
|
|
176
194
|
# A continue offer is pending: the prompt asks for the answer.
|
|
177
195
|
@continue_offer = nil
|
|
196
|
+
# The /exit ask is open: the next line is its answer (#answer_exit_ask).
|
|
197
|
+
@exit_ask = nil
|
|
178
198
|
# The status row: the worker's model, ctx, the session's memories.
|
|
179
199
|
@status = StatusRow.new(screen)
|
|
180
200
|
# Participants' names by author ("p:<id>"), from the join's state
|
|
@@ -470,6 +490,14 @@ module Samagotchi
|
|
|
470
490
|
|
|
471
491
|
# @return [Symbol, nil] :detach to end the loop
|
|
472
492
|
def submit(line)
|
|
493
|
+
if @exit_ask
|
|
494
|
+
# The exit ask is open: this line answers it. Ctrl-D and /detach
|
|
495
|
+
# detach without stopping anything, the way its 1 does.
|
|
496
|
+
return detach(kept_line(@exit_ask)) if line.nil? || command_registry.lookup_local(line.strip)&.id == :detach
|
|
497
|
+
|
|
498
|
+
return answer_exit_ask(line)
|
|
499
|
+
end
|
|
500
|
+
|
|
473
501
|
return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
|
|
474
502
|
|
|
475
503
|
text = line.strip
|
|
@@ -599,14 +627,104 @@ module Samagotchi
|
|
|
599
627
|
|
|
600
628
|
# Detach, and ask the worker to exit now. The worker decides: it stays
|
|
601
629
|
# up while anything still needs it (a turn, queued input, a continue
|
|
602
|
-
# offer, another UI, reminders), and the line says which.
|
|
603
|
-
#
|
|
630
|
+
# offer, another UI, reminders), and the line says which. When the only
|
|
631
|
+
# holds are ones the user can stop, it asks what to do with them instead
|
|
632
|
+
# of detaching (#answer_exit_ask).
|
|
633
|
+
# @return [Symbol, nil] :detach, or nil to stay at the prompt
|
|
604
634
|
def exit_worker
|
|
605
|
-
|
|
635
|
+
reply = @client.request_exit(client_id: @client_id)
|
|
636
|
+
holds = reply.status == 409 ? reply.json&.fetch("holds", nil) : nil
|
|
637
|
+
return detach(exit_line(reply)) unless holds.is_a?(Array) && !holds.empty?
|
|
638
|
+
|
|
639
|
+
@exit_ask = holds
|
|
640
|
+
@screen.commit(ask_lines(holds))
|
|
641
|
+
nil
|
|
642
|
+
rescue SystemCallError, IOError => e
|
|
643
|
+
detach(exit_failed_line(e.message))
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# The ask: what still runs, then the three answers.
|
|
647
|
+
def ask_lines(holds)
|
|
648
|
+
lines = holds.map do |hold|
|
|
649
|
+
items = Array(hold["items"])
|
|
650
|
+
singular, plural = hold_words(hold["kind"])
|
|
651
|
+
" • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
|
|
652
|
+
end
|
|
653
|
+
lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
|
|
654
|
+
(["This session still has:"] + lines).join("\n")
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# One entry of a hold: the command or name, with its detail in
|
|
658
|
+
# brackets; a delegate leads with its short id and its title in quotes.
|
|
659
|
+
def item_word(item, kind)
|
|
660
|
+
label = item["label"].to_s
|
|
661
|
+
label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
|
|
662
|
+
detail = item["detail"].to_s
|
|
663
|
+
detail.empty? ? label : "#{label} (#{detail})"
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# The answer to the exit ask.
|
|
667
|
+
# @return [Symbol, nil] :detach for 1 and 2, nil for 3 or a line the
|
|
668
|
+
# ask doesn't understand
|
|
669
|
+
def answer_exit_ask(line)
|
|
670
|
+
choice = EXIT_ANSWERS[line.to_s.strip.downcase]
|
|
671
|
+
unless choice
|
|
672
|
+
@screen.commit("Say 1 (keep running), 2 (stop them and exit) or 3 (back to the session).")
|
|
673
|
+
return nil
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
holds = @exit_ask
|
|
677
|
+
@exit_ask = nil
|
|
678
|
+
case choice
|
|
679
|
+
when :keep then detach(kept_line(holds))
|
|
680
|
+
when :stop then stop_and_exit
|
|
681
|
+
else
|
|
682
|
+
@screen.commit("(nothing stopped; the worker keeps running)")
|
|
683
|
+
nil
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# 1: detach, everything left running.
|
|
688
|
+
def kept_line(holds)
|
|
689
|
+
"Detached; the session keeps running (#{hold_words_list(holds)}). " \
|
|
690
|
+
"Re-attach with: #{via.attach(@client.session_id)}"
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# 2: ask the worker to stop them all and exit.
|
|
694
|
+
# @return [Symbol] :detach
|
|
695
|
+
def stop_and_exit
|
|
696
|
+
reply = @client.request_exit(client_id: @client_id, stop: true)
|
|
697
|
+
detach(exit_line(reply))
|
|
606
698
|
rescue SystemCallError, IOError => e
|
|
607
699
|
detach(exit_failed_line(e.message))
|
|
608
700
|
end
|
|
609
701
|
|
|
702
|
+
# "2 reminders, 1 background task, 1 delegate" from the ask's holds.
|
|
703
|
+
def hold_words_list(holds)
|
|
704
|
+
holds.map do |hold|
|
|
705
|
+
items = Array(hold["items"])
|
|
706
|
+
singular, plural = hold_words(hold["kind"])
|
|
707
|
+
"#{items.size} #{items.size == 1 ? singular : plural}"
|
|
708
|
+
end.join(", ")
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
|
|
712
|
+
|
|
713
|
+
# What a stop-all exit stopped: "stopped 2 reminders (api_health, git
|
|
714
|
+
# push), 1 background task (rails s)"; nil when it stopped nothing.
|
|
715
|
+
def stopped_words(stopped)
|
|
716
|
+
return nil unless stopped.is_a?(Hash)
|
|
717
|
+
|
|
718
|
+
parts = STOPPED_KEYS.filter_map do |key, kind|
|
|
719
|
+
names = Array(stopped[key])
|
|
720
|
+
next if names.empty?
|
|
721
|
+
|
|
722
|
+
singular, plural = hold_words(kind)
|
|
723
|
+
"#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
|
|
724
|
+
end
|
|
725
|
+
parts.empty? ? nil : "stopped #{parts.join(", ")}"
|
|
726
|
+
end
|
|
727
|
+
|
|
610
728
|
# /exit --delete: the worker exits as for /exit, then the session is
|
|
611
729
|
# deleted. When the worker stays up (another UI, queued input, ...),
|
|
612
730
|
# nothing is deleted.
|
|
@@ -656,7 +774,9 @@ module Samagotchi
|
|
|
656
774
|
# (SessionManager.archive_session). An empty session: the worker
|
|
657
775
|
# deletes it as it leaves.
|
|
658
776
|
def exit_and_archive
|
|
659
|
-
|
|
777
|
+
# /archive can't ask about what still runs: a child the archive will
|
|
778
|
+
# stop is not worth a question here.
|
|
779
|
+
reply = @client.request_exit(client_id: @client_id, ask: false)
|
|
660
780
|
id = @client.session_id
|
|
661
781
|
return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
|
|
662
782
|
return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
|
|
@@ -676,9 +796,11 @@ module Samagotchi
|
|
|
676
796
|
error = reply.json&.fetch("error", nil)
|
|
677
797
|
case reply.status
|
|
678
798
|
when 200
|
|
679
|
-
|
|
799
|
+
stopped = stopped_words(reply.json&.fetch("stopped", nil))
|
|
800
|
+
return EMPTY_EXIT if discards?(reply) && stopped.nil?
|
|
801
|
+
return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
|
|
680
802
|
|
|
681
|
-
"Detached; the session's worker is stopping. Resume with: #{via.resume(id)}"
|
|
803
|
+
"Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(id)}"
|
|
682
804
|
when 409
|
|
683
805
|
reason = reply.json&.fetch("reason", nil).to_s
|
|
684
806
|
"Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -236,6 +236,8 @@ module Samagotchi
|
|
|
236
236
|
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
|
|
237
237
|
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :participant, :session],
|
|
238
238
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
|
|
239
|
+
["GET", %r{\A/api/sessions/([^/]+)/holds\z}, :handle_session_holds, :owner, :session],
|
|
240
|
+
["POST", %r{\A/api/sessions/([^/]+)/holds/stop\z}, :handle_stop_holds, :owner, :session],
|
|
239
241
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :owner, :session],
|
|
240
242
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :owner, :session],
|
|
241
243
|
["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :owner, :session],
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
sendTurn,
|
|
5
5
|
cancelTurn,
|
|
6
6
|
stopSession,
|
|
7
|
+
sessionHolds,
|
|
8
|
+
stopSessionHolds,
|
|
7
9
|
restartSession,
|
|
8
10
|
deleteSession,
|
|
9
11
|
archiveSession,
|
|
@@ -98,6 +100,7 @@ import { createToasts } from "./toasts.js";
|
|
|
98
100
|
import { createUnqueue } from "./unqueue.js";
|
|
99
101
|
import { createStartPage } from "./start_page.js";
|
|
100
102
|
import { createSessionActions } from "./session_actions.js";
|
|
103
|
+
import { createStopSheet } from "./stop_sheet.js";
|
|
101
104
|
import { createRename } from "./session_rename.js";
|
|
102
105
|
import { createSendTurn } from "./send_turn.js";
|
|
103
106
|
import { createMarkdownTip } from "./markdown_tip.js";
|
|
@@ -565,12 +568,21 @@ const lists = createListsView({
|
|
|
565
568
|
|
|
566
569
|
// Continue, Archive, Delete and Stop, and the start page when the open
|
|
567
570
|
// session is gone (session_actions.js).
|
|
571
|
+
// The Stop sheet first (stop_sheet.js): its buttons are the two ways to stop,
|
|
572
|
+
// and the sheet is only shown when the worker has something the user can stop.
|
|
573
|
+
const stopSheet = createStopSheet({
|
|
574
|
+
doc: document, win: window,
|
|
575
|
+
stopAll: (id) => sessionActions.stopSelected(id, { stopHolds: true }),
|
|
576
|
+
stopSession: (id) => sessionActions.stopSelected(id),
|
|
577
|
+
});
|
|
578
|
+
|
|
568
579
|
const sessionActions = createSessionActions({
|
|
569
580
|
selected: () => selected,
|
|
570
581
|
state: () => current,
|
|
571
582
|
sessions: () => allSessions,
|
|
572
583
|
setSessions: (list) => { allSessions = list; },
|
|
573
|
-
api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession
|
|
584
|
+
api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession,
|
|
585
|
+
sessionHolds, stopSessionHolds },
|
|
574
586
|
eventsLive: () => !!eventsControl,
|
|
575
587
|
refresh,
|
|
576
588
|
lists,
|
|
@@ -584,6 +596,7 @@ const sessionActions = createSessionActions({
|
|
|
584
596
|
restoreDraft,
|
|
585
597
|
closeLiveStream: () => streamControl?.close(),
|
|
586
598
|
workerGone,
|
|
599
|
+
stopSheet,
|
|
587
600
|
});
|
|
588
601
|
// The title's ✎: /title through the chips' path (session_rename.js), the
|
|
589
602
|
// input over the title row on screen: the top bar's when it shows the
|
|
@@ -1659,6 +1672,9 @@ attachInput.addEventListener("change", () => {
|
|
|
1659
1672
|
attachInput.value = "";
|
|
1660
1673
|
});
|
|
1661
1674
|
// ── Composer height: grows with the text; the top-edge grip sets a floor ───
|
|
1675
|
+
// The floor is saved as it was set; fitPrompt holds it to half the window it is
|
|
1676
|
+
// drawn in (composer_size.js), so a floor saved in a bigger window fits this one
|
|
1677
|
+
// and comes back when the window is big again.
|
|
1662
1678
|
let promptFloor = Number(readStored(GRIP_KEY)) || null;
|
|
1663
1679
|
|
|
1664
1680
|
// The empty box: one line in a session, three on the start page.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// The composer's height and the gestures of the grip on its top edge. The
|
|
2
2
|
// height grows with the text up to a cap; the grip sets a taller floor (the
|
|
3
|
-
// cap then is at least that floor)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// cap then is at least that floor), and no floor — saved in another window or
|
|
4
|
+
// dragged here — takes more than half of the window the box is shown in. A
|
|
5
|
+
// release of the grip is one gesture: a drag that resizes, a drag far enough
|
|
6
|
+
// down that folds the box away, or a click (which, expanded, folds too — after
|
|
7
|
+
// a delay, so a double-click can still reset the height).
|
|
7
8
|
export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
|
|
8
9
|
export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
|
|
9
10
|
const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
|
|
@@ -15,15 +16,21 @@ export const GRIP_CLICK_FOLD_MS = 250; // how long a click on the expanded grip
|
|
|
15
16
|
// contentPx: the text's height (scrollHeight plus borders); floorPx: the
|
|
16
17
|
// grip's height, or null; minPx: the empty box's height.
|
|
17
18
|
export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PROMPT_MIN_PX }) {
|
|
18
|
-
|
|
19
|
+
// The floor, as THIS window can hold it: a floor saved in a bigger window
|
|
20
|
+
// (a rotated phone, a resized window, another device on the same origin) is
|
|
21
|
+
// held down to the share a drag may set here, and comes back whole when the
|
|
22
|
+
// window is big again — only the height shown shrinks, never the saved floor.
|
|
23
|
+
const floor = gripFloor(floorPx, viewportPx, minPx);
|
|
19
24
|
const cap = Math.max(floor, Math.round(viewportPx * PROMPT_CAP_SHARE));
|
|
20
25
|
return { height: Math.min(Math.max(contentPx, floor), cap), scrolls: contentPx > cap };
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
// A grip drag: the floor it asks for, kept between the minimum and the
|
|
24
|
-
// grip's share of the window.
|
|
29
|
+
// grip's share of the window. The same rule fits a floor saved earlier (see
|
|
30
|
+
// promptHeight), so the box never takes more of a window than a drag in it
|
|
31
|
+
// could have given it.
|
|
25
32
|
export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
|
|
26
|
-
return Math.
|
|
33
|
+
return Math.max(minPx, Math.min(px || 0, Math.round(viewportPx * PROMPT_GRIP_SHARE)));
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
// Whether a grip drag asks to fold the composer: it wants the box at least
|
|
@@ -194,6 +194,20 @@ export function stopSession(id, opts = {}) {
|
|
|
194
194
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// What this session's worker still has that the user can stop (reminders, a
|
|
198
|
+
// background task running, a delegate with a live worker), for the Stop
|
|
199
|
+
// button's sheet: 200 {holds: []} always — no worker, or one that can't say,
|
|
200
|
+
// is an empty list, so Stop stays one tap.
|
|
201
|
+
export function sessionHolds(id, opts = {}) {
|
|
202
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/holds`, { ...opts });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The sheet's "Stop all": the worker stops everything the user can before the
|
|
206
|
+
// Stop TERMs it. 200 {status: "stopped", stopped: {reminders, tasks, delegates}}.
|
|
207
|
+
export function stopSessionHolds(id, opts = {}) {
|
|
208
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/holds/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
209
|
+
}
|
|
210
|
+
|
|
197
211
|
// Kills a background task the session's conversation started (the
|
|
198
212
|
// stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
|
|
199
213
|
// no live worker.
|
|
@@ -1226,6 +1226,22 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1226
1226
|
.push-devices.sheet .pd-done{font-size:13px}
|
|
1227
1227
|
.push-devices.sheet .pd-meta,.push-devices.sheet .pd-note{font-size:12.5px}
|
|
1228
1228
|
.push-devices.sheet .pd-remove{padding:6px 12px;font-size:13px}
|
|
1229
|
+
/* The Stop button's sheet (stop_sheet.js): what still runs, and the three answers; a bottom sheet on a phone. */
|
|
1230
|
+
.stop-sheet .ctx-pop-head strong{font-family:inherit}
|
|
1231
|
+
.stop-sheet.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
|
|
1232
|
+
.ss-lines{list-style:none;display:flex;flex-direction:column;gap:6px;margin:4px 0 10px;padding:0}
|
|
1233
|
+
.ss-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 8px;padding:8px 10px;border-radius:10px;background:var(--bg-inset)}
|
|
1234
|
+
.ss-line b{color:var(--text-bright)}
|
|
1235
|
+
.ss-items{flex-basis:100%;color:var(--text-2)}
|
|
1236
|
+
.ss-note,.ss-more{font-size:11.5px;color:var(--text-muted)}
|
|
1237
|
+
.ss-acts{display:flex;gap:8px;flex-wrap:wrap}
|
|
1238
|
+
.ss-acts button{padding:8px 12px;border-radius:8px;font-size:13px;cursor:pointer;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text-2)}
|
|
1239
|
+
.ss-acts button:hover{background:var(--bg-hover);color:var(--text-hover)}
|
|
1240
|
+
.ss-stop-all{background:var(--warn-bg);border-color:var(--warn-border);color:var(--warn-text)}
|
|
1241
|
+
.ss-stop-all:hover{background:var(--warn-border);color:var(--text-hover)}
|
|
1242
|
+
.ss-cancel{background:transparent}
|
|
1243
|
+
.stop-sheet.sheet .ss-acts button{flex:1 1 auto;font-size:14px;padding:10px 12px}
|
|
1244
|
+
.ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
|
|
1229
1245
|
.ctx-pop-head{display:flex;align-items:baseline;gap:8px}
|
|
1230
1246
|
.ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
|
|
1231
1247
|
.ctx-pop-kind{font-size:11px;color:var(--text-muted)}
|
|
@@ -10,7 +10,8 @@ import { inTree } from "./sessions_list.js";
|
|
|
10
10
|
// Hooks: +selected()+ the open session's id or null; +state()+ its fields
|
|
11
11
|
// (session_state.js); +sessions()+ / +setSessions(list)+ the page's list;
|
|
12
12
|
// +api+: {continueSession, startReview, archiveSession, unarchiveSession,
|
|
13
|
-
// deleteSession, stopSession} (data.js);
|
|
13
|
+
// deleteSession, stopSession, sessionHolds, stopSessionHolds} (data.js);
|
|
14
|
+
// +eventsLive()+ the hub's stream is open (else
|
|
14
15
|
// +refresh()+ fetches the list); +lists+ (lists_view.js: render);
|
|
15
16
|
// +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
|
|
16
17
|
// +toast(text, action, options)+, +addCreatedSession(created)+,
|
|
@@ -30,6 +31,10 @@ export function createSessionActions({
|
|
|
30
31
|
selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
|
|
31
32
|
select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
|
|
32
33
|
alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
|
|
34
|
+
// The Stop sheet (stop_sheet.js): +open(id, holds)+ shows it and answers
|
|
35
|
+
// true; nothing to stop (or no sheet wired) is false, and Stop is the
|
|
36
|
+
// confirm.
|
|
37
|
+
stopSheet = { open: () => false },
|
|
33
38
|
}) {
|
|
34
39
|
// The session this tab is stopping: its stream may close before the stop
|
|
35
40
|
// request returns (workerGone reads it).
|
|
@@ -64,11 +69,39 @@ export function createSessionActions({
|
|
|
64
69
|
showNotice(goneSessionNotice(id, why));
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
// The info bar's Stop: the open session's worker
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
// The info bar's Stop: the open session's worker. It first asks the worker
|
|
73
|
+
// what still runs that the user can stop (GET …/holds); when there is
|
|
74
|
+
// something, the sheet (stop_sheet.js) asks what to do with it; when there
|
|
75
|
+
// is nothing, or no worker to ask, it is today's confirm and one tap.
|
|
76
|
+
// +stopHolds+ is the sheet's "Stop all": the worker's stop-all step, then
|
|
77
|
+
// the stop. A delegate or a background task outlives the worker either way
|
|
78
|
+
// (docs/sessions.md, "Stopping a worker"), which is why the sheet says so.
|
|
79
|
+
async function stopSelected(id = selected(), { stopHolds = false } = {}) {
|
|
80
|
+
if (!id) return;
|
|
81
|
+
|
|
82
|
+
if (!stopHolds) {
|
|
83
|
+
let holds = [];
|
|
84
|
+
try {
|
|
85
|
+
const answer = await api.sessionHolds(id);
|
|
86
|
+
holds = answer?.holds || [];
|
|
87
|
+
} catch {
|
|
88
|
+
holds = []; // a worker that can't say, or none: stop as before
|
|
89
|
+
}
|
|
90
|
+
if (stopSheet.open(id, holds)) return;
|
|
91
|
+
if (!confirm(`Stop session ${id}?`)) return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Stop all: the worker stops what it can first. A failure is said and
|
|
95
|
+
// doesn't hold the stop: the session goes either way, and the sheet's
|
|
96
|
+
// other button is exactly that.
|
|
97
|
+
if (stopHolds) {
|
|
98
|
+
try {
|
|
99
|
+
await api.stopSessionHolds(id);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
toast(`${cantText("stop what the session runs", e)} — stopping the session anyway`, null, { sticky: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
stoppingId = id;
|
|
73
106
|
try {
|
|
74
107
|
await api.stopSession(id);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The Stop button's sheet (session_actions.js asks the worker first): when
|
|
2
|
+
// the session's worker still has things the user can stop — reminders, a
|
|
3
|
+
// background task that runs, a delegate with a live worker — Stop asks what
|
|
4
|
+
// to do with them before TERMed-ing the worker. Nothing to stop, or no worker
|
|
5
|
+
// to ask, is today's one tap.
|
|
6
|
+
//
|
|
7
|
+
// "Stop all" runs the worker's stop-all step (POST …/holds/stop, the same
|
|
8
|
+
// HoldStop#stop_all the terminal's /exit ask uses) and then today's Stop;
|
|
9
|
+
// "Stop session only" is today's Stop exactly, so what survives keeps running
|
|
10
|
+
// (docs/sessions.md, "Stopping a worker"). Reminders are listed with a note:
|
|
11
|
+
// they live only in the worker, so they end with the session either way.
|
|
12
|
+
//
|
|
13
|
+
// A phone gets it as a bottom sheet, a wider window as a popover at the Stop
|
|
14
|
+
// button (chip_popover.js). Pure model and template plus createStopSheet, so
|
|
15
|
+
// the specs run them under node --test.
|
|
16
|
+
|
|
17
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
18
|
+
import { createChipPopover } from "./chip_popover.js";
|
|
19
|
+
|
|
20
|
+
// At most this many of a kind named in the sheet; the rest are one "+N more".
|
|
21
|
+
export const MAX_NAMED = 3;
|
|
22
|
+
|
|
23
|
+
// The Stop button the sheet opens at.
|
|
24
|
+
export const STOP_ANCHOR = "#infoStopBtn";
|
|
25
|
+
|
|
26
|
+
const KIND_WORDS = {
|
|
27
|
+
reminders: ["reminder", "reminders"],
|
|
28
|
+
background_tasks: ["background task", "background tasks"],
|
|
29
|
+
delegates: ["delegate", "delegates"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// One entry of a hold as the sheet writes it: the command, the reminder's
|
|
33
|
+
// name with its schedule, a delegate's short id and its title. A long command
|
|
34
|
+
// is cut short: the sheet must fit a phone without scrolling.
|
|
35
|
+
function itemWords(item, kind) {
|
|
36
|
+
const label = String(item?.label ?? item?.id ?? "").trim();
|
|
37
|
+
const detail = String(item?.detail ?? "").trim();
|
|
38
|
+
const cut = label.length > 42 ? `${label.slice(0, 42)}…` : label;
|
|
39
|
+
if (kind === "delegates") {
|
|
40
|
+
const id = String(item?.id ?? "").slice(0, 8);
|
|
41
|
+
return `${id} "${cut || "untitled"}"${detail ? ` ${detail}` : ""}`;
|
|
42
|
+
}
|
|
43
|
+
const name = cut || String(item?.id ?? "").slice(0, 8);
|
|
44
|
+
return detail ? `${name} (${detail})` : name;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// What the sheet says from the worker's holds (GET …/holds): null when there
|
|
48
|
+
// is nothing to ask about (no sheet: Stop stays one tap), else its lines, one
|
|
49
|
+
// per kind, and the total.
|
|
50
|
+
// @return {lines: [{kind, count, word, items, more, note}], total} | null
|
|
51
|
+
export function stopSheetModel(holds) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
for (const hold of Array.isArray(holds) ? holds : []) {
|
|
54
|
+
const kind = String(hold?.kind ?? "");
|
|
55
|
+
const items = Array.isArray(hold?.items) ? hold.items : [];
|
|
56
|
+
if (!items.length) continue;
|
|
57
|
+
|
|
58
|
+
const [singular, plural] = KIND_WORDS[kind] ?? [kind || "hold", `${kind || "holds"}s`];
|
|
59
|
+
lines.push({
|
|
60
|
+
kind,
|
|
61
|
+
count: items.length,
|
|
62
|
+
word: items.length === 1 ? singular : plural,
|
|
63
|
+
items: items.slice(0, MAX_NAMED).map((item) => itemWords(item, kind)),
|
|
64
|
+
more: Math.max(0, items.length - MAX_NAMED),
|
|
65
|
+
note: kind === "reminders" ? "they end with the session" : "",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (!lines.length) return null;
|
|
69
|
+
|
|
70
|
+
return { lines, total: lines.reduce((sum, line) => sum + line.count, 0) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function lineTemplate(line) {
|
|
74
|
+
return html`<li class="ss-line" data-kind=${line.kind}>
|
|
75
|
+
<b>${line.count} ${line.word}</b>
|
|
76
|
+
${line.note ? html` <span class="ss-note">(${line.note})</span>` : nothing}
|
|
77
|
+
${line.items.length ? html` <span class="ss-items">${line.items.join(" · ")}${line.more ? html` <span class="ss-more">+${line.more} more</span>` : nothing}</span>` : nothing}
|
|
78
|
+
</li>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The sheet's content (a lit-html template).
|
|
82
|
+
export function stopSheetTemplate(model) {
|
|
83
|
+
const things = model.total === 1 ? "1 thing" : `${model.total} things`;
|
|
84
|
+
return html`<div class="ctx-pop-head"><strong>This session still has</strong
|
|
85
|
+
><span class="ctx-pop-kind">${things}</span
|
|
86
|
+
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
|
|
87
|
+
><ul class="ss-lines">${model.lines.map(lineTemplate)}</ul
|
|
88
|
+
><div class="ss-acts"><button type="button" class="ss-stop-all" data-act="all">Stop all</button
|
|
89
|
+
><button type="button" class="ss-stop-only" data-act="session">Stop session only</button
|
|
90
|
+
><button type="button" class="ss-cancel" data-act="cancel">Cancel</button></div
|
|
91
|
+
><div class="ss-foot">Stopping the session ends its worker; a background task or a delegate runs on its own, so it stays.</div>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The sheet at the Stop button. +stopAll(id)+ stops what the worker can and
|
|
95
|
+
// then the session; +stopSession(id)+ is today's Stop; +open(id, holds)+ shows
|
|
96
|
+
// it only when there is something (false otherwise, so the caller can fall
|
|
97
|
+
// back to the plain confirm). @return {open, close, isOpen, pop}
|
|
98
|
+
export function createStopSheet({ doc = globalThis.document, win = globalThis.window,
|
|
99
|
+
anchor = STOP_ANCHOR, el = doc.createElement("span"),
|
|
100
|
+
stopAll = () => {}, stopSession = () => {} }) {
|
|
101
|
+
let model = null;
|
|
102
|
+
let forId = null;
|
|
103
|
+
|
|
104
|
+
const shell = createChipPopover({
|
|
105
|
+
id: "stopSheet", className: "ctx-popover stop-sheet", width: 400, el, anchor, openers: anchor,
|
|
106
|
+
summary: () => model, sheet: true, label: "Stop the session?", doc, win,
|
|
107
|
+
show: (data) => render(stopSheetTemplate(data), shell.pop),
|
|
108
|
+
onClose: () => { model = null; forId = null; },
|
|
109
|
+
onAct: (act) => {
|
|
110
|
+
const id = forId;
|
|
111
|
+
if (act === "all") { shell.close(); stopAll(id); }
|
|
112
|
+
else if (act === "session") { shell.close(); stopSession(id); }
|
|
113
|
+
else if (act === "cancel") shell.close();
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function open(id, holds) {
|
|
118
|
+
model = stopSheetModel(holds);
|
|
119
|
+
if (!model) return false;
|
|
120
|
+
forId = id;
|
|
121
|
+
shell.open(doc.querySelector(anchor) ?? el);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { open, close: () => shell.close(), isOpen: shell.isOpen, pop: shell.pop };
|
|
126
|
+
}
|
|
@@ -304,22 +304,25 @@ module Samagotchi
|
|
|
304
304
|
{ epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
|
|
305
305
|
end
|
|
306
306
|
|
|
307
|
-
# POST /api/sessions/:id/exit {client_id, delete}: a remote attached
|
|
307
|
+
# POST /api/sessions/:id/exit {client_id, delete, stop}: a remote attached
|
|
308
308
|
# TUI's /exit (and the worker's half of /exit --delete and /archive),
|
|
309
309
|
# relayed to the live worker's exit (BridgeClient#request_exit; never
|
|
310
310
|
# wakes one). client_id is passed on only when it is an attached TUI's
|
|
311
311
|
# (tui:…): any other is dropped, and then every stream counts as a UI,
|
|
312
|
-
# which only keeps the worker up.
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
# worker_timeout.
|
|
312
|
+
# which only keeps the worker up. stop: true means the ask's "stop them
|
|
313
|
+
# and exit". 200 the Bridge's {status: exiting, session_id, discard,
|
|
314
|
+
# stopped}; 409 held with its {status, reason, tasks, hint, holds};
|
|
315
|
+
# 503 not_live, 501 not_supported (an older worker), 504 worker_timeout.
|
|
316
316
|
def handle_exit(req, id)
|
|
317
317
|
body = body_hash(req) or return refusal(400, "invalid_json", "invalid JSON body")
|
|
318
318
|
@session_class.load(id, state_dir: default_state_dir)
|
|
319
319
|
client_id = body["client_id"] if body["client_id"].is_a?(String) && body["client_id"].start_with?(ClientId::TUI_PREFIX)
|
|
320
320
|
delete = body["delete"] == true
|
|
321
|
+
options = { delete: delete }
|
|
322
|
+
options[:stop] = true if body["stop"] == "all"
|
|
323
|
+
options[:ask] = false if body["ask"] == false
|
|
321
324
|
|
|
322
|
-
request = ->(client) { client.request_exit(client_id: client_id,
|
|
325
|
+
request = ->(client) { client.request_exit(client_id: client_id, **options) }
|
|
323
326
|
with_message(relay(id, bridge_client(id), request, what: "the worker was not asked to exit", cant: "exit from here") do |reply|
|
|
324
327
|
case reply.status
|
|
325
328
|
when 200 then json_response(200, reply.json || { status: "exiting", session_id: id })
|