samagotchi 0.157.0 → 0.159.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +1 -1
  4. data/bin/chi +5 -1
  5. data/docs/architecture.md +14 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +76 -17
  9. data/docs/configuration.md +16 -5
  10. data/docs/context.md +12 -4
  11. data/docs/guardrails.md +11 -8
  12. data/docs/hooks.md +47 -80
  13. data/docs/internals/bridge.md +2 -1
  14. data/docs/internals/bundles.md +51 -36
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +43 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/scheduling.md +228 -0
  20. data/docs/sessions.md +30 -6
  21. data/docs/testing.md +4 -4
  22. data/lib/samagotchi/bridge/card_places.rb +5 -13
  23. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  24. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  25. data/lib/samagotchi/bridge.rb +26 -2
  26. data/lib/samagotchi/bundle_command/check.rb +23 -10
  27. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  28. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  29. data/lib/samagotchi/bundle_command.rb +53 -44
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/client_id.rb +13 -3
  35. data/lib/samagotchi/config.rb +12 -0
  36. data/lib/samagotchi/context_command.rb +7 -5
  37. data/lib/samagotchi/context_move.rb +74 -0
  38. data/lib/samagotchi/context_sources.rb +4 -2
  39. data/lib/samagotchi/engine.rb +1 -6
  40. data/lib/samagotchi/extension_load.rb +20 -41
  41. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  42. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  43. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  44. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  45. data/lib/samagotchi/hook_routes.rb +2 -2
  46. data/lib/samagotchi/hooks.rb +2 -3
  47. data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
  48. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  49. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  50. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  51. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  52. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  54. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  55. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  56. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  57. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  58. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  59. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
  61. data/lib/samagotchi/memory_bundle/status.rb +30 -9
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  63. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  64. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  65. data/lib/samagotchi/memory_orphans.rb +4 -4
  66. data/lib/samagotchi/memory_paths.rb +102 -13
  67. data/lib/samagotchi/plugin/api.rb +1 -4
  68. data/lib/samagotchi/plugin/loader.rb +42 -5
  69. data/lib/samagotchi/project_command.rb +167 -0
  70. data/lib/samagotchi/project_init.rb +69 -0
  71. data/lib/samagotchi/project_scope.rb +6 -4
  72. data/lib/samagotchi/prompt_files.rb +10 -7
  73. data/lib/samagotchi/recap_place.rb +3 -4
  74. data/lib/samagotchi/run_options.rb +2 -0
  75. data/lib/samagotchi/schedule_command.rb +252 -0
  76. data/lib/samagotchi/schedules.rb +948 -0
  77. data/lib/samagotchi/session.rb +44 -18
  78. data/lib/samagotchi/session_access/contract.rb +1 -1
  79. data/lib/samagotchi/session_access/local.rb +20 -0
  80. data/lib/samagotchi/session_field.rb +5 -4
  81. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  82. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  83. data/lib/samagotchi/steer.rb +10 -0
  84. data/lib/samagotchi/system_prompt.rb +20 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  86. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  87. data/lib/samagotchi/testing/bundle.rb +5 -12
  88. data/lib/samagotchi/testing/install.rb +5 -5
  89. data/lib/samagotchi/testing/recorder.rb +1 -1
  90. data/lib/samagotchi/testing.rb +1 -1
  91. data/lib/samagotchi/tool_declarations.rb +44 -1
  92. data/lib/samagotchi/toolkit_binding.rb +1 -4
  93. data/lib/samagotchi/toolkit_command.rb +1 -4
  94. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  95. data/lib/samagotchi/tools/builtins.rb +17 -0
  96. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  97. data/lib/samagotchi/tools/delegate.rb +12 -8
  98. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  99. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +35 -0
  102. data/lib/samagotchi/web/project_list.rb +3 -2
  103. data/lib/samagotchi/web/public/chains.js +32 -8
  104. data/lib/samagotchi/web/public/index.html +10 -2
  105. data/lib/samagotchi/web/public/info_bar.js +4 -3
  106. data/lib/samagotchi/web/public/project_picker.js +86 -4
  107. data/lib/samagotchi/web/public/turn_events.js +4 -0
  108. data/lib/samagotchi/web/session_hub.rb +20 -0
  109. data/lib/samagotchi/worker.rb +24 -6
  110. data/lib/samagotchi/worker_boot.rb +3 -4
  111. data/lib/samagotchi/worker_inbound.rb +23 -0
  112. data/lib/samagotchi/worker_link.rb +5 -0
  113. data/lib/samagotchi/worker_wakes.rb +9 -1
  114. metadata +10 -2
  115. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -6,6 +6,7 @@ require "time"
