samagotchi 0.121.0 → 0.121.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +3 -1
  12. data/docs/internals/remote-sessions.md +15 -11
  13. data/docs/internals/web-push.md +22 -11
  14. data/docs/plugins.md +6 -2
  15. data/docs/sessions.md +15 -0
  16. data/docs/testing.md +41 -1
  17. data/lib/samagotchi/answer_command.rb +1 -1
  18. data/lib/samagotchi/bridge.rb +8 -8
  19. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  20. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  21. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  22. data/lib/samagotchi/bridge_client.rb +52 -65
  23. data/lib/samagotchi/bridge_secret.rb +9 -7
  24. data/lib/samagotchi/bundle_command/check.rb +27 -2
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  27. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  29. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  31. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  32. data/lib/samagotchi/child_move.rb +5 -7
  33. data/lib/samagotchi/children_status.rb +2 -1
  34. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  36. data/lib/samagotchi/guardrails_command.rb +41 -6
  37. data/lib/samagotchi/image_store.rb +6 -3
  38. data/lib/samagotchi/interrupted_turn.rb +6 -3
  39. data/lib/samagotchi/live_versions.rb +6 -7
  40. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  41. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  42. data/lib/samagotchi/plugin/api.rb +8 -5
  43. data/lib/samagotchi/plugin/sessions.rb +13 -4
  44. data/lib/samagotchi/push_command.rb +1 -1
  45. data/lib/samagotchi/relay_verifier.rb +2 -3
  46. data/lib/samagotchi/relay_watcher.rb +2 -2
  47. data/lib/samagotchi/remote_attach_command.rb +1 -1
  48. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  49. data/lib/samagotchi/reply_wait.rb +4 -3
  50. data/lib/samagotchi/review_command.rb +1 -1
  51. data/lib/samagotchi/run_options.rb +1 -0
  52. data/lib/samagotchi/session.rb +4 -0
  53. data/lib/samagotchi/session_access/local.rb +18 -8
  54. data/lib/samagotchi/session_access/remote.rb +5 -0
  55. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  56. data/lib/samagotchi/session_access.rb +21 -9
  57. data/lib/samagotchi/session_continue.rb +2 -3
  58. data/lib/samagotchi/session_manager.rb +118 -28
  59. data/lib/samagotchi/sessions_command.rb +10 -5
  60. data/lib/samagotchi/system_one/client.rb +6 -1
  61. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  62. data/lib/samagotchi/system_one/log.rb +21 -6
  63. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  66. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  67. data/lib/samagotchi/toolkit.rb +68 -2
  68. data/lib/samagotchi/tools/delegate.rb +1 -1
  69. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  70. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +123 -65
  73. data/lib/samagotchi/web/participant_access.rb +26 -7
  74. data/lib/samagotchi/web/participant_review.rb +2 -1
  75. data/lib/samagotchi/web/participant_view.rb +7 -3
  76. data/lib/samagotchi/web/participants_admin.rb +13 -0
  77. data/lib/samagotchi/web/public/app.js +1 -1
  78. data/lib/samagotchi/web/public/notifications.js +21 -1
  79. data/lib/samagotchi/web/public/participant.html +3 -2
  80. data/lib/samagotchi/web/public/participant.js +23 -9
  81. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  82. data/lib/samagotchi/web/public/push.js +4 -3
  83. data/lib/samagotchi/web/public/push_devices.js +17 -3
  84. data/lib/samagotchi/web/public/review_history.js +14 -2
  85. data/lib/samagotchi/web/public/review_start.js +1 -3
  86. data/lib/samagotchi/web/public/session_actions.js +14 -2
  87. data/lib/samagotchi/web/public/storage.js +16 -0
  88. data/lib/samagotchi/web/push/client.rb +5 -0
  89. data/lib/samagotchi/web/push/store.rb +16 -3
  90. data/lib/samagotchi/web/push_routes.rb +3 -2
  91. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  92. data/lib/samagotchi/web/session_create.rb +4 -4
  93. data/lib/samagotchi/web/session_hub.rb +6 -4
  94. data/lib/samagotchi/web/session_summary.rb +8 -16
  95. data/lib/samagotchi/worker.rb +1 -1
  96. data/lib/samagotchi/worker_link.rb +213 -0
  97. data/lib/samagotchi/worker_sidecar.rb +15 -37
  98. metadata +3 -1
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "owner_lock"
6
+ require_relative "version"
7
+ require_relative "worker_sidecar"
8
+ require_relative "bridge_client"
9
+ require_relative "chi_invocation"
10
+
11
+ module Samagotchi
12
+ # Whether a session's worker can be reached, and how: the one question
13
+ # every client asks before it talks to a worker's Bridge (a TUI attaching,
14
+ # chi web's relays and stream, `chi send`, a restart, `chi update`). It
15
+ # answers a typed outcome instead of a sidecar or nil, so nobody reads
16
+ # bridge.json by hand. The rule is the owner lock plus the sidecar's pid,
17
+ # never a port probe:
18
+ # - Live: a worker holds the lock and its sidecar names its pid (a sidecar
19
+ # with no pid: an older worker's, the lock alone) (#client makes the one
20
+ # client for it);
21
+ # - Starting: a worker holds the lock but has no sidecar of its own yet (or
22
+ # the lock's record is still being written); stuck when it has held it
23
+ # past START_LIMIT without one;
24
+ # - Leaving: the worker closed its Bridge and holds the lock for its last work;
25
+ # - Skew: a worker whose sidecar speaks a newer PROTOCOL than this chi;
26
+ # - Stale: a sidecar, but the lock is free (or a chi REPL holds it, which
27
+ # writes none): its worker died;
28
+ # - None: no worker and no sidecar, or a chi REPL.
29
+ # A kernel releases a dead worker's lock, so a free lock is the proof of
30
+ # death a TCP probe never gave (a port can be reused by another session's
31
+ # Bridge).
32
+ module WorkerLink
33
+ # The sidecar protocol this chi speaks; a sidecar with a bigger one is a
34
+ # newer worker's (Skew).
35
+ PROTOCOL = 1
36
+
37
+ # Seconds a worker may hold the lock without publishing its Bridge before
38
+ # Starting is stuck (SessionManager::WORKER_START_GRACE).
39
+ START_LIMIT = 30.0
40
+
41
+ # The sidecar's file name in a session folder.
42
+ FILE = WorkerSidecar::FILE
43
+
44
+ # A worker whose Bridge answers: its sidecar says how to reach it.
45
+ Live = Data.define(:sidecar, :owner) do
46
+ def initialize(sidecar:, owner: nil) = super
47
+
48
+ def live? = true
49
+ def worker_owned? = true
50
+
51
+ # The client for this worker (BridgeClient.for_sidecar: its port and its
52
+ # secret from this one read).
53
+ # @return [BridgeClient]
54
+ def client(session_id, host: BridgeClient::HOST, read_timeout: BridgeClient::READ_TIMEOUT)
55
+ BridgeClient.for_sidecar(session_id, sidecar, host: host, read_timeout: read_timeout)
56
+ end
57
+
58
+ def port = sidecar.port
59
+ def epoch = sidecar.epoch
60
+ def version = sidecar.version
61
+ def features = sidecar.features
62
+ def started_at = sidecar.started_at
63
+ end
64
+
65
+ # A worker holds the lock and has not published its Bridge (yet).
66
+ Starting = Data.define(:owner, :stuck) do
67
+ def initialize(owner:, stuck: false) = super
68
+
69
+ def live? = false
70
+ def worker_owned? = true
71
+ end
72
+
73
+ # A worker holds the lock with its Bridge closed (OwnerLock::Owner#leaving?).
74
+ Leaving = Data.define(:owner) do
75
+ def live? = false
76
+ def worker_owned? = true
77
+
78
+ # Seconds of its leave still to run.
79
+ def left = owner.leave_left
80
+ end
81
+
82
+ # No worker: no sidecar in the session folder, and no worker's lock (a
83
+ # chi REPL's, +owner+, is no worker's).
84
+ None = Data.define(:owner) do
85
+ def initialize(owner: nil) = super
86
+
87
+ def live? = false
88
+ def worker_owned? = false
89
+ end
90
+
91
+ # A sidecar no worker stands behind: the lock is free (reason :lock_free).
92
+ Stale = Data.define(:sidecar, :reason, :owner) do
93
+ def initialize(sidecar:, reason:, owner: nil) = super
94
+
95
+ def live? = false
96
+ def worker_owned? = false
97
+ end
98
+
99
+ # A worker whose sidecar's protocol is newer than this chi's (reason
100
+ # :protocol): it must not be driven by this chi.
101
+ Skew = Data.define(:sidecar, :owner, :reason) do
102
+ def live? = false
103
+ def worker_owned? = true
104
+
105
+ def message(session_id, via: ChiInvocation::HERE)
106
+ "session #{session_id}'s worker runs chi #{sidecar.version || "?"} (protocol #{sidecar.protocol}), " \
107
+ "newer than this chi #{Samagotchi::VERSION} (protocol #{PROTOCOL}): restart " \
108
+ "#{via.remote? ? via.web : "chi web"} or update this chi"
109
+ end
110
+ end
111
+
112
+ module_function
113
+
114
+ def not_live(session_id) = "no live worker for session #{session_id}"
115
+
116
+ # @param session_dir [String]
117
+ # @param unlink [Boolean] remove a Stale sidecar (the clients that would
118
+ # talk to it; never one a worker may be writing); false is a read-only
119
+ # look (`chi update`, a restart's poll)
120
+ # @param owner [OwnerLock::Owner, nil, :read] the lock's holder as the
121
+ # caller already read it (nil: free); :read reads it here
122
+ # @return [Live, Starting, Leaving, None, Stale, Skew]
123
+ def resolve(session_dir, unlink:, owner: :read, host: BridgeClient::HOST)
124
+ owner = OwnerLock.owner(session_dir) if owner == :read
125
+ stamp = file_stamp(session_dir)
126
+ sidecar = WorkerSidecar.read(session_dir)
127
+ return unowned(session_dir, sidecar, owner, stamp, unlink) if owner.nil? || owner.tui?
128
+ return Starting.new(owner: owner, stuck: false) unless owner.worker?
129
+ return Leaving.new(owner: owner) if owner.leaving?
130
+ return Starting.new(owner: owner, stuck: stuck?(owner)) unless ours?(sidecar, owner)
131
+ return Skew.new(sidecar: sidecar, owner: owner, reason: :protocol) if sidecar.protocol.to_i > PROTOCOL
132
+
133
+ Live.new(sidecar: sidecar, owner: owner)
134
+ end
135
+
136
+ # @return [BridgeClient, nil] a client for the session's Live worker
137
+ def client(session_id, session_dir:, unlink: true, host: BridgeClient::HOST, read_timeout: BridgeClient::READ_TIMEOUT)
138
+ link = resolve(session_dir, unlink: unlink, host: host)
139
+ link.live? ? link.client(session_id, host: host, read_timeout: read_timeout) : nil
140
+ end
141
+
142
+ # Wait for a worker (a freshly spawned one) to publish its Bridge: the
143
+ # first Live for which +until+ holds within +timeout+ seconds (polled
144
+ # every +interval+), else the last outcome seen. +until+ tells a new
145
+ # worker from the one a restart asked to leave (its sidecar is Live too).
146
+ # @param _session_id [String] the session waited for (as .client's; the
147
+ # outcome's #client takes it again)
148
+ # @param until [#call, nil] ->(live) { … }; nil takes any Live
149
+ # @param give_up [#call, nil] ->(outcome) { … }: stop at the first
150
+ # outcome it holds for (a waker's "no worker owns it any more")
151
+ # @return [Live, Starting, Leaving, None, Stale, Skew] the outcome it
152
+ # stopped at, else the last one seen
153
+ def await(_session_id, session_dir:, timeout:, unlink: true, interval: BridgeClient::SPAWN_POLL_INTERVAL,
154
+ until: nil, give_up: nil, host: BridgeClient::HOST)
155
+ wanted = binding.local_variable_get(:until) # a keyword: no plain read
156
+ last = nil
157
+ found = BridgeClient.poll(timeout, interval: interval) do
158
+ last = resolve(session_dir, unlink: unlink, host: host)
159
+ last if (last.live? && (wanted.nil? || wanted.call(last))) || give_up&.call(last)
160
+ end
161
+ found || last
162
+ end
163
+
164
+ # The session folders under +state_dir+ that hold a sidecar (live or not),
165
+ # in order.
166
+ # @return [Array<String>]
167
+ def each_dir(state_dir)
168
+ Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.map { |path| File.dirname(path) }
169
+ end
170
+
171
+ # A sidecar is the lock holder's own when it names its pid (or none: an
172
+ # older worker's) and a port.
173
+ def ours?(sidecar, owner)
174
+ return false unless sidecar&.port&.positive?
175
+
176
+ sidecar.pid.nil? || sidecar.pid == owner.pid
177
+ end
178
+ private_class_method :ours?
179
+
180
+ def stuck?(owner)
181
+ started = Time.iso8601(owner.started_at.to_s)
182
+ Time.now - started > START_LIMIT
183
+ rescue ArgumentError
184
+ false
185
+ end
186
+ private_class_method :stuck?
187
+
188
+ def unowned(session_dir, sidecar, owner, stamp, unlink)
189
+ return None.new(owner: owner) unless sidecar
190
+
191
+ unlink_unchanged(session_dir, stamp) if unlink
192
+ Stale.new(sidecar: sidecar, reason: :lock_free, owner: owner)
193
+ end
194
+ private_class_method :unowned
195
+
196
+ def file_stamp(session_dir)
197
+ stat = File.stat(WorkerSidecar.path(session_dir))
198
+ [stat.ino, stat.mtime]
199
+ rescue SystemCallError
200
+ nil
201
+ end
202
+ private_class_method :file_stamp
203
+
204
+ # Remove the sidecar only if it is the file that was read: a new worker's
205
+ # write is a new inode (AtomicFile renames).
206
+ def unlink_unchanged(session_dir, stamp)
207
+ return if stamp.nil? || file_stamp(session_dir) != stamp
208
+
209
+ FileUtils.rm_f(WorkerSidecar.path(session_dir))
210
+ end
211
+ private_class_method :unlink_unchanged
212
+ end
213
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
- require "socket"
6
5
  require_relative "atomic_file"
