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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/docs/architecture.md +10 -4
  4. data/docs/configuration.md +81 -0
  5. data/docs/internals/engine.md +17 -6
  6. data/docs/internals/participants.md +28 -8
  7. data/docs/internals/remote-sessions.md +6 -2
  8. data/docs/internals/working-set.md +2 -1
  9. data/docs/memory.md +20 -0
  10. data/docs/plugins.md +9 -2
  11. data/docs/releasing.md +11 -6
  12. data/docs/sessions.md +3 -3
  13. data/docs/sub-agent.md +1 -1
  14. data/docs/testing.md +5 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
  16. data/lib/samagotchi/bridge.rb +28 -2
  17. data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  21. data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
  23. data/lib/samagotchi/child_reports.rb +2 -1
  24. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  25. data/lib/samagotchi/config.rb +4 -0
  26. data/lib/samagotchi/context_sources.rb +1 -6
  27. data/lib/samagotchi/continue_offer.rb +8 -6
  28. data/lib/samagotchi/engine.rb +36 -25
  29. data/lib/samagotchi/guardrail_wiring.rb +9 -6
  30. data/lib/samagotchi/idle_scheduler.rb +17 -2
  31. data/lib/samagotchi/last_turn.rb +7 -0
  32. data/lib/samagotchi/log_subscriber.rb +2 -1
  33. data/lib/samagotchi/memory_orphans.rb +74 -0
  34. data/lib/samagotchi/memory_paths.rb +4 -4
  35. data/lib/samagotchi/parent_report.rb +11 -3
  36. data/lib/samagotchi/proxy_trace.rb +45 -0
  37. data/lib/samagotchi/reply_wait.rb +7 -2
  38. data/lib/samagotchi/send_command/busy.rb +5 -2
  39. data/lib/samagotchi/send_command/wait_target.rb +16 -11
  40. data/lib/samagotchi/session_access/local.rb +46 -10
  41. data/lib/samagotchi/session_inbox.rb +12 -8
  42. data/lib/samagotchi/session_metrics.rb +7 -6
  43. data/lib/samagotchi/session_participants.rb +3 -1
  44. data/lib/samagotchi/steer.rb +11 -1
  45. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
  46. data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
  47. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
  48. data/lib/samagotchi/tools/memory.rb +16 -9
  49. data/lib/samagotchi/turn_origin.rb +68 -0
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +233 -26
  52. data/lib/samagotchi/web/grants.rb +8 -4
  53. data/lib/samagotchi/web/info_probe.rb +5 -1
  54. data/lib/samagotchi/web/link_codes.rb +146 -0
  55. data/lib/samagotchi/web/participant_access.rb +15 -118
  56. data/lib/samagotchi/web/participant_review.rb +1 -1
  57. data/lib/samagotchi/web/participant_view.rb +7 -13
  58. data/lib/samagotchi/web/participants_admin.rb +136 -0
  59. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  60. data/lib/samagotchi/web/public/app.js +24 -0
  61. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  62. data/lib/samagotchi/web/public/data.js +29 -5
  63. data/lib/samagotchi/web/public/participant.js +19 -5
  64. data/lib/samagotchi/web/public/participant_model.js +5 -0
  65. data/lib/samagotchi/web/public/participant_names.js +2 -2
  66. data/lib/samagotchi/web/public/participants_panel.js +2 -2
  67. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  68. data/lib/samagotchi/web/public_url.rb +87 -0
  69. data/lib/samagotchi/web/server.rb +70 -10
  70. data/lib/samagotchi/worker.rb +30 -25
  71. data/lib/samagotchi/worker_inbound.rb +12 -9
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. data/lib/samagotchi/working_set.rb +3 -2
  74. metadata +10 -1
@@ -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
- Steer.sender_author(@guardrail_wiring.turn_author)
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? ? Steer.sender_author(@guardrail_wiring.turn_author) : nil
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, then the
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), then the
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
- source = ClientId.origin(origin.is_a?(Hash) ? origin[:client_id] : nil)
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: Steer.sender_author(origin.is_a?(Hash) ? origin[:author] : nil),
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 origin [Hash, String, nil] a turn's origin, or an author
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(origin, session: @session)
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 there is one,
1999
- # so payloads stay unchanged for callers that don't pass it.
2000
- def tag(event) = origin ? event.merge(origin: origin) : event
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 ({client_id:, enqueued_id:},
2036
- # and author: when someone other than the session's owner wrote it, from
2037
- # its input file; absent for the owner); when given, the turn's boundary
2038
- # events (:turn_started, :turn_completed, :turn_canceled, :turn_failed)
2039
- # carry it as `origin:`, and GuardrailWiring#begin_turn gets it
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, origin: nil,
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 = Steer.participant_author?(turn.origin&.dig(:author)) ? [] : @reminder_queue.inject!(turn.messages)
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: {client_id:,
87
- # enqueued_id:, author:}, each optional), and the git facts read afresh.
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 other origin.
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.is_a?(Hash) ? @origin[:author] : nil
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.join(STOP_JOIN_SECONDS)
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
@@ -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 = event.dig(:origin, :client_id) if event[:origin].is_a?(Hash)
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
- root = project_root(cwd)
28
- "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
29
- end
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
- turn = turn_id || result.turn_id
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. A file-delivered one's own turn
17
- # (its id also names the outcome of a session command that runs no
18
- # turn); through the Bridge, the next reply.
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.file? ? delivered.enqueued_id : nil, report_turn_id: delivered.enqueued_id)
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. Otherwise the
31
- # next reply, reported as the turn that was running when it went in
32
- # (+running_turn+, ReplyWait.own_turn), or as its own on an idle one.
33
- # A plain message the worker queued (its ack says queue: a
34
- # participant's review turn was running, which takes no steer) is a
35
- # --queue's.
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: its LAN address and port
363
- # when one runs on the LAN (the port it recorded, else web.port),
364
- # else 127.0.0.1 (+lan+ false).
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
- ip, port = local_web
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: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?,
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
- # [LAN ip or nil, port] of this machine's chi web (GET /api/info):
404
- # the port the running one recorded (Web::Running) when a chi web
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 [info["lan"], port] if info.is_a?(Hash)
448
+ return LocalWeb.new(ip: own_address(info["lan"]), port: port) if info.is_a?(Hash)
413
449
  end
414
- [nil, configured]
450
+ LocalWeb.new(ip: nil, port: configured)
415
451
  end
416
452
 
417
453
  def dir = @state_dir ? { state_dir: @state_dir } : {}