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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -1
- data/README.md +1 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +14 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +76 -17
- data/docs/configuration.md +16 -5
- data/docs/context.md +12 -4
- data/docs/guardrails.md +11 -8
- data/docs/hooks.md +47 -80
- data/docs/internals/bridge.md +2 -1
- data/docs/internals/bundles.md +51 -36
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +43 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/scheduling.md +228 -0
- data/docs/sessions.md +30 -6
- data/docs/testing.md +4 -4
- data/lib/samagotchi/bridge/card_places.rb +5 -13
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
- data/lib/samagotchi/bridge.rb +26 -2
- data/lib/samagotchi/bundle_command/check.rb +23 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +53 -44
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client_id.rb +13 -3
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_sources.rb +4 -2
- data/lib/samagotchi/engine.rb +1 -6
- data/lib/samagotchi/extension_load.rb +20 -41
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
- data/lib/samagotchi/memory_bundle/installer.rb +32 -14
- data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
- data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
- data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
- data/lib/samagotchi/memory_bundle/status.rb +30 -9
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/plugin/api.rb +1 -4
- data/lib/samagotchi/plugin/loader.rb +42 -5
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/recap_place.rb +3 -4
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +948 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +20 -0
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/session_manager/spawn.rb +25 -11
- data/lib/samagotchi/session_manager/worker_command.rb +13 -13
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -5
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/tool_declarations.rb +44 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/delegate.rb +12 -8
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -0
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +10 -2
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +24 -6
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +23 -0
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +10 -2
- 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
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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
|
|
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
|
|
7
|
-
#
|
|
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
|
|
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
|
|
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.
|
|
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
|