7
6
 
8
7
  module Samagotchi
@@ -14,15 +13,15 @@ module Samagotchi
14
13
  # one worker identity: the epoch in every event id, so a remote client
15
14
  # tells a restarted worker apart; nil from a worker before it), and its
16
15
  # Bridge's secret (every request names it in X-Chi-Bridge, Bridge#secret_ok?;
17
- # nil from a worker before it, which asks for none). The Bridge writes it
18
- # on start
19
- # and removes it on stop; one a dead worker left stays until a client's
20
- # probe finds its port closed (.live with unlink: true).
16
+ # nil from a worker before it, which asks for none), the pid of the worker
17
+ # process that wrote it (WorkerLink: it is Live only under the owner lock
18
+ # of that pid; nil from an older worker) and the WorkerLink::PROTOCOL it
19
+ # speaks (nil from an older one). The Bridge writes it on start and removes
20
+ # it on stop (only its own, by epoch); one a dead worker left stays until
21
+ # a client finds the lock free (WorkerLink.resolve with unlink: true).
21
22
  class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features,
22
- :epoch, :secret)
23
+ :epoch, :secret, :pid, :protocol)
23
24
  FILE = "bridge.json"
24
- # Seconds a liveness probe waits for the port to take a connect.
25
- PROBE_TIMEOUT = 0.2
26
25
 