6
6
 
7
7
  require_relative "../session"
8
8
  require_relative "../session_manager"
9
+ require_relative "../schedules"
9
10
  require_relative "../bridge_client"
10
11
  require_relative "../worker_link"
11
12
  require_relative "../config"
@@ -105,6 +106,7 @@ module Samagotchi
105
106
  @dir_mtime = nil # the state dir's mtime at the last glob
106
107
  @root_cache = {} # folder → project root, for sessions saved before project_root
107
108
  @next_full_probe = nil
109
+ @next_schedule_tick = nil
108
110
  @wakeup = Queue.new # stop wakes the tick thread out of its sleep
109
111
  @thread = nil
110
112
  @stopped = false
@@ -148,6 +150,8 @@ module Samagotchi
148
150
  check_installed
149
151
  end
150
152
  end
153
+ # Outside the projection's lock: a firing can wait for a recap.
154
+ schedule_tick
151
155
  nil
152
156
  end
153
157
 
@@ -314,6 +318,22 @@ module Samagotchi
314
318
  nil
315
319
  end
316
320
 
321
+ # chi web can be the scheduler when the user opts in (schedule.auto_tick):
322
+ # the same tick cron runs, gated by schedule.tick_interval_minutes and
323
+ # sharing schedules.lock, so cron and the hub never both fire.
324
+ def schedule_tick
325
+ return unless Config.get("schedule.auto_tick")
326
+
327
+ now = @now.call
328
+ return if @next_schedule_tick && now < @next_schedule_tick
329
+
330
+ @next_schedule_tick = now + (Samagotchi::Schedules.tick_interval_minutes * 60)
331
+ Samagotchi::Schedules.new(state_dir: @state_dir).tick
332
+ rescue StandardError => e
333
+ Log.warn(:web, "schedule_tick_failed", error: e.class.name, msg: e.message)
334
+ nil
335
+ end
336
+
317
337
  # A change (the first look too, unless it finds nothing) is a `chi`
318
338
  # event: chi web's terminal line and the open tabs hear it.
319
339
  def check_installed
@@ -374,7 +374,9 @@ module Samagotchi
374
374
  # chi web's Stop asks about these before TERMed-ing
375
375
  # the worker (its own holds routes).
376
376
  on_holds: -> { @idle_exit&.stoppable_holds },
377
- on_holds_stop: -> { @hold_stop&.stop_all })
377
+ on_holds_stop: -> { @hold_stop&.stop_all },
378
+ # `chi schedule tick` asks this before firing.
379
+ on_activity: method(:activity_snapshot))
378
380
  # What failed to load and what plugins showed as they loaded, now that
379
381
  # a UI can be there (a late one gets them in the snapshot).
380
382
  @engine.announce_load_events!
@@ -410,12 +412,12 @@ module Samagotchi
410
412
  # there is no live SSE or in-process cancel/answer).
411
413
  # @param on_input [#call, nil] called after the Bridge queues a turn
412
414
  def start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
413
- exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil)
415
+ exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil, on_activity: nil)
414
416
  require_relative "bridge"
415
417
  Samagotchi::Bridge.new(
416
418
  engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
417
419
  on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards,
418
- exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop
420
+ exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop, on_activity: on_activity
419
421
  ).start
420
422
  rescue StandardError => e
421
423
  Log.error(:bridge, "start_failed", echo: "Bridge: failed to start for session #{session_id}: #{e.class}: #{e.message}", sid: session_id, error: e.class.name)
