samagotchi 0.103.0 → 0.104.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 +54 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -24
- data/docs/cli.md +86 -12
- data/docs/guardrails.md +2 -1
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +4 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +153 -27
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/bridge.rb +7 -3
- 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/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +16 -6
- 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/launch_mode.rb +4 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/participants.rb +122 -0
- 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 +56 -1
- 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/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +174 -0
- 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 +83 -20
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
- 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/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -74
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +120 -4
- data/lib/samagotchi/worker.rb +26 -5
- metadata +10 -1
|
@@ -36,6 +36,9 @@ module Samagotchi
|
|
|
36
36
|
# No byte for this long on the stream (the Bridge pings every 15 s): a
|
|
37
37
|
# half-open connection (Wi-Fi gone, the laptop asleep), reconnected.
|
|
38
38
|
STREAM_IDLE_TIMEOUT = 40.0
|
|
39
|
+
# A read a join waits on (a turn's saved records, the children
|
|
40
|
+
# counts): a slow chi web can't hold the join up for long (L9).
|
|
41
|
+
JOIN_READ_TIMEOUT = 5
|
|
39
42
|
# chi web answers no live worker for the session (Mapping).
|
|
40
43
|
NO_WORKER = "no live worker for session %s on %s"
|
|
41
44
|
|
|
@@ -44,7 +47,7 @@ module Samagotchi
|
|
|
44
47
|
module_function
|
|
45
48
|
|
|
46
49
|
# @param route [Symbol] :turn, :command, :answer, :dismiss,
|
|
47
|
-
# :dequeue, :cancel
|
|
50
|
+
# :dequeue, :cancel, :exit, :recap, :stats, :children, :records
|
|
48
51
|
# @param reply [HTTPTransport::Reply]
|
|
49
52
|
# @param session_id [String]
|
|
50
53
|
# @param remote [String] the remote's name
|
|
@@ -133,6 +136,51 @@ module Samagotchi
|
|
|
133
136
|
post(:cancel, "cancel", { reason: reason })
|
|
134
137
|
end
|
|
135
138
|
|
|
139
|
+
# POST …/exit: the worker asked to exit (chi web passes client_id on
|
|
140
|
+
# only when it is an attached TUI's).
|
|
141
|
+
# @return [BridgeClient::Response] 200 {status: exiting, discard}, 409
|
|
142
|
+
# held {reason, tasks}, 408 late, 404 an older worker's
|
|
143
|
+
def request_exit(client_id:, delete: false)
|
|
144
|
+
body = { client_id: client_id }
|
|
145
|
+
body[:delete] = true if delete
|
|
146
|
+
post(:exit, "exit", body)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# POST …/recap: the saved recap, and a new one asked for.
|
|
150
|
+
# @return [BridgeClient::Response] 200 {enabled, saved, request, min_user_turns, session_off}
|
|
151
|
+
def request_recap = post(:recap, "recap", {})
|
|
152
|
+
|
|
153
|
+
# GET …/stats: the worker's metrics (its state's, for an older
|
|
154
|
+
# worker), nil when it didn't give them.
|
|
155
|
+
# @return [Hash, nil]
|
|
156
|
+
# @raise [Unreachable]
|
|
157
|
+
def stats
|
|
158
|
+
response = get(:stats, "stats", read_timeout: READ_TIMEOUT)
|
|
159
|
+
response.status == 200 ? response.json&.dig("metrics") : nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# GET …/children/counts: the session's delegates' counts there.
|
|
163
|
+
# @return [Hash] {running, waiting, unreported}
|
|
164
|
+
# @raise [Unreachable] chi web couldn't answer (or the session is gone)
|
|
165
|
+
def children_counts
|
|
166
|
+
response = get(:children, "children/counts", read_timeout: JOIN_READ_TIMEOUT)
|
|
167
|
+
raise Unreachable, "children counts: chi web answered #{response.status}" unless response.status == 200
|
|
168
|
+
|
|
169
|
+
response.json
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# GET …/turns/:turn_id/records: a turn's saved tool records and turn
|
|
173
|
+
# record there.
|
|
174
|
+
# @return [Hash] {tool_records: [...], turn_record: {...} | nil}
|
|
175
|
+
# @raise [Unreachable]
|
|
176
|
+
def turn_records(turn_id)
|
|
177
|
+
response = get(:records, "turns/#{URI.encode_www_form_component(turn_id.to_s)}/records",
|
|
178
|
+
read_timeout: JOIN_READ_TIMEOUT)
|
|
179
|
+
raise Unreachable, "turn records: chi web answered #{response.status}" unless response.status == 200
|
|
180
|
+
|
|
181
|
+
response.json
|
|
182
|
+
end
|
|
183
|
+
|
|
136
184
|
# Follow the session's events through chi web (see EventStream): with
|
|
137
185
|
# epoch_watch, announce_retries and STREAM_IDLE_TIMEOUT.
|
|
138
186
|
# @return [BridgeClient::EventStream] started
|
|
@@ -172,6 +220,13 @@ module Samagotchi
|
|
|
172
220
|
raise Unreachable, e.message
|
|
173
221
|
end
|
|
174
222
|
|
|
223
|
+
def get(route, path, read_timeout:)
|
|
224
|
+
reply = @remote.route_request(:get, "/api/sessions/#{@session_id}/#{path}", read_timeout: read_timeout)
|
|
225
|
+
Mapping.response(route, reply, session_id: @session_id, remote: @remote.name)
|
|
226
|
+
rescue RemoteFailed => e
|
|
227
|
+
raise Unreachable, e.message
|
|
228
|
+
end
|
|
229
|
+
|
|
175
230
|
def refused(reason)
|
|
176
231
|
Log.warn(:attached, "stream_refused", reason: reason, remote: @remote.name)
|
|
177
232
|
nil
|
|
@@ -28,6 +28,47 @@ module Samagotchi
|
|
|
28
28
|
# --remote: update chi there (chi update)").
|
|
29
29
|
class RemoteTooOld < RemoteFailed; end
|
|
30
30
|
|
|
31
|
+
# A remote's chi web refused this machine's token (401), or answers its
|
|
32
|
+
# LAN IP only (403 forbidden): Remote.access_error's words.
|
|
33
|
+
class AuthRefused < RemoteFailed; end
|
|
34
|
+
|
|
35
|
+
# The errors a participant's token meets (docs/internals/participants.md),
|
|
36
|
+
# one message each, built from the remote's label ("remote studio
|
|
37
|
+
# (http://…)"). chi web's code for each is its CODE.
|
|
38
|
+
|
|
39
|
+
# 401 token_revoked: the owner revoked the grant, or archived or
|
|
40
|
+
# deleted its session.
|
|
41
|
+
class TokenRevoked < AuthRefused
|
|
42
|
+
CODE = "token_revoked"
|
|
43
|
+
def initialize(label) = super("#{label}: this review link was revoked by the session's owner")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 401 token_expired: the grant's time ran out.
|
|
47
|
+
class TokenExpired < AuthRefused
|
|
48
|
+
CODE = "token_expired"
|
|
49
|
+
def initialize(label) = super("#{label}: this review link expired: ask the session's owner for a new one")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 403 not_permitted: a participant's token on a route only the session's
|
|
53
|
+
# owner may use.
|
|
54
|
+
class NotPermitted < RemoteFailed
|
|
55
|
+
CODE = "not_permitted"
|
|
56
|
+
def initialize(label) = super("#{label}: this token is a participant's: only the session's owner can do that")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 429 too_many_turns (P3b): the participant's turns already wait in the
|
|
60
|
+
# queue up to their limit.
|
|
61
|
+
class TooManyTurns < RemoteFailed
|
|
62
|
+
CODE = "too_many_turns"
|
|
63
|
+
def initialize(label) = super("#{label}: this participant has too many turns waiting: wait for one to finish")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 429 cap_reached (P3b): the participant's grant spent its cap.
|
|
67
|
+
class CapReached < RemoteFailed
|
|
68
|
+
CODE = "cap_reached"
|
|
69
|
+
def initialize(label) = super("#{label}: this participant's spending cap is reached: ask the session's owner to raise it")
|
|
70
|
+
end
|
|
71
|
+
|
|
31
72
|
# A remote's chi web refused a request for a reason this machine checks
|
|
32
73
|
# itself before sending (a parent's /continue yes, a loosening
|
|
33
74
|
# /parent-answers, images to a model that can't see them): +message+
|
|
@@ -47,11 +88,30 @@ module Samagotchi
|
|
|
47
88
|
# exits 2 with its message.
|
|
48
89
|
class BadRequest < Refused; end
|
|
49
90
|
|
|
91
|
+
# One participant's grant as chi share ls shows it
|
|
92
|
+
# (Web::Grants::Grant#public_h): never its token or digest. +state+
|
|
93
|
+
# "active", "revoked" or "expired".
|
|
94
|
+
ParticipantView = Data.define(:id, :session_id, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
|
|
95
|
+
:revoked_by, :last_used_at, :state) do
|
|
96
|
+
def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key] || hash[key.to_s]] })
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# What chi share made: the participant, their token and the link that
|
|
100
|
+
# carries it (both shown this once: chi keeps only a digest), and
|
|
101
|
+
# whether that chi web listens on the LAN (+lan+ false: the link works
|
|
102
|
+
# on that machine only).
|
|
103
|
+
Invitation = Data.define(:participant, :token, :link, :lan) do
|
|
104
|
+
def inspect = "#<Invitation #{participant.id}>"
|
|
105
|
+
alias_method :to_s, :inspect
|
|
106
|
+
end
|
|
107
|
+
|
|
50
108
|
# A session to create (chi send --new): a new one, or with +continues+
|
|
51
109
|
# the next link of that session's chain (which brings its own folder,
|
|
52
110
|
# LLM context, thinking, toolkit and settings: the rest is nil then).
|
|
53
111
|
# +preview+ set: the session starts idle with it as its preview, and
|
|
54
|
-
# +prompt+ (nil) goes in later as a turn (images, a session command)
|
|
112
|
+
# +prompt+ (nil) goes in later as a turn (images, a session command);
|
|
113
|
+
# +idle+ alone: it starts idle with no preview (a remote attach's new
|
|
114
|
+
# session, whose -p goes in once attached).
|
|
55
115
|
# +setup+ is a SessionSetup for Local, a SetupWords for Remote (the
|
|
56
116
|
# flags as typed: the server checks them against its own config and
|
|
57
117
|
# bundles). +client_id+ (chi send's, or chi answer's for a parent's
|
|
@@ -59,9 +119,9 @@ module Samagotchi
|
|
|
59
119
|
# which checks the first line and the model as chi send checks them
|
|
60
120
|
# here (Local checks before it creates).
|
|
61
121
|
NewSession = Data.define(:prompt, :preview, :continues, :working_directory, :model_name, :setup, :client_id,
|
|
62
|
-
:images) do
|
|
122
|
+
:images, :idle) do
|
|
63
123
|
def initialize(prompt:, preview: nil, continues: nil, working_directory: nil, model_name: nil, setup: nil,
|
|
64
|
-
client_id: nil, images: false) = super
|
|
124
|
+
client_id: nil, images: false, idle: false) = super
|
|
65
125
|
end
|
|
66
126
|
|
|
67
127
|
# chi send --new's setup flags as typed, for a Remote: +llm_context+
|
|
@@ -94,6 +154,10 @@ module Samagotchi
|
|
|
94
154
|
# its OwnerLock::Owner (a worker, a chi REPL, nil).
|
|
95
155
|
WorkerState = Data.define(:sidecar, :stopped, :owner) do
|
|
96
156
|
def live? = !sidecar.nil?
|
|
157
|
+
|
|
158
|
+
# No Bridge, and the owner is a worker leaving (writing its recap on
|
|
159
|
+
# leave): a wake waits for it (Local#wake).
|
|
160
|
+
def leaving? = !live? && !!owner&.leaving?
|
|
97
161
|
end
|
|
98
162
|
|
|
99
163
|
# What a wake for an attach did (Local#wake, chi web's POST …/wake):
|
|
@@ -101,6 +165,24 @@ module Samagotchi
|
|
|
101
165
|
# worker's (nil: its Bridge didn't come within the wait).
|
|
102
166
|
Woken = Data.define(:woke, :sidecar)
|
|
103
167
|
|
|
168
|
+
# A remote attach's wake (Remote#attach_wake, chi web's POST …/wake):
|
|
169
|
+
# +worker+ the live worker as chi web names it ({"epoch", "started_at",
|
|
170
|
+
# "version", "features"}, string keys: Remote#attach_client's), and
|
|
171
|
+
# what the TUI reads of that machine: its config's +default_model+,
|
|
172
|
+
# the session's parent +strategy+ (Guardrails::ParentPolicy::Strategy),
|
|
173
|
+
# the newest chi +installed+ there.
|
|
174
|
+
WakeReply = Data.define(:woke, :worker, :default_model, :strategy, :installed)
|
|
175
|
+
|
|
176
|
+
# A remote session's worker now (Remote#attach_worker, chi web's GET
|
|
177
|
+
# …/worker): +live+, +stopped+ (its stop marker), +worker+ as in
|
|
178
|
+
# WakeReply (nil: none runs), the parent +strategy+ read now, and that
|
|
179
|
+
# machine's +default_model+ (nil from a chi web before it sent one).
|
|
180
|
+
# +leaving+: its worker is leaving (WorkerState#leaving?), for up to
|
|
181
|
+
# +leave_seconds+ more there (nil from a chi web before it sent them).
|
|
182
|
+
WorkerReply = Data.define(:live, :stopped, :worker, :strategy, :default_model, :leaving, :leave_seconds) do
|
|
183
|
+
def initialize(live:, stopped:, worker:, strategy:, default_model: nil, leaving: false, leave_seconds: nil) = super
|
|
184
|
+
end
|
|
185
|
+
|
|
104
186
|
# What chi send --wait and chi answer read before anything is sent, so
|
|
105
187
|
# neither an older reply nor a turn that ends before the first look is
|
|
106
188
|
# taken for the answer (ReplyWait's cursor:, baseline:, turn:).
|
|
@@ -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
|
|
|
@@ -389,7 +389,7 @@ module Samagotchi
|
|
|
389
389
|
ok = ids.uniq.map do |given|
|
|
390
390
|
begin
|
|
391
391
|
id = @access.resolve_id(given)
|
|
392
|
-
released = @access.stop(id, wait:
|
|
392
|
+
released = @access.stop(id, wait: Samagotchi::SessionManager::STOP_WAIT)
|
|
393
393
|
rescue Samagotchi::SessionManager::OwnedByTUI => e
|
|
394
394
|
@stdout.flush
|
|
395
395
|
@stderr.puts e.message
|
|
@@ -399,13 +399,13 @@ module Samagotchi
|
|
|
399
399
|
@stderr.puts e.message
|
|
400
400
|
next false
|
|
401
401
|
end
|
|
402
|
-
#
|
|
402
|
+
# chi --resume ID, or chi --remote NAME --resume ID.
|
|
403
403
|
resume = @access.invocation.resume(id)
|
|
404
404
|
if released
|
|
405
|
-
@stdout.puts "Stopped session #{id}
|
|
405
|
+
@stdout.puts "Stopped session #{id}. #{resume} starts a fresh worker."
|
|
406
406
|
else
|
|
407
407
|
@stdout.flush
|
|
408
|
-
@stderr.puts "Stopped session #{id}, but its worker is still shutting down
|
|
408
|
+
@stderr.puts "Stopped session #{id}, but its worker is still shutting down; try #{resume} in a moment."
|
|
409
409
|
end
|
|
410
410
|
true
|
|
411
411
|
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
|
|
@@ -107,13 +107,15 @@ module Samagotchi
|
|
|
107
107
|
close_surface(surface)
|
|
108
108
|
end
|
|
109
109
|
if ended == :unanswered && attached.unanswered
|
|
110
|
-
# A link's strategy is read where the session is, its
|
|
111
|
-
# spelled its way.
|
|
110
|
+
# A link's strategy is read where the session is, now (L4), its
|
|
111
|
+
# commands spelled its way.
|
|
112
112
|
where = connector ? { strategy: link.parent_strategy, via: link.via } : {}
|
|
113
113
|
report_unanswered(attached.unanswered, session_id: client.session_id, state_dir: state_dir, **where)
|
|
114
114
|
end
|
|
115
115
|
report_ended(ended)
|
|
116
116
|
ended
|
|
117
|
+
ensure
|
|
118
|
+
link&.close
|
|
117
119
|
end
|
|
118
120
|
|
|
119
121
|
# This TUI's client id: tui:PID, or the connector's own (a remote one
|
|
@@ -193,6 +195,7 @@ module Samagotchi
|
|
|
193
195
|
resume = attach
|
|
194
196
|
end
|
|
195
197
|
|
|
198
|
+
await_leaving_worker(resume, sd, err) if resume
|
|
196
199
|
session = if resume
|
|
197
200
|
SessionManager.resume_session(resume, state_dir: sd)
|
|
198
201
|
else
|
|
@@ -206,12 +209,22 @@ module Samagotchi
|
|
|
206
209
|
end
|
|
207
210
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
208
211
|
client || raise(Error, format(LaunchMode::BRIDGE_LATE, session.id))
|
|
209
|
-
rescue SessionManager::OwnedByTUI
|
|
210
|
-
raise Error,
|
|
212
|
+
rescue SessionManager::OwnedByTUI => e
|
|
213
|
+
raise Error, e.message
|
|
211
214
|
rescue ArgumentError => e
|
|
212
215
|
raise Error, e.message
|
|
213
216
|
end
|
|
214
217
|
|
|
218
|
+
# The session's previous worker may still be leaving: its Bridge
|
|
219
|
+
# closed, the lock held while it writes its recap on leave (a resume
|
|
220
|
+
# now would start nothing, the lock taken). Wait for it to let go
|
|
221
|
+
# (OwnerLock.await_release), with one line saying so; any other owner
|
|
222
|
+
# is left to resume_session as before.
|
|
223
|
+
def await_leaving_worker(session_id, state_dir, err)
|
|
224
|
+
OwnerLock.await_release(Session.session_dir(session_id, state_dir: state_dir),
|
|
225
|
+
on_wait: ->(_owner) { err.puts(LaunchMode::RECAP_WAIT) })
|
|
226
|
+
end
|
|
227
|
+
|
|
215
228
|
# --model as typed: spawn_session stores the resolved ref and this name.
|
|
216
229
|
def model_ref(model)
|
|
217
230
|
ModelProfile.required_model_name(model)
|