samagotchi 0.9.0 → 0.10.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
@@ -20,10 +20,16 @@ module Samagotchi
20
20
 
21
21
  STATUS_IDLE = "idle"
22
22
  STATUS_RUNNING = "running"
23
- STATUS_COMPLETED = "completed"
24
23
  STATUS_ERROR = "error"
25
24
  STATUS_STOPPED = "stopped"
26
25
 
26
+ # A stopped session's marker, <session dir>/stopped: a stop (chi
27
+ # sessions stop, from another process) creates it instead of rewriting
28
+ # the session file a worker may be saving at the same moment, and a
29
+ # resume removes it. A file from before the marker says "stopped" in
30
+ # its status field, which reads the same.
31
+ STOPPED_FILE = "stopped"
32
+
27
33
  SORT_KEYS = %w[created_at updated_at].freeze
28
34
  SORT_ORDERS = %w[asc desc].freeze
29
35
 
@@ -147,37 +153,57 @@ module Samagotchi
147
153
  path = session_path(session_id, state_dir: state_dir)
148
154
  raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(path)
149
155
 
150
- data = JSON.parse(File.read(path))
151
- messages = (data["messages"] || []).map { |msg| symbolize_message_keys(msg) }
152
- pending = data["pending_question"]
153
- pending = symbolize_message_keys(pending) if pending.is_a?(Hash)
154
- used_mems = data["used_memory_names"] || data["used_memories"] || []
155
- new(
156
- id: data.fetch("id"),
157
- metadata_version: data.fetch("metadata_version", 1),
158
- mode: data.fetch("mode"),
159
- model_name: data.fetch("model_name"),
160
- working_directory: data.fetch("working_directory"),
161
- messages: messages,
162
- created_at: data.fetch("created_at"),
163
- updated_at: data.fetch("updated_at"),
164
- status: data.fetch("status", STATUS_IDLE),
165
- last_prompt: data.fetch("last_prompt", ""),
166
- first_preview: data.fetch("first_preview", ""),
167
- test_run: data.fetch("test_run", false),
168
- pending_question: pending,
169
- used_memory_names: Array(used_mems),
170
- project_root: data["project_root"],
171
- preloaded_memory_names: Array(data["preloaded_memory_names"]),
172
- muted_memory_names: Array(data["muted_memory_names"]),
173
- parent_id: data["parent_id"],
174
- scratch: data.fetch("scratch", false),
175
- last_turn: data["last_turn"]
176
- )
156
+ session = from_h(JSON.parse(File.read(path)))
157
+ session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
158
+ session
177
159
  rescue JSON::ParserError => e
178
160
  raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
179
161
  end
180
162
 