@@ -426,6 +428,22 @@ module Samagotchi
426
428
  Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
427
429
  end
428
430
 
431
+ # What this worker is busy with right now, for GET /activity: the same
432
+ # signals WorkerIdleExit#hold reads. A schedule never holds a worker up,
433
+ # so +reminders+ and +tasks_holding+ are reported for the log but are not a
434
+ # reason for a tick to skip a firing.
435
+ # @return [Hash]
436
+ def activity_snapshot
437
+ {
438
+ turn_running: @engine.turn_running?,
439
+ input_pending: input_pending?,
440
+ awaiting_continue: @turn_flow.awaiting_continue?,
441
+ open_streams: @bridge&.open_streams.to_i,
442
+ reminders: @engine.reminder_store&.any? ? 1 : 0,
443
+ tasks_holding: Degrade.call(:worker, "activity_tasks_unreadable") { @task_wakes&.watch&.holding&.size.to_i }
444
+ }
445
+ end
446
+
429
447
  # The delegate children of this session that are running a turn or have
430
448
  # a live worker. ChildrenStatus.of scans the session files; this runs on
431
449
  # an exit request, not every pass.
@@ -1033,9 +1051,9 @@ module Samagotchi
1033
1051
  kept: "Its note stays for the user's next message.", mark: task_wake)
1034
1052
  end
1035
1053
 
1036
- # The idle loop's sleep: the fallback tick, or less while reports wait
1037
- # out the start grace.
1038
- def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due }
1054
+ # The idle loop's sleep: the fallback tick, or less while reports or
1055
+ # attached-context wakes wait out the start grace.
1056
+ def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due || @inbound.context_wake_waiting? }
1039
1057
 
1040
1058
  # The wake budget is spent: the parent's UIs hear once that reports
1041
1059
  # wait for the next message.
@@ -3,9 +3,8 @@
3
3
  require_relative "session_manager"
4
4
 
5
5
  module Samagotchi
6
- # WorkerBoot: the worker process, from run_session_loop (what the boot
7
- # string SessionManager#worker_command writes runs, through
8
- # SessionManager.run_session_loop) to what it does as it leaves (CROSS-VERSION
6
+ # WorkerBoot: the worker process, from the entry the boot string names
7
+ # (WorkerBoot.run_session_loop) to what it does as it leaves (CROSS-VERSION
9
8
  # CONTRACT: spec/worker_contract_spec.rb). It takes the session's OwnerLock,
10
9
  # leaves the spawner's process tree, installs the signals and the project
11
10
  # env, and runs the Worker (worker.rb loads only then, after the lock). Calls
@@ -14,7 +13,7 @@ module Samagotchi
14
13
  module WorkerBoot
15
14
  class << self
16
15
  # Run the session loop inside the forked process: the worker's main,
17
- # which the boot string reaches through SessionManager.run_session_loop.
16
+ # which the boot string names directly.
18
17
  #
19
18
  # Every worker starts its per-session Bridge (the single live client
20
19
  # transport) on a port bound to 127.0.0.1 before the loop and stops it on
@@ -106,6 +106,7 @@ module Samagotchi
106
106
  @stopped = stopped
107
107
  @queue_command = queue_command
108
108
  @initial_prompt_taken = false
109
+ @context_wake_waiting = false
109
110
  end
110
111
 
111
112
  # Add the queued context notes to the conversation (between turns only,
@@ -148,12 +149,18 @@ module Samagotchi
148
149
  # @return [ContextWake, nil] the wake turn to run now
149
150
  # @raise [SystemCallError, IOError] the caller logs it (context_failed)
150
151
  def absorb_context(before_turn: false, wake: false)
152
+ @context_wake_waiting = false
151
153
  return nil unless before_turn || conversation_started?
152
154
  return nil if @stopped.call
153
155
 
154
156
  batch = @context_absorber.pending
155
157
  return nil unless batch
156
158
 
