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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +86 -12
  6. data/docs/guardrails.md +2 -1
  7. data/docs/internals/context-telemetry.md +6 -2
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/participants.md +137 -0
  10. data/docs/internals/remote-sessions.md +153 -27
  11. data/docs/plugins.md +7 -1
  12. data/docs/sessions.md +4 -2
  13. data/docs/sub-agent.md +8 -0
  14. data/docs/testing.md +68 -4
  15. data/lib/samagotchi/bridge.rb +7 -3
  16. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_invocation.rb +13 -16
  20. data/lib/samagotchi/context_status.rb +15 -2
  21. data/lib/samagotchi/engine.rb +16 -6
  22. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_scheduler.rb +34 -9
  25. data/lib/samagotchi/kernel_loop.rb +8 -1
  26. data/lib/samagotchi/launch_mode.rb +4 -0
  27. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  28. data/lib/samagotchi/owner_lock.rb +65 -5
  29. data/lib/samagotchi/participants.rb +122 -0
  30. data/lib/samagotchi/remote_attach_command.rb +119 -0
  31. data/lib/samagotchi/run_options.rb +14 -2
  32. data/lib/samagotchi/send_command.rb +1 -1
  33. data/lib/samagotchi/session.rb +8 -4
  34. data/lib/samagotchi/session_access/contract.rb +42 -4
  35. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  36. data/lib/samagotchi/session_access/local.rb +52 -1
  37. data/lib/samagotchi/session_access/remote.rb +112 -16
  38. data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
  39. data/lib/samagotchi/session_access.rb +85 -3
  40. data/lib/samagotchi/session_continue.rb +1 -1
  41. data/lib/samagotchi/session_inbox.rb +18 -9
  42. data/lib/samagotchi/session_manager.rb +21 -5
  43. data/lib/samagotchi/sessions_command.rb +4 -4
  44. data/lib/samagotchi/share_command.rb +174 -0
  45. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  46. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
  48. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  49. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  50. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  51. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  52. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  53. data/lib/samagotchi/terminal_ui.rb +4 -1
  54. data/lib/samagotchi/testing/waiting.rb +92 -9
  55. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +187 -74
  58. data/lib/samagotchi/web/grants.rb +302 -0
  59. data/lib/samagotchi/web/participant_access.rb +286 -0
  60. data/lib/samagotchi/web/principal.rb +29 -0
  61. data/lib/samagotchi/web/server.rb +3 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  63. data/lib/samagotchi/worker.rb +26 -5
  64. metadata +10 -1
@@ -2,12 +2,10 @@
2
2
 
3
3
  module Samagotchi
4
4
  # How the follow-up commands chi prints (ParentReport's chi answer and
5
- # chi send --wait, a stop's chi --resume) are spelled so that they work
6
- # when pasted: on this machine plain chi, against a remote (chi --remote
7
- # NAME, CHI_REMOTE=NAME alike) chi --remote NAME. What runs on this
8
- # machine only (--attach, --resume: remote attach is its own phase) has
9
- # no remote form: #attach and #resume are nil there, and #open_it and
10
- # #shows name the remote's chi web instead.
5
+ # chi send --wait, a stop's chi --resume, the attached TUI's re-attach
6
+ # lines) are spelled so that they work when pasted: on this machine plain
7
+ # chi, against a remote (chi --remote NAME, CHI_REMOTE=NAME alike) chi
8
+ # --remote NAME. A remote's chi web is named where the page is meant.
11
9
  # @!attribute remote [String, nil] the remote's name; nil: this machine
12
10
  ChiInvocation = Data.define(:remote) do
13
11
  def remote? = !remote.nil?
@@ -18,19 +16,18 @@ module Samagotchi
18
16
  # +words+ after #chi: command("answer ID …").
19
17
  def command(words) = "#{chi} #{words}"
20
18
 
21
- # chi --attach ID; nil remotely.
22
- def attach(id) = ("chi --attach #{id}" unless remote?)
19
+ # chi --attach ID (chi --remote NAME --attach ID).
20
+ def attach(id) = command("--attach #{id}")
23
21
 
24
- # chi --resume ID; nil remotely.
25
- def resume(id) = ("chi --resume #{id}" unless remote?)
22
+ # chi --resume ID (chi --remote NAME --resume ID).
23
+ def resume(id) = command("--resume #{id}")
26
24
 
