samagotchi 0.31.0 → 0.33.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 +70 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +3 -1
- data/docs/context.md +220 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +7 -3
- data/docs/plugins.md +79 -2
- data/docs/releasing.md +3 -2
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +6 -0
- data/lib/samagotchi/child_reports.rb +31 -12
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/context_absorber.rb +180 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +195 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +462 -0
- data/lib/samagotchi/engine.rb +19 -11
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/plugin/sessions.rb +2 -1
- data/lib/samagotchi/process_group.rb +160 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -4
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +7 -5
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_manager.rb +7 -6
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +36 -19
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
- data/lib/samagotchi/terminal_ui.rb +2 -4
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_declarations.rb +21 -0
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +111 -2
- data/lib/samagotchi/web/public/app.js +57 -28
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +24 -0
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +5 -3
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -3
- data/lib/samagotchi/web/public/turn_view.js +5 -2
- data/lib/samagotchi/worker.rb +154 -6
- metadata +18 -1
|
@@ -18,6 +18,7 @@ require_relative "cancel_reminder"
|
|
|
18
18
|
require_relative "list_reminders"
|
|
19
19
|
require_relative "list_sessions"
|
|
20
20
|
require_relative "send_note"
|
|
21
|
+
require_relative "context_read"
|
|
21
22
|
require_relative "delegate"
|
|
22
23
|
require_relative "delegate_result"
|
|
23
24
|
require_relative "ask_user_question"
|
|
@@ -50,6 +51,7 @@ module Samagotchi
|
|
|
50
51
|
ListReminders,
|
|
51
52
|
ListSessions,
|
|
52
53
|
SendNote,
|
|
54
|
+
ContextRead,
|
|
53
55
|
Delegate,
|
|
54
56
|
DelegateResult,
|
|
55
57
|
AskUserQuestion
|
|
@@ -92,6 +94,9 @@ module Samagotchi
|
|
|
92
94
|
ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
|
|
93
95
|
ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
|
|
94
96
|
SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
|
|
97
|
+
ContextRead::NAME => lambda do |call, kctx|
|
|
98
|
+
ContextRead.call(call[:content], offset: call[:offset], limit: call[:limit], peers: kctx.peers)
|
|
99
|
+
end,
|
|
95
100
|
Delegate::NAME => lambda do |call, kctx|
|
|
96
101
|
Delegate.call(call[:content], model: call[:model], session: call[:session], wait: call[:wait],
|
|
97
102
|
timeout: call[:timeout], peers: kctx.peers)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "../context_sources"
|
|
6
|
+
require_relative "../project_scope"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Tools
|
|
10
|
+
# Read the session's attached context (ContextSources): without a name
|
|
11
|
+
# the list, with one its header and text, paged by lines when it is
|
|
12
|
+
# longer than max_tool_output_chars. It reads snapshots only (never
|
|
13
|
+
# runs a source's command) and records what was read, so the web can
|
|
14
|
+
# show a change the agent hasn't read.
|
|
15
|
+
class ContextRead
|
|
16
|
+
NAME = "context_read"
|
|
17
|
+
DEFAULT_MAX_CHARS = 10_000
|
|
18
|
+
# Room for the header and the paging line inside the cap.
|
|
19
|
+
HEADER_ROOM = 1_000
|
|
20
|
+
|
|
21
|
+
def self.name = NAME
|
|
22
|
+
|
|
23
|
+
# @param content [String] the source's name; blank for the list
|
|
24
|
+
# @param offset [Integer, String, nil] the first line (1-based)
|
|
25
|
+
# @param limit [Integer, String, nil] how many lines at most
|
|
26
|
+
# @param peers [Peers, nil] the session (id, cwd, state dir)
|
|
27
|
+
def self.call(content, offset: nil, limit: nil, peers: nil, max_chars: nil, now: Time.now)
|
|
28
|
+
return "Error: this session's id is not known here" unless peers&.session_id
|
|
29
|
+
|
|
30
|
+
new(peers, max_chars: max_chars || config_cap, now: now).run(content.to_s.strip, offset: offset, limit: limit)
|
|
31
|
+
rescue ContextSources::Invalid => e
|
|
32
|
+
"Error: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.config_cap
|
|
36
|
+
value = Config.get("max_tool_output_chars").to_i
|
|
37
|
+
value.positive? ? value : DEFAULT_MAX_CHARS
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(peers, max_chars:, now:)
|
|
41
|
+
@state_dir = peers.state_dir
|
|
42
|
+
@own = ContextSources.session_location(peers.session_id, state_dir: @state_dir)
|
|
43
|
+
# The Engine's PeerView knows the session's project; a plain Peers
|
|
44
|
+
# (specs, a bare kernel) has only its folder.
|
|
45
|
+
root = peers.respond_to?(:project_root) ? peers.project_root : ProjectScope.root_for(peers.cwd)
|
|
46
|
+
@attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
|
|
47
|
+
@max_chars = [max_chars.to_i, HEADER_ROOM * 2].max
|
|
48
|
+
@now = now
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run(name, offset:, limit:)
|
|
52
|
+
return list if name.empty?
|
|
53
|
+
|
|
54
|
+
ContextSources.check_name!(name)
|
|
55
|
+
attached = @attached.find { |a| a.name == name }
|
|
56
|
+
return "Error: no attached source #{name}; context_read without a name lists them" unless attached
|
|
57
|
+
|
|
58
|
+
read(attached, offset: offset, limit: limit)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Muted sources are left out: this session ignores them.
|
|
64
|
+
def list
|
|
65
|
+
shown = @attached.reject { |a| @own.muted?(a.name) }
|
|
66
|
+
return "No attached context in this session." if shown.empty?
|
|
67
|
+
|
|
68
|
+
subs = @own.subscriptions
|
|
69
|
+
lines = shown.map do |a|
|
|
70
|
+
snapshot = a.snapshot
|
|
71
|
+
parts = ["- #{a.name}"]
|
|
72
|
+
parts << "why: #{a.source.why}" if a.source.why
|
|
73
|
+
hint = snapshot.hint || a.source.hint
|
|
74
|
+
parts << "hint: #{hint}" if hint
|
|
75
|
+
if snapshot.text?
|
|
76
|
+
parts << "fetched #{age(snapshot.fetched_at)}"
|
|
77
|
+
parts << "changed since you last read it: #{subs[a.name]&.read == snapshot.revision ? "no" : "yes"}"
|
|
78
|
+
else
|
|
79
|
+
parts << "no text yet"
|
|
80
|
+
end
|
|
81
|
+
parts << "last refresh failed: #{snapshot.error}" if snapshot.error
|
|
82
|
+
parts.join("; ")
|
|
83
|
+
end
|
|
84
|
+
"Attached context (#{shown.size}):\n#{lines.join("\n")}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def read(attached, offset:, limit:)
|
|
88
|
+
snapshot = attached.snapshot
|
|
89
|
+
header = header_lines(attached, snapshot)
|
|
90
|
+
unless snapshot.text?
|
|
91
|
+
return "#{header.join("\n")}\nNo text yet#{": the last refresh failed" if snapshot.error}."
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
lines = snapshot.text.lines
|
|
95
|
+
first = positive(offset) || 1
|
|
96
|
+
return "#{header.join("\n")}\nThe text has #{lines.size} lines; offset #{first} is past its end." if first > lines.size
|
|
97
|
+
|
|
98
|
+
page = lines[(first - 1)..] || []
|
|
99
|
+
page = page.first(positive(limit)) if positive(limit)
|
|
100
|
+
page = fit(page)
|
|
101
|
+
last = first + page.size - 1
|
|
102
|
+
@own.update_subscription(attached.name) { |sub| sub.with(read: snapshot.revision) }
|
|
103
|
+
|
|
104
|
+
if first > 1 || last < lines.size
|
|
105
|
+
more = last < lines.size ? " Pass offset: #{last + 1} for more." : ""
|
|
106
|
+
header << "Lines #{first}-#{last} of #{lines.size}.#{more}"
|
|
107
|
+
end
|
|
108
|
+
"#{header.join("\n")}\n---\n#{page.join}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def header_lines(attached, snapshot)
|
|
112
|
+
source = attached.source
|
|
113
|
+
hint = snapshot.hint || source.hint
|
|
114
|
+
header = ["#{source.name}#{" (#{hint})" if hint}"]
|
|
115
|
+
header << "Why: #{source.why}" if source.why
|
|
116
|
+
header << "Fetched: #{clock(snapshot.fetched_at)} (#{age(snapshot.fetched_at)})" if snapshot.fetched_at
|
|
117
|
+
header << "Summary: #{snapshot.summary}" if snapshot.summary
|
|
118
|
+
header << "Last refresh failed: #{snapshot.error}" if snapshot.error
|
|
119
|
+
header
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The lines that fit in the cap (at least one, cut when that one is
|
|
123
|
+
# longer than the cap).
|
|
124
|
+
def fit(lines)
|
|
125
|
+
budget = @max_chars - HEADER_ROOM
|
|
126
|
+
taken = []
|
|
127
|
+
used = 0
|
|
128
|
+
lines.each do |line|
|
|
129
|
+
break if used + line.length > budget && taken.any?
|
|
130
|
+
|
|
131
|
+
taken << (line.length > budget ? "#{line[0, budget]}…\n" : line)
|
|
132
|
+
used += line.length
|
|
133
|
+
end
|
|
134
|
+
taken
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def positive(value)
|
|
138
|
+
number = Integer(value.to_s, 10, exception: false)
|
|
139
|
+
number&.positive? ? number : nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def clock(iso)
|
|
143
|
+
Time.iso8601(iso).localtime.strftime("%Y-%m-%d %H:%M")
|
|
144
|
+
rescue ArgumentError
|
|
145
|
+
iso.to_s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def age(iso)
|
|
149
|
+
return "never" unless iso
|
|
150
|
+
|
|
151
|
+
seconds = (@now - Time.iso8601(iso)).to_i
|
|
152
|
+
if seconds < 60 then "just now"
|
|
153
|
+
elsif seconds < 3600 then "#{seconds / 60}m ago"
|
|
154
|
+
elsif seconds < 86_400 then "#{seconds / 3600}h ago"
|
|
155
|
+
else
|
|
156
|
+
"#{seconds / 86_400}d ago"
|
|
157
|
+
end
|
|
158
|
+
rescue ArgumentError
|
|
159
|
+
"at an unknown time"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../client_id"
|
|
3
4
|
require_relative "../session"
|
|
4
5
|
require_relative "../config"
|
|
5
6
|
require_relative "../model_profile"
|
|
@@ -21,7 +22,6 @@ module Samagotchi
|
|
|
21
22
|
# reply comes back (DelegateWait), never its trace.
|
|
22
23
|
class Delegate
|
|
23
24
|
NAME = "delegate"
|
|
24
|
-
CLIENT_PREFIX = "delegate"
|
|
25
25
|
MAX_CHILDREN_KEY = "session.max_children"
|
|
26
26
|
MAX_CHILDREN_DEFAULT = 4
|
|
27
27
|
# A child spawned this many seconds ago counts as running even before
|
|
@@ -113,7 +113,7 @@ module Samagotchi
|
|
|
113
113
|
|
|
114
114
|
# Only a reply after this delivery counts as the follow-up's.
|
|
115
115
|
DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
|
|
116
|
-
delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{
|
|
116
|
+
delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
|
|
117
117
|
state_dir: state_dir)
|
|
118
118
|
case delivered[:status]
|
|
119
119
|
when :accepted then id
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require_relative "../process_group"
|
|
4
5
|
require_relative "output_guardrails"
|
|
5
6
|
require_relative "shell"
|
|
6
7
|
|
|
@@ -95,7 +96,7 @@ module Samagotchi
|
|
|
95
96
|
|
|
96
97
|
Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
97
98
|
stdin.close
|
|
98
|
-
readers = [
|
|
99
|
+
readers = [stdout, stderr].map { |io| ProcessGroup::PipeReader.new(io, chunk: READ_CHUNK_BYTES) }
|
|
99
100
|
|
|
100
101
|
begin
|
|
101
102
|
until wait_thr.join(WAIT_SLICE_SEC)
|
|
@@ -132,7 +133,7 @@ module Samagotchi
|
|
|
132
133
|
stopped = stop_process_group(wait_thr.pid, readers)
|
|
133
134
|
held_open = (stopped ? :stopped : :detached) if outcome == :grace && !stopping
|
|
134
135
|
end
|
|
135
|
-
readers.each(
|
|
136
|
+
readers.each { |reader| reader.finish(poll: STOP_POLL_INTERVAL_SEC, grace: STOP_GRACE_SEC) }
|
|
136
137
|
stdout_text, stderr_text = readers.map(&:text)
|
|
137
138
|
end
|
|
138
139
|
end
|
|
@@ -162,95 +163,23 @@ module Samagotchi
|
|
|
162
163
|
# has members holding the output: TERM the group, then KILL it. A process
|
|
163
164
|
# that left the group (setsid) is out of reach; the pipes get closed.
|
|
164
165
|
# True once the output was released, false if something still holds it.
|
|
166
|
+
# Only the group: the leader's pid is reaped, so it may name another process.
|
|
165
167
|
def self.stop_process_group(pgid, readers)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
released = -> { readers.all?(&:done?) }
|
|
169
|
+
wait = ->(seconds) { readers.find { |r| !r.done? }&.wait(seconds) }
|
|
170
|
+
return true if ProcessGroup.stop(pgid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, stopped: released, wait: wait)
|
|
169
171
|
|
|
170
|
-
signal_group(pgid, "KILL")
|
|
171
172
|
await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil, cancelled: -> { false }) == :done
|
|
172
173
|
end
|
|
173
174
|
private_class_method :stop_process_group
|
|
174
175
|
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
Process.kill(signal, -pgid)
|
|
178
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
179
|
-
nil
|
|
180
|
-
end
|
|
181
|
-
private_class_method :signal_group
|
|
182
|
-
|
|
183
|
-
# Reads a pipe in chunks on its own thread, so the output read so far
|
|
184
|
-
# survives the pipe being closed under it.
|
|
185
|
-
class OutputReader
|
|
186
|
-
def initialize(io)
|
|
187
|
-
@io = io
|
|
188
|
-
@buffer = String.new(encoding: Encoding::BINARY)
|
|
189
|
-
@thread = Thread.new do
|
|
190
|
-
Thread.current.report_on_exception = false
|
|
191
|
-
loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
|
|
192
|
-
rescue IOError # EOFError is one
|
|
193
|
-
nil
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def done? = !@thread.alive?
|
|
198
|
-
|
|
199
|
-
def wait(seconds) = @thread.join(seconds)
|
|
200
|
-
|
|
201
|
-
# Closes the pipe (a reader still blocked gets IOError) and ends the
|
|
202
|
-
# thread; the text is safe to read afterwards.
|
|
203
|
-
def finish
|
|
204
|
-
@thread.join(STOP_POLL_INTERVAL_SEC) unless done?
|
|
205
|
-
close_quietly
|
|
206
|
-
@thread.kill unless @thread.join(STOP_GRACE_SEC)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def text
|
|
210
|
-
@buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
private
|
|
214
|
-
|
|
215
|
-
def close_quietly
|
|
216
|
-
@io.close unless @io.closed?
|
|
217
|
-
rescue IOError
|
|
218
|
-
nil
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
private_constant :OutputReader
|
|
222
|
-
|
|
176
|
+
# The shell still runs: TERM its group (or the shell alone, if it left
|
|
177
|
+
# the group), then KILL it once the grace is over.
|
|
223
178
|
def self.terminate_process_tree(pid)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
deadline = monotonic_time + STOP_GRACE_SEC
|
|
227
|
-
sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
|
|
228
|
-
|
|
229
|
-
signal_process(pid, "KILL") if process_alive?(pid)
|
|
179
|
+
ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, leader: true)
|
|
230
180
|
end
|
|
231
181
|
private_class_method :terminate_process_tree
|
|
232
182
|
|
|
233
|
-
def self.signal_process(pid, signal)
|
|
234
|
-
Process.kill(signal, -pid)
|
|
235
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
236
|
-
begin
|
|
237
|
-
Process.kill(signal, pid)
|
|
238
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
239
|
-
nil
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
private_class_method :signal_process
|
|
243
|
-
|
|
244
|
-
def self.process_alive?(pid)
|
|
245
|
-
Process.kill(0, pid)
|
|
246
|
-
true
|
|
247
|
-
rescue Errno::ESRCH
|
|
248
|
-
false
|
|
249
|
-
rescue Errno::EPERM
|
|
250
|
-
true
|
|
251
|
-
end
|
|
252
|
-
private_class_method :process_alive?
|
|
253
|
-
|
|
254
183
|
def self.monotonic_time
|
|
255
184
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
256
185
|
end
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require "time"
|
|
8
8
|
require_relative "../atomic_file"
|
|
9
|
+
require_relative "../process_group"
|
|
9
10
|
require_relative "shell"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
@@ -24,6 +25,9 @@ module Samagotchi
|
|
|
24
25
|
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
25
26
|
# task_wait and task_get add it to a task the user stopped (the web's
|
|
26
27
|
# stop-task button), so the model doesn't rerun the job.
|
|
28
|
+
# A record whose pid isn't a process group chi started (missing, 0, 1,
|
|
29
|
+
# not a number, or a pid another process reuses): nothing is signalled.
|
|
30
|
+
NOT_CHIS_PROCESS = "the task's process is gone or isn't the one chi started"
|
|
27
31
|
USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
|
|
28
32
|
|
|
29
33
|
module_function
|
|
@@ -54,13 +58,12 @@ module Samagotchi
|
|
|
54
58
|
|
|
55
59
|
wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
|
|
56
60
|
# Non-login, the shell Execute uses too (Shell).
|
|
57
|
-
pid =
|
|
61
|
+
pid = ProcessGroup.spawn(
|
|
58
62
|
spawn_env,
|
|
59
63
|
*Shell.argv(wrapped_command),
|
|
60
64
|
chdir: resolved_cwd,
|
|
61
65
|
out: output_io,
|
|
62
|
-
err: output_io
|
|
63
|
-
pgroup: true
|
|
66
|
+
err: output_io
|
|
64
67
|
)
|
|
65
68
|
Process.detach(pid)
|
|
66
69
|
|
|
@@ -185,26 +188,15 @@ module Samagotchi
|
|
|
185
188
|
refreshed = refresh_record(record)
|
|
186
189
|
return [refreshed, nil] unless refreshed["status"] == "running"
|
|
187
190
|
|
|
191
|
+
pid = refreshed["pid"]
|
|
192
|
+
# Checked again just before the signal: never a group chi didn't start.
|
|
193
|
+
return [mark_not_chis(refreshed), nil] unless ProcessGroup.leader?(pid)
|
|
194
|
+
|
|
188
195
|
refreshed["stop_requested_by"] = by.to_s
|
|
189
196
|
write_record(refreshed)
|
|
190
|
-
pid = refreshed["pid"].to_i
|
|
191
|
-
|
|
192
|
-
begin
|
|
193
|
-
Process.kill("TERM", -pid)
|
|
194
|
-
rescue Errno::ESRCH
|
|
195
|
-
# Process already exited; status refresh below handles final state.
|
|
196
|
-
end
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if process_alive?(pid)
|
|
202
|
-
begin
|
|
203
|
-
Process.kill("KILL", -pid)
|
|
204
|
-
rescue Errno::ESRCH
|
|
205
|
-
# Process exited between checks.
|
|
206
|
-
end
|
|
207
|
-
end
|
|
198
|
+
# A group that isn't ours (EPERM) is an error, not a stop.
|
|
199
|
+
ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, quiet: [Errno::ESRCH])
|
|
208
200
|
|
|
209
201
|
updated = mark_stopped(refresh_record(refreshed))
|
|
210
202
|
write_record(updated)
|
|
@@ -217,10 +209,18 @@ module Samagotchi
|
|
|
217
209
|
def refresh_record(record)
|
|
218
210
|
return record unless record["status"] == "running"
|
|
219
211
|
|
|
220
|
-
pid = record["pid"]
|
|
221
|
-
return
|
|
212
|
+
pid = record["pid"]
|
|
213
|
+
return record if ProcessGroup.leader?(pid)
|
|
214
|
+
# Gone: its exit code says how it ended.
|
|
215
|
+
return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
|
|
222
216
|
|
|
223
|
-
record
|
|
217
|
+
mark_not_chis(record)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
|
|
221
|
+
# ends as failed, unless its exit code or a stop on disk says more.
|
|
222
|
+
def mark_not_chis(record)
|
|
223
|
+
mark_finished_without_exit_code(record, missing_reason: NOT_CHIS_PROCESS)
|
|
224
224
|
end
|
|
225
225
|
|
|
226
226
|
# A session id (a uuid) is never a task's: task ids are a timestamp
|
|
@@ -338,16 +338,7 @@ module Samagotchi
|
|
|
338
338
|
filtered.join(" ")
|
|
339
339
|
end
|
|
340
340
|
|
|
341
|
-
def
|
|
342
|
-
Process.kill(0, pid)
|
|
343
|
-
true
|
|
344
|
-
rescue Errno::ESRCH
|
|
345
|
-
false
|
|
346
|
-
rescue Errno::EPERM
|
|
347
|
-
true
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
def mark_finished_without_exit_code(record)
|
|
341
|
+
def mark_finished_without_exit_code(record, missing_reason: "process_ended_without_exit_code")
|
|
351
342
|
record = current_record(record)
|
|
352
343
|
return record unless record["status"] == "running"
|
|
353
344
|
|
|
@@ -359,7 +350,7 @@ module Samagotchi
|
|
|
359
350
|
elsif exit_code.nil?
|
|
360
351
|
record["status"] = "failed"
|
|
361
352
|
record["exit_code"] = nil
|
|
362
|
-
record["stop_reason"] ||=
|
|
353
|
+
record["stop_reason"] ||= missing_reason
|
|
363
354
|
elsif exit_code.zero?
|
|
364
355
|
record["status"] = "completed"
|
|
365
356
|
record["exit_code"] = 0
|
|
@@ -401,10 +392,6 @@ module Samagotchi
|
|
|
401
392
|
def timestamp
|
|
402
393
|
Time.now.utc.iso8601
|
|
403
394
|
end
|
|
404
|
-
|
|
405
|
-
def monotonic_time
|
|
406
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
407
|
-
end
|
|
408
395
|
end
|
|
409
396
|
end
|
|
410
397
|
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "uri"
|
|
|
9
9
|
require "rack"
|
|
10
10
|
require "rack/request"
|
|
11
11
|
|
|
12
|
+
require_relative "../client_id"
|
|
12
13
|
require_relative "../answer_tail"
|
|
13
14
|
require_relative "../bridge_client"
|
|
14
15
|
require_relative "../bridge/bounded_queue"
|
|
@@ -29,6 +30,8 @@ require_relative "../config"
|
|
|
29
30
|
require_relative "../output_formatter"
|
|
30
31
|
require_relative "../image_store"
|
|
31
32
|
require_relative "../context_note"
|
|
33
|
+
require_relative "../context_sources"
|
|
34
|
+
require_relative "../context_providers"
|
|
32
35
|
require_relative "../recap_store"
|
|
33
36
|
require_relative "markdown_renderer"
|
|
34
37
|
require_relative "message_parts"
|
|
@@ -150,6 +153,10 @@ module Samagotchi
|
|
|
150
153
|
["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command],
|
|
151
154
|
["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
|
|
152
155
|
["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
|
|
156
|
+
["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list],
|
|
157
|
+
["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show],
|
|
158
|
+
["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add],
|
|
159
|
+
["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete],
|
|
153
160
|
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show],
|
|
154
161
|
["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete]
|
|
155
162
|
].freeze
|
|
@@ -1063,7 +1070,7 @@ module Samagotchi
|
|
|
1063
1070
|
# {error: "held" | "not_running" | "unsupported" | "failed", reason, detail} in words; 409
|
|
1064
1071
|
# owned_by_tui; 404.
|
|
1065
1072
|
def handle_restart(_req, id)
|
|
1066
|
-
result = @manager.restart_session(id, state_dir: @state_dir, client_id:
|
|
1073
|
+
result = @manager.restart_session(id, state_dir: @state_dir, client_id: ClientId::WEB_RESTART)
|
|
1067
1074
|
@hub&.touch(id)
|
|
1068
1075
|
json_response(200, { status: "restarted", session_id: id, from_version: result.from_version,
|
|
1069
1076
|
version: result.version })
|
|
@@ -1117,6 +1124,101 @@ module Samagotchi
|
|
|
1117
1124
|
error_response(404, "not_found", e.message)
|
|
1118
1125
|
end
|
|
1119
1126
|
|
|
1127
|
+
# ── Attached context (ContextSources): the session bar's chips ───────
|
|
1128
|
+
# A source's command isn't shown (the page may be on a phone over the
|
|
1129
|
+
# LAN), and the web adds a URL only, through an installed bundle's
|
|
1130
|
+
# provider (ContextProviders): never a command.
|
|
1131
|
+
|
|
1132
|
+
# GET /api/sessions/:id/context: the sources this session sees, muted
|
|
1133
|
+
# ones marked, with what the chips show.
|
|
1134
|
+
def handle_context_list(_req, id)
|
|
1135
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1136
|
+
own = ContextSources.session_location(id, state_dir: default_state_dir)
|
|
1137
|
+
subs = own.subscriptions
|
|
1138
|
+
rows = attached_context(session).map { |attached| context_row(attached, own, subs) }
|
|
1139
|
+
json_response(200, { session_id: id, sources: rows, can_add_url: ContextProviders.any? })
|
|
1140
|
+
rescue ArgumentError => e
|
|
1141
|
+
error_response(404, "not_found", e.message)
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
# GET /api/sessions/:id/context/:name: one source with its text.
|
|
1145
|
+
def handle_context_show(_req, id, name)
|
|
1146
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1147
|
+
attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
1148
|
+
own = ContextSources.session_location(id, state_dir: default_state_dir)
|
|
1149
|
+
json_response(200, context_row(attached, own, own.subscriptions).merge(text: attached.snapshot.text))
|
|
1150
|
+
rescue ArgumentError => e
|
|
1151
|
+
error_response(404, "not_found", e.message)
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# POST /api/sessions/:id/context {url, why}: the source an installed
|
|
1155
|
+
# bundle's provider makes of +url+, attached to this session.
|
|
1156
|
+
def handle_context_add(req, id)
|
|
1157
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1158
|
+
body = parse_json(request_body(req))
|
|
1159
|
+
return error_response(400, "invalid_json", "give {url, why}") unless body.is_a?(Hash) && body["url"].is_a?(String)
|
|
1160
|
+
|
|
1161
|
+
url = body["url"].strip
|
|
1162
|
+
return error_response(422, "not_a_url", "give an http(s) URL") unless url.match?(%r{\Ahttps?://\S+\z})
|
|
1163
|
+
|
|
1164
|
+
resolved = ContextProviders.resolve(url)
|
|
1165
|
+
return error_response(422, "no_provider", "no installed bundle resolves #{url[0, 200]}") unless resolved
|
|
1166
|
+
|
|
1167
|
+
own = ContextSources.session_location(session.id, state_dir: default_state_dir)
|
|
1168
|
+
own.add(web_source(resolved, body["why"], own.scope))
|
|
1169
|
+
json_response(201, { status: "attached", name: resolved.name })
|
|
1170
|
+
rescue ContextProviders::Invalid => e
|
|
1171
|
+
error_response(422, "no_provider", e.message)
|
|
1172
|
+
rescue ContextSources::Invalid => e
|
|
1173
|
+
error_response(409, "exists", e.message)
|
|
1174
|
+
rescue ArgumentError => e
|
|
1175
|
+
error_response(404, "not_found", e.message)
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
def web_source(resolved, why, scope)
|
|
1179
|
+
why = ContextSources.one_line(why.is_a?(String) ? why : nil, ContextSources::LINE_MAX_CHARS) || resolved.why
|
|
1180
|
+
ContextSources::Source.new(name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds, why: why,
|
|
1181
|
+
hint: resolved.hint, scope: scope, added_by: "web", created_at: Time.now.utc.iso8601,
|
|
1182
|
+
provider: resolved.bundle)
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
# DELETE /api/sessions/:id/context/:name: the session's own source is
|
|
1186
|
+
# detached; a project's is muted for this session only.
|
|
1187
|
+
def handle_context_delete(_req, id, name)
|
|
1188
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1189
|
+
attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
1190
|
+
if attached.location.session?
|
|
1191
|
+
attached.location.remove(attached.name)
|
|
1192
|
+
json_response(200, { status: "detached", name: attached.name })
|
|
1193
|
+
else
|
|
1194
|
+
ContextSources.session_location(id, state_dir: default_state_dir).mute(attached.name)
|
|
1195
|
+
json_response(200, { status: "muted", name: attached.name })
|
|
1196
|
+
end
|
|
1197
|
+
rescue ArgumentError => e
|
|
1198
|
+
error_response(404, "not_found", e.message)
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def attached_context(session)
|
|
1202
|
+
ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir)
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
# @return [ContextSources::Attached, nil]
|
|
1206
|
+
def find_context(session, name)
|
|
1207
|
+
ContextSources.check_name!(name)
|
|
1208
|
+
attached_context(session).find { |attached| attached.name == name }
|
|
1209
|
+
rescue ContextSources::Invalid
|
|
1210
|
+
nil
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
def context_row(attached, own, subs)
|
|
1214
|
+
source = attached.source
|
|
1215
|
+
snapshot = attached.snapshot
|
|
1216
|
+
{ name: source.name, scope: source.scope, kind: source.push? ? "push" : "cmd", every_seconds: source.every_seconds,
|
|
1217
|
+
why: source.why, hint: snapshot.hint || source.hint, summary: snapshot.summary, error: snapshot.error,
|
|
1218
|
+
fetched_at: snapshot.fetched_at, has_text: snapshot.text?, muted: own.muted?(source.name),
|
|
1219
|
+
unread: snapshot.text? && subs[source.name]&.read != snapshot.revision }
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1120
1222
|
def handle_stream(req, id)
|
|
1121
1223
|
# Validate session exists
|
|
1122
1224
|
begin
|
|
@@ -1491,7 +1593,14 @@ module Samagotchi
|
|
|
1491
1593
|
role = (m[:role] || m["role"]).to_s
|
|
1492
1594
|
content = (m[:content] || m["content"]).to_s
|
|
1493
1595
|
if Samagotchi::ContextNote.note?(m)
|
|
1494
|
-
|
|
1596
|
+
note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
|
|
1597
|
+
# A context wake turn's note starts that turn (Steer.turn_prompt?):
|
|
1598
|
+
# the page opens the turn with it and pairs it with its record.
|
|
1599
|
+
if Samagotchi::Steer.turn_prompt?(m)
|
|
1600
|
+
note[:turn_start] = true
|
|
1601
|
+
note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
|
|
1602
|
+
end
|
|
1603
|
+
filtered << note
|
|
1495
1604
|
next
|
|
1496
1605
|
end
|
|
1497
1606
|
# A plugin's steer: a row of the step that answered it, which the
|