163
+ # A field a session file must have (KeyError without it).
164
+ REQUIRED = Object.new.freeze
165
+
166
+ # The fields of <id>.json, in the order #save writes them: key => the
167
+ # value for a file without it (REQUIRED for the ones it must have).
168
+ # .from_h and #to_h both go by this table; messages and
169
+ # pending_question change key type on the way (symbols in memory,
170
+ # strings on disk).
171
+ FIELDS = {
172
+ "metadata_version" => 1,
173
+ "id" => REQUIRED,
174
+ "mode" => REQUIRED,
175
+ "model_name" => REQUIRED,
176
+ "working_directory" => REQUIRED,
177
+ "messages" => [],
178
+ "created_at" => REQUIRED,
179
+ "updated_at" => REQUIRED,
180
+ "status" => STATUS_IDLE,
181
+ "last_prompt" => "",
182
+ "first_preview" => "",
183
+ "test_run" => false,
184
+ "pending_question" => nil,
185
+ "used_memory_names" => [],
186
+ "project_root" => nil,
187
+ "preloaded_memory_names" => [],
188
+ "muted_memory_names" => [],
189
+ "parent_id" => nil,
190
+ "scratch" => false,
191
+ "last_turn" => nil
192
+ }.freeze
193
+
194
+ # A session from a parsed session file. +messages+ false leaves the
195
+ # conversation out (the lists' lightweight summaries).
196
+ # @raise [KeyError] for a missing REQUIRED field
197
+ def self.from_h(data, messages: true)
198
+ attrs = FIELDS.to_h do |key, default|
199
+ [key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
200
+ end
201
+ attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
202
+ pending = attrs[:pending_question]
203
+ attrs[:pending_question] = pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil
204
+ new(**attrs)
205
+ end
206
+
181
207
  # A prefix that names more than one session.
182
208
  class AmbiguousId < ArgumentError; end
183
209
 
@@ -250,30 +276,9 @@ module Samagotchi
250
276
  # An id that isn't one (hand-edited, or not ours) would be a path.
251
277
  return nil unless valid_id?(data["id"])
252
278
 
253
- used_mems = data["used_memory_names"] || data["used_memories"] || []
254
- pending = data["pending_question"]
255
- new(
256
- id: data.fetch("id"),
257
- metadata_version: data.fetch("metadata_version", 1),
258
- mode: data.fetch("mode"),
259
- model_name: data.fetch("model_name"),
260
- working_directory: data.fetch("working_directory"),
261
- messages: [],
262
- created_at: data.fetch("created_at"),
263
- updated_at: data.fetch("updated_at"),
264
- status: data.fetch("status", STATUS_IDLE),
265
- last_prompt: data.fetch("last_prompt", ""),
266
- first_preview: data.fetch("first_preview", ""),
267
- test_run: data.fetch("test_run", false),
268
- pending_question: pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil,
269
- used_memory_names: Array(used_mems),
270
- project_root: data["project_root"],
271
- preloaded_memory_names: Array(data["preloaded_memory_names"]),
272
- muted_memory_names: Array(data["muted_memory_names"]),
273
- parent_id: data["parent_id"],
274
- scratch: data.fetch("scratch", false),
275
- last_turn: data["last_turn"]
276
- )
279
+ session = from_h(data, messages: false)
280
+ session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: File.dirname(path))
281
+ session
277
282
  rescue JSON::ParserError, KeyError, SystemCallError
278
283
  nil
279
284
  end
@@ -290,45 +295,23 @@ module Samagotchi
290
295
  # Auto-compute first_preview if not yet cached and messages contain a user entry.
291
296
  compute_first_preview!
292
297
 
293
- record = {
294
- "metadata_version" => @metadata_version,
295
- "id" => @id,
296
- "mode" => @mode,
297
- "model_name" => @model_name,
298
- "working_directory" => @working_directory,
299
- "messages" => @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) },
300
- "created_at" => @created_at,
301
- "updated_at" => @updated_at,
302
- "status" => @status,
303
- "last_prompt" => @last_prompt,
304
- "first_preview" => @first_preview,
305
- "test_run" => !!@test_run,
306
- "pending_question" => @pending_question ? scrub_utf8(stringify_message_keys(@pending_question)) : nil,
307
- "used_memory_names" => Array(@used_memory_names),
308
- "project_root" => @project_root,
309
- "preloaded_memory_names" => Array(@preloaded_memory_names),
310
- "muted_memory_names" => Array(@muted_memory_names),
311
- "parent_id" => @parent_id,
312
- "scratch" => @scratch,
313
- "last_turn" => @last_turn
314
- }
315
-
316
- AtomicFile.write(path, JSON.pretty_generate(record) + "\n")
298
+ AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
317
299
  self
318
300
  end
319
301
 
320
- # Mark a session as running.
321
- def self.mark_running(session_id, state_dir: default_state_dir)
322
- session = load(session_id, state_dir: state_dir)
323
- session.status = STATUS_RUNNING
324
- session.save(state_dir: state_dir)
325
- end
326
-
327
- # Mark a session as completed.
328
- def self.mark_completed(session_id, state_dir: default_state_dir)
329
- session = load(session_id, state_dir: state_dir)
330
- session.status = STATUS_COMPLETED
331
- session.save(state_dir: state_dir)
302
+ # The session as #save writes it (FIELDS, in their order).
303
+ def to_h
304
+ FIELDS.keys.to_h do |key|
305
+ value = case key
306
+ when "messages" then @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) }
307
+ when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
308
+ when "test_run" then !!@test_run
309
+ when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
310
+ Array(instance_variable_get(:"@#{key}"))
311
+ else instance_variable_get(:"@#{key}")
312
+ end
313
+ [key, value]
314
+ end
332
315
  end