27
- # What shows the session ("… shows it"): chi --attach ID, or the
28
- # remote's chi web.
29
- def shows(id) = attach(id) || web
25
+ # What shows the session ("… shows it"): its attach.
26
+ def shows(id) = attach(id)
30
27
 
31
- # Where the user opens it ("open it: …"): chi --attach ID or the web,
32
- # or the remote's chi web.
33
- def open_it(id) = remote? ? web : "#{attach(id)} or the web"
28
+ # Where the user opens it ("open it: …"): its attach, or the web (the
29
+ # remote's chi web).
30
+ def open_it(id) = "#{attach(id)} or #{remote? ? web : "the web"}"
34
31
 
35
32
  # The remote's chi web, by name.
36
33
  def web = "chi web on #{remote}"
@@ -116,13 +116,20 @@ module Samagotchi
116
116
  # last status line's bucket is where rises count from)
117
117
  # @param llm_context [LLMContextStrategy::Resolved, nil] the turn's
118
118
  # strategy (its budget)
119
- def initialize(conversation: [], llm_context: nil)
119
+ # @param counted_chars_per_token [Float, nil] the last turn's
120
+ # #counted_chars_per_token: the turn's estimate before its first count
121
+ # uses it instead of the configured ratio, so the value doesn't dip at
122
+ # a turn's start (the chat loop's chars leave out the tool schemas the
123
+ # server counts)
124
+ def initialize(conversation: [], llm_context: nil, counted_chars_per_token: nil)
120
125
  @budget = llm_context&.budget_tokens
121
126
  @forget = llm_context&.forget? || false
122
127
  @offered = self.class.last_line(conversation).to_s.include?(OFFER_TOOL)
123
128
  @enabled = self.class.enabled?
124
129
  chars_per_token = Config.get("context.chars_per_token").to_f
125
130
  @chars_per_token = chars_per_token.positive? ? chars_per_token : DEFAULT_CHARS_PER_TOKEN
131
+ @estimate_chars_per_token = counted_chars_per_token.to_f.positive? ? counted_chars_per_token.to_f : @chars_per_token
132
+ @counted_chars_per_token = nil
126
133
  parsed = Config.get("context.status_thresholds").to_s.split(",").map { |value| value.strip.to_i }
127
134
  .select { |value| value.between?(1, 99) }.uniq.sort
128
135
  @thresholds = parsed.empty? ? DEFAULT_THRESHOLDS : parsed
@@ -137,6 +144,10 @@ module Samagotchi
137
144
  # @return [Hash, nil] the status line's value ({est_pct:, bucket:});
138
145
  # nil with context.status off or before the first request
139
146
  attr_reader :display
147
+ # @return [Float, nil] the chars per token of the last request the
148
+ # server counted (its prompt chars over its prompt tokens, images
149
+ # aside), for the next turn's tracker; nil before a count
150
+ attr_reader :counted_chars_per_token
140
151
 
141
152
  def enabled? = @enabled
142
153
 
@@ -212,6 +223,8 @@ module Samagotchi
212
223
 
213
224
  @counted = { chars: prompt_chars, image_tokens: image_tokens }
214
225
  @edited = false
226
+ text_tokens = usage[:prompt_tokens].to_i - image_tokens.to_i
227
+ @counted_chars_per_token = prompt_chars.to_f / text_tokens if prompt_chars.positive? && text_tokens.positive?
215
228
  value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
216
229
  @display = value if value
217
230
  end
@@ -243,7 +256,7 @@ module Samagotchi
243
256
  source = "server"
244
257
  else
245
258
  window_tokens = counted_against(window.tokens)
246
- used_tokens = (prompt_chars / @chars_per_token).ceil + image_tokens
259
+ used_tokens = (prompt_chars / @estimate_chars_per_token).ceil + image_tokens
247
260
  source = "estimate"
248
261
  end
249
262
 
@@ -1858,6 +1858,11 @@ module Samagotchi
1858
1858
  @recap&.write_now(on_start: on_start)
1859
1859
  end
1860
1860
 
1861
+ # The longest #write_recap_now waits (recap.timeout), nil when no recap
1862
+ # is set up.
1863
+ # @return [Float, nil]
1864
+ def recap_timeout = @recap&.timeout
1865
+
1861
1866
  # Ask for a recap now (/recap), without waiting for the idle window.
