samagotchi 0.103.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +86 -12
  6. data/docs/guardrails.md +2 -1
  7. data/docs/internals/context-telemetry.md +6 -2
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/participants.md +137 -0
  10. data/docs/internals/remote-sessions.md +153 -27
  11. data/docs/plugins.md +7 -1
  12. data/docs/sessions.md +4 -2
  13. data/docs/sub-agent.md +8 -0
  14. data/docs/testing.md +68 -4
  15. data/lib/samagotchi/bridge.rb +7 -3
  16. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_invocation.rb +13 -16
  20. data/lib/samagotchi/context_status.rb +15 -2
  21. data/lib/samagotchi/engine.rb +16 -6
  22. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_scheduler.rb +34 -9
  25. data/lib/samagotchi/kernel_loop.rb +8 -1
  26. data/lib/samagotchi/launch_mode.rb +4 -0
  27. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  28. data/lib/samagotchi/owner_lock.rb +65 -5
  29. data/lib/samagotchi/participants.rb +122 -0
  30. data/lib/samagotchi/remote_attach_command.rb +119 -0
  31. data/lib/samagotchi/run_options.rb +14 -2
  32. data/lib/samagotchi/send_command.rb +1 -1
  33. data/lib/samagotchi/session.rb +8 -4
  34. data/lib/samagotchi/session_access/contract.rb +42 -4
  35. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  36. data/lib/samagotchi/session_access/local.rb +52 -1
  37. data/lib/samagotchi/session_access/remote.rb +112 -16
  38. data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
  39. data/lib/samagotchi/session_access.rb +85 -3
  40. data/lib/samagotchi/session_continue.rb +1 -1
  41. data/lib/samagotchi/session_inbox.rb +18 -9
  42. data/lib/samagotchi/session_manager.rb +21 -5
  43. data/lib/samagotchi/sessions_command.rb +4 -4
  44. data/lib/samagotchi/share_command.rb +174 -0
  45. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  46. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
  48. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  49. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  50. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  51. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  52. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  53. data/lib/samagotchi/terminal_ui.rb +4 -1
  54. data/lib/samagotchi/testing/waiting.rb +92 -9
  55. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +187 -74
  58. data/lib/samagotchi/web/grants.rb +302 -0
  59. data/lib/samagotchi/web/participant_access.rb +286 -0
  60. data/lib/samagotchi/web/principal.rb +29 -0
  61. data/lib/samagotchi/web/server.rb +3 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  63. data/lib/samagotchi/worker.rb +26 -5
  64. metadata +10 -1
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_access"
5
+ require_relative "session_access/remote"
6
+ require_relative "launch_mode"
7
+ require_relative "model_profile"
8
+ require_relative "cli/exit"
9
+ require_relative "terminal_ui"
10
+ require_relative "terminal_ui/attach_launcher"
11
+ require_relative "terminal_ui/remote_connector"
12
+
13
+ module Samagotchi
14
+ # chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT]: the
15
+ # attached TUI on a session of another machine, through its chi web
16
+ # (bin/chi branches here after RunOptions.parse, before its own checks,
17
+ # which are this machine's). The run flags come parsed; the ones that
18
+ # run this machine's REPL or read its files were refused already
19
+ # (SessionAccess::Contract::RUN). The setup flags go as typed: that
20
+ # machine checks them against its own config and bundles.
21
+ class RemoteAttachCommand
22
+ # @param options [Hash] RunOptions.parse's
23
+ # @param access [SessionAccess::Remote]
24
+ def initialize(options, access:, stderr: $stderr, launcher: TerminalUI::AttachLauncher,
25
+ connector: TerminalUI::RemoteConnector)
26
+ @options = options
27
+ @access = access
28
+ @stderr = stderr
29
+ @launcher = launcher
30
+ @connector = connector
31
+ end
32
+
33
+ # @return [Integer] the exit status: AttachLauncher.exit_status's, 1 a
34
+ # launch that failed, 2 a usage error, 130 a Ctrl-C before the TUI
35
+ # ran (the wake, a wait for a leaving worker there)
36
+ def run
37
+ if (conflict = LaunchMode.attached_conflict(@options))
38
+ return failed(conflict)
39
+ end
40
+
41
+ existing = @options[:attach] || @options[:resume]
42
+ return usage(new_needs_dir) if !existing && !absolute_dir?
43
+
44
+ @access.require_feature!(SessionAccess::Remote::ATTACH_FEATURE)
45
+ id = existing ? @access.resolve(existing) : create
46
+ return id if id.is_a?(Integer)
47
+
48
+ ignored(id) if existing
49
+ launch(id, existing: existing)
50
+ rescue TerminalUI::AttachLauncher::Error, SessionAccess::RemoteFailed, SessionAccess::Refused,
51
+ SessionManager::OwnedByTUI, ModelProfile::MissingModel, ArgumentError => e
52
+ e.is_a?(SessionAccess::BadRequest) ? usage(e.message) : failed(e.message)
53
+ rescue Interrupt
54
+ CLI::Exit::INTERRUPTED
55
+ end
56
+
57
+ private
58
+
59
+ def launch(id, existing:)
60
+ model = @options[:model]
61
+ ended = @launcher.run(attach: (id if @options[:attach]), resume: (id if @options[:resume]), prompt: @options[:prompt],
62
+ model: (model if existing), no_interrupt: !!@options[:no_interrupt],
63
+ default_input: !@options[:no_default_input],
64
+ llm_context: (existing && @options[:llm_context]) || {},
65
+ thinking: (@options[:thinking] if existing),
66
+ connector: @connector.new(@access, session_id: id))
67
+ @launcher.exit_status(ended)
68
+ end
69
+
70
+ # A new session there, idle (its -p goes in once attached), as chi send
71
+ # --new makes one: the create's warnings printed as it prints them.
72
+ # @return [String, Integer] its id, or the exit status of a refusal
73
+ def create
74
+ settings = Array(@options[:sets]).to_h do |pair|
75
+ key, sep, value = pair.partition("=")
76
+ return usage("--set takes KEY=VALUE, not #{pair}") if sep.empty? || key.strip.empty?
77
+
78
+ [key.strip, value]
79
+ end
80
+ llm = @options[:llm_context] || {}
81
+ words = SessionAccess::SetupWords.new(
82
+ llm_context: (llm unless llm.empty?), thinking: @options[:thinking]&.to_s, toolkit: @options[:toolkit],
83
+ tools: Array(@options[:tools]), no_tools: Array(@options[:no_tools]),
84
+ settings: (settings unless settings.empty?), parent_answers: @options[:parent_answers]
85
+ )
86
+ created = @access.create(SessionAccess::NewSession.new(prompt: nil, idle: true, working_directory: @options[:dir],
87
+ model_name: @options[:model], setup: words,
88
+ client_id: @connector.client_id))
89
+ @stderr.puts("chi: warning: #{created.model_warning}") if created.model_warning
90
+ @stderr.puts("chi: warning: --parent-answers #{created.parent_answers_warning}") if created.parent_answers_warning
91
+ created.id
92
+ end
93
+
94
+ # An existing session keeps its own setup: the line says which flags
95
+ # went unused, as chi --attach here says it.
96
+ def ignored(id)
97
+ flags = [("--dir" if @options[:dir]), ("--toolkit" if @options[:toolkit]), ("--tool" if @options[:tools]),
98
+ ("--no-tool" if @options[:no_tools]), ("--set" if @options[:sets]),
99
+ ("--parent-answers" if @options[:parent_answers])].compact
100
+ TerminalUI::AttachLauncher.warn_memory_flags_ignored(id, [], [], @stderr, toolkit_flags: flags)
101
+ end
102
+
103
+ def absolute_dir? = @options[:dir].to_s.start_with?("/")
104
+
105
+ def new_needs_dir
106
+ "a new session on #{@access.name} needs --dir, that machine's absolute path (or --attach ID / --resume ID)"
107
+ end
108
+
109
+ def failed(message)
110
+ @stderr.puts("Error: #{message}")
111
+ CLI::Exit::FAILED
112
+ end
113
+
114
+ def usage(message)
115
+ @stderr.puts("Error: #{message}")
116
+ CLI::Exit::USAGE
117
+ end
118
+ end
119
+ end
@@ -14,7 +14,7 @@ module Samagotchi
14
14
  module RunOptions