333
316
 
334
317
  # Mark a session as errored.
@@ -339,11 +322,24 @@ module Samagotchi
339
322
  session.save(state_dir: state_dir)
340
323
  end
341
324
 
342
- # Mark a session as stopped.
325
+ # Mark a session as stopped (STOPPED_FILE); its session file stays as
326
+ # it is.
327
+ # @raise [ArgumentError] when there is no such session
343
328
  def self.mark_stopped(session_id, state_dir: default_state_dir)
344
- session = load(session_id, state_dir: state_dir)
345
- session.status = STATUS_STOPPED
346
- session.save(state_dir: state_dir)
329
+ raise ArgumentError, "Session not found: #{session_id}" unless exist?(session_id, state_dir: state_dir)
330
+
331
+ dir = session_dir(session_id, state_dir: state_dir)
332
+ FileUtils.mkdir_p(dir)
333
+ FileUtils.touch(File.join(dir, STOPPED_FILE))
334
+ end
335
+
336
+ # Remove the stop marker (a resume, before its worker starts).
337
+ def self.clear_stopped(session_id, state_dir: default_state_dir)
338
+ FileUtils.rm_f(File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE))
339
+ end
340
+
341
+ def self.stopped_marker?(session_id, state_dir: default_state_dir)
342
+ File.exist?(File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE))
347
343
  end
348
344
 
349
345
  # An id that is not a session id: it could name a path outside the
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "session"
4
4
  require_relative "session_manager"
5
+ require_relative "cli/command"
5
6
 
6
7
  module Samagotchi
7
8
  # `chi sessions archive|unarchive`: hide sessions from every list and keep
@@ -9,6 +10,8 @@ module Samagotchi
9
10
  # unarchive_session), one line per id. Delegated children follow their
10
11
  # parent.
11
12
  class SessionArchiveCommand