1862
1867
  # @return [Symbol] IdleRecap#request_now's answer, or :off
1863
1868
  def request_recap
@@ -1887,9 +1892,12 @@ module Samagotchi
1887
1892
  self
1888
1893
  end
1889
1894
 
1890
- # Stop the shared idle scheduler (TerminalUI calls this when the REPL exits).
1891
- def stop_idle
1892
- @idle_scheduler&.stop
1895
+ # Stop the shared idle scheduler (TerminalUI calls this when the REPL
1896
+ # exits). +join+: wait for a tick under way (IdleScheduler#stop); a
1897
+ # caller holding #synchronize_events passes false, since a recap's emit
1898
+ # in that tick waits for the same lock.
1899
+ def stop_idle(join: true)
1900
+ @idle_scheduler&.stop(join: join)
1893
1901
  self
1894
1902
  end
1895
1903
 
@@ -1947,9 +1955,11 @@ module Samagotchi
1947
1955
  # run it resumes started (ModelResult#run_start, kept in the offer by
1948
1956
  # TurnFlow), so it keeps that run's reasoning; nil starts cold (a
1949
1957
  # model change while the offer stood, every other turn).
1950
- # @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:});
1951
- # when given, the turn's boundary events (:turn_started, :turn_completed,
1952
- # :turn_canceled, :turn_failed) carry it as `origin:`
1958
+ # @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:},
1959
+ # and author: when someone other than the session's owner wrote it, from
1960
+ # its input file; absent for the owner); when given, the turn's boundary
1961
+ # events (:turn_started, :turn_completed, :turn_canceled, :turn_failed)
1962
+ # carry it as `origin:`, and GuardrailWiring#begin_turn gets it
1953
1963
  # @param images [Array<Hash>] the prompt's images: {path:} (a file on this
1954
1964
  # machine; in-process and attached-TUI callers only) or {file:, name:}
1955
1965
  # (already in the session's images/, e.g. a web upload). They are
@@ -83,12 +83,18 @@ module Samagotchi
83
83
  @protected = nil
84
84
  end
85
85
 
86
- # A turn starts: its origin, and the git facts read afresh.
86
+ # A turn starts: its origin (Engine#run_turn's: {client_id:,
87
+ # enqueued_id:, author:}, each optional), and the git facts read afresh.
87
88
  def begin_turn(origin)
88
89
  @origin = origin
89
90
  @git = Guardrails::GitInfo.new
90
91
  end
91
92
 
93
+ # Who wrote the running turn when it isn't the session's owner (its
94
+ # origin's author); nil for the owner's turns and every other origin.
95
+ # @return [String, nil]
96
+ def turn_author = @origin.is_a?(Hash) ? @origin[:author] : nil
97
+
92
98
  # The gate every tool call asks first.
93
99
  # @return [Guardrails::Gate]
94
100
  def gate
@@ -254,7 +254,7 @@ module Samagotchi
254
254
  end
255
255
 
256
256
  # @return [Array(Integer, Integer)] the recap length range, e.g. [2, 4]
257
- attr_reader :generation, :inactivity, :min_user_turns, :sentences
257
+ attr_reader :generation, :inactivity, :min_user_turns, :sentences, :timeout
258
258
 
259
259
  # @param callable [#call] true while a continue offer waits for an
260
260
  # answer (the Worker's or the REPL's TurnFlow); read at each attempt
@@ -18,23 +18,37 @@ module Samagotchi
18
18
  # raises is logged and isolated — the remaining jobs keep polling.
19
19
  class IdleScheduler
20
20
  POLL_INTERVAL_SECONDS = 0.5
21
+ # How long #stop waits for the thread to finish its tick before it
22
+ # kills it.
23
+ STOP_JOIN_SECONDS = 1.0
24
+ THREAD_NAME = "idle_scheduler"
21
25
 
22
- attr_reader :engine, :jobs
26
+ attr_reader :engine, :jobs, :interval
23
27
 
24
- def initialize(engine:, jobs: [])
28
+ # +interval+: the pause between ticks (a spec passes a long one, so its
29
+ # own #tick is the only one after the first).
30
+ def initialize(engine:, jobs: [], interval: POLL_INTERVAL_SECONDS)
25
31
  raise ArgumentError, "IdleScheduler requires an engine" unless engine
26
32
 
27
33
  @engine = engine