15
15
  # The flags an underscored spelling is refused for (--recap_base_url),
16
16
  # with the config registry's.
17
- KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
17
+ KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
18
18
 
19
19
  module_function
20
20
 
@@ -107,6 +107,14 @@ module Samagotchi
107
107
  [options, cli_overrides]
108
108
  end
109
109
 
110
+ # The switches that take a value (--model NAME, -p TEXT), long and
111
+ # short: a word after one is its value, not a flag.
112
+ # @return [Array<String>]
113
+ def value_switches
114
+ parser_switches(build_parser({}, {})).select { |switch| switch.is_a?(OptionParser::Switch::RequiredArgument) }
115
+ .flat_map { |switch| switch.long + switch.short }.push("--no-tool").uniq
116
+ end
117
+
110
118
  # Every switch the parser takes, long and short, a --[no-]x as --x and
111
119
  # --no-x (--no-tool too, read before the parse), for what classifies the
112
120
  # run flags.
@@ -138,12 +146,14 @@ module Samagotchi
138
146
  chi context <add|push|ls|show|refresh|rm|mute|unmute> live external text attached to sessions
139
147
  chi send [-m TEXT] ID...|--new a message into sessions, as if typed there
140
148
  chi answer ID --question QID --option N answer a question a session waits on
149
+ chi share ID --name NAME | ls [ID] | revoke ID [P]|--all participants who review a session
141
150
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
142
151
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
143
152
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
144
153
  chi models [--format json] [TEXT] the models --model takes, from every host