13
+ include CLI::Command
14
+
12
15
  USAGE = {
13
16
  "archive" => <<~TEXT,
14
17
  Usage: chi sessions archive (ID|PREFIX)...
@@ -36,8 +39,9 @@ module Samagotchi
36
39
 
37
40
  # @return [Integer] exit status: 0 all done, 1 any refused or unknown, 2 usage
38
41
  def run
39
- if @argv.any? { |arg| %w[-h --help help].include?(arg) }
40
- @stdout.puts(USAGE.fetch(@action))
42
+ # Help anywhere wins, even after an unknown option.
43
+ if @argv.any? { |arg| HELP_WORDS.include?(arg) }
44
+ @stdout.puts(usage_text)
41
45
  return 0
42
46
  end
43
47
  bad = @argv.find { |arg| arg.start_with?("-") }
@@ -91,17 +95,7 @@ module Samagotchi
91
95
  false
92
96
  end
93
97
 
94
- # stdout is buffered when it's a pipe: flush the lines already printed,
95
- # so the output keeps the order of the ids given.
96
- def error_line(text)
97
- @stdout.flush
98
- @stderr.puts(text)
99
- end
100
-
101
- def usage_error(message)
102
- error_line("chi sessions #{@action}: #{message}")
103
- @stderr.puts(USAGE.fetch(@action))
104
- 2
105
- end
98
+ def command_name = "chi sessions #{@action}"
99
+ def usage_text = USAGE.fetch(@action)
106
100
  end
107
101
  end
@@ -30,7 +30,6 @@ module Samagotchi
30
30
  ROLLBACK_COMMAND = "!rollback"
31
31
  CONTINUE_COMMAND = TurnFlow::CONTINUE_COMMAND
32
32
  SHELL_BANG_PREFIX = "!"
33
- RESERVED_MODEL_ARGS = %w[clear default none off].freeze
34
33
  ALIAS_USAGE = "usage /model <model> --alias <name> [--default]"
35
34
  # /exit --delete: delete the session on the way out.
36
35
  EXIT_DELETE_FLAG = "--delete"
@@ -355,10 +354,10 @@ module Samagotchi
355
354
 
356
355
  if alias_name
357
356
  return ["--alias requires a model name: #{ALIAS_USAGE}", false] if arg.empty?
358
- return ["--alias cannot be combined with clear/default/none/off", false] if RESERVED_MODEL_ARGS.include?(arg.downcase)
357
+ return ["--alias cannot be combined with clear/default/none/off", false] if ConfigFile::RESERVED_MODEL_ALIASES.include?(arg.downcase)
359
358
 
360
359
  # Validate the alias before switching, so a bad one changes nothing.
361
- invalid = alias_name_error(alias_name, arg)
360
+ invalid = ConfigFile.model_alias_error(alias_name, arg)
362
361
  return ["invalid alias: #{invalid}", false] if invalid
363
362
 
364
363
  switch_model(arg, persist_default: persist_default)
@@ -378,7 +377,7 @@ module Samagotchi
378
377
  else
379
378
  return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
380
379
 
381
- if RESERVED_MODEL_ARGS.include?(arg.downcase)
380
+ if ConfigFile::RESERVED_MODEL_ALIASES.include?(arg.downcase)
382
381
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
383
382
 
384
383
  switch_model(@default_model)
@@ -394,23 +393,6 @@ module Samagotchi
394
393
  end
395
394
  end
396
395
 
397
- # Mirrors ConfigFile.write_model_alias!'s checks without writing.
398
- # @return [String, nil] what is wrong with the alias name
399
- def alias_name_error(alias_name, target)
400
- ak = alias_name.strip
401
- return "alias name is required" if ak.empty?
402
-
403
- lk = ak.downcase
404
- return "alias name '#{ak}' is reserved" if RESERVED_MODEL_ARGS.include?(lk)
405
- return "alias name must not contain whitespace" if ak.match?(/\s/)
406
- return "alias name must not start with '-'" if ak.start_with?("-")
407
- return "alias name must not contain '/'" if ak.include?("/")
408
- return "alias name must match /[a-z0-9][a-z0-9._-]*/i (got '#{ak}')" unless ak.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
409
- return "alias must not point to itself" if lk == target.strip.downcase
410
-
411
- nil
412
- end
413
-
414
396
  # Engine owns the switch (alias resolution, profile, kernel, client and
415
397
  # the optional default persist); the session keeps the new model.
416
398
  def switch_model(name, persist_default: false)
@@ -2,12 +2,16 @@
2
2
 
3
3
  require_relative "session"
4
4
  require_relative "session_manager"
5
+ require_relative "cli/command"
6
+ require_relative "cli/flags"
5
7
 
6
8
  module Samagotchi
7
9
  # `chi sessions delete`: remove sessions for good (SessionManager.delete_session),
8
10
  # one line per id. A live worker's session is refused unless --force stops
9
11
  # the worker first; a chi REPL's never is.
10
12
  class SessionDeleteCommand
13
+ include CLI::Command
14
+
11
15
  USAGE = <<~TEXT
12
16
  Usage: chi sessions delete [--force] (ID|PREFIX)...
13
17
  Deletes each session: its history, notes, images and queued input.
@@ -17,6 +21,7 @@ module Samagotchi
17
21
  chi REPL is always refused
18
22
  TEXT
19
23
  PREVIEW_LIMIT = 60
24
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) { |f| f.switch "-f", "--force" }
20
25
 
21
26
  # @param argv [Array<String>] the arguments after "sessions delete"
22
27
  def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
@@ -29,30 +34,24 @@ module Samagotchi
29
34
  # @return [Integer] exit status: 0 all deleted, 1 any refused or
30
35
  # unknown, 2 usage