27
26
  def self.path(session_dir) = File.join(session_dir, FILE)
28
27
 
@@ -35,7 +34,8 @@ module Samagotchi
35
34
  port = data["port"]
36
35
  new(port: port.respond_to?(:to_i) ? port.to_i : 0, bind: data["bind"], session_id: data["session_id"],
37
36
  started_at: data["started_at"], version: data["version"], input_format: data["input_format"],
38
- features: Array(data["features"]).map(&:to_s), epoch: data["epoch"]&.to_s, secret: secret_in(data["secret"]))
37
+ features: Array(data["features"]).map(&:to_s), epoch: data["epoch"]&.to_s, secret: secret_in(data["secret"]),
38
+ pid: integer_in(data["pid"]), protocol: integer_in(data["protocol"]))
39
39
  rescue JSON::ParserError, SystemCallError
40
40
  nil
41
41
  end
@@ -46,34 +46,10 @@ module Samagotchi
46
46
 
47
47
  def self.secret_in(value) = value.is_a?(String) && SECRET_SHAPE.match?(value) ? value : nil
48
48
 
49
- # The sidecar of a worker whose Bridge takes a connect on its port, or
50
- # nil. One whose port refuses (its worker died) is stale: +unlink+
51
- # removes it (the clients that would talk to it), else it stays (a
52
- # read-only look, like `chi update`'s).
53
- # @return [WorkerSidecar, nil]
54
- def self.live(session_dir, unlink:, host: "127.0.0.1", timeout: PROBE_TIMEOUT)
55
- sidecar = read(session_dir)
56
- return nil unless sidecar&.port&.positive?
57
-
58
- begin
59
- # Not Socket.tcp: with connect_timeout on macOS 26 it hands back the
60
- # socket of a refused connect (the kernel's second connect() says
61
- # EISCONN, SO_ERROR is unchecked), so a dead worker's port read as
62
- # live. Ruby bug #22223, fixed in 3.4.11 / 4.0.7, not in 3.3.
63
- # TCPSocket checks SO_ERROR and raises ECONNREFUSED on every Ruby,
64
- # so this stays even once 3.3 is dropped.
65
- TCPSocket.new(host, sidecar.port, connect_timeout: timeout).close
66
- sidecar
67
- rescue StandardError
68
- FileUtils.rm_f(path(session_dir)) if unlink
69
- nil
70
- end
71
- rescue StandardError
72
- nil
73
- end
49
+ def self.integer_in(value) = value.is_a?(Integer) ? value : nil
74
50
 
