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
|
@@ -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
|
|
6
|
-
# when pasted: on this machine plain
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
|
22
|
-
def attach(id) = ("
|
|
19
|
+
# chi --attach ID (chi --remote NAME --attach ID).
|
|
20
|
+
def attach(id) = command("--attach #{id}")
|
|
23
21
|
|
|
24
|
-
# chi --resume ID
|
|
25
|
-
def resume(id) = ("
|
|
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"):
|
|
28
|
-
|
|
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: …"):
|
|
32
|
-
#
|
|
33
|
-
def open_it(id) =
|
|
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
|
-
|
|
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 / @
|
|
259
|
+
used_tokens = (prompt_chars / @estimate_chars_per_token).ceil + image_tokens
|
|
247
260
|
source = "estimate"
|
|
248
261
|
end
|
|
249
262
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
1891
|
-
|
|
1892
|
-
|
|
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
|
|
1952
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
82
|
-
break if @stopped
|
|
83
|
-
|
|
108
|
+
until @stopped
|
|
84
109
|
tick
|
|
85
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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(
|
|
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
|