145
154
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
146
- chi --remote NAME <send|answer|note|context|sessions> … those commands on that machine (or CHI_REMOTE=NAME)
155
+ chi --remote NAME <send|answer|note|context|sessions|share> … those commands on that machine (or CHI_REMOTE=NAME)
156
+ chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
147
157
  chi self version, paths, model and bundles
148
158
  chi update [--dry-run] update this chi, its bundles and the desktop helper
149
159
  (each subcommand takes --help)
@@ -152,6 +162,8 @@ module Samagotchi
152
162
  opts.on("-p", "--prompt PROMPT", "Run PROMPT as the first turn, then stay in the REPL") { |p| options[:prompt] = p }
153
163
  opts.on("--resume SESSION_ID", "Resume a previous session by its ID (or a unique prefix of it)") { |s| options[:resume] = s }
154
164
  opts.on("--attach SESSION_ID", "Attach to a session's worker, waking one if it has exited; ID or a unique prefix") { |s| options[:attach] = s }
165
+ opts.on("--dir DIR", "With --remote: a new session's folder on that machine, its absolute path (required there; " \
166
+ "here cd instead)") { |d| options[:dir] = d }
155
167
  opts.on("--[no-]shared", "Run the session in a background worker and attach to it, so `chi web` can share it (the default, config session.shared; with --resume: that session); --no-shared runs a plain in-process REPL") { |v| options[:shared] = v }
156
168
  opts.on("--model NAME", "Model for this run, not saved (overrides --resume's; config default.model) [env SAMAGOTCHI_DEFAULT_MODEL]") { |m| options[:model] = m; cli_overrides["default.model"] = m }
