samagotchi 0.103.0 → 0.105.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 +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +97 -21
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +15 -1
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +22 -1
- data/docs/internals/participants.md +167 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/internals/working-set.md +4 -3
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +89 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
- data/lib/samagotchi/bridge.rb +17 -10
- data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +13 -16
- data/lib/samagotchi/config.rb +1 -1
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +52 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +8 -1
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +9 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/run_options.rb +14 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +8 -4
- data/lib/samagotchi/session_access/contract.rb +42 -4
- data/lib/samagotchi/session_access/dispatch.rb +29 -10
- data/lib/samagotchi/session_access/local.rb +52 -1
- data/lib/samagotchi/session_access/remote.rb +112 -16
- data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
- data/lib/samagotchi/session_access.rb +85 -3
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +21 -5
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +14 -11
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/steer.rb +83 -10
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
- data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
- data/lib/samagotchi/terminal_ui.rb +4 -1
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +303 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/index.html +6 -5
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +1 -1
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +44 -11
- data/lib/samagotchi/worker_inbound.rb +5 -2
- metadata +12 -1
|
@@ -170,7 +170,7 @@ module Samagotchi
|
|
|
170
170
|
private_class_method def self.discard_failed_link(id, state_dir)
|
|
171
171
|
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
172
172
|
if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
|
|
173
|
-
!SessionManager.stop_session(orphan, state_dir: state_dir, wait:
|
|
173
|
+
!SessionManager.stop_session(orphan, state_dir: state_dir, wait: SessionManager::STOP_WAIT)
|
|
174
174
|
raise SessionManager::DeleteRefused.new(orphan, :still_stopping)
|
|
175
175
|
end
|
|
176
176
|
|
|
@@ -45,18 +45,23 @@ module Samagotchi
|
|
|
45
45
|
# "cut" or "queue"
|
|
46
46
|
# @param images [Array<Hash>] image refs ({file:, name:}) in the
|
|
47
47
|
# session's images/
|
|
48
|
+
# @param author [String, nil] who wrote the message when it isn't the
|
|
49
|
+
# session's owner (Web::Principal#author); nil, the owner's, is never
|
|
50
|
+
# written, so an owner's file is as it always was
|
|
51
|
+
# @param now [Time] the time the file is named by (a spec's clock)
|
|
48
52
|
# @return [String, false] the input file's path, or false
|
|
49
53
|
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
|
|
50
|
-
delivery: nil)
|
|
54
|
+
delivery: nil, author: nil, now: Time.now)
|
|
51
55
|
images = Array(images)
|
|
52
56
|
input_dir = File.join(session_dir, INPUT_DIR)
|
|
53
57
|
FileUtils.mkdir_p(input_dir)
|
|
54
58
|
|
|
55
|
-
path = File.join(input_dir, "#{
|
|
59
|
+
path = File.join(input_dir, "#{now.strftime("%Y%m%d%H%M%S%9N")}.json")
|
|
56
60
|
record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
|
|
57
61
|
"no_interrupt" => (no_interrupt ? true : nil),
|
|
58
62
|
"delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
|
|
59
|
-
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) })
|
|
63
|
+
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }),
|
|
64
|
+
"author" => author }.compact
|
|
60
65
|
AtomicFile.write(path, JSON.generate(record))
|
|
61
66
|
path
|
|
62
67
|
rescue StandardError
|
|
@@ -67,9 +72,11 @@ module Samagotchi
|
|
|
67
72
|
# to the conversation between turns. It never starts a turn.
|
|
68
73
|
# @param source [String] where it came from ("cli", "slack", "session")
|
|
69
74
|
# @param from_session [String, nil] the sending session, for a peer's note
|
|
75
|
+
# @param now [Time] the time the file is named and stamped by (a spec's clock)
|
|
70
76
|
# @return [String] the note file's path
|
|
71
77
|
# @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
|
|
72
|
-
def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil
|
|
78
|
+
def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil,
|
|
79
|
+
now: Time.now)
|
|
73
80
|
body = checked_text(text)
|
|
74
81
|
sd = state_dir || Session.default_state_dir
|
|
75
82
|
notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
|
|
@@ -77,10 +84,10 @@ module Samagotchi
|
|
|
77
84
|
|
|
78
85
|
# The random part keeps two writers in one nanosecond apart; the
|
|
79
86
|
# timestamp keeps the names in arrival order.
|
|
80
|
-
name = "#{
|
|
87
|
+
name = "#{now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
|
|
81
88
|
path = File.join(notes_dir, name)
|
|
82
89
|
record = { "text" => body, "source" => source.to_s, "from_session" => from_session,
|
|
83
|
-
"from_cwd" => from_cwd, "created_at" =>
|
|
90
|
+
"from_cwd" => from_cwd, "created_at" => now.iso8601 }.compact
|
|
84
91
|
AtomicFile.write(path, JSON.generate(record))
|
|
85
92
|
path
|
|
86
93
|
end
|
|
@@ -166,10 +173,11 @@ module Samagotchi
|
|
|
166
173
|
# so an id'd name and a bare one of the same timestamp keep order);
|
|
167
174
|
# every reader globs *.txt.
|
|
168
175
|
# @param enqueued_id [String, nil] the turn's input id (LastTurn#enqueued_id)
|
|
169
|
-
|
|
176
|
+
# @param now [Time] the time the file is named by (a spec's clock)
|
|
177
|
+
def self.write_output(session_dir, response, enqueued_id: nil, now: Time.now)
|
|
170
178
|
output_dir = File.join(session_dir, OUTPUT_DIR)
|
|
171
179
|
FileUtils.mkdir_p(output_dir)
|
|
172
|
-
timestamp =
|
|
180
|
+
timestamp = now.strftime("%Y%m%d%H%M%S%9N")
|
|
173
181
|
AtomicFile.write(File.join(output_dir, "#{output_name(timestamp, enqueued_id)}.txt"), response.to_s)
|
|
174
182
|
end
|
|
175
183
|
|
|
@@ -229,7 +237,8 @@ module Samagotchi
|
|
|
229
237
|
data = JSON.parse(File.read(claimed_file).to_s)
|
|
230
238
|
return UNREADABLE_INPUT unless data.is_a?(Hash)
|
|
231
239
|
|
|
232
|
-
|
|
240
|
+
# author: only a file that names one (an owner's names none).
|
|
241
|
+
origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"], author: data["author"] }.compact
|
|
233
242
|
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
234
243
|
Input.new(prompt: data["prompt"].to_s, origin: origin.empty? ? nil : origin, no_interrupt: data["no_interrupt"] == true,
|
|
235
244
|
images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
|
|
@@ -69,6 +69,8 @@ module Samagotchi
|
|
|
69
69
|
autoload :SessionRetention, File.expand_path("session_retention", __dir__)
|
|
70
70
|
# session_continue requires this file.
|
|
71
71
|
autoload :SessionContinue, File.expand_path("session_continue", __dir__)
|
|
72
|
+
# Needed only on an archive or a delete (it loads Web::Grants).
|
|
73
|
+
autoload :Participants, File.expand_path("participants", __dir__)
|
|
72
74
|
|
|
73
75
|
class SessionManager
|
|
74
76
|
# Raised when the interactive TUI owns the session: it runs its own Engine
|
|
@@ -384,6 +386,8 @@ module Samagotchi
|
|
|
384
386
|
stopped = tree.select { |sid| owners[sid] }
|
|
385
387
|
stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
|
|
386
388
|
archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
|
|
389
|
+
# D8: an archive ends the review (unarchiving doesn't restore it).
|
|
390
|
+
Participants.end_sessions(archived, by: "archive", state_dir: sd)
|
|
387
391
|
{ id: id, archived: archived, stopped: stopped, discarded: discarded }
|
|
388
392
|
end
|
|
389
393
|
|
|
@@ -706,6 +710,10 @@ module Samagotchi
|
|
|
706
710
|
end
|
|
707
711
|
|
|
708
712
|
STOP_CANCEL_WAIT = 3.0
|
|
713
|
+
# How long a stop from the CLI (chi sessions stop, delete, a continue's
|
|
714
|
+
# orphan) waits for the worker to let go of the session, here or
|
|
715
|
+
# through chi web (POST …/stop's wait, capped at it).
|
|
716
|
+
STOP_WAIT = 10
|
|
709
717
|
# The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
|
|
710
718
|
# not the user's Stop, which only ends the turn ("user").
|
|
711
719
|
STOP_CANCEL_REASON = "session_stopped"
|
|
@@ -869,7 +877,7 @@ module Samagotchi
|
|
|
869
877
|
# @raise [OwnedByTUI] a chi REPL owns it (never stopped from here)
|
|
870
878
|
# @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
|
|
871
879
|
# outlived the wait
|
|
872
|
-
def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait:
|
|
880
|
+
def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: STOP_WAIT)
|
|
873
881
|
sd = state_dir || Session.default_state_dir
|
|
874
882
|
given = id_or_prefix.to_s
|
|
875
883
|
# Only a plain id: anything else could name a path outside the state dir.
|
|
@@ -897,6 +905,7 @@ module Samagotchi
|
|
|
897
905
|
end
|
|
898
906
|
PluginSessionState.remove(id)
|
|
899
907
|
removed.concat(ContextSources.remove_session(id, state_dir: sd))
|
|
908
|
+
Participants.end_sessions([id], by: "delete", state_dir: sd)
|
|
900
909
|
{ id: id, removed: removed, stopped: !owner.nil? }
|
|
901
910
|
end
|
|
902
911
|
|
|
@@ -960,7 +969,8 @@ module Samagotchi
|
|
|
960
969
|
# loop run in-process (specs) traps nothing.
|
|
961
970
|
signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
|
|
962
971
|
worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
|
|
963
|
-
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals
|
|
972
|
+
idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval, signals: signals,
|
|
973
|
+
owner_lock: @owner_lock)
|
|
964
974
|
result = begin
|
|
965
975
|
worker.run
|
|
966
976
|
rescue StandardError, ScriptError => e
|
|
@@ -1137,10 +1147,10 @@ module Samagotchi
|
|
|
1137
1147
|
# session's images/
|
|
1138
1148
|
# @return [String, false] the input file's path, or false.
|
|
1139
1149
|
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
1140
|
-
images: [], delivery: nil)
|
|
1150
|
+
images: [], delivery: nil, author: nil)
|
|
1141
1151
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1142
1152
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1143
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery)
|
|
1153
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
|
|
1144
1154
|
end
|
|
1145
1155
|
|
|
1146
1156
|
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
@@ -1202,6 +1212,10 @@ module Samagotchi
|
|
|
1202
1212
|
# @param no_interrupt [Boolean] the turn runs with the raised iteration
|
|
1203
1213
|
# limit (an attached TUI's --no-interrupt), through the Bridge or the
|
|
1204
1214
|
# file; sent only when true, so older callees see no new field
|
|
1215
|
+
# @param author [String, nil] who wrote it when it isn't the session's
|
|
1216
|
+
# owner (Web::Principal#author: chi web stamps it, never the body);
|
|
1217
|
+
# to the Bridge or the file only when given, so an owner's turn is
|
|
1218
|
+
# sent and saved as before
|
|
1205
1219
|
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
1206
1220
|
# stand-in (the web's specs); #session_owner is optional
|
|
1207
1221
|
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
@@ -1215,7 +1229,7 @@ module Samagotchi
|
|
|
1215
1229
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1216
1230
|
# @raise [ArgumentError] no such session
|
|
1217
1231
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
|
|
1218
|
-
state_dir: nil, manager: self, bridge: nil)
|
|
1232
|
+
author: nil, state_dir: nil, manager: self, bridge: nil)
|
|
1219
1233
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1220
1234
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
1221
1235
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
@@ -1230,6 +1244,7 @@ module Samagotchi
|
|
|
1230
1244
|
# An old worker ignores the field: it cuts anyway (its own default).
|
|
1231
1245
|
options[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1232
1246
|
options[:no_interrupt] = true if no_interrupt
|
|
1247
|
+
options[:author] = author if author
|
|
1233
1248
|
reply = client.post_turn(**options)
|
|
1234
1249
|
ack = reply.json
|
|
1235
1250
|
return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
|
|
@@ -1257,6 +1272,7 @@ module Samagotchi
|
|
|
1257
1272
|
input[:images] = images unless images.empty?
|
|
1258
1273
|
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1259
1274
|
input[:no_interrupt] = true if no_interrupt
|
|
1275
|
+
input[:author] = author if author
|
|
1260
1276
|
path = manager.write_turn_input(session_id, **input)
|
|
1261
1277
|
return DeliverResult.new(status: :failed) unless path
|
|
1262
1278
|
|
|
@@ -17,6 +17,7 @@ require_relative "session_metrics/cost"
|
|
|
17
17
|
require_relative "session_metrics/usage"
|
|
18
18
|
require_relative "session_metrics/side_totals"
|
|
19
19
|
require_relative "side_usage"
|
|
20
|
+
require_relative "steer"
|
|
20
21
|
|
|
21
22
|
module Samagotchi
|
|
22
23
|
# SessionMetrics is a persistent, error-isolated collector of per-session
|
|
@@ -174,6 +175,9 @@ module Samagotchi
|
|
|
174
175
|
# The turn the user started by answering the step-limit question with
|
|
175
176
|
# Continue (:turn_started's +resumed+): it resumed the run before it.
|
|
176
177
|
:resumed,
|
|
178
|
+
# Who wrote the turn when it wasn't the owner (:turn_started's
|
|
179
|
+
# origin author, "p:<id>"); its record carries it, the owner's none.
|
|
180
|
+
:author,
|
|
177
181
|
:id,
|
|
178
182
|
:started_at,
|
|
179
183
|
:started_monotonic,
|
|
@@ -695,6 +699,7 @@ module Samagotchi
|
|
|
695
699
|
gen_switch: false,
|
|
696
700
|
continue: event[:continue] ? true : false,
|
|
697
701
|
resumed: event[:resumed] ? true : false,
|
|
702
|
+
author: Steer.sender_author(event[:origin].is_a?(Hash) ? event[:origin][:author] : nil),
|
|
698
703
|
id: event[:turn_id] || SecureRandom.uuid,
|
|
699
704
|
started_at: now.iso8601(3),
|
|
700
705
|
started_monotonic: monotonic_time,
|
|
@@ -735,6 +740,7 @@ module Samagotchi
|
|
|
735
740
|
# from one written before the mark.
|
|
736
741
|
record[:continue] = @turn.continue
|
|
737
742
|
record[:resumed] = @turn.resumed
|
|
743
|
+
record[:author] = @turn.author if @turn.author
|
|
738
744
|
record.merge!(turn_token_fields(@turn))
|
|
739
745
|
@turn_records << record
|
|
740
746
|
count_turn(record)
|
|
@@ -751,7 +757,7 @@ module Samagotchi
|
|
|
751
757
|
{
|
|
752
758
|
id: @turn.id, status: "canceled", started_at: @turn.started_at, completed_at: now.iso8601(3),
|
|
753
759
|
duration_ms: elapsed_ms(@turn.started_monotonic), cancellation_reason: "worker_stopped",
|
|
754
|
-
continue: @turn.continue, resumed: @turn.resumed
|
|
760
|
+
continue: @turn.continue, resumed: @turn.resumed, **{ author: @turn.author }.compact
|
|
755
761
|
}.merge(turn_token_fields(@turn))
|
|
756
762
|
end
|
|
757
763
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Loaded only when a session is read: it brings Web::Grants (and Rack).
|
|
5
|
+
autoload :Participants, File.expand_path("participants", __dir__)
|
|
6
|
+
|
|
7
|
+
# What a session's Engine knows of its participants: participants.json
|
|
8
|
+
# (Participants.read), read at each turn's start (#refresh!), never
|
|
9
|
+
# written here. A session nobody was invited to has no sidecar: one
|
|
10
|
+
# File.exist? per turn. See docs/internals/participants.md (Attribution).
|
|
11
|
+
class SessionParticipants
|
|
12
|
+
# @param state_dir [#call] → the sessions' state dir
|
|
13
|
+
# @param clock [#call] → Time, what "still active" is measured at
|
|
14
|
+
def initialize(state_dir:, clock: -> { Time.now })
|
|
15
|
+
@state_dir = state_dir
|
|
16
|
+
@clock = clock
|
|
17
|
+
@session_id = nil
|
|
18
|
+
@entries = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Reads +session+'s sidecar again.
|
|
22
|
+
def refresh!(session)
|
|
23
|
+
@session_id = session&.id
|
|
24
|
+
@entries = read
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Whether anyone is still in the review (Participants::Entry#active?),
|
|
29
|
+
# as of the last #refresh!.
|
|
30
|
+
def active? = @entries.any? { |entry| entry.active?(@clock.call) }
|
|
31
|
+
|
|
32
|
+
# The name +author+ ("p:<id>") had when it was invited, nil for none
|
|
33
|
+
# (the owner, or a participant the sidecar doesn't name). A miss reads
|
|
34
|
+
# the sidecar again: someone invited after the turn began. +session+:
|
|
35
|
+
# the session it is asked for, when that may not be the last refreshed
|
|
36
|
+
# (the worker's running save, before the Engine's first turn).
|
|
37
|
+
def name_for(author, session: nil)
|
|
38
|
+
return nil unless Steer.participant_author?(author)
|
|
39
|
+
|
|
40
|
+
refresh!(session) if session && session.id != @session_id
|
|
41
|
+
id = author.to_s.delete_prefix("p:")
|
|
42
|
+
entry = @entries.find { |e| e.id == id } || (@entries = read).find { |e| e.id == id }
|
|
43
|
+
entry&.name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def read
|
|
49
|
+
return [] if @session_id.to_s.empty?
|
|
50
|
+
|
|
51
|
+
dir = Session.session_dir(@session_id, state_dir: @state_dir.call)
|
|
52
|
+
return [] unless File.exist?(File.join(dir, "participants.json"))
|
|
53
|
+
|
|
54
|
+
Participants.read(@session_id, state_dir: @state_dir.call)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -176,29 +176,32 @@ module Samagotchi
|
|
|
176
176
|
# project's. Machine-local (this cwd's project): remotely every
|
|
177
177
|
# project's (D4), and --scope=project is --cwd's project there (the
|
|
178
178
|
# server finds its root). The rows are the server's (list_summaries).
|
|
179
|
+
# The picker (chi note from a script): filters apply before the limit,
|
|
180
|
+
# and test runs stay out, unless this is one (SAMAGOTCHI_ENV=test, CI):
|
|
181
|
+
# then its own sessions are what it looks for. The plain listing shows
|
|
182
|
+
# them, marked [test], here and remotely.
|
|
183
|
+
picker = @opts[:live] || cwd || (format && format != "text")
|
|
184
|
+
include_tests = !picker || Samagotchi::Session.test_session_env?
|
|
179
185
|
if @access.remote?
|
|
180
186
|
return usage_error("--scope=project on a remote needs --cwd: a folder there") if scope == "project" && !cwd
|
|
181
187
|
|
|
182
188
|
project = scope == "project" ? cwd : nil
|
|
183
189
|
scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} on #{@access.name} (--scope=all: every project)" }
|
|
184
|
-
return list_summaries(project, scope_note)
|
|
190
|
+
return list_summaries(project, scope_note, include_tests: include_tests)
|
|
185
191
|
end
|
|
186
192
|
project = scope == "all" || cwd ? nil : Samagotchi::ProjectScope.root_for(Dir.pwd)
|
|
187
193
|
scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} (--scope=all: every project)" }
|
|
188
194
|
|
|
189
|
-
|
|
190
|
-
# and test runs stay out, unless this is one (SAMAGOTCHI_ENV=test, CI):
|
|
191
|
-
# then its own sessions are what it looks for.
|
|
192
|
-
return list_summaries(project, scope_note) if @opts[:live] || cwd || (format && format != "text")
|
|
195
|
+
return list_summaries(project, scope_note, include_tests: include_tests) if picker
|
|
193
196
|
|
|
194
197
|
list_plain(project, scope_note)
|
|
195
198
|
end
|
|
196
199
|
|
|
197
|
-
def list_summaries(project, scope_note)
|
|
200
|
+
def list_summaries(project, scope_note, include_tests:)
|
|
198
201
|
live, cwd, limit, include_archived, format, sort, order =
|
|
199
202
|
@opts.values_at(:live, :cwd, :limit, :include_archived, :format, :sort, :order)
|
|
200
203
|
summaries = @access.summaries(
|
|
201
|
-
live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests:
|
|
204
|
+
live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: include_tests,
|
|
202
205
|
project_root: project,
|
|
203
206
|
include_archived: include_archived, sort: sort, order: order
|
|
204
207
|
)
|
|
@@ -389,7 +392,7 @@ module Samagotchi
|
|
|
389
392
|
ok = ids.uniq.map do |given|
|
|
390
393
|
begin
|
|
391
394
|
id = @access.resolve_id(given)
|
|
392
|
-
released = @access.stop(id, wait:
|
|
395
|
+
released = @access.stop(id, wait: Samagotchi::SessionManager::STOP_WAIT)
|
|
393
396
|
rescue Samagotchi::SessionManager::OwnedByTUI => e
|
|
394
397
|
@stdout.flush
|
|
395
398
|
@stderr.puts e.message
|
|
@@ -399,13 +402,13 @@ module Samagotchi
|
|
|
399
402
|
@stderr.puts e.message
|
|
400
403
|
next false
|
|
401
404
|
end
|
|
402
|
-
#
|
|
405
|
+
# chi --resume ID, or chi --remote NAME --resume ID.
|
|
403
406
|
resume = @access.invocation.resume(id)
|
|
404
407
|
if released
|
|
405
|
-
@stdout.puts "Stopped session #{id}
|
|
408
|
+
@stdout.puts "Stopped session #{id}. #{resume} starts a fresh worker."
|
|
406
409
|
else
|
|
407
410
|
@stdout.flush
|
|
408
|
-
@stderr.puts "Stopped session #{id}, but its worker is still shutting down
|
|
411
|
+
@stderr.puts "Stopped session #{id}, but its worker is still shutting down; try #{resume} in a moment."
|
|
409
412
|
end
|
|
410
413
|
true
|
|
411
414
|
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "cli/command"
|
|
6
|
+
require_relative "cli/exit"
|
|
7
|
+
require_relative "cli/flags"
|
|
8
|
+
require_relative "session"
|
|
9
|
+
require_relative "session_access"
|
|
10
|
+
require_relative "session_access/local"
|
|
11
|
+
require_relative "web/grants"
|
|
12
|
+
require_relative "web/qr"
|
|
13
|
+
|
|
14
|
+
module Samagotchi
|
|
15
|
+
# `chi share`: invite a participant to review a session (the
|
|
16
|
+
# shared-sessions plan: a Web::Grants grant, one session, one person or
|
|
17
|
+
# agent, 7 days unless --expires says otherwise), list the participants,
|
|
18
|
+
# revoke them. Local or through chi --remote NAME (SessionAccess). The
|
|
19
|
+
# token is printed once, in the link; chi keeps only its digest. See
|
|
20
|
+
# docs/internals/participants.md.
|
|
21
|
+
class ShareCommand
|
|
22
|
+
include CLI::Command
|
|
23
|
+
|
|
24
|
+
USAGE = <<~TEXT
|
|
25
|
+
Usage: chi share ID --name NAME [--agent] [--expires 3d]
|
|
26
|
+
chi share ls [ID] [--format text|json]
|
|
27
|
+
chi share revoke ID [PARTICIPANT] | --all
|
|
28
|
+
ID --name NAME invite NAME to review session ID: prints the link they open (chi web on
|
|
29
|
+
the LAN, web.host: lan) and its QR code at a terminal. The link is shown
|
|
30
|
+
once; chi keeps only a digest of its token.
|
|
31
|
+
--agent the participant is an agent (the same link, used as a Bearer token)
|
|
32
|
+
--expires N Nh or Nd, 1h to 30d (default 7d)
|
|
33
|
+
ls [ID] the participants of ID, or of every session (never a token)
|
|
34
|
+
revoke ID [P] end participant P's link, or every link to ID; --all every session's
|
|
35
|
+
Archiving or deleting a session revokes its links. A participant sees the session's title
|
|
36
|
+
and status; comments come in a later chi.
|
|
37
|
+
TEXT
|
|
38
|
+
|
|
39
|
+
SUBCOMMANDS = %w[ls revoke].freeze
|
|
40
|
+
|
|
41
|
+
# The invite's flags (chi share ID …), and ls's and revoke's.
|
|
42
|
+
FLAGS = CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
43
|
+
f.value "--name"
|
|
44
|
+
f.switch "--agent"
|
|
45
|
+
f.value "--expires"
|
|
46
|
+
end
|
|
47
|
+
SUB_FLAGS = {
|
|
48
|
+
"ls" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--format" },
|
|
49
|
+
"revoke" => CLI::Flags.new(help: HELP_WORDS) { |f| f.switch "--all" }
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
EXPIRES_RE = /\A(\d+)([hd])\z/
|
|
53
|
+
TIME_FORMAT = "%m-%d %H:%M"
|
|
54
|
+
|
|
55
|
+
# @param access [SessionAccess::Local, SessionAccess::Remote, nil]
|
|
56
|
+
# where the sessions are; default this machine's, in +state_dir+
|
|
57
|
+
# @param tty [Boolean, nil] print the QR code (default: stdout is a TTY)
|
|
58
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil, tty: nil)
|
|
59
|
+
@argv = argv.dup
|
|
60
|
+
@stdin = stdin
|
|
61
|
+
@stdout = stdout
|
|
62
|
+
@stderr = stderr
|
|
63
|
+
@access = access || SessionAccess::Local.new(state_dir: state_dir)
|
|
64
|
+
@tty = tty.nil? ? @stdout.respond_to?(:tty?) && @stdout.tty? : tty
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
|
|
68
|
+
def run
|
|
69
|
+
first = @argv[0]
|
|
70
|
+
if first.nil? || HELP_WORDS.include?(first)
|
|
71
|
+
@stdout.puts(USAGE)
|
|
72
|
+
return 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@sub = SUBCOMMANDS.include?(first) ? first : nil
|
|
76
|
+
parsed = parse_flags(@sub ? SUB_FLAGS.fetch(@sub) : FLAGS, @sub ? @argv[1..] : @argv)
|
|
77
|
+
return parsed if parsed.is_a?(Integer)
|
|
78
|
+
|
|
79
|
+
send(:"run_#{@sub || "invite"}", parsed.options, parsed.args)
|
|
80
|
+
rescue SessionAccess::BadRequest => e
|
|
81
|
+
usage_error(e.message)
|
|
82
|
+
rescue ArgumentError => e
|
|
83
|
+
error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : e.message.sub(/\ASession not found: /, "no session ")}")
|
|
84
|
+
CLI::Exit::FAILED
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def command_name = @sub ? "chi share #{@sub}" : "chi share"
|
|
90
|
+
def usage_on_error = nil
|
|
91
|
+
|
|
92
|
+
def run_invite(options, args)
|
|
93
|
+
return usage_error("give one session ID") unless args.size == 1
|
|
94
|
+
return usage_error("--name NAME: who the link is for") if options[:name].to_s.strip.empty?
|
|
95
|
+
|
|
96
|
+
ttl = expires(options[:expires])
|
|
97
|
+
return usage_error("--expires takes Nh or Nd, 1h to 30d (e.g. 3d)") if ttl == false
|
|
98
|
+
|
|
99
|
+
id = @access.resolve(args.first)
|
|
100
|
+
invitation = @access.invite(id, name: options[:name], kind: options[:agent] ? "agent" : "human", ttl: ttl)
|
|
101
|
+
print_invitation(id, invitation)
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run_ls(options, args)
|
|
106
|
+
return usage_error("ls takes at most one session ID") if args.size > 1
|
|
107
|
+
|
|
108
|
+
format = options[:format]
|
|
109
|
+
return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
|
|
110
|
+
|
|
111
|
+
id = args.first && @access.resolve(args.first)
|
|
112
|
+
rows = @access.participants(id)
|
|
113
|
+
if format == "json"
|
|
114
|
+
@stdout.puts(JSON.generate(rows.map(&:to_h)))
|
|
115
|
+
elsif rows.empty?
|
|
116
|
+
@stdout.puts(id ? "no participants in #{id[0, 8]} (chi share #{id[0, 8]} --name NAME invites one)" : "no participants")
|
|
117
|
+
else
|
|
118
|
+
print_table(rows)
|
|
119
|
+
end
|
|
120
|
+
0
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def run_revoke(options, args)
|
|
124
|
+
if options[:all]
|
|
125
|
+
return usage_error("--all takes no session ID") unless args.empty?
|
|
126
|
+
elsif !args.size.between?(1, 2)
|
|
127
|
+
return usage_error("give a session ID (and a participant), or --all")
|
|
128
|
+
end
|
|
129
|
+
participant = args[1]
|
|
130
|
+
return usage_error("not a participant: #{participant} (chi share ls lists them)") if participant && !Web::Grants::ID_RE.match?(participant)
|
|
131
|
+
|
|
132
|
+
id = args.first && @access.resolve(args.first)
|
|
133
|
+
revoked = @access.revoke_participants(id, participant: participant)
|
|
134
|
+
revoked.each { |row| @stdout.puts("#{row.id} #{row.name} revoked") }
|
|
135
|
+
@stdout.puts("nothing to revoke") if revoked.empty?
|
|
136
|
+
0
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Seconds, nil for the default, false for words that aren't Nh/Nd.
|
|
140
|
+
def expires(words)
|
|
141
|
+
return nil if words.nil?
|
|
142
|
+
|
|
143
|
+
match = EXPIRES_RE.match(words.to_s.strip) or return false
|
|
144
|
+
seconds = match[1].to_i * (match[2] == "h" ? 3600 : Web::Grants::DAY)
|
|
145
|
+
seconds.between?(3600, Web::Grants::MAX_TTL) ? seconds : false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def print_invitation(id, invitation)
|
|
149
|
+
row = invitation.participant
|
|
150
|
+
@stdout.puts("#{row.id} #{row.name} (#{row.kind}, #{row.role}) · session #{id[0, 8]} · expires #{local(row.expires_at, "%Y-%m-%d %H:%M")}")
|
|
151
|
+
@stdout.puts(invitation.link)
|
|
152
|
+
@stdout.puts("(shown once: chi keeps only its digest; chi share revoke #{id[0, 8]} #{row.id} ends it)")
|
|
153
|
+
unless invitation.lan
|
|
154
|
+
@stdout.puts("chi web isn't on the LAN there (web.host: lan): this link opens on that machine only")
|
|
155
|
+
end
|
|
156
|
+
@stdout.puts(Web::QR.lines(invitation.link)) if @tty
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
COLUMNS = ["SESSION", "PARTICIPANT", "NAME", "KIND", "ROLE", "CREATED", "EXPIRES", "LAST USED", "STATE"].freeze
|
|
160
|
+
|
|
161
|
+
def print_table(rows)
|
|
162
|
+
cells = rows.map do |row|
|
|
163
|
+
[row.session_id[0, 8], row.id, row.name, row.kind, row.role, local(row.created_at), local(row.expires_at),
|
|
164
|
+
row.last_used_at ? local(row.last_used_at) : "-", row.state]
|
|
165
|
+
end
|
|
166
|
+
widths = COLUMNS.each_index.map { |i| ([COLUMNS[i]] + cells.map { |c| c[i] }).map(&:length).max }
|
|
167
|
+
[COLUMNS, *cells].each do |line|
|
|
168
|
+
@stdout.puts(line.each_with_index.map { |cell, i| i == line.size - 1 ? cell : cell.ljust(widths[i]) }.join(" "))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def local(stamp, format = TIME_FORMAT) = Time.iso8601(stamp.to_s).localtime.strftime(format)
|
|
173
|
+
end
|
|
174
|
+
end
|