samagotchi 0.157.0 → 0.158.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 +27 -1
- data/README.md +1 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -6
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +21 -17
- data/docs/configuration.md +7 -5
- data/docs/context.md +5 -3
- data/docs/guardrails.md +8 -7
- data/docs/hooks.md +44 -80
- data/docs/internals/bridge.md +1 -1
- data/docs/internals/bundles.md +47 -34
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +39 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/sessions.md +5 -5
- 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/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 +15 -43
- 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/context_command.rb +7 -5
- 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/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 +39 -119
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- 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 +30 -35
- data/lib/samagotchi/memory_bundle/status.rb +12 -10
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- 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 +1 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- 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 +1 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/delegate.rb +12 -8
- 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/index.html +6 -1
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- metadata +3 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
|
@@ -25,7 +25,7 @@ require_relative "recording_api"
|
|
|
25
25
|
|
|
26
26
|
module Samagotchi
|
|
27
27
|
module Testing
|
|
28
|
-
# A hook (a plugin's chi.on block
|
|
28
|
+
# A hook (a plugin's chi.on block) raised while the kit
|
|
29
29
|
# fired it: chi would log it and go on, a test fails with it.
|
|
30
30
|
class HookFailed < StandardError
|
|
31
31
|
# @return [Hooks::Failure]
|
|
@@ -53,8 +53,7 @@ module Samagotchi
|
|
|
53
53
|
|
|
54
54
|
# One bundle loaded for a test through chi's own seams: its plugin
|
|
55
55
|
# through Plugin::Loader.register_file (Plugin::Api, a real
|
|
56
|
-
# Plugin::Context)
|
|
57
|
-
# Hooks::BundleLoader.load_hook_file, into real registries (commands
|
|
56
|
+
# Plugin::Context), into real registries (commands
|
|
58
57
|
# and tools with chi's built-ins in them, so a name clash fails as in
|
|
59
58
|
# chi). Only the edges are recorded: the Host and hook Runtime
|
|
60
59
|
# (Recorder), ctx.sessions (Testing::Sessions), chi.init tasks and
|
|
@@ -130,8 +129,6 @@ module Samagotchi
|
|
|
130
129
|
# @return [Module, nil] the module the plugin was evaluated in
|
|
131
130
|
# (bundle.namespace::ThinkingWatch)
|
|
132
131
|
attr_reader :namespace
|
|
133
|
-
# @return [Module] the module the hook files were evaluated in
|
|
134
|
-
attr_reader :hooks_namespace
|
|
135
132
|
# @return [Array<Init>]
|
|
136
133
|
attr_reader :inits
|
|
137
134
|
# @return [Array<StagedTools>]
|
|
@@ -152,6 +149,9 @@ module Samagotchi
|
|
|
152
149
|
@session_moves = 1
|
|
153
150
|
own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
|
|
154
151
|
@own_sandbox = sandbox.nil? && Sandbox.current.nil?
|
|
152
|
+
# With no active sandbox, this one saves ENV at construction and
|
|
153
|
+
# restores that copy at shutdown; don't lazily build it inside a block
|
|
154
|
+
# that sets ENV for one example (your own helper, or ClimateControl).
|
|
155
155
|
@sandbox = sandbox || Sandbox.current || Sandbox.enter
|
|
156
156
|
build(host, own)
|
|
157
157
|
rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
|
|
@@ -184,13 +184,6 @@ module Samagotchi
|
|
|
184
184
|
@plugin = built
|
|
185
185
|
end
|
|
186
186
|
end
|
|
187
|
-
@hooks_namespace = Module.new
|
|
188
|
-
@manifest.hooks.each do |basename, hook|
|
|
189
|
-
next if hook.events.empty?
|
|
190
|
-
|
|
191
|
-
Hooks::BundleLoader.load_hook_file(@name, basename, File.join(@dir, "hooks", basename), hook, hooks,
|
|
192
|
-
settings: @settings, namespace: @hooks_namespace)
|
|
193
|
-
end
|
|
194
187
|
self
|
|
195
188
|
rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
|
|
196
189
|
shutdown
|
|
@@ -15,7 +15,7 @@ module Samagotchi
|
|
|
15
15
|
# A bundle as chi installs and loads it, unlike Bundle (its source as it
|
|
16
16
|
# is): MemoryBundle::Installer copies it into the sandbox's state (sha256
|
|
17
17
|
# and requires_chi checked), then chi's own loaders load what is
|
|
18
|
-
# installed (ExtensionLoad:
|
|
18
|
+
# installed (ExtensionLoad: config hooks, then the plugins). What
|
|
19
19
|
# failed names the bundle's source files, not the installed copies.
|
|
20
20
|
#
|
|
21
21
|
# result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
|
|
@@ -23,8 +23,8 @@ module Samagotchi
|
|
|
23
23
|
module Install
|
|
24
24
|
# warnings: the Installer's; plugin_failures / hook_failures:
|
|
25
25
|
# [Guardrails::LoadFailures::Failure] of this bundle, each location
|
|
26
|
-
# in the source dir (hook_failures: the guardrails'
|
|
27
|
-
# plugin's
|
|
26
|
+
# in the source dir (hook_failures: the guardrails': a required
|
|
27
|
+
# plugin's load failure, a config hook's); registered: what it registered (#registered).
|
|
28
28
|
Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
|
|
29
29
|
def failures = plugin_failures + hook_failures
|
|
30
30
|
end
|
|
@@ -35,7 +35,7 @@ module Samagotchi
|
|
|
35
35
|
Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources, :prompt_sections)
|
|
36
36
|
# A command: its mid_turn policy and its selection-bar labels (chi.annotation).
|
|
37
37
|
Command = Data.define(:name, :mid_turn, :labels)
|
|
38
|
-
# A hook on an event: the bundle, the hook (
|
|
38
|
+
# A hook on an event: the bundle, the hook (the plugin's label) and its priority.
|
|
39
39
|
Hook = Data.define(:bundle, :hook, :priority)
|
|
40
40
|
|
|
41
41
|
module_function
|
|
@@ -119,7 +119,7 @@ module Samagotchi
|
|
|
119
119
|
# installed copy to the source dir.
|
|
120
120
|
def own(failures, name, installed, dir)
|
|
121
121
|
failures.list.select { |f| f.what.end_with?("(bundle #{name})") }.map do |failure|
|
|
122
|
-
# The plugin is installed into plugin
|
|
122
|
+
# The plugin is installed into plugin/.
|
|
123
123
|
location = failure.location&.sub(%r{\A#{Regexp.escape(installed)}/(?:plugin/)?}, "#{dir}/")
|
|
124
124
|
failure.dup.tap { |copy| copy.location = location }
|
|
125
125
|
end
|
|
@@ -9,7 +9,7 @@ require_relative "judgments"
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
module Testing
|
|
11
11
|
# A line to the user (ctx.notify, event[:notify]); +hook+ is the
|
|
12
|
-
# plugin's
|
|
12
|
+
# plugin's label ("plugin.rb (bundle x)").
|
|
13
13
|
Notice = Data.define(:text, :level, :hook, :fallback_for)
|
|
14
14
|
# Text put into the running turn (ctx.steer, event[:steer]).
|
|
15
15
|
Steer = Data.define(:text, :hook)
|
data/lib/samagotchi/testing.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# The bundle test kit (docs/plugins.md, "Testing a bundle"): load a bundle's
|
|
4
|
-
# plugin
|
|
4
|
+
# plugin through chi's own loaders, registries, Context and
|
|
5
5
|
# guardrail Gate, with only the edges recorded (the session's Host, the hook
|
|
6
6
|
# runtime, ctx.sessions, init tasks), in a sandboxed environment.
|
|
7
7
|
#
|
|
@@ -375,7 +375,7 @@ module Samagotchi
|
|
|
375
375
|
},
|
|
376
376
|
cwd: {
|
|
377
377
|
type: "string",
|
|
378
|
-
description: "The new child's working folder: a worktree or subfolder of this session's repository
|
|
378
|
+
description: "The new child's working folder: a worktree or subfolder of this session's project (a repository, or a `chi project init` folder; create a worktree with execute `git worktree add ../<repo>-<name> -b <branch>` first). Default: this session's folder"
|
|
379
379
|
},
|
|
380
380
|
toolkit: {
|
|
381
381
|
type: "string",
|
|
@@ -139,10 +139,7 @@ module Samagotchi
|
|
|
139
139
|
|
|
140
140
|
toolkit = Toolkit.new(name: name)
|
|
141
141
|
if session.toolkit == toolkit
|
|
142
|
-
if session.
|
|
143
|
-
session.toolkit_origin = "chosen"
|
|
144
|
-
session.save(state_dir: @state_dir.call)
|
|
145
|
-
end
|
|
142
|
+
session.save(state_dir: @state_dir.call) if session.choose_toolkit_origin!
|
|
146
143
|
return true
|
|
147
144
|
end
|
|
148
145
|
|
|
@@ -47,10 +47,7 @@ module Samagotchi
|
|
|
47
47
|
# Nothing the prompt carries changes (full +delegate on full): only
|
|
48
48
|
# the origin is saved, so a no-op choice still counts as user-selected.
|
|
49
49
|
if after.prompt_key == before.prompt_key
|
|
50
|
-
if session.
|
|
51
|
-
session.toolkit_origin = "chosen"
|
|
52
|
-
@save.call(session)
|
|
53
|
-
end
|
|
50
|
+
@save.call(session) if session.choose_toolkit_origin!
|
|
54
51
|
return [listing(before) + "\n(no change to what the next turn declares)", false, nil]
|
|
55
52
|
end
|
|
56
53
|
|
|
@@ -281,9 +281,10 @@ module Samagotchi
|
|
|
281
281
|
private_class_method :unknown_toolkit
|
|
282
282
|
|
|
283
283
|
# The new child's folder: the parent's, or +cwd+ (relative to the
|
|
284
|
-
# parent's) when it is a folder of the parent's
|
|
285
|
-
#
|
|
286
|
-
#
|
|
284
|
+
# parent's) when it is a folder of the parent's project: in any of its
|
|
285
|
+
# repository's worktrees (one project_root, from the common git dir), or
|
|
286
|
+
# in its marked folder. Outside both only a subfolder of the parent's
|
|
287
|
+
# folder. Nothing is created
|
|
287
288
|
# here: the model makes a worktree with execute, where guardrails see it.
|
|
288
289
|
# @return [String] an absolute folder, or an Error: line
|
|
289
290
|
def self.child_cwd(cwd, parent)
|
|
@@ -294,8 +295,8 @@ module Samagotchi
|
|
|
294
295
|
return "Error: cwd #{folder} is not a folder (create the worktree first)" unless File.directory?(folder)
|
|
295
296
|
|
|
296
297
|
folder = File.realpath(folder)
|
|
297
|
-
unless
|
|
298
|
-
return "Error: cwd must be a folder of this session's
|
|
298
|
+
unless same_project?(folder, base)
|
|
299
|
+
return "Error: cwd must be a folder of this session's project (a worktree or subfolder of it), not #{folder}"
|
|
299
300
|
end
|
|
300
301
|
if MemoryPaths.in_repo?(base) && !work_tree?(folder)
|
|
301
302
|
return "Error: cwd #{folder} is not in a work tree of this session's repository (a git dir, or a bare " \
|
|
@@ -327,8 +328,11 @@ module Samagotchi
|
|
|
327
328
|
def self.within?(path, dir) = path == dir || path.start_with?("#{dir}/")
|
|
328
329
|
private_class_method :within?
|
|
329
330
|
|
|
330
|
-
|
|
331
|
-
|
|
331
|
+
# One project (a git repository or a marked folder): the same project
|
|
332
|
+
# root, so a nested repository or marked project is another one. A base
|
|
333
|
+
# in no project: a subfolder of it.
|
|
334
|
+
def self.same_project?(folder, base)
|
|
335
|
+
if MemoryPaths.in_project?(base)
|
|
332
336
|
MemoryPaths.project_root(folder) == MemoryPaths.project_root(base)
|
|
333
337
|
else
|
|
334
338
|
within?(folder, File.realpath(base))
|
|
@@ -336,7 +340,7 @@ module Samagotchi
|
|
|
336
340
|
rescue SystemCallError
|
|
337
341
|
false
|
|
338
342
|
end
|
|
339
|
-
private_class_method :
|
|
343
|
+
private_class_method :same_project?
|
|
340
344
|
|
|
341
345
|
# @return [String] the child's id, or an Error: line
|
|
342
346
|
def self.follow_up(task, session:, parent:, state_dir:)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -17,6 +17,7 @@ require_relative "../delivery"
|
|
|
17
17
|
require_relative "../answer_tail"
|
|
18
18
|
require_relative "../answer_refs"
|
|
19
19
|
require_relative "../question_asked"
|
|
20
|
+
require_relative "../project_init"
|
|
20
21
|
require_relative "../bridge_client"
|
|
21
22
|
require_relative "../worker_link"
|
|
22
23
|
require_relative "../bridge/bounded_queue"
|
|
@@ -268,6 +269,7 @@ module Samagotchi
|
|
|
268
269
|
["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
|
|
269
270
|
["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
|
|
270
271
|
["GET", %r{\A/api/projects\z}, :handle_projects, :owner],
|
|
272
|
+
["POST", %r{\A/api/projects\z}, :handle_project_create, :owner],
|
|
271
273
|
["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
|
|
272
274
|
["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
|
|
273
275
|
["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
|
|
@@ -1006,6 +1008,39 @@ module Samagotchi
|
|
|
1006
1008
|
json_response(200, ProjectList.build(@hub.snapshot, start_dir: start_dir, current_root: current_root))
|
|
1007
1009
|
end
|
|
1008
1010
|
|
|
1011
|
+
def handle_project_create(req)
|
|
1012
|
+
body = parse_json(request_body(req))
|
|
1013
|
+
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
1014
|
+
|
|
1015
|
+
requested = body["dir"]
|
|
1016
|
+
requested = requested.strip if requested.is_a?(String)
|
|
1017
|
+
unless requested.is_a?(String) && !requested.empty? && !requested.include?("\0")
|
|
1018
|
+
return error_response(400, "invalid_dir", "dir must be an absolute folder")
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
home = Dir.home
|
|
1022
|
+
dir = if requested == "~"
|
|
1023
|
+
home
|
|
1024
|
+
elsif requested.start_with?("~/")
|
|
1025
|
+
File.join(home, requested.delete_prefix("~/"))
|
|
1026
|
+
else
|
|
1027
|
+
requested
|
|
1028
|
+
end
|
|
1029
|
+
return error_response(400, "invalid_dir", "dir must be an absolute folder") unless dir.start_with?("/")
|
|
1030
|
+
|
|
1031
|
+
# One normal form for the decision, the mkdir and the realpath (a `..` after a symlink would differ).
|
|
1032
|
+
dir = File.expand_path(dir)
|
|
1033
|
+
outcome = ProjectInit.call(dir, home: home)
|
|
1034
|
+
return error_response(422, outcome.reason.to_s, outcome.message) if outcome.kind == :refused
|
|
1035
|
+
|
|
1036
|
+
if outcome.kind == :git
|
|
1037
|
+
failed = ProjectInit.ensure_folder(dir)
|
|
1038
|
+
return error_response(422, failed.reason.to_s, failed.message) if failed
|
|
1039
|
+
end
|
|
1040
|
+
dir = File.realpath(dir)
|
|
1041
|
+
json_response(200, outcome: outcome.kind.to_s, root: outcome.root, dir: dir)
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1009
1044
|
# A scope folder from the page: [dir, nil] (dir nil when none was
|
|
1010
1045
|
# given), or [nil, a 400 response] when it isn't an existing absolute
|
|
1011
1046
|
# folder (a bookmarked worktree deleted since, a typo).
|
|
@@ -6,8 +6,8 @@ require_relative "push/attention"
|
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
module Web
|
|
9
|
-
# Projects represented by the session hub, plus the start
|
|
10
|
-
# when
|
|
9
|
+
# Projects represented by the session hub, plus the start and current
|
|
10
|
+
# projects even when they have no sessions.
|
|
11
11
|
module ProjectList
|
|
12
12
|
module_function
|
|
13
13
|
|
|
@@ -24,6 +24,7 @@ module Samagotchi
|
|
|
24
24
|
(groups[root] ||= []) << summary
|
|
25
25
|
end
|
|
26
26
|
groups[default_root] ||= [] if valid_root?(default_root)
|
|
27
|
+
groups[current_root] ||= [] if valid_root?(current_root)
|
|
27
28
|
|
|
28
29
|
rows = groups.map do |root, sessions|
|
|
29
30
|
default = root == default_root
|
|
@@ -1488,6 +1488,11 @@ body.has-session #newBtn .brand-new{display:inline}
|
|
|
1488
1488
|
.project-all::before{content:"";position:absolute;left:-1px;right:-1px;top:-9px;height:1px;background:var(--border);pointer-events:none}
|
|
1489
1489
|
.project-note{padding:5px 2px;color:var(--text-muted);font-size:12px}
|
|
1490
1490
|
.project-error{color:var(--warn-text)}
|
|
1491
|
+
.project-picker .project-new{width:100%;text-align:left;background:var(--bg-inset);color:var(--accent)}
|
|
1492
|
+
.project-create{display:grid;gap:8px;padding:8px 2px}
|
|
1493
|
+
.project-create label{color:var(--text-muted);font-size:12px}
|
|
1494
|
+
.project-create input{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset);color:var(--text)}
|
|
1495
|
+
.project-create-actions{display:flex;justify-content:flex-end;gap:8px}
|
|
1491
1496
|
body.has-session #scope .scope-where{display:none}
|
|
1492
1497
|
/* A phone has no room for both; the hidden strip's pill (count, live) wins. */
|
|
1493
1498
|
@media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
|
|
@@ -1780,7 +1785,7 @@ a{color:var(--accent)}
|
|
|
1780
1785
|
@media (max-width:600px),(pointer:coarse){
|
|
1781
1786
|
#composer textarea,.question-freeform,.continue-reason,.participants-popover input,.participants-popover select,
|
|
1782
1787
|
.rs-form textarea,.pp-link input,.llmctx-field select,.llmctx-field input,.ws-add-in,.rename-input,
|
|
1783
|
-
#modelSearch{font-size:16px}
|
|
1788
|
+
#modelSearch,.project-create input{font-size:16px}
|
|
1784
1789
|
}
|
|
1785
1790
|
</style>
|
|
1786
1791
|
</head>
|
|
@@ -24,10 +24,19 @@ function projectRow(row) {
|
|
|
24
24
|
</a>`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function projectParent(rootLabel) {
|
|
28
|
+
if (typeof rootLabel !== "string" || !rootLabel) return "~/";
|
|
29
|
+
const slash = rootLabel.lastIndexOf("/");
|
|
30
|
+
if (slash < 0) return "~/";
|
|
31
|
+
const parent = rootLabel.slice(0, slash);
|
|
32
|
+
return parent ? `${parent.replace(/\/$/, "")}/` : "/";
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
// +rows+ is the API's ordered list. When there is no project scope, the
|
|
28
36
|
// all-projects row is current; a bad ?dir= has no current project to mark.
|
|
29
37
|
export function pickerTemplate({ rows = [], loading = false, error = "", currentDir = null,
|
|
30
|
-
allCurrent = false
|
|
38
|
+
allCurrent = false, createOpen = false, createPath = "~/", creating = false,
|
|
39
|
+
createError = "", onPathInput = () => {} } = {}) {
|
|
31
40
|
const allHref = allScopeHref("", currentDir);
|
|
32
41
|
return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
|
|
33
42
|
data-act="close" aria-label="Close">×</button></div>
|
|
@@ -39,7 +48,15 @@ export function pickerTemplate({ rows = [], loading = false, error = "", current
|
|
|
39
48
|
<span class="project-main"><span class="project-name">All projects</span
|
|
40
49
|
><span class="project-hint">Every session</span></span>
|
|
41
50
|
${allCurrent ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}
|
|
42
|
-
</a>
|
|
51
|
+
</a>${createOpen ? html`<form class="project-create" aria-busy=${creating}>
|
|
52
|
+
<label for="projectCreatePath">Folder path</label>
|
|
53
|
+
<input id="projectCreatePath" name="dir" type="text" autocomplete="off" .value=${createPath}
|
|
54
|
+
aria-invalid=${createError ? "true" : "false"} aria-describedby=${createError ? "projectCreateError" : nothing}
|
|
55
|
+
@input=${onPathInput}>
|
|
56
|
+
${createError ? html`<div class="project-note project-error" id="projectCreateError" role="alert">${createError}</div>` : nothing}
|
|
57
|
+
<div class="project-create-actions"><button type="submit">${creating ? "Creating…" : "Create"}</button>
|
|
58
|
+
<button type="button" class="ghost" data-act="cancel-new">Cancel</button></div>
|
|
59
|
+
</form>` : html`<button type="button" class="project-row project-new" data-act="new-project">New project…</button>`}
|
|
43
60
|
</nav>`;
|
|
44
61
|
}
|
|
45
62
|
|
|
@@ -51,6 +68,12 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
|
51
68
|
let error = "";
|
|
52
69
|
// Only the newest read is drawn; a close drops the one out (latest.js).
|
|
53
70
|
const latest = createLatest();
|
|
71
|
+
// Creating a project must not invalidate an in-flight list read.
|
|
72
|
+
const createLatestAnswer = createLatest();
|
|
73
|
+
let createOpen = false;
|
|
74
|
+
let createPath = "~/";
|
|
75
|
+
let creating = false;
|
|
76
|
+
let createError = "";
|
|
54
77
|
|
|
55
78
|
const currentDir = () => scopeError() ? null : scope.dir || null;
|
|
56
79
|
const paint = (focus = false) => {
|
|
@@ -58,6 +81,8 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
|
58
81
|
render(pickerTemplate({
|
|
59
82
|
rows, loading, error, currentDir: currentDir(),
|
|
60
83
|
allCurrent: !scope.dir && !failedScope,
|
|
84
|
+
createOpen, createPath, creating, createError,
|
|
85
|
+
onPathInput: (event) => { createPath = event.currentTarget.value; },
|
|
61
86
|
}), shell.pop);
|
|
62
87
|
if (focus) {
|
|
63
88
|
const target = shell.pop.querySelector('.project-row[aria-current="page"]') ||
|
|
@@ -89,11 +114,53 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
|
89
114
|
if (!fresh()) return;
|
|
90
115
|
loading = false;
|
|
91
116
|
if (shell.isOpen()) {
|
|
92
|
-
|
|
117
|
+
// An open form keeps the caret: a late list must not pull focus to a row.
|
|
118
|
+
paint(focus && !createOpen);
|
|
93
119
|
shell.replace();
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
122
|
|
|
123
|
+
async function submitProject() {
|
|
124
|
+
if (creating) return;
|
|
125
|
+
const fresh = createLatestAnswer.begin();
|
|
126
|
+
creating = true;
|
|
127
|
+
createError = "";
|
|
128
|
+
paint();
|
|
129
|
+
shell.replace();
|
|
130
|
+
try {
|
|
131
|
+
const answer = await api("/api/projects", { method: "POST", body: JSON.stringify({ dir: createPath }) });
|
|
132
|
+
if (!fresh()) return;
|
|
133
|
+
win.location.assign(projectScopeHref(answer.dir, ""));
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (!fresh()) return;
|
|
136
|
+
createError = e?.body?.detail || e?.message || String(e);
|
|
137
|
+
creating = false;
|
|
138
|
+
paint();
|
|
139
|
+
shell.replace();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function openCreateForm() {
|
|
144
|
+
createOpen = true;
|
|
145
|
+
createPath = projectParent(rows.find((row) => row.default)?.root_label);
|
|
146
|
+
createError = "";
|
|
147
|
+
creating = false;
|
|
148
|
+
paint();
|
|
149
|
+
shell.replace();
|
|
150
|
+
const input = shell.pop.querySelector("#projectCreatePath");
|
|
151
|
+
input?.focus();
|
|
152
|
+
input?.setSelectionRange(input.value.length, input.value.length);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function closeCreateForm() {
|
|
156
|
+
createLatestAnswer.drop();
|
|
157
|
+
createOpen = false;
|
|
158
|
+
creating = false;
|
|
159
|
+
createError = "";
|
|
160
|
+
paint();
|
|
161
|
+
shell.replace();
|
|
162
|
+
}
|
|
163
|
+
|
|
97
164
|
const shell = createChipPopover({
|
|
98
165
|
id: "projectPicker", className: "ctx-popover project-picker", width: 380, el, anchor: "#scopePick",
|
|
99
166
|
summary: () => ({}), sheet: true, label: "Projects", doc, win,
|
|
@@ -101,10 +168,25 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
|
101
168
|
rows = [];
|
|
102
169
|
loading = true;
|
|
103
170
|
error = "";
|
|
171
|
+
createOpen = false;
|
|
172
|
+
createPath = "~/";
|
|
173
|
+
creating = false;
|
|
174
|
+
createError = "";
|
|
104
175
|
paint();
|
|
105
176
|
void load(true);
|
|
106
177
|
},
|
|
107
|
-
|
|
178
|
+
onSubmit: () => { void submitProject(); },
|
|
179
|
+
onAct: (name) => {
|
|
180
|
+
if (name === "new-project") openCreateForm();
|
|
181
|
+
else if (name === "cancel-new") closeCreateForm();
|
|
182
|
+
},
|
|
183
|
+
onClose: () => {
|
|
184
|
+
latest.drop();
|
|
185
|
+
createLatestAnswer.drop();
|
|
186
|
+
createOpen = false;
|
|
187
|
+
creating = false;
|
|
188
|
+
createError = "";
|
|
189
|
+
},
|
|
108
190
|
});
|
|
109
191
|
|
|
110
192
|
return { pop: shell.pop, isOpen: shell.isOpen, load };
|
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.158.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -435,7 +435,6 @@ files:
|
|
|
435
435
|
- lib/samagotchi/hook_question.rb
|
|
436
436
|
- lib/samagotchi/hook_routes.rb
|
|
437
437
|
- lib/samagotchi/hooks.rb
|
|
438
|
-
- lib/samagotchi/hooks/bundle_loader.rb
|
|
439
438
|
- lib/samagotchi/hooks/loader.rb
|
|
440
439
|
- lib/samagotchi/hooks/registry.rb
|
|
441
440
|
- lib/samagotchi/hooks/stream_watch.rb
|
|
@@ -592,7 +591,9 @@ files:
|
|
|
592
591
|
- lib/samagotchi/plugin_tasks.rb
|
|
593
592
|
- lib/samagotchi/private_files.rb
|
|
594
593
|
- lib/samagotchi/process_group.rb
|
|
594
|
+
- lib/samagotchi/project_command.rb
|
|
595
595
|
- lib/samagotchi/project_env.rb
|
|
596
|
+
- lib/samagotchi/project_init.rb
|
|
596
597
|
- lib/samagotchi/project_scope.rb
|
|
597
598
|
- lib/samagotchi/prompt.rb
|
|
598
599
|
- lib/samagotchi/prompt_files.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
|