157
169
  opts.on("--profile NAME", Samagotchi::ModelProfile::NAMES,
@@ -646,7 +646,7 @@ module Samagotchi
646
646
  # its preview; the user can attach and send it again.
647
647
  unless result
648
648
  attach = @access.invocation.attach(session.id)
649
- @info.puts("#{session.id} failed: its worker did not start; the session is kept#{" (#{attach})" if attach}")
649
+ @info.puts("#{session.id} failed: its worker did not start; the session is kept (#{attach})")
650
650
  return false
651
651
  end
652
652
  unless result.accepted?
@@ -395,10 +395,12 @@ module Samagotchi
395
395
  # @param messages [Array<Hash>] a conversation to start from (a fork's
396
396
  # seed); [] by default
397
397
  # @param setup [SessionSetup] the session's own settings (none by default)
398
+ # @param now [Time] its created_at and updated_at (a spec's clock)
398
399
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
399
400
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
400
- scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil)
401
- now = Time.now.iso8601(3)
401
+ scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil,
402
+ now: Time.now)
403
+ now = now.iso8601(3)
402
404
  resolved_test = if test_run.nil?
403
405
  test_session_env?
404
406
  else
@@ -614,9 +616,11 @@ module Samagotchi
614
616
  # Never call it from a trap handler (Mutex#lock raises ThreadError
615
617
  # there; WorkerSignals saves on a thread of its own) or from inside a
616
618
  # save (the lock isn't re-entrant).
617
- def save(state_dir: self.class.default_state_dir)
619
+ # @param now [Time, nil] the updated_at to stamp (a spec's clock); nil,
620
+ # the time the lock is taken
621
+ def save(state_dir: self.class.default_state_dir, now: nil)
618
622
  @save_lock.synchronize do
619
- @updated_at = Time.now.iso8601(3)
623
+ @updated_at = (now || Time.now).iso8601(3)
620
624
  # Persist with current metadata version so new flag is written
621
625
  @metadata_version = METADATA_VERSION
622
626
  FileUtils.mkdir_p(state_dir)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../config"
4
+
3
5
  module Samagotchi
4
6
  module SessionAccess
5
7
  # Which session commands and flags run against another machine's
@@ -7,13 +9,16 @@ module Samagotchi
7
9
  # and which run on this machine only (refused under a remote, exit 2:
8
10
  # "chi <cmd> runs on this machine only: run it on <remote>"). The v1 scope is the remote-sessions plan's
9
11
  # D3: send, answer, note, a session's context (add, push, ls, show, rm)
10
- # and sessions list/stop/restart/archive/unarchive/delete; the rest
12
+ # and sessions list/stop/restart/archive/unarchive/delete, and chi share
13
+ # (the shared-sessions plan's P2); the rest
11
14
  # stays local. bin/chi's subcommands that act on the machine, not on
12
- # sessions, are MACHINE. The run flags (--attach, -p, --resume) are not
13
- # subcommands and not in this table (remote attach is its own phase).
15
+ # sessions, are MACHINE. The run flags (chi --attach, -p, --resume: a
16
+ # session in this terminal, a remote attach there) are RUN's.
14
17
  #
15
18
  # spec/session_access_contract_spec.rb fails when a subcommand, one of
16
- # its subcommands or one of a supported one's flags is in neither.
19
+ # its subcommands or one of a supported one's flags is in neither, or
20
+ # a run flag (RunOptions.switches) is in neither RUN list and isn't a
21
+ # config registry flag.
17
22
  module Contract
18
23
  SUPPORTED = :supported
19
24
  LOCAL_ONLY = :local_only
@@ -111,11 +116,44 @@ module Samagotchi
111
116
  "sessions clean" => LOCAL_ONLY,
112
117
  "sessions stats" => LOCAL_ONLY,
113
118
 
119
+ # Participants (docs/internals/participants.md): the remote's link,
120
+ # through its chi web's participants routes.
121
+ "share" => SUPPORTED,
122
+ "share --name" => SUPPORTED,
123
+ "share --agent" => SUPPORTED,
124
+ "share --expires" => SUPPORTED,
125
+ "share ls" => SUPPORTED,
126
+ "share ls --format" => SUPPORTED,
127
+ "share revoke" => SUPPORTED,
128
+ "share revoke --all" => SUPPORTED,
129
+
114
130
  "broadcast" => LOCAL_ONLY
115
131
  }.freeze
116
132
 
133
+ # bin/chi's run flags under a remote (chi --remote NAME --attach ID,
134
+ # RemoteAttachCommand): what a remote attach takes, and what runs
135
+ # this machine's REPL or reads this machine's files (P4-D2..D4). A
136
+ # config registry flag (--recap-base-url …) is this machine's
137
+ # config: LOCAL_ONLY without being listed.
138
+ RUN = {
139
+ SUPPORTED => %w[--attach --resume -p --prompt --shared --model --thinking --llm-context --llm-context-apply
140
+ --llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
141
+ --no-default-input --dir --version -h --help],
142
+ LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
143
+ --port --open --new-token --scope --web-markdown --no-web-markdown]
144
+ }.freeze
145
+ # A short run flag's name in a refusal.
146
+ RUN_WORDS = { "-v" => "--verbose" }.freeze
147
+
117
148
  module_function
118
149
 
150
+ # @param flag [String] a run flag as RunOptions.switches lists it
151
+ # @return [Symbol, nil] SUPPORTED or LOCAL_ONLY; nil: not a run flag
152
+ def run_status(flag)
153
+ RUN.each { |status, flags| return status if flags.include?(flag) }
154
+ LOCAL_ONLY if Config.cli_entries.any? { |entry| [entry.cli_flag, entry.cli_flag.sub("--", "--no-")].include?(flag) }
155
+ end
156
+
119
157
  # @param words [Array<String>] a command, maybe its subcommand, maybe
120
158
  # a flag's long name: status("context", "add", "--cmd")
121
159
  # @return [Symbol, nil] SUPPORTED, LOCAL_ONLY, or nil when not listed
@@ -21,10 +21,8 @@ module Samagotchi
21
21
  # bin/chi's own words besides its SUBCOMMANDS (a refusal names them).
22
22
  OWN_WORDS = %w[web scratch self].freeze
23
23
  HELP = %w[--version -h --help].freeze
24
- # The run flags (not subcommands) a refusal names: remote attach is
25
- # its own phase (P4).
26
- RUN_FLAGS = { "--attach" => "--attach", "--resume" => "--resume", "-p" => "-p", "--prompt" => "-p",
27
- "--shared" => "--shared", "--no-shared" => "--no-shared" }.freeze
24
+ # --non-interactive's refusal: what runs one turn there instead.
25
+ ONE_TURN = "(%s send --new --dir D -m TEXT --wait runs one turn there)"
28
26
 