159
+ if wake && context_wake_waiting_for?(batch)
160
+ @context_wake_waiting = true
161
+ return nil
162
+ end
163
+
157
164
  waking = wake ? context_wake_for(batch) : nil
158
165
  turn_id = SecureRandom.uuid if waking
159
166
  batch.deliveries.each do |delivery|
@@ -170,6 +177,10 @@ module Samagotchi
170
177
  waking && ContextWake.new(delivery: waking, turn_id: turn_id)
171
178
  end
172
179
 
180
+ # Whether #absorb_context left an eligible wake batch uncommitted for
181
+ # the start grace, so the idle loop can revisit it when grace expires.
182
+ def context_wake_waiting? = @context_wake_waiting
183
+
173
184
  # A failed wake turn kept nothing but its note: the note goes back to
174
185
  # a plain note (no turn start, the background wording), so a reload
175
186
  # draws no empty turn for it. +wake+: a ContextWake or a TaskWake.
@@ -300,6 +311,18 @@ module Samagotchi
300
311
  waking.first
301
312
  end
302
313
 
314
+ def context_wake_waiting_for?(batch)
315
+ return false unless @wakes.in_grace?
316
+
317
+ candidates = batch.deliveries.select(&:wake_note)
318
+ return false if candidates.empty? || !context_wakes_on?
319
+ return false unless @wakes.context_ready?(awaiting_continue: @awaiting_continue.call, names: candidates.map(&:name))
320
+
321
+ candidates.any? do |delivery|
322
+ delivery.peer ? @wakes.peer_in_a_row < @wakes.peer_turns : !woke_lately?(delivery.subscription, Time.now)
323
+ end
324
+ end
325
+
303
326
  # A peer's answer waits as a note: the UIs hear it once per
304
327
  # interactive input (WorkerWakes#notice_peer_budget!). +key+: the
305
328
  # budget that holds it.
@@ -34,6 +34,11 @@ module Samagotchi
34
34
  # newer worker's (Skew).
35
35
  PROTOCOL = 1
36
36
 
37
+ # A worker whose sidecar publishes this may be asked what it is busy with
38
+ # (Bridge's GET /activity, Worker#activity_snapshot): a sidecar without it
39
+ # is an older chi's, and asking it would only cost a 404.
40
+ ACTIVITY_FEATURE = "activity"
41
+
37
42
  # Seconds a worker may hold the lock without publishing its Bridge before
38
43
  # Starting is stuck (SessionManager::WORKER_START_GRACE).
39
44
  START_LIMIT = 30.0
@@ -101,7 +101,15 @@ module Samagotchi
101
101
  # not paused, past the start grace and under the budget. +names+: the
102
102
  # sources asking, for the log line when the budget holds them.
103
103
  def context_open?(awaiting_continue:, names:)
104
- return false if @paused || awaiting_continue || in_grace?
104
+ return false if in_grace?
105
+
106
+ context_ready?(awaiting_continue:, names:)
107
+ end
108
+
109
+ # The context wake gates apart from start grace; used to decide whether
110
+ # a wake must remain pending until that grace ends.
111
+ def context_ready?(awaiting_continue:, names:)
112
+ return false if @paused || awaiting_continue
105
113
 
106
114
  if @in_a_row >= max
107
115
  Log.info(:worker, "context_wake_held", reason: "max_wakes", names: names.join(","))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.157.0
4
+ version: 0.159.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -176,6 +176,7 @@ files:
176
176
  - docs/memory.md
177
177
  - docs/plugins.md
178
178
  - docs/releasing.md
179
+ - docs/scheduling.md
179
180
  - docs/sessions.md
180
181
  - docs/sub-agent.md
181
182
  - docs/system-one.md
@@ -435,7 +436,6 @@ files:
435
436
  - lib/samagotchi/hook_question.rb
436
437
  - lib/samagotchi/hook_routes.rb
437
438
  - lib/samagotchi/hooks.rb
438
- - lib/samagotchi/hooks/bundle_loader.rb
439
439
  - lib/samagotchi/hooks/loader.rb