28
34
  @jobs = jobs.compact.freeze
35
+ @interval = interval
36
+ # The loop's pause: a pop with the interval as its timeout, which
37
+ # #stop ends at once by pushing.
38
+ @wake = Queue.new
29
39
  @thread = nil
40
+ # The thread a stop(join: false) left to finish its tick.
41
+ @unjoined = nil
30
42
  @stopped = false
31
43
  end
32
44
 
33
45
  # Spawn the polling thread (idempotent; no-op when there are no jobs).
46
+ # A stopped scheduler doesn't start again.
34
47
  def start
35
48
  return self if running? || @jobs.empty?
36
49
 
37
50
  @thread = Thread.new { run_loop }
51
+ @thread.name = THREAD_NAME
38
52
  self
39
53
  end
40
54
 
@@ -42,11 +56,24 @@ module Samagotchi
42
56
  !@thread.nil? && @thread.alive? && !@stopped
43
57
  end
44
58
 
45
- # Stop the polling thread (idempotent).
46
- def stop
59
+ # Stop the polling thread (idempotent): it ticks no more and its pause
60
+ # ends at once. +join+: wait up to STOP_JOIN_SECONDS for a tick under
61
+ # way to finish, then kill the thread. A caller holding a lock a tick
62
+ # may take (Worker#leave_idle holds the event log, which a recap's emit
63
+ # takes) passes join: false: the thread finishes its tick and exits by
64
+ # itself, and a later joining stop (Engine#shutdown, the worker's
65
+ # teardown) still joins it. From the scheduler's own thread (a job's
66
+ # tick) it never joins.
67
+ def stop(join: true)
47
68
  @stopped = true
48
- @thread&.kill
69
+ @wake << :stop
70
+ thread = @thread || @unjoined
49
71
  @thread = nil
72
+ @unjoined = join ? nil : thread
73
+ return if thread.nil? || !join || thread == Thread.current
74
+
75
+ thread.kill unless thread.join(STOP_JOIN_SECONDS)
76
+ nil
50
77
  end
51
78
 
52
79
  # Swap the job +old+ for +new+ (Engine#session= rebuilding the recap):
@@ -78,11 +105,9 @@ module Samagotchi
78
105
  private
79
106
 
80
107
  def run_loop
81
- loop do
82
- break if @stopped
83
-
108
+ until @stopped
84
109
  tick
85
- sleep(POLL_INTERVAL_SECONDS)
110
+ @wake.pop(timeout: @interval)
86
111
  end
87
112
  rescue StandardError => e
88
113
  Log.exception(:idle, "scheduler_crashed", e, echo: "[IdleScheduler] scheduler thread crashed: #{e.class}: #{e.message}")
@@ -147,6 +147,11 @@ module Samagotchi
147
147
  # write; the Engine's own (it hands it to a kernel it was given).
148
148
  attr_accessor :reminder_store
149
149
 
150
+ # @return [Float, nil] the last counted request's chars per token
151
+ # (ContextStatus#counted_chars_per_token), for the next turn's first
152
+ # estimate (no dip at a turn's start)
153
+ attr_accessor :counted_chars_per_token
154
+
150
155
  # @return [ModelProfile] the active prompt profile
151
156
  attr_reader :profile
152
157
 
@@ -309,7 +314,8 @@ module Samagotchi
309
314
 
310
315
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
311
316
  conversation = prepare_conversation(messages, keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since))
312
- context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
317
+ context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context,
318
+ counted_chars_per_token: @counted_chars_per_token)
313
319
  emit = ->(event) { emit_stream_event(on_stream_event, event) }
314
320
  @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