29
27
  # A usage error in the flag itself (--remote with no name, a bad one).
30
28
  class Usage < StandardError; end
@@ -54,6 +52,14 @@ module Samagotchi
54
52
 
55
53
  def remote? = !@remote.nil?
56
54
 
55
+ # Whether the arguments are run flags, no subcommand: a session in
56
+ # this terminal (bin/chi's RunOptions parse them; under a remote,
57
+ # RemoteAttachCommand runs them).
58
+ def run_flags?
59
+ command = @argv.first
60
+ command.nil? || command.start_with?("-")
61
+ end
62
+
57
63
  # Whether the arguments run here as they would without a remote: chi
58
64
  # remote, --version, --help.
59
65
  def here?
@@ -67,7 +73,8 @@ module Samagotchi
67
73
  def refusal
68
74
  words = local_words or return nil
69
75
 
70
- "chi #{words} runs on this machine only: run it on #{@remote}"
76
+ line = "chi #{words} runs on this machine only: run it on #{@remote}"
77
+ words == "--non-interactive" ? "#{line} #{format(ONE_TURN, "chi --remote #{@remote}")}" : line
71
78
  end
72
79
 
73
80
  # The session command against the remote: refused (exit 2), or run
@@ -101,11 +108,11 @@ module Samagotchi
101
108
  private
102
109
 
103
110
  # The words that run here only: the command (and its subcommand, a
104
- # flag) the Contract lists local_only, a machine command, or the run
105
- # flags (a session in this terminal, attach). nil: supported.
111
+ # flag) the Contract lists local_only, a machine command, or a run
112
+ # flag Contract::RUN lists local_only. nil: supported.
106
113
  def local_words
107
114
  command = @argv.first
108
- return run_words if command.nil? || command.start_with?("-")
115
+ return run_words if run_flags?
109
116
  return command if Contract::MACHINE.include?(command) || Contract.status(command).nil?
110
117
  return command if Contract.status(command) == Contract::LOCAL_ONLY
111
118
 
@@ -124,9 +131,21 @@ module Samagotchi
124
131
  @argv.drop(1).take_while { |arg| arg != "--" }.select { |arg| arg.start_with?("--") }.map { |arg| arg.split("=", 2).first }
125
132
  end
126
133
 
134
+ # The first local-only run flag, a value (-p TEXT, --model NAME)
135
+ # never read as one. An unknown flag is the parser's to refuse.
127
136
  def run_words
128
- flag = @argv.take_while { |arg| arg != "--" }.map { |arg| arg.split("=", 2).first }.find { |arg| RUN_FLAGS.key?(arg) }
129
- flag ? RUN_FLAGS[flag] : "(a session in this terminal)"
137
+ require_relative "../run_options"
138
+ values = RunOptions.value_switches
139
+ words = @argv.take_while { |arg| arg != "--" }
140
+ index = 0
141
+ while index < words.size
142
+ arg = words[index]
143
+ flag = arg.start_with?("--") ? arg.split("=", 2).first : arg[0, 2]
144
+ return Contract::RUN_WORDS.fetch(flag, flag) if arg.start_with?("-") && Contract.run_status(flag) == Contract::LOCAL_ONLY
145
+
146
+ index += values.include?(arg) ? 2 : 1
147
+ end
148
+ nil
130
149
  end
131
150
  end
132
151
  end
@@ -4,6 +4,7 @@ require_relative "../session"
4
4
  require_relative "../session_access"
5
5
  require_relative "../reply_wait"
6
6
  require_relative "../bridge_client"
7
+ require_relative "../owner_lock"
7
8
  require_relative "../parent_report"
8
9
  require_relative "../note_delivery"
9
10
  require_relative "../image_store"
@@ -12,6 +13,10 @@ require_relative "../send_command/busy"
12
13
  require_relative "../chi_invocation"
13
14
  require_relative "../worker_sidecar"
14
15
  require_relative "../launch_mode"
16
+ require_relative "../participants"
17
+ require_relative "../config"
18
+ require_relative "../web/grants"
19
+ require_relative "../web/info_probe"
15
20
 
16
21
  module Samagotchi
17
22
  # Loaded on first use: SessionManager and its ~80 files only when a
@@ -69,7 +74,10 @@ module Samagotchi
69
74
 
70
75
  # The session's worker for an attach: the live one as is, else
71
76
  # resumed and waited for up to +wait+ seconds (chi --attach on this
