samagotchi 0.107.0 → 0.109.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 +69 -1
- data/docs/architecture.md +10 -4
- data/docs/configuration.md +81 -0
- data/docs/internals/engine.md +17 -6
- data/docs/internals/participants.md +28 -8
- data/docs/internals/remote-sessions.md +6 -2
- data/docs/internals/working-set.md +2 -1
- data/docs/memory.md +20 -0
- data/docs/plugins.md +9 -2
- data/docs/releasing.md +11 -6
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +1 -1
- data/docs/testing.md +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
- data/lib/samagotchi/bridge.rb +28 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
- data/lib/samagotchi/child_reports.rb +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_sources.rb +1 -6
- data/lib/samagotchi/continue_offer.rb +8 -6
- data/lib/samagotchi/engine.rb +36 -25
- data/lib/samagotchi/guardrail_wiring.rb +9 -6
- data/lib/samagotchi/idle_scheduler.rb +17 -2
- data/lib/samagotchi/last_turn.rb +7 -0
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/memory_orphans.rb +74 -0
- data/lib/samagotchi/memory_paths.rb +4 -4
- data/lib/samagotchi/parent_report.rb +11 -3
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/reply_wait.rb +7 -2
- data/lib/samagotchi/send_command/busy.rb +5 -2
- data/lib/samagotchi/send_command/wait_target.rb +16 -11
- data/lib/samagotchi/session_access/local.rb +46 -10
- data/lib/samagotchi/session_inbox.rb +12 -8
- data/lib/samagotchi/session_metrics.rb +7 -6
- data/lib/samagotchi/session_participants.rb +3 -1
- data/lib/samagotchi/steer.rb +11 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
- data/lib/samagotchi/tools/memory.rb +16 -9
- data/lib/samagotchi/turn_origin.rb +68 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +233 -26
- data/lib/samagotchi/web/grants.rb +8 -4
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +15 -118
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +7 -13
- data/lib/samagotchi/web/participants_admin.rb +136 -0
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +24 -0
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/participant.js +19 -5
- data/lib/samagotchi/web/public/participant_model.js +5 -0
- data/lib/samagotchi/web/public/participant_names.js +2 -2
- data/lib/samagotchi/web/public/participants_panel.js +2 -2
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/server.rb +70 -10
- data/lib/samagotchi/worker.rb +30 -25
- data/lib/samagotchi/worker_inbound.rb +12 -9
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- data/lib/samagotchi/working_set.rb +3 -2
- metadata +10 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "context_note"
|
|
|
13
13
|
require_relative "context_status"
|
|
14
14
|
require_relative "steer"
|
|
15
15
|
require_relative "turn_note"
|
|
16
|
+
require_relative "turn_origin"
|
|
16
17
|
require_relative "model_profile"
|
|
17
18
|
require_relative "effective_model"
|
|
18
19
|
require_relative "session_setting"
|
|
@@ -57,6 +58,7 @@ require_relative "hook_routes"
|
|
|
57
58
|
require_relative "guardrails"
|
|
58
59
|
require_relative "reminder_store"
|
|
59
60
|
require_relative "reminder_queue"
|
|
61
|
+
require_relative "memory_orphans"
|
|
60
62
|
require_relative "tools/memory"
|
|
61
63
|
require_relative "muted_memories"
|
|
62
64
|
require_relative "used_memories"
|
|
@@ -623,7 +625,7 @@ module Samagotchi
|
|
|
623
625
|
def side_author(kind)
|
|
624
626
|
return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
|
|
625
627
|
|
|
626
|
-
|
|
628
|
+
@guardrail_wiring.turn_author
|
|
627
629
|
end
|
|
628
630
|
|
|
629
631
|
RECAP_SIDE_KIND = "recap"
|
|
@@ -631,7 +633,7 @@ module Samagotchi
|
|
|
631
633
|
# The running turn's author when a participant wrote it, else nil
|
|
632
634
|
# (ctx.turn_author, the hooks' author).
|
|
633
635
|
# @return [String, nil]
|
|
634
|
-
def turn_author = turn_running? ?
|
|
636
|
+
def turn_author = turn_running? ? @guardrail_wiring.turn_author : nil
|
|
635
637
|
|
|
636
638
|
# @return [SessionMetrics] the per-session analytics collector
|
|
637
639
|
attr_reader :metrics
|
|
@@ -984,7 +986,8 @@ module Samagotchi
|
|
|
984
986
|
|
|
985
987
|
# Announce what failed to load and what plugins showed while loading,
|
|
986
988
|
# once, as soon as the owner can show it (the worker's Bridge is up, the
|
|
987
|
-
# REPL renders events): the guardrail and plugin warnings,
|
|
989
|
+
# REPL renders events): the guardrail and plugin warnings, an orphaned
|
|
990
|
+
# project memory folder (label: "memory", MemoryOrphans), then the
|
|
988
991
|
# held notices (between_turns) and cards (in_turn: false, so late
|
|
989
992
|
# joiners get them). Without this call the first turn announces them
|
|
990
993
|
# (#announce_guardrail_failures).
|
|
@@ -997,6 +1000,8 @@ module Samagotchi
|
|
|
997
1000
|
announce({ type: :guardrail_warning, message: message }) if message
|
|
998
1001
|
plugins = @plugin_failures.message
|
|
999
1002
|
announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
|
|
1003
|
+
memory = MemoryOrphans.message
|
|
1004
|
+
announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
|
|
1000
1005
|
@extension_load.held_events.each do |event|
|
|
1001
1006
|
announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
|
|
1002
1007
|
end
|
|
@@ -1448,8 +1453,8 @@ module Samagotchi
|
|
|
1448
1453
|
|
|
1449
1454
|
# Once per Engine, on its first turn: what failed to load, so every UI
|
|
1450
1455
|
# (REPL, attached TUI, web) shows it: the guardrails, then the plugins
|
|
1451
|
-
# (label: "plugins"; the UIs say guardrails without one),
|
|
1452
|
-
# notices and cards plugins showed as they loaded.
|
|
1456
|
+
# (label: "plugins"; the UIs say guardrails without one), an orphaned
|
|
1457
|
+
# project memory folder (label: "memory"), then the notices and cards plugins showed as they loaded.
|
|
1453
1458
|
def announce_guardrail_failures(on_event)
|
|
1454
1459
|
return if @guardrail_failures_announced
|
|
1455
1460
|
|
|
@@ -1458,6 +1463,8 @@ module Samagotchi
|
|
|
1458
1463
|
emit_event(on_event, { type: :guardrail_warning, message: message }) if message
|
|
1459
1464
|
plugins = @plugin_failures.message
|
|
1460
1465
|
emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
|
|
1466
|
+
memory = MemoryOrphans.message
|
|
1467
|
+
emit_event(on_event, { type: :guardrail_warning, message: memory, label: "memory" }) if memory
|
|
1461
1468
|
@extension_load.held_events.each { |event| emit_event(on_event, event) }
|
|
1462
1469
|
end
|
|
1463
1470
|
private :announce_guardrail_failures
|
|
@@ -1494,14 +1501,14 @@ module Samagotchi
|
|
|
1494
1501
|
# @param limit [Integer, nil] the turn ran out of iterations at this
|
|
1495
1502
|
# limit: "exhausted" => true, "limit" => N, so a wait with nobody to
|
|
1496
1503
|
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
1504
|
+
# @param origin [TurnOrigin] the turn's (TurnOrigin::NONE for no sender)
|
|
1497
1505
|
# @param event [Hash] the turn's end event (#stop_facts)
|
|
1498
1506
|
def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
|
|
1499
|
-
|
|
1500
|
-
enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
|
|
1507
|
+
enqueued_id = origin.enqueued_id
|
|
1501
1508
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1502
|
-
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
1509
|
+
origin: origin.source, exhausted: limit ? true : nil, limit: limit,
|
|
1503
1510
|
enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
|
|
1504
|
-
author:
|
|
1511
|
+
author: origin.participant_author,
|
|
1505
1512
|
**stop_facts(event))
|
|
1506
1513
|
end
|
|
1507
1514
|
|
|
@@ -1532,10 +1539,9 @@ module Samagotchi
|
|
|
1532
1539
|
# author:, and author_name: from participants.json), {} for the owner.
|
|
1533
1540
|
# The worker's drain names a participant's merged lines with it, and
|
|
1534
1541
|
# its running save a participant's prompt.
|
|
1535
|
-
# @param
|
|
1542
|
+
# @param author [String, nil] a turn's or a line's author (TurnOrigin#author)
|
|
1536
1543
|
# @param session [Session, nil] the session it is for (the turn's)
|
|
1537
|
-
def author_fields(
|
|
1538
|
-
author = origin.is_a?(Hash) ? origin[:author] : origin
|
|
1544
|
+
def author_fields(author, session: @session)
|
|
1539
1545
|
return {} unless Steer.participant_author?(author)
|
|
1540
1546
|
|
|
1541
1547
|
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
@@ -1995,9 +2001,11 @@ module Samagotchi
|
|
|
1995
2001
|
:settings, :id, :limit, :resumed, :resume_from) do
|
|
1996
2002
|
def resumed? = resumed ? true : false
|
|
1997
2003
|
|
|
1998
|
-
# The turn's boundary events carry the origin only when
|
|
1999
|
-
# so payloads stay unchanged for callers
|
|
2000
|
-
|
|
2004
|
+
# The turn's boundary events carry the origin only when it names a
|
|
2005
|
+
# sender (not TurnOrigin::NONE), so payloads stay unchanged for callers
|
|
2006
|
+
# that don't pass one; as its wire Hash (TurnOrigin#to_h), which the
|
|
2007
|
+
# Bridge and the UIs read.
|
|
2008
|
+
def tag(event) = origin.empty? ? event : event.merge(origin: origin.to_h)
|
|
2001
2009
|
|
|
2002
2010
|
# Seconds since the turn started (the cancel note says how long it ran).
|
|
2003
2011
|
def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
@@ -2032,11 +2040,13 @@ module Samagotchi
|
|
|
2032
2040
|
# run it resumes started (ModelResult#run_start, kept in the offer by
|
|
2033
2041
|
# TurnFlow), so it keeps that run's reasoning; nil starts cold (a
|
|
2034
2042
|
# model change while the offer stood, every other turn).
|
|
2035
|
-
# @param origin [Hash, nil] who queued the turn (
|
|
2036
|
-
# and author
|
|
2037
|
-
# its input file;
|
|
2038
|
-
#
|
|
2039
|
-
#
|
|
2043
|
+
# @param origin [TurnOrigin, Hash, nil] who queued the turn (client_id,
|
|
2044
|
+
# enqueued_id, and author when someone other than the session's owner
|
|
2045
|
+
# wrote it, from its input file; none for the owner; a Hash is read as
|
|
2046
|
+
# one, TurnOrigin.of; TurnOrigin::NONE, the default, names no sender
|
|
2047
|
+
# and so does nil or {}); when it names one, the turn's boundary events
|
|
2048
|
+
# (:turn_started, :turn_completed, :turn_canceled, :turn_failed) carry
|
|
2049
|
+
# it as `origin:` (its wire Hash), and GuardrailWiring#begin_turn gets it
|
|
2040
2050
|
# @param images [Array<Hash>] the prompt's images: {path:} (a file on this
|
|
2041
2051
|
# machine; in-process and attached-TUI callers only) or {file:, name:}
|
|
2042
2052
|
# (already in the session's images/, e.g. a web upload). They are
|
|
@@ -2056,8 +2066,8 @@ module Samagotchi
|
|
|
2056
2066
|
# other error (e.g. an LLM::ProviderError) emits :turn_failed and
|
|
2057
2067
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
2058
2068
|
# one-line summary:.
|
|
2059
|
-
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
|
|
2060
|
-
images: [], id: nil, resumed: false, resume_from: nil)
|
|
2069
|
+
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
|
|
2070
|
+
origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil)
|
|
2061
2071
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
2062
2072
|
continue: continue, id: id)
|
|
2063
2073
|
if continue
|
|
@@ -2108,6 +2118,7 @@ module Samagotchi
|
|
|
2108
2118
|
# the turn flag, its cancel controller and sink. The cross-thread state
|
|
2109
2119
|
# is set here and cleared in #release_turn only.
|
|
2110
2120
|
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil)
|
|
2121
|
+
origin = TurnOrigin.of(origin)
|
|
2111
2122
|
# Track the active session for recap and status snapshot.
|
|
2112
2123
|
@session = session
|
|
2113
2124
|
# status is turn state: running now, idle again before the turn's end
|
|
@@ -2151,7 +2162,7 @@ module Samagotchi
|
|
|
2151
2162
|
turn_started[:cut_after] = steer_cut_after
|
|
2152
2163
|
# A participant's turn names them (author, author_name) for the UIs'
|
|
2153
2164
|
# badge; the owner's turn_started stays as it was.
|
|
2154
|
-
turn_started.merge!(author_fields(turn.origin, session: session))
|
|
2165
|
+
turn_started.merge!(author_fields(turn.origin.author, session: session))
|
|
2155
2166
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
2156
2167
|
raise image_error if image_error
|
|
2157
2168
|
|
|
@@ -2305,7 +2316,7 @@ module Samagotchi
|
|
|
2305
2316
|
# system prompt invalidates cache for the entire prefix.
|
|
2306
2317
|
# Not into a participant's turn: the owner's reminders are the owner's,
|
|
2307
2318
|
# and would be used up there under the review boundary.
|
|
2308
|
-
due_reminders =
|
|
2319
|
+
due_reminders = turn.origin.participant? ? [] : @reminder_queue.inject!(turn.messages)
|
|
2309
2320
|
if due_reminders.any?
|
|
2310
2321
|
emit_event(turn.on_event, {
|
|
2311
2322
|
type: :reminder_injected,
|
|
@@ -2314,7 +2325,7 @@ module Samagotchi
|
|
|
2314
2325
|
end
|
|
2315
2326
|
|
|
2316
2327
|
unless turn.continue
|
|
2317
|
-
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin) }
|
|
2328
|
+
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author) }
|
|
2318
2329
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2319
2330
|
turn.messages << user_message
|
|
2320
2331
|
session.last_prompt = turn.prompt
|
|
@@ -11,6 +11,7 @@ require_relative "version"
|
|
|
11
11
|
require_relative "hooks"
|
|
12
12
|
require_relative "edit_preview"
|
|
13
13
|
require_relative "tool_activity"
|
|
14
|
+
require_relative "turn_origin"
|
|
14
15
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# An Engine's tool guardrails: who can approve (the interface), the YAML
|
|
@@ -55,6 +56,7 @@ module Samagotchi
|
|
|
55
56
|
@failures = Guardrails::LoadFailures.new
|
|
56
57
|
@rules_mutex = Mutex.new
|
|
57
58
|
@git = Guardrails::GitInfo.new
|
|
59
|
+
@origin = TurnOrigin::NONE
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
# Who can answer an approval: :repl, :worker or :non_interactive (the
|
|
@@ -83,17 +85,18 @@ module Samagotchi
|
|
|
83
85
|
@protected = nil
|
|
84
86
|
end
|
|
85
87
|
|
|
86
|
-
# A turn starts: its origin (Engine#run_turn's
|
|
87
|
-
#
|
|
88
|
+
# A turn starts: its origin (Engine#run_turn's TurnOrigin; nil or a
|
|
89
|
+
# Hash is read as one, TurnOrigin.of), and the git facts read afresh.
|
|
88
90
|
def begin_turn(origin)
|
|
89
|
-
@origin = origin
|
|
91
|
+
@origin = TurnOrigin.of(origin)
|
|
90
92
|
@git = Guardrails::GitInfo.new
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
# 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
|
|
96
|
+
# origin's participant author); nil for the owner's turns and every
|
|
97
|
+
# other origin.
|
|
95
98
|
# @return [String, nil]
|
|
96
|
-
def turn_author = @origin.
|
|
99
|
+
def turn_author = @origin.participant_author
|
|
97
100
|
|
|
98
101
|
# The gate every tool call asks first.
|
|
99
102
|
# @return [Guardrails::Gate]
|
|
@@ -229,7 +232,7 @@ module Samagotchi
|
|
|
229
232
|
# @return [Guardrails::Context]
|
|
230
233
|
def context
|
|
231
234
|
Guardrails::Context.new(cwd: Dir.pwd, session_id: @session_lookup.call&.id, interface: interface,
|
|
232
|
-
origin: @origin, git: @git)
|
|
235
|
+
origin: @origin.wire, git: @git)
|
|
233
236
|
end
|
|
234
237
|
|
|
235
238
|
# Ask the user to approve a call the gate voted `ask` on, through the
|
|
@@ -63,7 +63,10 @@ module Samagotchi
|
|
|
63
63
|
# takes) passes join: false: the thread finishes its tick and exits by
|
|
64
64
|
# itself, and a later joining stop (Engine#shutdown, the worker's
|
|
65
65
|
# teardown) still joins it. From the scheduler's own thread (a job's
|
|
66
|
-
# tick) it never joins.
|
|
66
|
+
# tick) it never joins. A thread that died of an error #run_loop doesn't
|
|
67
|
+
# rescue (a SystemStackError, a job's `exit`) is logged here, not raised
|
|
68
|
+
# again by the join into the caller (Engine#shutdown would skip the rest
|
|
69
|
+
# of its teardown: plugin tasks, MCP servers).
|
|
67
70
|
def stop(join: true)
|
|
68
71
|
@stopped = true
|
|
69
72
|
@wake << :stop
|
|
@@ -72,7 +75,7 @@ module Samagotchi
|
|
|
72
75
|
@unjoined = join ? nil : thread
|
|
73
76
|
return if thread.nil? || !join || thread == Thread.current
|
|
74
77
|
|
|
75
|
-
thread.kill unless thread
|
|
78
|
+
thread.kill unless join_thread(thread)
|
|
76
79
|
nil
|
|
77
80
|
end
|
|
78
81
|
|
|
@@ -104,6 +107,18 @@ module Samagotchi
|
|
|
104
107
|
|
|
105
108
|
private
|
|
106
109
|
|
|
110
|
+
# The thread joined within STOP_JOIN_SECONDS (truthy) or not (nil).
|
|
111
|
+
def join_thread(thread)
|
|
112
|
+
thread.join(STOP_JOIN_SECONDS)
|
|
113
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- the thread's own death, re-raised by join
|
|
114
|
+
# A status of nil: the thread ended with this exception. Anything else
|
|
115
|
+
# (an interrupt of the caller's own while it waited) is the caller's.
|
|
116
|
+
raise unless thread.status.nil?
|
|
117
|
+
|
|
118
|
+
Log.exception(:idle, "scheduler_died", e, echo: "[IdleScheduler] scheduler thread died: #{e.class}: #{e.message}")
|
|
119
|
+
thread
|
|
120
|
+
end
|
|
121
|
+
|
|
107
122
|
def run_loop
|
|
108
123
|
until @stopped
|
|
109
124
|
tick
|
data/lib/samagotchi/last_turn.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "steer"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# How a session's last turn ended (Session#last_turn), saved as
|
|
5
7
|
# session.json's "last_turn" and sent in the web's session summary.
|
|
@@ -44,6 +46,11 @@ module Samagotchi
|
|
|
44
46
|
# The stop facts this turn has (LastTurn::STOP_FACTS), symbol keys.
|
|
45
47
|
# @return [Hash{Symbol => Object}]
|
|
46
48
|
def stop_facts = to_h.slice(*self.class::STOP_FACTS).compact
|
|
49
|
+
|
|
50
|
+
# The turn's author when a participant's (Steer.sender_author, as
|
|
51
|
+
# TurnOrigin#participant_author reads a running turn's), else nil.
|
|
52
|
+
# @return [String, nil]
|
|
53
|
+
def participant_author = Steer.sender_author(author)
|
|
47
54
|
end
|
|
48
55
|
|
|
49
56
|
# Why a turn stopped, for a parent agent's wait (ReplyWait::Result takes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "log"
|
|
4
|
+
require_relative "turn_origin"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# The event trail: a SessionObserver subscriber (next to SessionMetrics)
|
|
@@ -181,7 +182,7 @@ module Samagotchi
|
|
|
181
182
|
end
|
|
182
183
|
|
|
183
184
|
def origin(event)
|
|
184
|
-
client =
|
|
185
|
+
client = TurnOrigin.from(event[:origin])&.client_id
|
|
185
186
|
client ? { client_id: client } : {}
|
|
186
187
|
end
|
|
187
188
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "memory_paths"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A project memory folder left behind by a move: memories copied to a
|
|
7
|
+
# machine (or a path) where the project root differs, so its key
|
|
8
|
+
# (MemoryPaths.project_key: basename + MD5 of the root) differs too, and
|
|
9
|
+
# chi finds no project memories without a word about why.
|
|
10
|
+
#
|
|
11
|
+
# Pure: one listing of the projects dir, no git subprocess. Said at
|
|
12
|
+
# session start (Engine#announce_load_events!, label "memory") and under a
|
|
13
|
+
# memory_read miss (Tools::MemoryRead); never in the system prompt.
|
|
14
|
+
module MemoryOrphans
|
|
15
|
+
# @!attribute computed [String] the folder chi reads (this project's key)
|
|
16
|
+
# @!attribute candidates [Array<String>] same-basename folders, other hashes
|
|
17
|
+
# @!attribute computed_exists [Boolean] whether +computed+ is a folder (empty of memories)
|
|
18
|
+
Found = Data.define(:computed, :candidates, :computed_exists)
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# The orphan candidates for +cwd+'s project, or nil: only inside a git
|
|
23
|
+
# repository (outside one the root is cwd, and every src/ or tmp/ would
|
|
24
|
+
# match), and only while the computed folder holds no *.md (a
|
|
25
|
+
# memory_write's mkdir_p or a project-scope bundle install alone must
|
|
26
|
+
# not silence it).
|
|
27
|
+
# @return [Found, nil]
|
|
28
|
+
def find(env: ENV, cwd: Dir.pwd)
|
|
29
|
+
return nil unless MemoryPaths.in_repo?(cwd)
|
|
30
|
+
|
|
31
|
+
projects = MemoryPaths.projects_dir(env: env)
|
|
32
|
+
computed = MemoryPaths.project_dir(env: env, cwd: cwd)
|
|
33
|
+
return nil if File.directory?(computed) && Dir.children(computed).any? { |name| name.end_with?(".md") }
|
|
34
|
+
|
|
35
|
+
key = File.basename(computed)
|
|
36
|
+
# The root's basename, never a split of a key on "_" (a basename may hold one).
|
|
37
|
+
pattern = /\A#{Regexp.escape(File.basename(MemoryPaths.project_root(cwd)))}_\h{8}\z/
|
|
38
|
+
names = Dir.children(projects).select { |name| name != key && name.match?(pattern) }
|
|
39
|
+
candidates = names.sort.map { |name| File.join(projects, name) }.select { |path| File.directory?(path) }
|
|
40
|
+
return nil if candidates.empty?
|
|
41
|
+
|
|
42
|
+
Found.new(computed: computed, candidates: candidates, computed_exists: File.directory?(computed))
|
|
43
|
+
rescue SystemCallError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The one text for the condition (the startup warning and the
|
|
48
|
+
# memory_read hint both say it), or nil when there is none.
|
|
49
|
+
def message(env: ENV, cwd: Dir.pwd)
|
|
50
|
+
found = find(env: env, cwd: cwd)
|
|
51
|
+
found && text(found)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def text(found)
|
|
55
|
+
computed = found.computed
|
|
56
|
+
lead = "no project memories in #{computed}"
|
|
57
|
+
if found.candidates.size == 1
|
|
58
|
+
candidate = found.candidates.first
|
|
59
|
+
fix = if found.computed_exists
|
|
60
|
+
"move its files into #{computed} (e.g. `mv #{candidate}/* #{computed}/`)"
|
|
61
|
+
else
|
|
62
|
+
"`mv #{candidate} #{computed}`"
|
|
63
|
+
end
|
|
64
|
+
"#{lead}, but #{candidate} has the same project name with another path's hash (memories moved from " \
|
|
65
|
+
"another machine or path?). A name match can't prove it's this project; if it is, #{fix}."
|
|
66
|
+
else
|
|
67
|
+
fix = found.computed_exists ? "move its files into #{computed}" : "`mv <that folder> #{computed}`"
|
|
68
|
+
"#{lead}, but these have the same project name with another path's hash (memories moved from another " \
|
|
69
|
+
"machine or path?): #{found.candidates.join(", ")}. A name match can't prove one is this project; if one " \
|
|
70
|
+
"is, #{fix}."
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -23,10 +23,10 @@ module Samagotchi
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# One folder per project root: basename + 8 hex chars of MD5(full root path).
|
|
26
|
-
def project_key(cwd = Dir.pwd)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
def project_key(cwd = Dir.pwd) = key_for_root(project_root(cwd))
|
|
27
|
+
|
|
28
|
+
# #project_key for a root already resolved (a session's project_root).
|
|
29
|
+
def key_for_root(root) = "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
|
|
30
30
|
|
|
31
31
|
# The repository's common git directory identifies the project; every linked
|
|
32
32
|
# worktree shares it. Returns the checkout holding it when it is named .git,
|
|
@@ -335,13 +335,21 @@ module Samagotchi
|
|
|
335
335
|
turn = turn_id || result.turn_id
|
|
336
336
|
turn.to_s.empty? ? WAITING_FOR_OWNER : "#{WAITING_FOR_OWNER}: #{via.command("send --wait --turn #{turn} #{session_id}")}"
|
|
337
337
|
else
|
|
338
|
-
|
|
339
|
-
wait_again = via.command("send --wait --turn #{turn} #{session_id}") unless turn.to_s.empty?
|
|
340
|
-
hint = wait_again || shows
|
|
338
|
+
hint = still_running_hint(session_id, turn_id || result.turn_id, via: via)
|
|
341
339
|
timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
|
|
342
340
|
end
|
|
343
341
|
end
|
|
344
342
|
|
|
343
|
+
# What a wait that left the turn running (a --timeout, a Ctrl-C) offers
|
|
344
|
+
# next: the wait for that turn when its id is known, else the wait for
|
|
345
|
+
# the session's running turn, with the attach that shows it.
|
|
346
|
+
# @param turn [String, nil] the waited turn's id
|
|
347
|
+
def still_running_hint(session_id, turn, via: ChiInvocation::HERE)
|
|
348
|
+
return via.command("send --wait --turn #{turn} #{session_id}") unless turn.to_s.empty?
|
|
349
|
+
|
|
350
|
+
"#{via.command("send --wait #{session_id}")} waits again; #{via.shows(session_id)} shows it"
|
|
351
|
+
end
|
|
352
|
+
|
|
345
353
|
# The whole question for stderr: header, text, numbered options, what
|
|
346
354
|
# else it takes, and the commands that answer it.
|
|
347
355
|
# @return [String] lines, newline-terminated
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The request headers a proxy in front of chi adds and chi's own clients
|
|
5
|
+
# never send: chi web takes a request with one as proxied
|
|
6
|
+
# (Web::ProxyGate), a Bridge refuses it (Bridge#browser_request?). Names
|
|
7
|
+
# are compared lower-cased with "_" read as "-", so an underscore twin
|
|
8
|
+
# (X_Forwarded_For) counts too. See docs/configuration.md "Behind an auth
|
|
9
|
+
# proxy".
|
|
10
|
+
module ProxyTrace
|
|
11
|
+
# Any name starting with one of these: X-Forwarded-For/-Host/-Proto/
|
|
12
|
+
# -Email/-User…, oauth2-proxy's X-Auth-Request-*, Cloudflare's Cf-*,
|
|
13
|
+
# Tailscale Serve's Tailscale-User-*, Authentik's X-Authentik-*, Google
|
|
14
|
+
# IAP's X-Goog-*, Azure App Service's X-MS-Client-Principal*, AWS ALB's
|
|
15
|
+
# X-Amzn-Oidc-*, Envoy's X-Envoy-*.
|
|
16
|
+
PREFIXES = %w[x-forwarded- x-auth-request- cf- tailscale- x-authentik- remote- x-goog- x-ms-client-principal
|
|
17
|
+
x-amzn-oidc- x-envoy- x-original-forwarded-].freeze
|
|
18
|
+
# And these whole names.
|
|
19
|
+
NAMES = %w[forwarded x-real-ip via true-client-ip x-client-ip x-original-url x-original-uri].freeze
|
|
20
|
+
|
|
21
|
+
# The identity headers known setups send (docs/configuration.md's
|
|
22
|
+
# table): chi web's hint when the proxy sends one of them but not the
|
|
23
|
+
# one configured.
|
|
24
|
+
IDENTITY_HEADERS = %w[X-Forwarded-Email X-Auth-Request-Email Cf-Access-Authenticated-User-Email Tailscale-User-Login
|
|
25
|
+
Remote-Email X-Authentik-Email X-Goog-Authenticated-User-Email X-MS-Client-Principal-Name
|
|
26
|
+
X-Amzn-Oidc-Identity X-Forwarded-User X-Auth-Request-User Remote-User].freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param name [String] a header's name as sent (any case, - or _)
|
|
31
|
+
def header?(name)
|
|
32
|
+
key = normalize(name)
|
|
33
|
+
NAMES.include?(key) || PREFIXES.any? { |prefix| key.start_with?(prefix) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize(name) = name.to_s.downcase.tr("_", "-")
|
|
37
|
+
|
|
38
|
+
# The header name of a Rack env key (HTTP_X_FORWARDED_FOR →
|
|
39
|
+
# x-forwarded-for), nil for a key that isn't a header's.
|
|
40
|
+
def env_name(key)
|
|
41
|
+
key = key.to_s
|
|
42
|
+
key.start_with?("HTTP_") ? key.delete_prefix("HTTP_").downcase.tr("_", "-") : nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -221,10 +221,15 @@ module Samagotchi
|
|
|
221
221
|
# merged): that turn runs (its mark has another id, or none from a
|
|
222
222
|
# worker that writes ids), or, idle, a turn not its ended since the
|
|
223
223
|
# baseline. last_turn or a reply file being its own is never that, nor
|
|
224
|
-
# an input with a tombstone (a removal's, written before its claim)
|
|
224
|
+
# an input with a tombstone (a removal's, written before its claim),
|
|
225
|
+
# nor one whose claimed file is still there: a drain deletes the file
|
|
226
|
+
# it merges as it reads it, while a turn of its own keeps it until the
|
|
227
|
+
# turn ends (WorkerInbound#take_input), so a look between that claim
|
|
228
|
+
# and the turn's running save (idle, an earlier turn's end since the
|
|
229
|
+
# baseline) doesn't take the earlier turn's reply for it.
|
|
225
230
|
def merged_elsewhere?(session, turn, baseline, session_dir)
|
|
226
231
|
return false if session.last_turn&.enqueued_id == turn || SessionInbox.reply_for(session_dir, turn)
|
|
227
|
-
return false if SessionInbox.removed?(session_dir, turn)
|
|
232
|
+
return false if SessionInbox.removed?(session_dir, turn) || SessionInbox.claimed_input?(session_dir, turn)
|
|
228
233
|
|
|
229
234
|
if session.status == Session::STATUS_RUNNING
|
|
230
235
|
running_id = session.interrupted_turn&.enqueued_id
|
|
@@ -40,9 +40,12 @@ module Samagotchi
|
|
|
40
40
|
# What the user is told, after the session's short id.
|
|
41
41
|
# @param via [ChiInvocation] how its commands are spelled
|
|
42
42
|
def message(via: ChiInvocation::HERE)
|
|
43
|
-
return "busy: a turn is running; wait or #{via.remote? ? "open it in #{via.web}" : "attach"}" unless error == "waiting"
|
|
44
|
-
|
|
45
43
|
id = session_id
|
|
44
|
+
unless error == "waiting"
|
|
45
|
+
return "busy: a turn is running; --queue runs the message after it, " \
|
|
46
|
+
"#{via.command("send --wait #{id}")} waits for it, #{via.attach(id)} shows it"
|
|
47
|
+
end
|
|
48
|
+
|
|
46
49
|
# The whole question (its options, its tags) for what a parent may
|
|
47
50
|
# give.
|
|
48
51
|
q = ParentReport.question(question, strategy: strategy)
|
|
@@ -13,30 +13,35 @@ module Samagotchi
|
|
|
13
13
|
WaitTarget = Data.define(:turn, :report_turn_id, :queued, :ended_before) do
|
|
14
14
|
def initialize(turn: nil, report_turn_id: nil, queued: false, ended_before: false) = super
|
|
15
15
|
|
|
16
|
-
# A --new session's first message
|
|
17
|
-
#
|
|
18
|
-
# turn)
|
|
16
|
+
# A --new session's first message: its own turn, the ack's id (a
|
|
17
|
+
# file-delivered one's also names the outcome of a session command
|
|
18
|
+
# that runs no turn), so a worker that dies in it and the next one's
|
|
19
|
+
# settle end the wait (TurnCheckpoint.settled_turn). A message merged
|
|
20
|
+
# into a turn starting then falls back to that turn's reply
|
|
21
|
+
# (ReplyWait's turn: without queued:); no id (an older worker), the
|
|
22
|
+
# next reply.
|
|
19
23
|
# @param delivered [SessionManager::DeliverResult, nil] nil when the
|
|
20
24
|
# message started the session itself (no delivery)
|
|
21
25
|
def self.first_message(delivered)
|
|
22
26
|
return new unless delivered
|
|
23
27
|
|
|
24
|
-
new(turn: delivered.
|
|
28
|
+
new(turn: delivered.enqueued_id, report_turn_id: delivered.enqueued_id)
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
# A message to an existing session. --queue: its own turn, the ack's
|
|
28
32
|
# id, also when the turn running at the check ended before it went in
|
|
29
33
|
# (an idle worker's ack says next_step, and the file still runs as
|
|
30
|
-
# that turn). A file fallback's id names its turn too
|
|
31
|
-
#
|
|
32
|
-
# (
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
34
|
+
# that turn). A file fallback's id names its turn too, and so does a
|
|
35
|
+
# plain message to an idle session, as #first_message's. Otherwise
|
|
36
|
+
# (a session left running by a dead worker, the busy check's owner
|
|
37
|
+
# gone) the next reply, reported as the turn that was running when it
|
|
38
|
+
# went in (+running_turn+, ReplyWait.own_turn). A plain message the
|
|
39
|
+
# worker queued (its ack says queue: a participant's review turn was
|
|
40
|
+
# running, which takes no steer) is a --queue's.
|
|
36
41
|
# @param running [Boolean] the session was running when it went in
|
|
37
42
|
def self.message(delivered, queue:, running:, running_turn:)
|
|
38
43
|
queue ||= delivered.delivery == Delivery::QUEUE
|
|
39
|
-
turn = delivered.enqueued_id if queue || delivered.file?
|
|
44
|
+
turn = delivered.enqueued_id if queue || delivered.file? || !running
|
|
40
45
|
report = if queue then turn
|
|
41
46
|
elsif running then running_turn
|
|
42
47
|
else delivered.enqueued_id
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
3
5
|
require_relative "../session"
|
|
4
6
|
require_relative "../session_access"
|
|
5
7
|
require_relative "../reply_wait"
|
|
@@ -18,6 +20,8 @@ require_relative "../participants"
|
|
|
18
20
|
require_relative "../config"
|
|
19
21
|
require_relative "../web/grants"
|
|
20
22
|
require_relative "../web/info_probe"
|
|
23
|
+
require_relative "../web/public_url"
|
|
24
|
+
require_relative "../web/link_codes"
|
|
21
25
|
require_relative "../web/running"
|
|
22
26
|
|
|
23
27
|
module Samagotchi
|
|
@@ -359,17 +363,24 @@ module Samagotchi
|
|
|
359
363
|
grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
|
|
360
364
|
end
|
|
361
365
|
|
|
362
|
-
# The link names this machine's chi web:
|
|
363
|
-
#
|
|
364
|
-
# else
|
|
366
|
+
# The link names this machine's chi web: web.public_url from this
|
|
367
|
+
# machine's config (never what the port answers) when it is set (the
|
|
368
|
+
# token in the fragment, as chi web's own invite), else its LAN address
|
|
369
|
+
# and port when one runs on the LAN (the port it recorded, else
|
|
370
|
+
# web.port), else 127.0.0.1 (+lan+ false: the link opens on this
|
|
371
|
+
# machine only).
|
|
365
372
|
# @return [Invitation]
|
|
366
373
|
# @raise [ArgumentError] no such session
|
|
367
374
|
# @raise [BadRequest] a bad name, kind or ttl, an archived session
|
|
368
375
|
def invite(id, name:, kind:, ttl: nil, cap: nil)
|
|
369
376
|
issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap, state_dir: state_dir!)
|
|
370
|
-
|
|
377
|
+
public_url = configured_public_url
|
|
378
|
+
web = public_url ? nil : local_web
|
|
379
|
+
link = if public_url then public_url.link(link_code(issued), "s=#{id}")
|
|
380
|
+
else "http://#{web.ip || "127.0.0.1"}:#{web.port}/?token=#{issued.token}#s=#{id}"
|
|
381
|
+
end
|
|
371
382
|
Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
|
|
372
|
-
link:
|
|
383
|
+
link: link, lan: !public_url.nil? || !web.ip.nil?,
|
|
373
384
|
warning: ReviewStart.share_warning(Session.load(id, **dir)))
|
|
374
385
|
rescue Web::Grants::Invalid => e
|
|
375
386
|
raise BadRequest.new(e.message, code: "invalid_participant")
|
|
@@ -400,18 +411,43 @@ module Samagotchi
|
|
|
400
411
|
WEB_PORT = 4567
|
|
401
412
|
LOCAL_PROBE_TIMEOUT = 1.0
|
|
402
413
|
|
|
403
|
-
#
|
|
404
|
-
# the port the running one recorded
|
|
405
|
-
# answers there, else config's web.port.
|
|
414
|
+
# Where this machine's chi web answers (GET /api/info): its LAN ip
|
|
415
|
+
# (nil without one) and the port the running one recorded
|
|
416
|
+
# (Web::Running) when a chi web answers there, else config's web.port.
|
|
417
|
+
LocalWeb = Data.define(:ip, :port)
|
|
418
|
+
|
|
419
|
+
# The LAN address a probe answered, only if it is one of this
|
|
420
|
+
# machine's: whatever listens on the port may be another user's
|
|
421
|
+
# listener naming its own (nil then: the link names 127.0.0.1).
|
|
422
|
+
def own_address(ip)
|
|
423
|
+
return nil unless ip.is_a?(String)
|
|
424
|
+
|
|
425
|
+
Socket.ip_address_list.any? { |addr| addr.ip_address == ip } ? ip : nil
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# A one-time code for the grant's link (Web::LinkCodes), until it expires.
|
|
429
|
+
def link_code(issued)
|
|
430
|
+
ttl = [Time.iso8601(issued.grant.expires_at) - grants.now, 1].max
|
|
431
|
+
Web::LinkCodes.for_grants(grants.path).issue(kind: :grant, token: issued.token, ttl: ttl)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# web.public_url as this machine's config has it; nil when unset or
|
|
435
|
+
# not a valid one (chi web refuses to start on that).
|
|
436
|
+
def configured_public_url
|
|
437
|
+
Web::PublicUrl.from_config(url: Config.get("web.public_url"))
|
|
438
|
+
rescue Web::PublicUrl::Invalid
|
|
439
|
+
nil
|
|
440
|
+
end
|
|
441
|
+
|
|
406
442
|
def local_web
|
|
407
443
|
configured = Config.get("web.port").to_s.to_i
|
|
408
444
|
configured = WEB_PORT unless configured.positive?
|
|
409
445
|
recorded = Web::Running.port(path: Web::Running.path(state_dir: state_dir!))
|
|
410
446
|
[recorded, configured].compact.uniq.each do |port|
|
|
411
447
|
info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
|
|
412
|
-
return
|
|
448
|
+
return LocalWeb.new(ip: own_address(info["lan"]), port: port) if info.is_a?(Hash)
|
|
413
449
|
end
|
|
414
|
-
|
|
450
|
+
LocalWeb.new(ip: nil, port: configured)
|
|
415
451
|
end
|
|
416
452
|
|
|
417
453
|
def dir = @state_dir ? { state_dir: @state_dir } : {}
|