440
440
  - lib/samagotchi/hooks/registry.rb
441
441
  - lib/samagotchi/hooks/stream_watch.rb
@@ -500,6 +500,7 @@ files:
500
500
  - lib/samagotchi/log_subscriber.rb
501
501
  - lib/samagotchi/memory_bundle.rb
502
502
  - lib/samagotchi/memory_bundle/asset_installer.rb
503
+ - lib/samagotchi/memory_bundle/asset_state.rb
503
504
  - lib/samagotchi/memory_bundle/builder.rb
504
505
  - lib/samagotchi/memory_bundle/bundle_hook.rb
505
506
  - lib/samagotchi/memory_bundle/entry_size.rb
@@ -592,7 +593,9 @@ files:
592
593
  - lib/samagotchi/plugin_tasks.rb
593
594
  - lib/samagotchi/private_files.rb
594
595
  - lib/samagotchi/process_group.rb
596
+ - lib/samagotchi/project_command.rb
595
597
  - lib/samagotchi/project_env.rb
598
+ - lib/samagotchi/project_init.rb
596
599
  - lib/samagotchi/project_scope.rb
597
600
  - lib/samagotchi/prompt.rb
598
601
  - lib/samagotchi/prompt_files.rb
@@ -639,6 +642,8 @@ files:
639
642
  - lib/samagotchi/run_options.rb
640
643
  - lib/samagotchi/sampling_settings.rb
641
644
  - lib/samagotchi/sandbox_layout.rb
645
+ - lib/samagotchi/schedule_command.rb
646
+ - lib/samagotchi/schedules.rb
642
647
  - lib/samagotchi/self_report.rb
643
648
  - lib/samagotchi/send_command.rb
644
649
  - lib/samagotchi/send_command/busy.rb
@@ -801,6 +806,7 @@ files:
801
806
  - lib/samagotchi/tools/builtin_calls.rb
802
807
  - lib/samagotchi/tools/builtins.rb
803
808
  - lib/samagotchi/tools/cancel_reminder.rb
809
+ - lib/samagotchi/tools/cancel_schedule.rb
804
810
  - lib/samagotchi/tools/context_read.rb
805
811
  - lib/samagotchi/tools/deferred_tools.rb
806
812
  - lib/samagotchi/tools/delegate.rb
@@ -813,6 +819,7 @@ files:
813
819
  - lib/samagotchi/tools/forget_outputs.rb
814
820
  - lib/samagotchi/tools/index_hint.rb
815
821
  - lib/samagotchi/tools/list_reminders.rb
822
+ - lib/samagotchi/tools/list_schedules.rb
816
823
  - lib/samagotchi/tools/list_sessions.rb
817
824
  - lib/samagotchi/tools/memory.rb
818
825
  - lib/samagotchi/tools/objectives_tool.rb
@@ -822,6 +829,7 @@ files:
822
829
  - lib/samagotchi/tools/ref_text.rb
823
830
  - lib/samagotchi/tools/register_reminder.rb
824
831
  - lib/samagotchi/tools/registry.rb
832
+ - lib/samagotchi/tools/schedule_task.rb
825
833
  - lib/samagotchi/tools/send_note.rb
826
834
  - lib/samagotchi/tools/shell.rb
827
835
  - lib/samagotchi/tools/task_create.rb