72
- # machine; chi web's POST …/wake for a remote one).
77
+ # machine; chi web's POST …/wake for a remote one). A worker still
78
+ # leaving (its Bridge closed, its recap on leave being written) is
79
+ # waited for first (OwnerLock.await_release: its recap's timeout and
80
+ # a margin), so the resume finds the session free.
73
81
  # @return [Woken] its sidecar nil when the Bridge didn't come in time
74
82
  # @raise [ArgumentError] no such session
75
83
  # @raise [Refused] code scratch: a leftover chi scratch session
@@ -81,6 +89,7 @@ module Samagotchi
81
89
  live = WorkerSidecar.live(session_dir(id), unlink: true)
82
90
  return Woken.new(woke: false, sidecar: live) if live
83
91
 
92
+ OwnerLock.await_release(session_dir(id))
84
93
  SessionManager.resume_session(id, **dir)
85
94
  found = BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: wait)
86
95
  Woken.new(woke: true, sidecar: found && WorkerSidecar.read(session_dir(id)))
@@ -322,8 +331,50 @@ module Samagotchi
322
331
  end
323
332
  end
324
333
 
334
+ # --- participants (chi share)
335
+
336
+ # Remote's: through this machine's grants store, as chi web would.
337
+ # @return [Array<ParticipantView>]
338
+ def participants(id = nil)
339
+ grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
340
+ end
341
+
342
+ # The link names this machine's chi web: its LAN address when one
343
+ # runs on the LAN (web.port), else 127.0.0.1 (+lan+ false).
344
+ # @return [Invitation]
345
+ # @raise [ArgumentError] no such session
346
+ # @raise [BadRequest] a bad name, kind or ttl, an archived session
347
+ def invite(id, name:, kind:, ttl: nil)
348
+ issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, state_dir: state_dir!)
349
+ ip, port = local_web
350
+ Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
351
+ link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?)
352
+ rescue Web::Grants::Invalid => e
353
+ raise BadRequest.new(e.message, code: "invalid_participant")
354
+ end
355
+
356
+ # @return [Array<ParticipantView>] the ones it revoked
357
+ def revoke_participants(id = nil, participant: nil)
358
+ Participants.revoke(grants, session_id: id, id: participant, state_dir: state_dir!)
359
+ .map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
360
+ end
361
+
325
362
  private
326
363
 
364
+ def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
365
+
366
+ # Web::Server::DEFAULT_PORT, without loading chi web.
367
+ WEB_PORT = 4567
368
+ LOCAL_PROBE_TIMEOUT = 1.0
369
+
370
+ # [LAN ip or nil, port] of this machine's chi web (GET /api/info).
371
+ def local_web
372
+ port = Config.get("web.port").to_s.to_i
373
+ port = WEB_PORT unless port.positive?
374
+ info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
375
+ [info.is_a?(Hash) ? info["lan"] : nil, port]
376
+ end
377
+
327
378
  def dir = @state_dir ? { state_dir: @state_dir } : {}
328
379
  def state_dir! = @state_dir || Session.default_state_dir
329
380
  def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
@@ -74,8 +74,8 @@ module Samagotchi
74
74
  # add checks the same).
75
75
  # @return [RemoteFailed, nil]
76
76
  def self.info_problem(entry, reply)
77
- failure = auth_failure(entry, reply)
78
- return RemoteFailed.new(failure) if failure
77
+ refused = access_error(entry, reply)
78
+ return refused if refused
79
79
  unless reply.status == 200 && reply.json.is_a?(Hash) && reply.json["app"] == "chi-web"
80
80
  return RemoteFailed.new("#{entry.label}: not chi web (GET /api/info answered #{reply.status})")
81
81
  end
@@ -91,20 +91,31 @@ module Samagotchi
91
91
  "update chi there (chi update)"
92
92
  end
93
93
 
94
- # The words for a 401 or a 403 forbidden from +entry+'s chi web, nil
95
- # for any other answer (chi remote add says the same).
96
- def self.auth_failure(entry, reply)
94
+ # The errors any route of +entry+'s chi web can answer, each its one
95
+ # message: the token refused (401 unauthorized) or ended (401
96
+ # token_revoked / token_expired), the LAN IP only (403 forbidden), a
97
+ # participant's token on an owner's route (403 not_permitted) or over
98
+ # its limits (429 too_many_turns / cap_reached). nil for any other
99
+ # answer (chi remote add says the same).
100
+ # @return [RemoteFailed, nil]
101
+ def self.access_error(entry, reply)
102
+ error = reply.error
103
+ known = ACCESS_ERRORS.find { |klass| error == klass::CODE }
104
+ return known.new(entry.label) if known
105
+
97
106
  case reply.status