75
51
  def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: [],
76
- epoch: nil, secret: nil) = super
52
+ epoch: nil, secret: nil, pid: nil, protocol: nil) = super
77
53
 
78
54
  # The secret never shows in an inspect (a log line, an error's message).
79
55
  def inspect = "#<#{self.class.name} port=#{port} session_id=#{session_id.inspect} epoch=#{epoch.inspect}>"
@@ -81,14 +57,16 @@ module Samagotchi
81
57
 
82
58
  # Written whole or not at all, readable by this user only (0600 from the
83
59
  # moment the file exists, AtomicFile: no other account learns the
84
- # worker's port or its secret from it); input_format, epoch and secret
85
- # only when they name one.
60
+ # worker's port or its secret from it); input_format, epoch, secret, pid
61
+ # and protocol only when they name one.
86
62
  def write(session_dir)
87
63
  FileUtils.mkdir_p(session_dir)
88
64
  record = to_h.transform_keys(&:to_s)
89
65
  record.delete("input_format") if input_format.nil?
90
66
  record.delete("epoch") if epoch.nil?
91
67
  record.delete("secret") if secret.nil?
68
+ record.delete("pid") if pid.nil?
69
+ record.delete("protocol") if protocol.nil?
92
70
  AtomicFile.write(self.class.path(session_dir), "#{JSON.pretty_generate(record)}\n", perm: 0o600)
93
71
  end
94
72
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.121.0
4
+ version: 0.121.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -197,6 +197,7 @@ files:
197
197
  - lib/samagotchi/bridge_client.rb
198
198
  - lib/samagotchi/bridge_client/event_stream.rb
199
199
  - lib/samagotchi/bridge_client/sse_parser.rb
200
+ - lib/samagotchi/bridge_client/stream_open.rb
200
201
  - lib/samagotchi/bridge_client/stream_request.rb
201
202
  - lib/samagotchi/bridge_secret.rb
202
203
  - lib/samagotchi/broadcast/recipients.rb
@@ -890,6 +891,7 @@ files:
890
891
  - lib/samagotchi/worker.rb
891
892
  - lib/samagotchi/worker_idle_exit.rb
892
893
  - lib/samagotchi/worker_inbound.rb
894
+ - lib/samagotchi/worker_link.rb
893
895
  - lib/samagotchi/worker_sidecar.rb
894
896
  - lib/samagotchi/worker_signals.rb
895
897
  - lib/samagotchi/worker_task_wakes.rb