315
321
  Turn.new(
@@ -609,6 +615,7 @@ module Samagotchi
609
615
  response = generation.response.to_s
610
616
  turn.context.generation_done(generation.usage, prompt_chars: request.prompt.length, image_tokens: request.image_tokens,
611
617
  window: request.window)
618
+ @counted_chars_per_token = turn.context.counted_chars_per_token || @counted_chars_per_token
612
619
  emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
613
620
  thinking_chars: thinking_chars(response, generation.streamed_thinking),
614
621
  served_model: generation.served_model, requested_model: turn.model_name,
@@ -19,6 +19,10 @@ module Samagotchi
19
19
  # A worker spawned or resumed for an attach whose Bridge never came (%s:
20
20
  # the session id).
21
21
  BRIDGE_LATE = "the worker for session %s did not start its Bridge in time"
22
+ # An attach (or a resume) that found the session's worker leaving, its
23
+ # Bridge closed and its recap on leave still being written: it waits for
24
+ # the worker to let go (OwnerLock.await_release), then starts a new one.
25
+ RECAP_WAIT = "(waiting for the session to finish its recap…)"
22
26
 
23
27
  module_function
24
28
 
@@ -63,6 +63,11 @@ module Samagotchi
63
63
  # The session every request is for; Engine sets it with the adapter.
64
64
  # Goes out as a Session-Id header (OpenAIChat#chat).
65
65
  attr_accessor :session_id
66
+ # The last counted request's chars per token
67
+ # (ContextStatus#counted_chars_per_token), carried to the next turn's
68
+ # tracker: its first estimate counts the tool schemas the chars leave
69
+ # out, so the ctx value doesn't dip at each turn's start.
70
+ attr_accessor :counted_chars_per_token
66
71
 
67
72
  # @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
68
73
  # @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
@@ -585,7 +590,8 @@ module Samagotchi
585
590
  @recovery_repeated = false
586
591
  # The names the last request declared deferred.
587
592
  @deferred = []
588
- @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
593
+ @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context,
594
+ counted_chars_per_token: loop.counted_chars_per_token)
589
595
  loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
590
596
  emit: method(:emit))
591
597
  end
@@ -976,6 +982,7 @@ module Samagotchi
976
982
  "completion_tokens" => usage.completion_tokens } })
977
983
  @context.generation_done(counts, prompt_chars: @request_chars.to_i, image_tokens: @request_image_tokens.to_i,
978
984
  window: window)
985
+ @loop.counted_chars_per_token = @context.counted_chars_per_token || @loop.counted_chars_per_token
979
986
  end
980
987
 
981
988
  # The model's answer at debug level, as the native loop dumps its
@@ -19,17 +19,37 @@ module Samagotchi
19
19
  DEFAULT_WAIT = 2.0
20
20
  RETRY_INTERVAL = 0.05
21
21
  OWNER_READ_ATTEMPTS = 10
22
+ # Seconds .await_release waits past a leaving worker's own budget: its
23
+ # last save, the task watch's absorb, its exit.
24
+ LEAVE_MARGIN = 5.0
25
+ # Seconds between .await_release's looks at the lock.
26
+ RELEASE_POLL = 0.1
22
27
 
23
28
  # Who holds the lock, as .owner reads it from the lock file. Its fields
24
29
  # may be nil for a moment right after acquisition.
25
- # kind: "worker" or "tui"; pid as written (an Integer).
26
- Owner = Data.define(:pid, :kind, :started_at) do
27
- def initialize(pid: nil, kind: nil, started_at: nil) = super
30
+ # kind: "worker" or "tui"; pid as written (an Integer). leaving_at (an
31
+ # ISO 8601 time) and leave_wait (seconds): set once a worker is leaving
32
+ # (#mark_leaving), its Bridge closed, the lock held for its last work
33
+ # (the recap on leave) for up to leave_wait.
34
+ Owner = Data.define(:pid, :kind, :started_at, :leaving_at, :leave_wait) do
35
+ def initialize(pid: nil, kind: nil, started_at: nil, leaving_at: nil, leave_wait: nil) = super
28
36
 
29
37
  # The interactive TUI's: it reads no input files and takes no notes.
30
38
  def tui? = kind == "tui"
31
39
 
32
40
  def worker? = kind == "worker"
41
+
42
+ # A worker that closed its Bridge and releases the lock once its
43
+ # last work is done.
44
+ def leaving? = worker? && !leaving_at.nil?
45
+
46
+ # Seconds of its leave_wait still to run at +now+ (0 when past).
47
+ def leave_left(now = Time.now)
48
+ started = Time.iso8601(leaving_at.to_s)
49
+ [leave_wait.to_f - (now - started), 0.0].max
50
+ rescue ArgumentError
51
+ leave_wait.to_f
52
+ end
33
53
  end
34
54
 
35
55
  # Take the lock, retrying for up to +wait+ seconds (another process's
@@ -71,12 +91,35 @@ module Samagotchi
71
91
 
72
92
  sleep(RETRY_INTERVAL / 5)
73
93
  end