31
36
  def run
32
- options = parse or return 2
33
- return 0 if options[:help]
37
+ options = parse
38
+ return options if options.is_a?(Integer)
34
39
 
35
40
  options[:ids].uniq.map { |given| delete(given, force: options[:force]) }.all? ? 0 : 1
36
41
  end
37
42
 
38
43
  private
39
44
 
45
+ def command_name = "chi sessions delete"
46
+
47
+ # @return [Hash, Integer] the options, or the exit status after the
48
+ # help or a usage error
40
49
  def parse
41
- options = { ids: [], force: false }
42
- until @argv.empty?
43
- arg = @argv.shift
44
- case arg
45
- when "-h", "--help", "help"
46
- @stdout.puts(USAGE)
47
- return { help: true }
48
- when "-f", "--force" then options[:force] = true
49
- when /\A-/ then return usage_error("unknown option #{arg}")
50
- else options[:ids] << arg
51
- end
52
- end
53
- return usage_error("give session ids") if options[:ids].empty?
50
+ parsed = parse_flags(FLAGS, @argv, force: false)
51
+ return parsed if parsed.is_a?(Integer)
52
+ return usage_error("give session ids") if parsed.args.empty?
54
53
 
55
- options
54
+ parsed.options.merge(ids: parsed.args)
56
55
  end
57
56
 
58
57
  # @return [Boolean] whether the session is gone
@@ -88,18 +87,5 @@ module Samagotchi
88
87
  rescue ArgumentError, JSON::ParserError
89
88
  nil
90
89
  end
91
-
92
- # stderr isn't buffered, stdout is when it's a pipe: flush the lines
93
- # already printed, so the output keeps the order of the ids given.
94
- def error_line(text)
95
- @stdout.flush
96
- @stderr.puts(text)
97
- end
98
-
99
- def usage_error(message)
100
- error_line("chi sessions delete: #{message}")
101
- @stderr.puts(USAGE)
102
- nil
103
- end
104
90
  end
105
91
  end
@@ -233,7 +233,7 @@ module Samagotchi
233
233
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
234
234
  .select { |s| root.nil? || in_folder?(s.working_directory, root) }
235
235
  .filter_map do |s|
236
- owner = session_owner(s.id, state_dir: sd)&.fetch("kind", nil)
236
+ owner = session_owner(s.id, state_dir: sd)&.kind
237
237
  owned = owner == "worker"
238
238
  next if live && !owned
239
239
 
@@ -277,7 +277,7 @@ module Samagotchi
277
277
  owner = owners[sid]
278
278
  next unless owner
279
279
 
280
- raise OwnedByTUI, sid if owner["kind"] == "tui"
280
+ raise OwnedByTUI, sid if owner.tui?
281
281
  next unless Session.load(sid, state_dir: sd).status == Session::STATUS_RUNNING
282
282
 
283
283
  raise ArchiveRefused.new(id, sid == id ? :busy : :busy_child, busy_id: sid)
@@ -374,12 +374,12 @@ module Samagotchi
374
374
  def self.resume_session(session_id, state_dir: nil)
375
375
  sd = state_dir || Session.default_state_dir
376
376
  session = Session.load(session_id, state_dir: sd)
377
- owner = session_owner(session.id, state_dir: sd)
378
- raise OwnedByTUI, session.id if owner && owner["kind"] == "tui"
379
- return session if owner
377
+ return session if refuse_tui!(session.id, state_dir: sd)
380
378
 
381
379
  # status is turn state, not liveness: a new worker runs no turn yet,
382
- # and must not find the session stopped (it would exit).
380
+ # and must not find the session stopped (it would exit): neither the
381
+ # marker nor a status field from before it.
382
+ Session.clear_stopped(session.id, state_dir: sd)
383
383
  session.status = Session::STATUS_IDLE
384
384
  session.save(state_dir: sd)
385
385
  spawn_worker_for_session(session, state_dir: sd)