98
- when 401 then "#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
99
- "chi remote add #{entry.name} #{entry.url} --token-file FILE)"
107
+ when 401 then AuthRefused.new("#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
108
+ "chi remote add #{entry.name} #{entry.url} --token-file FILE)")
100
109
  when 403
101
- return nil unless reply.error == "forbidden"
110
+ return nil unless error == "forbidden"
102
111
 
103
112
  ip = reply.json["detail"].to_s[/ and (\S+) are answered/, 1]
104
- "#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname"
113
+ AuthRefused.new("#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname")
105
114
  end
106
115
  end
107
116
 
117
+ ACCESS_ERRORS = [TokenRevoked, TokenExpired, NotPermitted, TooManyTurns, CapReached].freeze
118
+
108
119
  # --- ids and sessions
109
120
 
110
121
  # @raise [Session::AmbiguousId]
@@ -220,6 +231,40 @@ module Samagotchi
220
231
 
221
232
  # @return [NoteDelivery::Result]
222
233
  # @raise [SessionInbox::NoteRejected]
234
+ # --- participants (chi share)
235
+
236
+ # @param id [String, nil] one session's, or every session's
237
+ # @return [Array<ParticipantView>]
238
+ def participants(id = nil)
239
+ reply = request(:get, id ? "#{session_path(id)}/participants" : "/api/participants")
240
+ raise_refusal(reply) unless reply.status == 200
241
+
242
+ Array(reply.json["participants"]).map { |row| ParticipantView.from_h(row) }
243
+ end
244
+
245
+ # @param ttl [Integer, nil] seconds; nil: the grant's default
246
+ # @return [Invitation]
247
+ # @raise [BadRequest] a bad name, kind or ttl, an archived session
248
+ def invite(id, name:, kind:, ttl: nil)
249
+ reply = request(:post, "#{session_path(id)}/participants", body: { name: name, kind: kind, ttl: ttl }.compact)
250
+ raise_refusal(reply) unless reply.status == 201
251
+
252
+ json = reply.json
253
+ Invitation.new(participant: ParticipantView.from_h(json.fetch("participant")), token: json.fetch("token"),
254
+ link: json.fetch("link"), lan: json["lan"] == true)
255
+ end
256
+
257
+ # Revokes one participant (+participant+), a session's, or with no
258
+ # +id+ every session's.
259
+ # @return [Array<ParticipantView>] the ones it revoked
260
+ def revoke_participants(id = nil, participant: nil)
261
+ reply = request(:delete, id ? "#{session_path(id)}/participants" : "/api/participants",
262
+ query: { id: participant }.compact)
263
+ raise_refusal(reply) unless reply.status == 200
264
+
265
+ Array(reply.json["revoked"]).map { |row| ParticipantView.from_h(row) }
266
+ end
267
+
223
268
  def note(id, text:, source:)
224
269
  reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
225
270
  json = reply.json.is_a?(Hash) ? reply.json : {}
@@ -317,13 +362,13 @@ module Samagotchi
317
362
 
318
363
  # --- sessions
319
364
 
320
- # chi web stops it and waits its own bounded time (Web::App::
321
- # STOP_WAIT_SECONDS, not +wait+).
365
+ # chi web stops it and waits up to +wait+ seconds (capped there at
366
+ # SessionManager::STOP_WAIT; a chi web before the field waits its
367
+ # Web::App::STOP_WAIT_SECONDS).
322
368
  # @return [Boolean] whether the worker had released it by then, as
323
369
  # chi web's released says (absent: true)
324
370
  def stop(id, wait:)
325
- _ = wait
326
- reply = request(:post, "#{session_path(id)}/stop")
371
+ reply = request(:post, "#{session_path(id)}/stop", body: { wait: wait })
327
372
  raise_refusal(reply) unless reply.status == 200
328
373
 
329
374
  reply.json["released"] != false
@@ -454,6 +499,50 @@ module Samagotchi
454
499
  WebBridgeClient.new(remote: self, session_id: id, worker_key: worker["epoch"] || worker["started_at"])
455
500
  end
456
501
 