74
- Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"])
94
+ Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"],
95
+ leaving_at: data["leaving_at"], leave_wait: data["leave_wait"])
75
96
  end
76
97
  rescue Errno::ENOENT
77
98
  nil
78
99
  end
79
100
 
101
+ # Wait for a leaving worker (Owner#leaving?) to release the lock: up to
102
+ # its leave_wait still to run plus LEAVE_MARGIN, looking every
103
+ # RELEASE_POLL seconds. An owner that isn't leaving (a live worker, a
104
+ # stuck one, a chi REPL) or no owner returns at once. A Ctrl-C (Interrupt)
105
+ # stops the wait as it would any sleep.
106
+ # @param on_wait [#call, nil] called once with the Owner before waiting
107
+ # @return [Symbol] :not_leaving, :released, or :timeout (still held)
108
+ def self.await_release(session_dir, on_wait: nil, margin: LEAVE_MARGIN, clock: method(:monotonic_now),
109
+ sleeper: method(:sleep), now: -> { Time.now })
110
+ holder = owner(session_dir)
111
+ return :not_leaving unless holder&.leaving?
112
+
113
+ on_wait&.call(holder)
114
+ deadline = clock.call + holder.leave_left(now.call) + margin
115
+ loop do
116
+ return :released unless owner(session_dir)
117
+ return :timeout if clock.call >= deadline
118
+
119
+ sleeper.call(RELEASE_POLL)
120
+ end
121
+ end
122
+
80
123
  def self.path(session_dir)
81
124
  File.join(session_dir, FILE)
82
125
  end
@@ -99,11 +142,28 @@ module Samagotchi
99
142
  def initialize(file, kind:)
100
143
  @file = file
101
144
  @kind = kind.to_s
