samagotchi 0.158.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 +21 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -6
- data/docs/cli.md +56 -1
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +3 -1
- data/docs/hooks.md +3 -0
- data/docs/internals/bridge.md +1 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/memory.md +5 -1
- data/docs/scheduling.md +228 -0
- data/docs/sessions.md +25 -1
- data/lib/samagotchi/bridge.rb +26 -2
- data/lib/samagotchi/bundle_command.rb +38 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +13 -3
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +84 -103
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +17 -12
- data/lib/samagotchi/memory_bundle/status.rb +20 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +948 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +20 -0
- data/lib/samagotchi/session_manager/spawn.rb +25 -11
- data/lib/samagotchi/session_manager/worker_command.rb +13 -13
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- 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/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/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- 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 +8 -1
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
# bin/chi subcommands that act on this machine: never sessions. chi
|
|
27
27
|
# remote (this machine's list of remotes) runs here under a remote too
|
|
28
28
|
# (Dispatch); the others are refused there.
|
|
29
|
-
MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home project].freeze
|
|
29
|
+
MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home project schedule].freeze
|
|
30
30
|
|
|
31
31
|
# "command", "command sub" or either with a flag (its long name) →
|
|
32
32
|
# SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
|
|
@@ -11,6 +11,7 @@ require_relative "../parent_report"
|
|
|
11
11
|
require_relative "../note_delivery"
|
|
12
12
|
require_relative "../image_store"
|
|
13
13
|
require_relative "../context_candidates"
|
|
14
|
+
require_relative "../worker_inbound"
|
|
14
15
|
require_relative "../context_sources"
|
|
15
16
|
require_relative "../archive_store"
|
|
16
17
|
require_relative "../session_chain"
|
|
@@ -24,6 +25,7 @@ require_relative "../participants"
|
|
|
24
25
|
require_relative "../config"
|
|
25
26
|
require_relative "../live_versions"
|
|
26
27
|
require_relative "../grants"
|
|
28
|
+
require_relative "../log"
|
|
27
29
|
require_relative "../web/info_probe"
|
|
28
30
|
require_relative "../web/public_url"
|
|
29
31
|
require_relative "../web/link_codes"
|
|
@@ -314,6 +316,16 @@ module Samagotchi
|
|
|
314
316
|
before, written = attached.location.locked(name) do
|
|
315
317
|
[attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
|
|
316
318
|
end
|
|
319
|
+
if fetched.wake && before != written.revision && !target.project? && attached.location.session? &&
|
|
320
|
+
attached.source.scope == "session"
|
|
321
|
+
begin
|
|
322
|
+
subscription = attached.location.subscription(name)
|
|
323
|
+
recent_wake = context_wake_recent?(subscription)
|
|
324
|
+
SessionManager.wake_for_context(target.session_id, **dir) unless recent_wake || Config.get("context.wake") == false
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
Log.exception(:context, "context_push_wake_failed", e, sid: target.session_id, source: name)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
317
329
|
ContextPush.new(status: :pushed, before: before, revision: written.revision)
|
|
318
330
|
end
|
|
319
331
|
|
|
@@ -513,6 +525,14 @@ module Samagotchi
|
|
|
513
525
|
def state_dir! = @state_dir || Session.default_state_dir
|
|
514
526
|
def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
|
|
515
527
|
|
|
528
|
+
def context_wake_recent?(subscription)
|
|
529
|
+
return false unless subscription.wakes_at
|
|
530
|
+
|
|
531
|
+
Time.now - Time.iso8601(subscription.wakes_at) < WorkerInbound::CONTEXT_WAKE_WINDOW
|
|
532
|
+
rescue ArgumentError
|
|
533
|
+
false
|
|
534
|
+
end
|
|
535
|
+
|
|
516
536
|
def attached_to(target, shadowed: false)
|
|
517
537
|
ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
|
|
518
538
|
shadowed: shadowed)
|
|
@@ -155,23 +155,37 @@ module Samagotchi
|
|
|
155
155
|
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
156
156
|
# :scratch, :owned, :starting)
|
|
157
157
|
def wake_for_report(session_id, state_dir: nil, forward: true)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: forward,
|
|
159
|
+
event: :delegate_woke_parent)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A pushed context source asked for a wake: start an idle-exited
|
|
163
|
+
# session, but never clear a stop or forward an archived link.
|
|
164
|
+
# @return [Symbol] :woken, or why not (:gone, :stopped, :archived,
|
|
165
|
+
# :scratch, :owned, :starting)
|
|
166
|
+
def wake_for_context(session_id, state_dir: nil)
|
|
167
|
+
wake_existing_session(session_id, state_dir: state_dir || Session.default_state_dir, forward: false,
|
|
168
|
+
event: :context_woke_session)
|
|
169
|
+
end
|
|
162
170
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
private def wake_existing_session(session_id, state_dir:, forward:, event:)
|
|
172
|
+
return :gone unless Session.exist?(session_id, state_dir: state_dir)
|
|
173
|
+
return :owned if session_owner(session_id, state_dir: state_dir)
|
|
174
|
+
return :stopped if Session.stopped_marker?(session_id, state_dir: state_dir)
|
|
175
|
+
|
|
176
|
+
if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
|
|
177
|
+
following = forward && SessionChain.next_of(session_id, state_dir: state_dir)
|
|
178
|
+
return following ? wake_existing_session(following, state_dir: state_dir, forward: false, event: event) : :archived
|
|
166
179
|
end
|
|
167
|
-
return :starting if StartingMarker.fresh?(session_id, state_dir:
|
|
180
|
+
return :starting if StartingMarker.fresh?(session_id, state_dir: state_dir)
|
|
168
181
|
|
|
169
|
-
session = Session.load(session_id, state_dir:
|
|
182
|
+
session = Session.load(session_id, state_dir: state_dir)
|
|
170
183
|
return :stopped if session.status == Session::STATUS_STOPPED
|
|
171
184
|
return :scratch if session.scratch
|
|
172
185
|
|
|
173
|
-
resume_session(session_id, state_dir:
|
|
174
|
-
|
|
186
|
+
resume_session(session_id, state_dir: state_dir)
|
|
187
|
+
details = event == :delegate_woke_parent ? { parent: session_id[0, 8] } : { sid: session_id[0, 8] }
|
|
188
|
+
Log.info(:worker, event, **details)
|
|
175
189
|
:woken
|
|
176
190
|
rescue OwnedByTUI
|
|
177
191
|
:owned
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
class SessionManager
|
|
5
5
|
# CROSS-VERSION CONTRACT: this boot string, the entry it names
|
|
6
|
-
# (run_session_loop(id, state_dir:)
|
|
7
|
-
# spawn_options sets and the input/ file
|
|
6
|
+
# (WorkerBoot.run_session_loop(id, state_dir:)), the legacy forwarder
|
|
7
|
+
# below, the env keys spawn_options sets and the input/ file
|
|
8
8
|
# format are read by a NEWER chi than the one writing them. Change them
|
|
9
9
|
# only additively (spec/worker_contract_spec.rb).
|
|
10
10
|
BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
|
|
@@ -28,9 +28,9 @@ module Samagotchi
|
|
|
28
28
|
|
|
29
29
|
# WorkerCommand: the worker's command line and env (CROSS-VERSION
|
|
30
30
|
# CONTRACT: spec/worker_contract_spec.rb); the worker process itself is
|
|
31
|
-
# WorkerBoot (worker_boot.rb)
|
|
32
|
-
#
|
|
33
|
-
# through samagotchi/session_manager.
|
|
31
|
+
# WorkerBoot (worker_boot.rb). The boot command loads it directly; the
|
|
32
|
+
# forwarder (run_session_loop) remains for older boot strings. Calls no
|
|
33
|
+
# other part. Meant to be loaded through samagotchi/session_manager.
|
|
34
34
|
module WorkerCommandPart
|
|
35
35
|
# Build the opts hash passed to Process.spawn for a forked worker. The
|
|
36
36
|
# child inherits this process's ENV and reads config.yml itself (as it
|
|
@@ -94,13 +94,13 @@ module Samagotchi
|
|
|
94
94
|
opts
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# The worker's entry
|
|
98
|
-
# CONTRACT: spec/worker_contract_spec.rb): it
|
|
97
|
+
# The worker's legacy entry for boot strings written by older chi
|
|
98
|
+
# (CROSS-VERSION CONTRACT: spec/worker_contract_spec.rb): it forwards to
|
|
99
99
|
# WorkerBoot.run_session_loop, which runs the worker process. Looked up
|
|
100
100
|
# by name on purpose: a constant reference here would put SessionManager
|
|
101
101
|
# back into one cycle with Worker, Engine and Bridge (the class map).
|
|
102
|
-
# REMOVE
|
|
103
|
-
# ~/.claude/plans/arch-worker-boot.md).
|
|
102
|
+
# REMOVE in the next release: boot strings name WorkerBoot since this
|
|
103
|
+
# one (see ~/.claude/plans/arch-worker-boot.md).
|
|
104
104
|
def run_session_loop(session_id, state_dir: nil, **)
|
|
105
105
|
require_relative "../worker_boot"
|
|
106
106
|
Samagotchi.const_get(:WorkerBoot).run_session_loop(session_id, state_dir: state_dir, **)
|
|
@@ -118,8 +118,8 @@ module Samagotchi
|
|
|
118
118
|
# there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
|
|
119
119
|
def worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
|
|
120
120
|
home: ChiHome.current(gem_spec: gem_spec))
|
|
121
|
-
boot = "require 'samagotchi/
|
|
122
|
-
"Samagotchi::
|
|
121
|
+
boot = "require 'samagotchi/worker_boot'; " \
|
|
122
|
+
"Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
|
|
123
123
|
return [RbConfig.ruby, "-I", File.expand_path("../..", __dir__), "-e", boot] unless gem_spec
|
|
124
124
|
return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
|
|
125
125
|
|
|
@@ -146,9 +146,9 @@ module Samagotchi
|
|
|
146
146
|
else
|
|
147
147
|
"gem 'samagotchi', '>= 0.a'"
|
|
148
148
|
end
|
|
149
|
-
home.boot_argv(run: "begin; #{activate}; require 'samagotchi/
|
|
149
|
+
home.boot_argv(run: "begin; #{activate}; require 'samagotchi/worker_boot'; " \
|
|
150
150
|
"#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
|
|
151
|
-
"Samagotchi::
|
|
151
|
+
"Samagotchi::WorkerBoot.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
|
|
152
152
|
end
|
|
153
153
|
end
|
|
154
154
|
|
|
@@ -303,6 +303,49 @@ module Samagotchi
|
|
|
303
303
|
properties: {}
|
|
304
304
|
}
|
|
305
305
|
},
|
|
306
|
+
{
|
|
307
|
+
name: "schedule_task",
|
|
308
|
+
description: "Register a durable schedule for this session: a prompt that runs on a clock outside the worker " \
|
|
309
|
+
"(chi schedule tick, run by cron, systemd or chi web's hub). Unlike register_reminder it needs no " \
|
|
310
|
+
"live worker and never holds one up. Give exactly one of every, weekday+time or date.",
|
|
311
|
+
parameters: {
|
|
312
|
+
type: "object",
|
|
313
|
+
properties: {
|
|
314
|
+
title: { type: "string", description: "A short name for the schedule" },
|
|
315
|
+
prompt: { type: "string", description: "What each firing does (non-empty, at most 16 KiB)" },
|
|
316
|
+
every: { type: "string", description: "Interval: \"30m\", \"2h\", \"1d\" (UTC math: drifts an hour across a DST change)" },
|
|
317
|
+
weekday: { type: "integer", description: "1=Mon … 7=Sun, used with time" },
|
|
318
|
+
time: { type: "string", description: "HH:MM in the machine's local zone (with weekday)" },
|
|
319
|
+
date: { type: "string", description: "One local moment: 2026-11-20T09:00" },
|
|
320
|
+
mode: { type: "string", description: "chain (default): the next link of this session's chain. same: a queued turn in this session" },
|
|
321
|
+
keep_links: { type: "integer", description: "Chain links to keep (default 5; 0 keeps all)" }
|
|
322
|
+
},
|
|
323
|
+
required: ["prompt"]
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "list_schedules",
|
|
328
|
+
description: "List the durable schedules of this chain (all: true for every schedule on this machine): id, mode, recurrence, next due and target.",
|
|
329
|
+
parameters: {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: {
|
|
332
|
+
include_disabled: { type: "boolean", description: "Include the disabled schedules and the fired one-offs" },
|
|
333
|
+
all: { type: "boolean", description: "Every schedule on this machine, not just this chain" }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: "cancel_schedule",
|
|
339
|
+
description: "Cancel a durable schedule of this chain by its id, a unique prefix of it, or its title (all: true for one from another chain).",
|
|
340
|
+
parameters: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
name: { type: "string", description: "The schedule's id or title" },
|
|
344
|
+
all: { type: "boolean", description: "Allow cancelling a schedule of another session's chain" }
|
|
345
|
+
},
|
|
346
|
+
required: ["name"]
|
|
347
|
+
}
|
|
348
|
+
},
|
|
306
349
|
{
|
|
307
350
|
name: "list_sessions",
|
|
308
351
|
description: "List the other chi sessions of this project (newest first, up to 20): id, whether a worker runs it, its folder and last prompt; other projects: cwd \"/\". Use it to find the session send_note should go to.",
|
|
@@ -47,6 +47,8 @@ module Samagotchi
|
|
|
47
47
|
"web_fetch" => { content: "url", fallback: :prefix_or_raw },
|
|
48
48
|
"register_reminder" => { content: "name", fallback: :prefix_or_raw },
|
|
49
49
|
"cancel_reminder" => { content: "name", fallback: :prefix_or_raw },
|
|
50
|
+
"schedule_task" => { content: "prompt", fallback: :prefix_or_raw },
|
|
51
|
+
"cancel_schedule" => { content: "name", fallback: :prefix_or_raw },
|
|
50
52
|
"list_sessions" => { fallback: :prefix, fallback_key: "cwd" },
|
|
51
53
|
"send_note" => { content: "text" },
|
|
52
54
|
"context_read" => { content: "name", fallback: :prefix_or_raw },
|
|
@@ -18,6 +18,9 @@ require_relative "web_fetch"
|
|
|
18
18
|
require_relative "register_reminder"
|
|
19
19
|
require_relative "cancel_reminder"
|
|
20
20
|
require_relative "list_reminders"
|
|
21
|
+
require_relative "schedule_task"
|
|
22
|
+
require_relative "list_schedules"
|
|
23
|
+
require_relative "cancel_schedule"
|
|
21
24
|
require_relative "list_sessions"
|
|
22
25
|
require_relative "send_note"
|
|
23
26
|
require_relative "context_read"
|
|
@@ -54,6 +57,9 @@ module Samagotchi
|
|
|
54
57
|
RegisterReminder,
|
|
55
58
|
CancelReminder,
|
|
56
59
|
ListReminders,
|
|
60
|
+
ScheduleTask,
|
|
61
|
+
ListSchedules,
|
|
62
|
+
CancelSchedule,
|
|
57
63
|
ListSessions,
|
|
58
64
|
SendNote,
|
|
59
65
|
ContextRead,
|
|
@@ -103,6 +109,17 @@ module Samagotchi
|
|
|
103
109
|
end,
|
|
104
110
|
CancelReminder::NAME => ->(call, kctx) { CancelReminder.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
105
111
|
ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
112
|
+
ScheduleTask::NAME => lambda do |call, kctx|
|
|
113
|
+
ScheduleTask.call(call[:content], title: call[:title], prompt: call[:prompt], every: call[:every],
|
|
114
|
+
weekday: call[:weekday], time: call[:time], date: call[:date], mode: call[:mode],
|
|
115
|
+
keep_links: call[:keep_links], peers: kctx.peers)
|
|
116
|
+
end,
|
|
117
|
+
ListSchedules::NAME => lambda do |call, kctx|
|
|
118
|
+
ListSchedules.call(call[:content], include_disabled: call[:include_disabled], all: call[:all], peers: kctx.peers)
|
|
119
|
+
end,
|
|
120
|
+
CancelSchedule::NAME => lambda do |call, kctx|
|
|
121
|
+
CancelSchedule.call(call[:content], all: call[:all], peers: kctx.peers)
|
|
122
|
+
end,
|
|
106
123
|
ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
|
|
107
124
|
SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
|
|
108
125
|
ContextRead::NAME => lambda do |call, kctx|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# Cancel a durable schedule of this session's chain by its id, a unique
|
|
6
|
+
# prefix of it, or its title (docs/scheduling.md). A schedule of another
|
|
7
|
+
# session's chain is refused unless the call says `all: true`: `chi
|
|
8
|
+
# schedule cancel` is the machine-wide way.
|
|
9
|
+
class CancelSchedule
|
|
10
|
+
NAME = "cancel_schedule"
|
|
11
|
+
|
|
12
|
+
def self.name = NAME
|
|
13
|
+
|
|
14
|
+
def self.call(content, all: false, peers: nil)
|
|
15
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
16
|
+
# for the session lifecycle until a schedule is actually used.
|
|
17
|
+
require_relative "../schedules"
|
|
18
|
+
name = content.to_s.strip
|
|
19
|
+
return "Error: cancel_schedule needs the schedule's id or title" if name.empty?
|
|
20
|
+
|
|
21
|
+
want_all = all == true || all.to_s.strip.casecmp?("true")
|
|
22
|
+
schedules = Schedules.new(state_dir: peers&.state_dir)
|
|
23
|
+
entry = schedules.lookup(name)
|
|
24
|
+
return "No schedule named #{name}." if entry.nil?
|
|
25
|
+
|
|
26
|
+
if want_all || peers&.session_id.nil?
|
|
27
|
+
schedules.cancel(name)
|
|
28
|
+
return "Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless schedules.chain_of(peers.session_id).any? { |id| id == entry.anchor || id == entry.head }
|
|
32
|
+
return "Error: #{entry.id} belongs to another session's chain. " \
|
|
33
|
+
"chi schedule cancel #{entry.id} cancels it, or say all: true if you meant it."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
schedules.cancel(name)
|
|
37
|
+
"Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
"Error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# The durable schedules of this session's chain (docs/scheduling.md): id,
|
|
6
|
+
# mode, recurrence, next due and target. Another session's schedules are
|
|
7
|
+
# out of reach unless the call says `all: true`; `chi schedule list` is the
|
|
8
|
+
# machine-wide view. Schedules is required inside the call, so the testing
|
|
9
|
+
# kit does not pay for the session lifecycle at load time.
|
|
10
|
+
class ListSchedules
|
|
11
|
+
NAME = "list_schedules"
|
|
12
|
+
|
|
13
|
+
def self.name = NAME
|
|
14
|
+
|
|
15
|
+
def self.call(_content, include_disabled: false, all: false, peers: nil)
|
|
16
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
17
|
+
# for the session lifecycle until a schedule is actually used.
|
|
18
|
+
require_relative "../schedules"
|
|
19
|
+
wanted = include_disabled == true || include_disabled.to_s.strip.casecmp?("true")
|
|
20
|
+
want_all = all == true || all.to_s.strip.casecmp?("true")
|
|
21
|
+
schedules = Samagotchi::Schedules.new(state_dir: peers&.state_dir)
|
|
22
|
+
|
|
23
|
+
scoped = !want_all && peers&.session_id
|
|
24
|
+
entries = scoped ? schedules.for_chain(peers.session_id, include_disabled: wanted) : schedules.entries(include_disabled: wanted)
|
|
25
|
+
|
|
26
|
+
if entries.empty?
|
|
27
|
+
scoped ? "No schedules in this session's chain. `chi schedule list` shows every schedule on this machine." : "No schedules."
|
|
28
|
+
else
|
|
29
|
+
lines = entries.map do |entry|
|
|
30
|
+
flags = []
|
|
31
|
+
flags << "skips #{entry.skips} of #{entry.max_skips}" if entry.skips.positive?
|
|
32
|
+
flags << "disabled after #{entry.failures} failures" unless entry.enabled
|
|
33
|
+
"#{entry.id} #{entry.target_words} #{entry.recurrence_words} next #{entry.next_due} #{flags.join(", ")}".rstrip
|
|
34
|
+
end
|
|
35
|
+
lines << "These are this session's chain only; `chi schedule list` shows every schedule on this machine." if scoped
|
|
36
|
+
lines.join("\n")
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
"Error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Tools
|
|
5
|
+
# Register a durable schedule for this session (docs/scheduling.md): a
|
|
6
|
+
# prompt that runs on a clock, out of the worker's memory, until it is
|
|
7
|
+
# cancelled. The default mode is `chain`: each firing is the next link of
|
|
8
|
+
# this session's chain, so a long-running routine keeps a fresh context.
|
|
9
|
+
# Unlike register_reminder, a schedule needs no live worker — a tick run by
|
|
10
|
+
# cron (or chi web's hub) wakes one — and it never holds an idle worker up.
|
|
11
|
+
class ScheduleTask
|
|
12
|
+
NAME = "schedule_task"
|
|
13
|
+
|
|
14
|
+
def self.name = NAME
|
|
15
|
+
|
|
16
|
+
def self.call(content, title: nil, prompt: nil, every: nil, weekday: nil, time: nil, date: nil,
|
|
17
|
+
mode: nil, keep_links: nil, peers: nil)
|
|
18
|
+
# Required here, not at the top of the file: the kit does not pay
|
|
19
|
+
# for the session lifecycle until a schedule is actually used.
|
|
20
|
+
require_relative "../schedules"
|
|
21
|
+
return "Error: this session's id is not known here" unless peers&.session_id
|
|
22
|
+
|
|
23
|
+
prompt = content.to_s if prompt.to_s.strip.empty?
|
|
24
|
+
schedules = Schedules.new(state_dir: peers.state_dir)
|
|
25
|
+
entry = schedules.add(
|
|
26
|
+
target: peers.session_id, prompt: prompt, title: title,
|
|
27
|
+
recurrence: Schedules.recurrence(every: every, weekday: weekday, time: time, date: date),
|
|
28
|
+
mode: mode.nil? || mode.to_s.strip.empty? ? Schedules::CHAIN : mode.to_s.strip,
|
|
29
|
+
keep_links: keep_links
|
|
30
|
+
)
|
|
31
|
+
"Scheduled #{entry.id}: #{entry.target_words} · #{entry.recurrence_words} · next #{entry.next_due}. " \
|
|
32
|
+
"It fires outside this worker (chi schedule tick, run by cron or chi web's hub); " \
|
|
33
|
+
"chi schedule list and cancel_schedule show and stop it."
|
|
34
|
+
rescue Schedules::Usage, Session::NotFound, Session::AmbiguousId => e
|
|
35
|
+
"Error: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -128,10 +128,21 @@ export function chainLinks(list, id, nexts = nextsOf(list), byId = new Map(list.
|
|
|
128
128
|
return links;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Whether the chain's oldest link the page holds still names a link it does
|
|
132
|
+
// not: a schedule's prune (docs/scheduling.md, keep_links) or a delete. The
|
|
133
|
+
// walk stops there, so every count comes from the surviving root, and the page
|
|
134
|
+
// says "earlier links pruned" rather than linking to a session that is gone.
|
|
135
|
+
export function linksPruned(links, byId) {
|
|
136
|
+
const root = links[0];
|
|
137
|
+
return !!root?.continues && !byId.has(root.continues);
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
// The session page's neighbours of +id+: {prev, next}, each {id, session}
|
|
132
|
-
// or null
|
|
133
|
-
//
|
|
134
|
-
//
|
|
141
|
+
// or null, and +pruned+: the session's own `continues` names a link the list
|
|
142
|
+
// does not hold (a schedule pruned it, or it was deleted). +continues+: the
|
|
143
|
+
// open session's own `continues`, when the list may not have caught up with it.
|
|
144
|
+
// A previous link the list doesn't hold is null: its link would open a
|
|
145
|
+
// not-found page.
|
|
135
146
|
export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
136
147
|
const self = list.find((s) => s.id === id);
|
|
137
148
|
const prevId = self?.continues || continues || null;
|
|
@@ -139,7 +150,7 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
|
|
|
139
150
|
const prev = prevSession ? { id: prevId, session: prevSession } : null;
|
|
140
151
|
const nextSession = nextsOf(list).get(id) || null;
|
|
141
152
|
const next = nextSession ? { id: nextSession.id, session: nextSession } : null;
|
|
142
|
-
return { prev, next };
|
|
153
|
+
return { prev, next, pruned: !!prevId && !prevSession };
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
const pad = (n) => String(n).padStart(2, "0");
|
|
@@ -188,14 +199,22 @@ export function foldChains(fams, all, { fold = true } = {}) {
|
|
|
188
199
|
let changed = false;
|
|
189
200
|
for (const f of fams) {
|
|
190
201
|
const links = chainLinks(all, f.head.id, nexts, byId);
|
|
202
|
+
const pruned = linksPruned(links, byId);
|
|
191
203
|
if (links.length < 2) {
|
|
192
|
-
|
|
204
|
+
// A chain pruned to one link is still a chain: its card carries the chip
|
|
205
|
+
// and the popover says what is behind it.
|
|
206
|
+
if (pruned) {
|
|
207
|
+
changed = true;
|
|
208
|
+
out.push({ ...f, chain: { links, at: 0, folded: fold, pruned } });
|
|
209
|
+
} else {
|
|
210
|
+
out.push(f);
|
|
211
|
+
}
|
|
193
212
|
continue;
|
|
194
213
|
}
|
|
195
214
|
changed = true;
|
|
196
215
|
const at = links.findIndex((s) => s.id === f.head.id);
|
|
197
216
|
if (fold && links.slice(at + 1).some((s) => heads.has(s.id))) continue;
|
|
198
|
-
out.push({ ...f, chain: { links, at, folded: fold } });
|
|
217
|
+
out.push({ ...f, chain: { links, at, folded: fold, pruned } });
|
|
199
218
|
}
|
|
200
219
|
return changed ? out : fams;
|
|
201
220
|
}
|
|
@@ -233,7 +252,8 @@ export function chainChipTemplate(f, { open = false } = {}) {
|
|
|
233
252
|
const text = n === links.length ? `#${n}` : `#${n} of ${links.length}`;
|
|
234
253
|
const earlier = at;
|
|
235
254
|
const later = links.length - 1 - at;
|
|
236
|
-
const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""
|
|
255
|
+
const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : "",
|
|
256
|
+
f.chain.pruned ? "earlier links pruned" : ""].filter(Boolean);
|
|
237
257
|
const title = `Session chain: link ${n} of ${links.length} (${parts.join(", ")}); open the list`;
|
|
238
258
|
const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
|
|
239
259
|
const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
|
|
@@ -275,7 +295,11 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
|
|
|
275
295
|
date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
|
|
276
296
|
rowBadgesTemplate(waiting, stopped, s.status)}${s.archived ? archivedBadge : nothing}</button></div>`;
|
|
277
297
|
});
|
|
278
|
-
|
|
298
|
+
const prunedRow = f?.chain?.pruned
|
|
299
|
+
? html`<div class="chain-row chain-pruned" role="listitem"><span class="cr-n" aria-hidden="true">↩</span>
|
|
300
|
+
<span class="fr-preview">earlier links pruned</span></div>`
|
|
301
|
+
: nothing;
|
|
302
|
+
return html`<div class="family-rows chain-rows" role="list">${rows}${prunedRow}</div>`;
|
|
279
303
|
}
|
|
280
304
|
|
|
281
305
|
// What the page does with a refused continue (POST /api/sessions
|
|
@@ -1384,7 +1384,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1384
1384
|
/* One run: the groups' chips are the meta's. */
|
|
1385
1385
|
#infoBar .meta .bar-group{display:contents}
|
|
1386
1386
|
#infoBar .meta .bar-sep{display:none}
|
|
1387
|
-
#infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
|
|
1387
|
+
#infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.chain-pruned,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
|
|
1388
1388
|
#infoBar .info-actions{display:none}
|
|
1389
1389
|
#infoBar .meta .status+.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
1390
1390
|
/* ⚙ alone, the start page's dot when the session set its own (its values in the tooltip and accessible name). */
|
|
@@ -1577,6 +1577,9 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
|
|
|
1577
1577
|
.chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
1578
1578
|
/* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
|
|
1579
1579
|
.chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
|
|
1580
|
+
/* A chain pruned by a schedule (docs/scheduling.md): the popover's last line and the info bar say the earlier links are gone. */
|
|
1581
|
+
.chain-row.chain-pruned,.chain-row.chain-pruned .cr-n{color:var(--text-muted)}
|
|
1582
|
+
#infoBar .meta .chain-pruned{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap}
|
|
1580
1583
|
/* The session page's links to the previous and the next link of its chain. */
|
|
1581
1584
|
#infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
|
|
1582
1585
|
@media(hover:hover){#infoBar .meta a.chain-link:hover{background:var(--accent-bg)}}
|
|
@@ -98,10 +98,11 @@ export function infoBarHtml(bar) {
|
|
|
98
98
|
if (!bar) return nothing;
|
|
99
99
|
const memories = bar.memories || [];
|
|
100
100
|
const setup = html`<button type="button" class="id" title=${`Session id ${bar.id}: click to copy it`}>${String(bar.id).slice(0, 8)}</button><span class=${bar.status ? `status ${bar.status}` : "status"}><span>${bar.status || ""}</span></span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${settingsChip(bar.settings)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}`;
|
|
101
|
-
const related = !!(bar.chain?.prev || bar.chain?.next || bar.parent || bar.kids || bar.participants);
|
|
102
|
-
//
|
|
101
|
+
const related = !!(bar.chain?.prev || bar.chain?.next || bar.chain?.pruned || bar.parent || bar.kids || bar.participants);
|
|
102
|
+
// A chain pruned by a schedule (docs/scheduling.md): the link before this one
|
|
103
|
+
// is gone, so there is nothing to link to — the bar says so instead.
|
|
103
104
|
const relations = related
|
|
104
|
-
? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
|
|
105
|
+
? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.pruned && !bar.chain?.prev ? html`<span class="model chain-pruned" title="The links before this one were pruned by a schedule">↩ earlier links pruned</span>` : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
|
|
105
106
|
: nothing;
|
|
106
107
|
const telemetry = html`<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class=${`model speed${bar.speed.empty ? " empty" : ""}`} title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}<span class=${`model session-time${bar.clock ? "" : " empty"}`} title=${bar.clock ? "This session's time: its turns, and the running one" : "This session's time: no turn yet"}>${bar.clock || CLOCK_NONE}</span>${workerBadgeTemplate(bar.worker)}`;
|
|
107
108
|
return html`<div class="meta"><span class="bar-group setup">${setup}</span>${relations}<span class="bar-sep" aria-hidden="true"></span><span class="bar-group telemetry">${telemetry}</span></div>`;
|
|
@@ -133,6 +133,9 @@ const CONTEXT_CLIENT = "context:";
|
|
|
133
133
|
// A turn chi ran because a background task the session started finished
|
|
134
134
|
// unseen: task:<task id> (the Worker's finish wake).
|
|
135
135
|
const TASK_CLIENT = "task:";
|
|
136
|
+
// A durable schedule's firing (chi schedule, the schedule tools):
|
|
137
|
+
// system:schedule:<schedule id>.
|
|
138
|
+
const SCHEDULE_CLIENT = "system:schedule:";
|
|
136
139
|
|
|
137
140
|
// A task id as people read it: the random part a task_create id ends with
|
|
138
141
|
// (it starts with the time), else its first 8 chars (TaskRecord.short_id).
|
|
@@ -149,6 +152,7 @@ export function clientLabel(clientId) {
|
|
|
149
152
|
const id = String(clientId);
|
|
150
153
|
if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
|
|
151
154
|
if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
|
|
155
|
+
if (id.startsWith(SCHEDULE_CLIENT)) return "schedule";
|
|
152
156
|
const colon = id.indexOf(":");
|
|
153
157
|
return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
|
|
154
158
|
}
|
|
@@ -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
|