502
+ # This chi web has +feature+ (GET /api/info's), else RemoteTooOld in
503
+ # Remote.too_old_message's words.
504
+ # @raise [RemoteFailed]
505
+ def require_feature!(feature)
506
+ check_server!
507
+ raise RemoteTooOld, too_old(@version) unless Array(@features).include?(feature)
508
+ end
509
+
510
+ # POST …/wake: the session's live worker for a remote attach, started
511
+ # when none runs, waited for up to +wait+ seconds there (a leaving
512
+ # worker first: +read_timeout+ covers that wait too).
513
+ # @return [WakeReply]
514
+ # @raise [SessionManager::OwnedByTUI] a chi REPL has it there
515
+ # @raise [Refused] a leftover scratch session (code scratch), or its
516
+ # Bridge didn't come in time (code worker_timeout): chi web's words,
517
+ # LaunchMode's constants
518
+ # @raise [ArgumentError] no such session
519
+ def attach_wake(id, wait:, read_timeout: nil)
520
+ reply = request(:post, "#{session_path(id)}/wake", body: { wait: wait }, read_timeout: read_timeout)
521
+ if reply.status != 200 && %w[scratch worker_timeout].include?(reply.error)
522
+ raise Refused.new((reply.json["message"] || reply.json["detail"]).to_s, code: reply.error)
523
+ end
524
+
525
+ raise_refusal(reply) unless reply.status == 200
526
+
527
+ json = reply.json
528
+ WakeReply.new(woke: json["woke"] == true, worker: json["worker"], default_model: json["default_model"],
529
+ strategy: strategy_or_hard(json["parent_strategy"]), installed: json["installed"])
530
+ end
531
+
532
+ # GET …/worker: the session's worker now (never started).
533
+ # @return [WorkerReply]
534
+ # @raise [ArgumentError] no such session
535
+ def attach_worker(id)
536
+ reply = request(:get, "#{session_path(id)}/worker")
537
+ raise_refusal(reply) unless reply.status == 200
538
+
539
+ json = reply.json
540
+ seconds = json["leave_seconds"]
541
+ WorkerReply.new(live: json["live"] == true, stopped: json["stopped"] == true, worker: json["worker"],
542
+ strategy: strategy_or_hard(json["parent_strategy"]), default_model: json["default_model"],
543
+ leaving: json["leaving"] == true, leave_seconds: (seconds.to_f if seconds.is_a?(Numeric)))
544
+ end
545
+
457
546
  # One request to a route, as the session commands make them (the
458
547
  # server check first; a refused token or a missing route raise
459
548
  # RemoteFailed): WebBridgeClient's, which maps the reply itself.
@@ -476,7 +565,8 @@ module Samagotchi
476
565
  def request(method, path, **)
477
566
  check_server!
478
567
  reply = @transport.call(method, path, **)
479
- raise RemoteFailed, Remote.auth_failure(@entry, reply) if Remote.auth_failure(@entry, reply)
568
+ refused = Remote.access_error(@entry, reply)
569
+ raise refused if refused
480
570
  raise RemoteTooOld, too_old(@version) if missing_route?(reply)
481
571
 
482
572
  reply
@@ -486,7 +576,10 @@ module Samagotchi
486
576
  return if @checked
487
577
 
488
578
  reply = @transport.call(:get, "/api/info")
489
- @version = reply.json["version"] if reply.json.is_a?(Hash)
579
+ if reply.json.is_a?(Hash)
580
+ @version = reply.json["version"]
581
+ @features = Array(reply.json["features"])
582
+ end
490
583
  problem = Remote.info_problem(@entry, reply)
491
584
  raise problem if problem
492
585
 
@@ -495,6 +588,8 @@ module Samagotchi
495
588
 
496
589
  def too_old(version) = Remote.too_old_message(@entry, version)
497
590
 
591
+ def strategy_or_hard(json) = strategy_of(json) || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
592
+
498
593
  # chi web's 404 for a path no route takes (#serve_static's), not a
499
594
  # route's own "no such session".
500
595
  def missing_route?(reply)
@@ -527,6 +622,7 @@ module Samagotchi
527
622
  # spawn_session raises here (chi send: could not start a session).
528
623
  when "invalid_model" then raise ModelProfile::MissingModel, message.to_s
529
624
  when /\Ainvalid_/, "missing_fields", "bad_images"
625
+ # invalid_participant: chi share's name, kind or ttl, an archived session
530
626
  raise BadRequest.new(message.to_s, code: error)
531
627
  end
532
628
  raise RemoteFailed, "#{@entry.label} answered #{reply.status}#{" #{error}" unless error.empty?}" \
@@ -535,7 +631,7 @@ module Samagotchi
535
631
 
536
632
  def create_body(spec)
537
633
  body = { model: spec.model_name, client_id: spec.client_id, history: false }
538
- if spec.preview
634
+ if spec.preview || spec.idle
539
635
  body.merge!(idle: true, preview: spec.preview)
540
636
  else
541
637
  body[:prompt] = spec.prompt