145
+ @record = { "pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3) }
146
+ write_record
147
+ end
148
+
149
+ # The holder is leaving: its Bridge is closed and it releases the lock
150
+ # within +wait+ seconds (its recap on leave's timeout), which
151
+ # .await_release reads. .owner retries the moment the file is empty.
152
+ # @param wait [Numeric]
153
+ def mark_leaving(wait:)
154
+ return if @file.closed?
155
+
156
+ @record = @record.merge("leaving_at" => Time.now.iso8601(3), "leave_wait" => wait.to_f)
157
+ write_record
158
+ end
159
+
160
+ def write_record
102
161
  @file.truncate(0)
103
162
  @file.rewind
104
- @file.write(JSON.generate("pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3)))
163
+ @file.write(JSON.generate(@record))
105
164
  @file.flush
106
165
  end
166
+ private :write_record
107
167
 
108
168
  # Release the lock (process exit releases it too).
109
169
  def release
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "archive_store"
8
+ require_relative "session"
9
+ require_relative "web/grants"
10
+
11
+ module Samagotchi
12
+ # A session's participants: the people (or agents) its owner invited to
13
+ # review it (`chi share`, the owner's participants routes), each through a
14
+ # Web::Grants grant. Two files in the session dir, neither in the session
15
+ # JSON (chi web never saves that, and a worker would overwrite a field it
16
+ # added):
17
+ # - participants.json, the sidecar: who is or was invited, never a token
18
+ # or a digest. Written by whoever changes a grant (chi web, chi share);
19
+ # the worker reads it (Participants.read; P3's system-prompt line). A
20
+ # session nobody was invited to has none, so its files stay as they were.
21
+ # - participants.jsonl, the audit trail (0600, append-only): grants issued,
22
+ # revoked and expired, a participant's first use and each new peer,
23
+ # every route refused to them.
24
+ # Neither is copied to a continue, a fork or a delegate. See
25
+ # docs/internals/participants.md.
26
+ module Participants
27
+ FILE = "participants.json"
28
+ LOG = "participants.jsonl"
29
+
30
+ # One participant as the sidecar has them. +removed_at+ when the grant
31
+ # was revoked (nil while it works, or once it simply expired:
32
+ # +expires_at+ says when).
33
+ Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at) do
34
+ def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key.to_s]] })
35
+
36
+ # Still in the review at +now+.
37
+ def active?(now)
38
+ removed_at.nil? && Time.iso8601(expires_at.to_s) > now
39
+ rescue ArgumentError
40
+ false
41
+ end
42
+ end
43
+
44
+ module_function
45
+
46
+ # @return [Array<Entry>] the session's participants, [] without any
47
+ def read(session_id, state_dir: Session.default_state_dir)
48
+ file = File.join(Session.session_dir(session_id, state_dir: state_dir), FILE)
49
+ return [] unless File.exist?(file)
50
+
51
+ data = JSON.parse(File.read(file))
52
+ Array(data.is_a?(Hash) ? data["participants"] : nil).filter_map { |hash| Entry.from_h(hash) if hash.is_a?(Hash) }
53
+ rescue JSON::ParserError, SystemCallError
54
+ []
55
+ end
56
+
57
+ # The sidecar rewritten from +grants+ (that session's Web::Grants::Grant
58
+ # list). Nothing is written for a session without grants, nor for one
59
+ # whose dir is gone.
60
+ def write(session_id, grants, state_dir: Session.default_state_dir)
61
+ return if grants.empty?
62
+
63
+ dir = Session.session_dir(session_id, state_dir: state_dir)
64
+ return unless File.directory?(dir)
65
+
66
+ entries = grants.map do |grant|
67
+ { "id" => grant.id, "kind" => grant.kind, "name" => grant.name, "role" => grant.role,
68
+ "added_at" => grant.created_at, "expires_at" => grant.expires_at, "removed_at" => grant.revoked_at }
69
+ end
70
+ AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate({ "participants" => entries })}\n", perm: 0o600)
71
+ end
72
+
73
+ # One audit line: {"at", "event", …fields}. Nothing for a session whose
74
+ # dir is gone (deleted, pruned).
75
+ def log(session_id, event, state_dir: Session.default_state_dir, at: Time.now, **fields)
76
+ dir = Session.session_dir(session_id, state_dir: state_dir)
77
+ return unless File.directory?(dir)
78
+
79
+ line = JSON.generate({ "at" => at.utc.iso8601(3), "event" => event.to_s }.merge(fields.transform_keys(&:to_s)))
80
+ File.open(File.join(dir, LOG), File::WRONLY | File::APPEND | File::CREAT, 0o600) { |io| io.write("#{line}\n") }
81
+ end
82
+
83
+ # Invites a participant to +session_id+: the grant (+grants+, a
84
+ # Web::Grants), the sidecar and the audit line.
85
+ # @return [Web::Grants::Issued]
86
+ # @raise [Web::Grants::Invalid] an archived session, or bad values
87
+ # @raise [ArgumentError] no such session
88
+ def invite(grants, session_id:, name:, kind: "human", ttl: nil, state_dir: Session.default_state_dir)
89
+ raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
90
+ if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
91
+ raise Web::Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
92
+ end
93
+
94
+ issued = grants.issue(session_id: session_id, name: name, kind: kind, **{ ttl: ttl }.compact)
95
+ write(session_id, grants.list(session_id: session_id), state_dir: state_dir)
96
+ grant = issued.grant
97
+ log(session_id, :issued, state_dir: state_dir, participant: grant.id, name: grant.name, kind: grant.kind,
98
+ role: grant.role, expires_at: grant.expires_at)
99
+ issued
100
+ end
101
+
102
+ # Revokes grants (Web::Grants#revoke's arguments) and brings each
103
+ # affected session's sidecar and audit trail along.
104
+ # @return [Array<Web::Grants::Grant>] the grants it revoked
105
+ def revoke(grants, session_id: nil, id: nil, by: "owner", state_dir: Session.default_state_dir)
106
+ revoked = grants.revoke(session_id: session_id, id: id, by: by)
107
+ revoked.group_by(&:session_id).each do |sid, ended|
108
+ write(sid, grants.list(session_id: sid), state_dir: state_dir)
109
+ ended.each { |grant| log(sid, :revoked, state_dir: state_dir, participant: grant.id, by: by) }
110
+ end
111
+ revoked
112
+ end
113
+
114
+ # Ends the grants of sessions that were archived or deleted
115
+ # (SessionManager): +by+ "archive" or "delete". Nothing is written for a
116
+ # session nobody was invited to.
117
+ def end_sessions(session_ids, by:, state_dir: Session.default_state_dir)
118
+ grants = Web::Grants.new(Web::Grants.path(state_dir: state_dir))
119
+ session_ids.each { |sid| revoke(grants, session_id: sid, by: by, state_dir: state_dir) }
120
+ end
121
+ end
122
+ end