@@ -1,191 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "registry"
4
- require "digest"
5
- require_relative "../log"
6
- require_relative "../guardrails/load_failures"
7
- require_relative "../memory_bundle/bundle_hook"
8
- require_relative "../memory_bundle/provenance"
9
-
10
- module Samagotchi
11
- # Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
12
- # evaluated inside `Samagotchi::Bundles::<BundleName>` so that two bundles
13
- # shipping a file with the same basename (e.g. `guardrails.rb`) cannot collide
14
- # on a top-level constant.
15
- module Bundles; end
16
-
17
- module Hooks
18
- # Loads bundle-owned hook plugins into a Registry.
19
- #
20
- # Unlike +Hooks::Loader+ (which `require`s a file and resolves a top-level
21
- # constant), this loader `module_eval`s each plugin file inside a
22
- # per-bundle namespace module. This gives:
23
- # - zero class-name collisions across bundles
24
- # - a clean uninstall path (`Registry#unregister_bundle`)
25
- #
26
- # Each plugin must define a class whose PascalCase name matches the file
27
- # basename and whose instances respond to +call(event)+.
28
- #
29
- # A "fail_closed" before_tool_call hook is required: if it is missing,
30
- # fails to load, or its file's sha256 differs from the one recorded at
31
- # install, the failure is reported and the gate denies every tool call.
32
- # Any hook whose sha256 differs is not loaded (reinstall the bundle
33
- # after editing a hook by hand), nor any of a bundle whose requires_chi
34
- # this chi doesn't meet.
35
- #
36
- # The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
37
- # - "fail_closed" (on :before_tool_call): a raising guardrail denies
38
- # the tool call (fail-closed).
39
- # - "log": warn to stderr and continue.
40
- # - "skip" (default): silently continue.
41
- # Whatever the policy, a hook file that raises is told to the user once
42
- # per hook (its +hook_failed+ callback, the Engine's
43
- # HookRoutes#plugin_hook_failed): :skip would otherwise leave no trace.
44
- module BundleLoader
45
- class << self
46
- # Load all hook plugins for a bundle into the given registry.
47
- #
48
- # @param bundle_name [String]
49
- # @param hooks_dir [String] directory containing the bundle's .rb hook files
50
- # @param metadata [Hash] basename => { event:, on_error:, priority:,
51
- # sha256: } (MemoryBundle::BundleHook.parse reads it)
52
- # @param registry [Samagotchi::Hooks::Registry]
53
- # @param failures [Guardrails::LoadFailures, nil] collects hooks that
54
- # failed to load
55
- # @param settings [Hash] the bundle's section of config.yml
56
- # `bundles:` (string keys); a hook class whose initialize takes an
57
- # argument gets it
58
- # @param manifest [MemoryBundle::InstalledManifest, nil] the installed
59
- # bundle's manifest; when this chi doesn't meet its requires_chi no
60
- # hook loads (each is reported, as the plugin loader does)
61
- # @param hook_failed [#call, nil] (label, "Class: message", effect:)
62
- # when a hook file raises at fire time — the Engine's HookRoutes;
63
- # nil when there is no UI to tell (the test kit)
64
- # @return [Integer] number of hooks successfully registered
65
- def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, manifest: nil, hook_failed: nil)
66
- return 0 unless metadata.is_a?(Hash)
67
-
68
- too_old = manifest&.requires_chi_failure
69
- loaded = 0
70
- metadata.each do |raw_basename, raw_meta|
71
- basename = raw_basename.to_s
72
- next if basename.empty?
73
-
74
- hook = MemoryBundle::BundleHook.parse(raw_meta)
75
- # A hook with no declared event cannot be auto-registered.
76
- next if hook.events.empty?
77
-
78
- fail_closed = hook.events.any? { |event| hook.fail_closed?(event) }
79
- what = "hook #{basename} (bundle #{bundle_name})"
80
- file = hooks_dir && File.join(hooks_dir, basename)
81
- unless file && File.exist?(file)
82
- failures&.add(what, "the file is missing", required: true) if fail_closed
83
- next
84
- end
85
- if (mismatch = too_old || sha_mismatch(file, hook.sha256, required: fail_closed))
86
- Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
87
- failures&.add(what, mismatch, required: fail_closed)
88
- next
89
- end
90
-
91
- begin
92
- load_hook_file(bundle_name, basename, file, hook, registry, settings: settings, hook_failed: hook_failed)
93
- loaded += 1
94
- rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
95
- raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
96
-
97
- Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
98
- failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed,
99
- location: Guardrails::LoadFailures.location(e, File.dirname(hooks_dir)))
100
- end
101
- end
102
- loaded
103
- end
104
-
105
- # Build one hook file and register it in +registry+ under each of
106
- # its events (one instance for all: its state is shared), with its
107
- # priority and error policy (a fail_closed before_tool_call one
108
- # denies when it raises). No missing-file, sha or requires_chi
109
- # check: #load makes them; the test kit loads a bundle's source
110
- # this way.
111
- # @param hook [MemoryBundle::BundleHook] its manifest entry
112
- # @param namespace [Module, nil] the module the file is evaluated
113
- # in (default: the bundle's, #namespace_for)
114
- # @param hook_failed [#call, nil] (label, "Class: message", effect:)
115
- # on every raise at fire time, whatever the policy; nil gives no
116
- # notice (the log still has a :log hook's failures)
117
- # @raise [Exception] what loading or building the file raised;
118
- # nothing is registered then
119
- def load_hook_file(bundle_name, basename, file, hook, registry, settings: {}, namespace: nil, hook_failed: nil)
120
- plugin = instantiate(bundle_name, basename, file, settings: settings, namespace: namespace)
121
- hook.events.each do |event_name|
122
- event_sym = event_name.to_sym
123
- fail_closed = hook.fail_closed?(event_sym)
124
- policy = if fail_closed then :deny
125
- elsif hook.on_error == "log" then :log
126
- else :skip
127
- end
128
- label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
129
- # Every policy tells, once per label: a hook file under the
130
- # default :skip would raise with no trace anywhere. The label
131
- # names the file, the bundle and the event, so one label per
132
- # hook and a stream hook can't nag.
133
- on_failure = hook_failed && lambda { |hook_label, failed|
134
- hook_failed.call("#{hook_label} #{event_sym} hook", failed, effect: policy)
135
- }
136
- handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
137
- fields: { bundle: bundle_name }, on_failure: on_failure) { |event| plugin.call(event) }
138
- registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: hook.priority, &handler)
139
- end
140
- nil
141
- end
142
-
143
- # Why the file doesn't match the sha256 recorded at install, or nil.
144
- # With none recorded, only a required hook counts as a mismatch.
145
- def sha_mismatch(file, recorded, required:)
146
- return nil if MemoryBundle::Provenance.unchecked?(recorded)
147
-
148
- expected = MemoryBundle::Provenance.recorded_sha(recorded)
149
- return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
150
-
151
- actual = MemoryBundle::Provenance.file_sha(file)
152
- return nil if actual == expected
153
-
154
- "its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
155
- end
156
-
157
- # Evaluate a plugin file inside the bundle's namespace module (or
158
- # +namespace+) and
159
- # return an instance that responds to #call. A class whose
160
- # initialize takes an argument gets the settings (one Hash, string
161
- # keys); one that takes none is built bare.
162
- def instantiate(bundle_name, basename, file, settings: {}, namespace: nil)
163
- ns = namespace || namespace_for(bundle_name)
164
- content = File.read(file)
165
- ns.module_eval(content, file, 1)
166
- klass = Hooks.class_for(basename, ns)
167
- instance = Hooks.build_plugin(klass, settings)
168
- raise ArgumentError, "plugin #{klass.name.split("::").last} does not respond to #call" unless instance.respond_to?(:call)
169
-
170
- instance
171
- end
172
-
173
- # The (lazily created) namespace module for a bundle.
174
- # Sanitization is made injective by appending a short digest so that
175
- # `my-bundle` and `my_bundle` do not collide.
176
- def namespace_for(bundle_name)
177
- raw = bundle_name.to_s
178
- base = raw.gsub(/[^A-Za-z0-9_]/, "_")
179
- base = "B_#{base}" unless base.match?(/\A[A-Z]/)
180
- digest = Digest::MD5.hexdigest(raw)[0, 4]
181
- ns_name = "#{base}_#{digest}"
182
- if Samagotchi::Bundles.const_defined?(ns_name.to_sym, false)
183
- Samagotchi::Bundles.const_get(ns_name.to_sym)
184
- else
185
- Samagotchi::Bundles.const_set(ns_name.to_sym, Module.new)
186
- end
187
- end
188
- end
189
- end
190
- end
191
- end