@@ -404,13 +404,12 @@ module Samagotchi
404
404
  # @raise [OwnedByTUI] when the interactive TUI owns the session
405
405
  def self.stop_session(session_id, state_dir: nil, wait: nil)
406
406
  sd = state_dir || Session.default_state_dir
407
- owner = session_owner(session_id, state_dir: sd)
408
- raise OwnedByTUI, session_id if owner && owner["kind"] == "tui"
407
+ owner = refuse_tui!(session_id, state_dir: sd)
409
408
 
410
409
  cancel_running_turn(session_id, state_dir: sd)
411
410
  # Mark first: a worker that has not taken the lock yet sees it and exits.
412
411
  Session.mark_stopped(session_id, state_dir: sd)
413
- pid = owner && owner["pid"].to_i
412
+ pid = owner&.pid.to_i
414
413
  begin
415
414
  Process.kill("TERM", pid) if pid && pid > 0
416
415
  rescue Errno::ESRCH
@@ -545,9 +544,8 @@ module Samagotchi
545
544
  dir = Session.session_dir(id, state_dir: sd)
546
545
  raise ArgumentError, "no session #{given}" unless File.exist?(path) || Dir.exist?(dir)
547
546
 
548
- owner = session_owner(id, state_dir: sd)
547
+ owner = refuse_tui!(id, state_dir: sd)
549
548
  if owner
550
- raise OwnedByTUI, id if owner["kind"] == "tui"
551
549
  raise DeleteRefused.new(id, :worker_running) unless stop
552
550
  raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
553
551
  end
@@ -594,6 +592,7 @@ module Samagotchi
594
592
  # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
595
593
  # @param poll_interval [Numeric, nil] seconds between the loop's fallback
596
594
  # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
595
+ # A stopped session's worker exits 0 and a crashed one 1, there.
597
596
  # @return [Symbol] :idle_exit or :exit_requested
598
597
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
599
598
  idle_exit_minutes: nil, poll_interval: nil)
@@ -619,6 +618,10 @@ module Samagotchi
619
618
  @owner_lock.release
620
619
  end
621
620
  Log.info(:worker, "stop", reason: result)
621
+ # A stopped session is left as it is: no resume for input that came
622
+ # in, no discard.
623
+ exit(0) if result == :stopped
624
+ exit(1) if result == :crashed
622
625
  # Only after the release: a writer that still saw this worker as the
623
626
  # owner may have queued input since the last check. Either it finds no
624
627
  # owner after its write and wakes one, or this finds its input.
@@ -776,7 +779,7 @@ module Samagotchi
776
779
  owner = delivery_owner(manager, session_id, state_dir)
777
780
  # A TUI that took the session between the resume and the write never
778
781
  # reads input files; a later worker would replay this one.
779
- if owner&.fetch("kind", nil) == "tui"
782
+ if owner&.tui?
780
783
  FileUtils.rm_f(path) if path.is_a?(String)
781
784
  raise OwnedByTUI, session_id
782
785
  end
@@ -810,10 +813,21 @@ module Samagotchi
810
813
  # The session's live owner: the OwnerLock holder. The pid file workers
811
814
  # once wrote is never read: a stale one whose pid the OS reused would
812
815
  # make a session look owned.
813
- # @return [Hash, nil] {"pid", "kind" ("worker"/"tui"), ...} or nil
816
+ # @return [OwnerLock::Owner, nil]
814
817
  def self.session_owner(session_id, state_dir: nil)
815
818
  OwnerLock.owner(Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir))
816
819
  end
820
+
821
+ # The session's owner (#session_owner), unless it is the interactive
822
+ # TUI, which nothing else may resume, stop or delete.
823
+ # @return [OwnerLock::Owner, nil]
824
+ # @raise [OwnedByTUI]
825
+ def self.refuse_tui!(session_id, state_dir: nil)
826
+ owner = session_owner(session_id, state_dir: state_dir)
827
+ raise OwnedByTUI, session_id if owner&.tui?
828
+
829
+ owner
830
+ end
817
831
  end
818
832
  end
819
833