samagotchi 0.150.0 → 0.151.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/bundle-authoring.md +1 -1
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +96 -0
  7. data/docs/internals/bridge.md +53 -17
  8. data/docs/internals/bundles.md +1 -0
  9. data/docs/internals/web-frontend.md +2 -2
  10. data/docs/memory.md +1 -1
  11. data/docs/plugins.md +43 -3
  12. data/lib/samagotchi/block_renderers.rb +115 -0
  13. data/lib/samagotchi/bridge/card_places.rb +93 -0
  14. data/lib/samagotchi/bridge/card_store.rb +219 -122
  15. data/lib/samagotchi/bridge.rb +12 -4
  16. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/engine.rb +20 -2
  19. data/lib/samagotchi/memory_bundle/asset_installer.rb +7 -1
  20. data/lib/samagotchi/memory_bundle/builder.rb +5 -3
  21. data/lib/samagotchi/memory_bundle/installed_bundle.rb +8 -5
  22. data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  24. data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
  25. data/lib/samagotchi/memory_bundle/provenance.rb +4 -2
  26. data/lib/samagotchi/session_chain.rb +2 -7
  27. data/lib/samagotchi/session_commands.rb +15 -9
  28. data/lib/samagotchi/session_manager.rb +10 -7
  29. data/lib/samagotchi/steer.rb +17 -0
  30. data/lib/samagotchi/terminal_ui.rb +4 -4
  31. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  32. data/lib/samagotchi/turn_flow.rb +2 -3
  33. data/lib/samagotchi/version.rb +1 -1
  34. data/lib/samagotchi/web/app.rb +22 -12
  35. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  36. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  37. data/lib/samagotchi/web/message_display.rb +3 -3
  38. data/lib/samagotchi/web/participant_view.rb +1 -1
  39. data/lib/samagotchi/web/public/app.js +2 -0
  40. data/lib/samagotchi/web/public/block_frame.js +202 -0
  41. data/lib/samagotchi/web/public/block_host.js +502 -0
  42. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  43. data/lib/samagotchi/web/public/block_source.js +37 -0
  44. data/lib/samagotchi/web/public/bubbles.js +9 -5
  45. data/lib/samagotchi/web/public/card.js +17 -10
  46. data/lib/samagotchi/web/public/cards_place.js +2 -2
  47. data/lib/samagotchi/web/public/claim_link.js +4 -2
  48. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  49. data/lib/samagotchi/web/public/index.html +2 -0
  50. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  51. data/lib/samagotchi/web/public/text_roots.js +6 -0
  52. data/lib/samagotchi/web/public/turn_html.js +4 -1
  53. data/lib/samagotchi/web/session_show.rb +1 -1
  54. data/lib/samagotchi/worker.rb +4 -2
  55. metadata +10 -1
@@ -961,7 +961,7 @@ module Samagotchi
961
961
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
962
962
  command_queued command_ran context_added card hook_notice guardrail_warning
963
963
  plugin_init_started plugin_init_finished working_set_updated participants_changed
964
- verdict_recorded objectives_updated turn_rolled_back].freeze
964
+ verdict_recorded objectives_updated].freeze
965
965
 
966
966
  # Put a transport-level event into the ordered event log. Unlike turn
967
967
  # events it reaches only persistent observers (no turn sink, no memory
@@ -1107,6 +1107,8 @@ module Samagotchi
1107
1107
  # @raise [ArgumentError] a card without a title, or a bad action
1108
1108
  def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
1109
1109
  card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
1110
+ command = Thread.current[running_command_key]
1111
+ card[:command] = command if command
1110
1112
  @event_routes.route(card, **EventRoutes::CARD_MARKS)
1111
1113
  card[:id]
1112
1114
  end
@@ -1126,6 +1128,20 @@ module Samagotchi
1126
1128
  Thread.current[key] = outer
1127
1129
  end
1128
1130
 
1131
+ # Run a session command's dispatch: a card shown on this thread says
1132
+ # it's that command's (:card's command:), for a reload that no longer has
1133
+ # the command's line above it (Bridge::CardStore). A thread of its own
1134
+ # isn't the command, nor a card shown after it.
1135
+ # @param name [String] the command, "/short"
1136
+ # @return the block's value
1137
+ def running_command(name)
1138
+ outer = Thread.current[running_command_key]
1139
+ Thread.current[running_command_key] = name
1140
+ yield
1141
+ ensure
1142
+ Thread.current[running_command_key] = outer
1143
+ end
1144
+
1129
1145
  # Whether an anytime command (/btw) still runs on its thread.
1130
1146
  def anytime_running?
1131
1147
  @lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
@@ -2266,7 +2282,7 @@ module Samagotchi
2266
2282
 
2267
2283
  messages = @session&.messages || []
2268
2284
  covered = state[:covered].to_i
2269
- turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
2285
+ turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
2270
2286
  { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
2271
2287
  end
2272
2288
 
@@ -2974,6 +2990,8 @@ module Samagotchi
2974
2990
 
2975
2991
  private
2976
2992
 
2993
+ def running_command_key = :"samagotchi_command_#{object_id}"
2994
+
2977
2995
  # The one named seam for replacing a session's conversation (the
2978
2996
  # array is replaced, never mutated in place). No lock: a reader gets
2979
2997
  # the old array or the new one, and both are whole.
@@ -269,7 +269,7 @@ module Samagotchi
269
269
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
270
270
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
271
271
 
272
- @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src)
272
+ @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
273
273
  end
274
274
  return {} if @dry_run
275
275
 
@@ -300,6 +300,12 @@ module Samagotchi
300
300
  end
301
301
  end
302
302
 
303
+ # A file only a block renderer's load names: the page loads it, nothing runs it.
304
+ def page_only?(file)
305
+ @manifest.block_renderers.any? { |renderer| renderer.load.include?(file) } &&
306
+ @manifest.context_providers.none? { |provider| provider.cmd.include?(file) }
307
+ end
308
+
303
309
  # Copies +src+ to +dest+, or (a dev install) links +dest+ to it. An
304
310
  # existing link goes first: a copy over it would write into the source.
305
311
  def place(src, dest)
@@ -30,8 +30,8 @@ module Samagotchi
30
30
  # hooks basename => BundleHook; hook_files basename => path
31
31
  # rules guardrails/*.yml paths
32
32
  # plugin the installed plugin file, nil without one
33
- # scripts file name => path; context_providers the parsed list
34
- InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers)
33
+ # scripts file name => path; context_providers, block_renderers the parsed lists
34
+ InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers, :block_renderers)
35
35
 
36
36
  attr_reader :built_files, :warnings, :placeholder_warnings, :out_path, :staging_dir
37
37
 
@@ -179,7 +179,8 @@ module Samagotchi
179
179
  rules: Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : [],
180
180
  plugin: plugin && File.file?(plugin) ? plugin : nil,
181
181
  scripts: installed_scripts(prov, record),
182
- context_providers: record ? ContextProviders.parse_list(record.context_providers) : []
182
+ context_providers: record ? ContextProviders.parse_list(record.context_providers) : [],
183
+ block_renderers: record ? BlockRenderers.parse_list(record.block_renderers) : []
183
184
  )
184
185
  end
185
186
 
@@ -223,6 +224,7 @@ module Samagotchi
223
224
  needs: record&.needs,
224
225
  scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
225
226
  context_providers: parts.context_providers,
227
+ block_renderers: parts.block_renderers,
226
228
  file_descriptions: file_descriptions(record, files_map),
227
229
  session_settings: record&.session_settings || [],
228
230
  session_setting_types: record&.session_setting_types || {},
@@ -26,9 +26,9 @@ module Samagotchi
26
26
  # guardrails basename => recorded sha256 ("" when none)
27
27
  # scripts file name => recorded sha256 ("" when none)
28
28
  # plugin PluginRef, nil without one
29
- # needs, context_providers
30
- # the lists as recorded (nil when absent): Manifest.parse_needs
31
- # and ContextProviders.parse_list check them
29
+ # needs, context_providers, block_renderers
30
+ # the lists as recorded (nil when absent): Manifest.parse_needs,
31
+ # ContextProviders.parse_list and BlockRenderers.parse_list check them
32
32
  # includes a profile's recorded members, nil for a plain bundle
33
33
  # session_settings
34
34
  # the keys a session may set (chi --set), [] when none
@@ -43,12 +43,12 @@ module Samagotchi
43
43
  # error why manifest.json couldn't be read (the rest empty), else nil
44
44
  class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
45
45
  :requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
46
- :includes, :context_providers, :session_settings, :session_setting_types, :settings,
46
+ :includes, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings,
47
47
  :setting_types, :dev,
48
48
  :error)
49
49
  OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
50
50
  requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
51
- includes: nil, context_providers: nil, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {}, dev: false,
51
+ includes: nil, context_providers: nil, block_renderers: nil, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {}, dev: false,
52
52
  error: nil }.freeze
53
53
 
54
54
  def initialize(name:, **fields)
@@ -86,6 +86,7 @@ module Samagotchi
86
86
  guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
87
87
  scripts: mapping(h["scripts"]) { |meta| sha(meta) },
88
88
  plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
89
+ block_renderers: h["block_renderers"],
89
90
  includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
90
91
  session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
91
92
  session_setting_types: mapping(h["session_setting_types"]) { |type| type.to_s },
@@ -164,6 +165,8 @@ module Samagotchi
164
165
  def needs? = needs.is_a?(Array) && !needs.empty?
165
166
 
166
167
  def context_providers? = context_providers.is_a?(Array) && !context_providers.empty?
168
+
169
+ def block_renderers? = block_renderers.is_a?(Array) && !block_renderers.empty?
167
170
  end
168
171
  end
169
172
  end
@@ -42,6 +42,7 @@ module Samagotchi
42
42
  requires_chi: bundle.requires_chi, needs: bundle.needs,
43
43
  # A record read with symbol keys: back to what a manifest.yml holds.
44
44
  context_providers: JSON.parse(JSON.generate(Array(bundle.context_providers))),
45
+ block_renderers: JSON.parse(JSON.generate(Array(bundle.block_renderers))),
45
46
  session_settings: bundle.session_settings, session_setting_types: bundle.session_setting_types,
46
47
  settings: bundle.settings, setting_types: bundle.setting_types
47
48
  )
@@ -516,6 +516,7 @@ module Samagotchi
516
516
  incoming_dir: @bundle_dir,
517
517
  scripts_files: installed.script_files,
518
518
  context_providers: @manifest.context_providers,
519
+ block_renderers: @manifest.block_renderers,
519
520
  file_descriptions: @manifest.file_descriptions,
520
521
  session_settings: @manifest.session_settings,
521
522
  session_setting_types: @manifest.session_setting_types,
@@ -7,6 +7,8 @@ require_relative "../context_providers"
7
7
  require_relative "bundle_hook"
8
8
 
9
9
  module Samagotchi
10
+ autoload :BlockRenderers, File.expand_path("../block_renderers", __dir__)
11
+
10
12
  module MemoryBundle
11
13
  # Parses and validates a bundle manifest.yml.
12
14
  #
@@ -41,6 +43,9 @@ module Samagotchi
41
43
  # cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
42
44
  # why: GitHub PR # optional
43
45
  # every_seconds: 300 # optional
46
+ # block_renderers: # optional — a fence's language → the scripts/
47
+ # - lang: mermaid # files chi web's page loads to draw it
48
+ # load: [lib.js, draw.js] # (BlockRenderers); each one a scripts: key
44
49
  # session_settings: [cap] # optional — the settings a session may set
45
50
  # # (chi --set cost-cap.cap=3), read by the
46
51
  # # plugin from ctx.settings at call time;
@@ -71,7 +76,7 @@ module Samagotchi
71
76
  # the description a mapping gave, for the files that have one; hooks:
72
77
  # name → BundleHook.
73
78
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
74
- :scripts, :context_providers, :session_settings, :session_setting_types, :settings, :setting_types
79
+ :scripts, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings, :setting_types
75
80
 
76
81
  def initialize(path:)
77
82
  @path = path
@@ -94,6 +99,7 @@ module Samagotchi
94
99
  @needs = self.class.parse_needs(raw["needs"])
95
100
  @scripts = parse_scripts(raw["scripts"])
96
101
  @context_providers = parse_context_providers(raw["context_providers"])
102
+ @block_renderers = parse_block_renderers(raw["block_renderers"])
97
103
  @includes = parse_includes(raw["includes"])
98
104
  @session_settings = self.class.parse_session_settings(raw["session_settings"])
99
105
  @session_setting_types = self.class.parse_session_setting_types(raw["session_settings"])
@@ -163,7 +169,7 @@ module Samagotchi
163
169
  # Writes a fresh manifest.yml from computed checksums at `dir`.
164
170
  # +file_descriptions+ (name → text) writes those files as mappings.
165
171
  def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
166
- plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
172
+ plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], block_renderers: [], file_descriptions: {},
167
173
  session_settings: [], session_setting_types: {}, settings: nil, setting_types: {})
168
174
  FileUtils.mkdir_p(dir)
169
175
  manifest = {
@@ -187,6 +193,7 @@ module Samagotchi
187
193
  manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
188
194
  manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
189
195
  manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
196
+ manifest["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
190
197
  manifest["session_settings"] = session_settings_entry(session_settings, session_setting_types) unless Array(session_settings).empty?
191
198
  manifest["settings"] = session_settings_entry(settings, setting_types) unless settings.nil?
192
199
  File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
@@ -362,6 +369,20 @@ module Samagotchi
362
369
  raise ValidationError, e.message
363
370
  end
364
371
 
372
+ # @return [Array<BlockRenderers::Renderer>] each load is a scripts: key
373
+ def parse_block_renderers(raw)
374
+ return [] if raw.nil?
375
+
376
+ renderers = BlockRenderers.parse_list(raw)
377
+ renderers.each do |renderer|
378
+ missing = renderer.load - @scripts.keys
379
+ raise ValidationError, "block_renderers: #{renderer.lang} loads #{missing.join(", ")}, not under scripts:" unless missing.empty?
380
+ end
381
+ renderers
382
+ rescue BlockRenderers::Invalid => e
383
+ raise ValidationError, e.message
384
+ end
385
+
365
386
  # includes: → the names in order, duplicates dropped; [] when absent.
366
387
  # A meta ships nothing of its own, so files, hooks or a plugin next
367
388
  # to it is a ValidationError.
@@ -92,7 +92,7 @@ module Samagotchi
92
92
 
93
93
  begin
94
94
  data = JSON.parse(File.read(mjson), symbolize_names: true)
95
- rescue JSON::ParserError, SystemCallError => e
95
+ rescue JSON::ParserError, EncodingError, SystemCallError => e # a key that isn't UTF-8 can't be a symbol
96
96
  if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
97
97
  yield name, InstalledBundle.unreadable(name, "manifest.json is unreadable: #{e.message}")
98
98
  end
@@ -172,11 +172,12 @@ module Samagotchi
172
172
  # @param scripts_files [Hash] file name => installed script; the
173
173
  # sha256 of each is recorded (ContextFetch checks it before a run)
174
174
  # @param context_providers [Array<ContextProviders::Provider>]
175
+ # @param block_renderers [Array<BlockRenderers::Renderer>]
175
176
  # @param dev [Boolean] a dev install: its files are links to the
176
177
  # source, recorded as DEV_SHA (no sha256 check) with "dev": true
177
178
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
178
179
  guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
179
- scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
180
+ scripts_files: {}, context_providers: [], block_renderers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
180
181
  dev: false)
181
182
  FileUtils.mkdir_p(@bundle_dir)
182
183
  bases_dir = File.join(@bundle_dir, "bases")
@@ -289,6 +290,7 @@ module Samagotchi
289
290
  end
290
291
  end
291
292
  manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
293
+ manifest_data["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
292
294
  manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
293
295
  manifest_data["session_setting_types"] = session_setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless session_setting_types.to_h.empty?
294
296
  manifest_data["settings"] = settings.map(&:to_s) unless settings.nil?
@@ -129,7 +129,7 @@ module Samagotchi
129
129
  # A turn's opening, not a `!shell` line: a command the user ran after
130
130
  # the answer leaves it the last word.
131
131
  def later_turn?(message)
132
- Steer.turn_prompt?(message) && !shell_line?(message)
132
+ Steer.turn_opening?(message)
133
133
  end
134
134
 
135
135
  # At most +max+ bytes of +text+'s start, cut on a character.
@@ -181,18 +181,13 @@ module Samagotchi
181
181
 
182
182
  text = msg[:content]
183
183
  next unless text.is_a?(String) && !text.strip.empty?
184
- next if shell_line?(msg)
184
+ next if Steer.shell_line?(msg)
185
185
 
186
186
  return text.strip
187
187
  end
188
188
  nil
189
189
  end
190
190
 
191
- # A stored `!shell` line: "!(cmd)\n<output>", no turn_id.
192
- def shell_line?(message)
193
- message[:turn_id].nil? && message[:content].is_a?(String) && message[:content].start_with?("!(")
194
- end
195
-
196
191
  def clock(iso)
197
192
  iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
198
193
  rescue ArgumentError
@@ -317,16 +317,9 @@ module Samagotchi
317
317
  text = line.to_s.strip
318
318
  entry = @registry.lookup(text)
319
319
  return nil unless entry&.handler
320
- return run_bundle_command(entry, text, confirm: confirm) unless entry.source == "core"
321
320
 
322
- # Only commands that may ask take the flag.
323
- return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
324
- # Who sent it goes to its handler per call, never through an ivar:
325
- # an anytime command runs on another thread against this instance.
326
- return continue_command(text, as_parent: as_parent) if entry.id == :continue
327
- return parent_answers(text, user_ui: user_ui) if entry.id == :parent_answers
328
-
329
- instance_exec(text, &entry.handler)
321
+ # A card it shows names it (Engine#running_command).
322
+ @engine.running_command(entry.name) { dispatch(entry, text, confirm:, as_parent:, user_ui:) }
330
323
  end
331
324
 
332
325
  # /continue [yes|no|no, <reason>]: the continue offer's answer.
@@ -367,6 +360,19 @@ module Samagotchi
367
360
 
368
361
  private
369
362
 
363
+ def dispatch(entry, text, confirm:, as_parent:, user_ui:)
364
+ return run_bundle_command(entry, text, confirm: confirm) unless entry.source == "core"
365
+
366
+ # Only commands that may ask take the flag.
367
+ return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
368
+ # Who sent it goes to its handler per call, never through an ivar:
369
+ # an anytime command runs on another thread against this instance.
370
+ return continue_command(text, as_parent: as_parent) if entry.id == :continue
371
+ return parent_answers(text, user_ui: user_ui) if entry.id == :parent_answers
372
+
373
+ instance_exec(text, &entry.handler)
374
+ end
375
+
370
376
  # The resolved ref (what the session stores and --alias/--default write).
371
377
  def model_name = @engine.effective_model_ref
372
378
 
@@ -1018,15 +1018,18 @@ module Samagotchi
1018
1018
  turns.is_a?(Array) && turns.empty?
1019
1019
  end
1020
1020
 
1021
- # A folder's cards.json is empty only when it is a JSON object whose
1022
- # turns is an integer zero. Anything unreadable, not JSON, not an
1023
- # object, the wrong type, or turns not an integer reads as "not empty":
1024
- # a broken or wrong-shape cards file keeps the folder instead of
1025
- # raising (Array#/Hash#to_i) or reading as empty.
1021
+ # A folder's cards.json is empty only when it is a JSON object with no
1022
+ # turn in it: an empty turn_log, or an older file's (Bridge::CardStore
1023
+ # version 1) turns an integer zero. Anything unreadable, not JSON, not an
1024
+ # object, the wrong type, or neither of those reads as "not empty": a
1025
+ # broken or wrong-shape cards file keeps the folder instead of raising
1026
+ # (Array#/Hash#to_i) or reading as empty.
1026
1027
  def self.cards_empty?(path)
1027
1028
  data = read_json(path)
1028
- turns = data.is_a?(Hash) ? data["turns"] : nil
1029
- turns.is_a?(Integer) && turns.zero?
1029
+ return false unless data.is_a?(Hash)
1030
+
1031
+ turns = data["turns"]
1032
+ data["turn_log"] == [] || (turns.is_a?(Integer) && turns.zero?)
1030
1033
  end
1031
1034
 
1032
1035
  # A session-dir JSON file's contents, or nil when it is missing,
@@ -336,6 +336,23 @@ module Samagotchi
336
336
  prompt?(message) && (!input?(message) || start)
337
337
  end
338
338
 
339
+ # A stored `!shell` line: "!(cmd)\n<output>", no turn id. A user message,
340
+ # so #turn_prompt? takes it; no turn ran.
341
+ def shell_line?(message)
342
+ return false unless message.is_a?(Hash)
343
+
344
+ content = message[:content] || message["content"]
345
+ (message[:turn_id] || message["turn_id"]).nil? && content.is_a?(String) && content.start_with?("!(")
346
+ end
347
+
348
+ # A turn's opening as the page draws it (TURN_PROMPT, bubbles.js): a prompt
349
+ # or a wake turn's note or report, not a `!shell` line. What the recap, the
350
+ # cards' count and "a later turn" go by. #turn_prompt? stays what bounds a
351
+ # turn's slice, where a `!shell` line ends one.
352
+ def turn_opening?(message)
353
+ turn_prompt?(message) && !shell_line?(message)
354
+ end
355
+
339
356
  # Call a loop's drain: +at_answer+ goes only to a drain that takes it
340
357
  # (the Engine's); a caller's own drain (a queue's #drain) is called bare.
341
358
  # A failing drain drains nothing (the loop keeps going).
@@ -544,13 +544,13 @@ module Samagotchi
544
544
 
545
545
  # The session's cards.json and pending_card.json, kept as a worker's
546
546
  # Bridge keeps them (it owns the session as a worker would): this REPL's
547
- # kept turns count on from the saved count, so a card an earlier worker
548
- # showed stays that many turns back, and its own cards and notices are
549
- # saved for the next; an open card with actions is on disk, where
547
+ # turns go on the same turn log, so a card an earlier worker showed keeps its
548
+ # place above the turns after it, and its own cards and notices are saved
549
+ # for the next; an open card with actions is on disk, where
550
550
  # `chi web`'s session hub reads it to mark the session as waiting.
551
551
  def keep_cards(session)
552
552
  path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
553
- @cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
553
+ @cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path, messages: -> { @engine.session&.messages }))
554
554
  keep_pending_card(session)
555
555
  end
556
556
 
@@ -171,17 +171,20 @@ module Samagotchi
171
171
 
172
172
  # A turn begins (before Engine#run_turn and the worker's running save):
173
173
  # the overlay starts as the history plus +prompt+ (text only: no
174
- # images, no turn id), marked at iteration 0, so a worker killed before
175
- # the loop's first boundary leaves the prompt and a mark the next one
176
- # settles. A continue turn (no prompt) is the history, marked.
174
+ # images), marked at iteration 0, so a worker killed before the loop's
175
+ # first boundary leaves the prompt and a mark the next one settles. The
176
+ # prompt carries +turn_id+, as the first boundary's copy does: a card's
177
+ # place counts from it (Bridge::CardStore). A continue turn (no prompt)
178
+ # is the history, marked.
177
179
  # Every mark of the turn carries +enqueued_id+ and a participant's
178
180
  # +author+ (InterruptedTurn's).
179
181
  # @param prompt [String, nil]
182
+ # @param turn_id [String, nil] the id the turn runs under
180
183
  # @param enqueued_id [String, nil] the input the turn runs
181
184
  # @param author [Hash] a participant's prompt's author fields
182
185
  # (Engine#author_fields), so a kill before the first boundary leaves
183
186
  # it theirs; {} for the owner's
184
- def begin_turn(prompt: nil, enqueued_id: nil, author: {})
187
+ def begin_turn(prompt: nil, turn_id: nil, enqueued_id: nil, author: {})
185
188
  @boundaries = 0
186
189
  @enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
187
190
  @author = author[:author]
@@ -190,7 +193,10 @@ module Samagotchi
190
193
  messages = Array(@session.messages).dup
191
194
  # Stamped by the wall clock here, a moment before the turn's own
192
195
  # stamp (Engine's Turn#wall), which the turn's first boundary saves.
193
- messages << { role: "user", content: prompt, **author, MessageTime::KEY => MessageTime.iso(MessageTime.now) } unless prompt.to_s.empty?
196
+ unless prompt.to_s.empty?
197
+ messages << { role: "user", content: prompt, **(turn_id ? { turn_id: turn_id } : {}), **author,
198
+ MessageTime::KEY => MessageTime.iso(MessageTime.now) }
199
+ end
194
200
  @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
195
201
  enqueued_id: @enqueued_id, author: @author) }
196
202
  end
@@ -191,8 +191,8 @@ module Samagotchi
191
191
  # Restore the pre-turn checkpoint (!rollback after a Ctrl-C). A pending
192
192
  # offer goes too: the turn it offered to continue is gone. (The REPL
193
193
  # reads every line as an answer while an offer is pending; a worker
194
- # also takes !rollback then.) :turn_rolled_back tells the cards' count
195
- # (Bridge::CardStore) that the turn's prompt is gone.
194
+ # also takes !rollback then.) The cards' places follow the messages
195
+ # (Bridge::CardStore).
196
196
  # @return [Boolean] false when there is none
197
197
  def rollback!
198
198
  return false unless @checkpoint
@@ -200,7 +200,6 @@ module Samagotchi
200
200
  restore(@checkpoint)
201
201
  @checkpoint = nil
202
202
  @offer = nil
203
- @engine.announce({ type: :turn_rolled_back })
204
203
  true
205
204
  end
206
205
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.150.0"
4
+ VERSION = "0.151.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -74,6 +74,7 @@ require_relative "session_create"
74
74
  require_relative "session_control"
75
75
  require_relative "verify_fresh_routes"
76
76
  require_relative "push_routes"
77
+ require_relative "block_renderer_routes"
77
78
  require_relative "update_check"
78
79
  require_relative "update_ops"
79
80
  require_relative "proxy_gate"
@@ -107,6 +108,7 @@ module Samagotchi
107
108
  include SessionControl
108
109
  include VerifyFreshRoutes
109
110
  include PushRoutes
111
+ include BlockRendererRoutes
110
112
  include ProxyGate
111
113
 
112
114
  DEFAULT_HOST = BridgeClient::HOST
@@ -222,8 +224,11 @@ module Samagotchi
222
224
  private
223
225
 
224
226
  # No page of chi web's may be framed by another (clickjacking): every
225
- # HTML answer says so. The page's full CSP is a later change.
226
- FRAME_HEADERS = { "X-Frame-Options" => "DENY", "Content-Security-Policy" => "frame-ancestors 'none'" }.freeze
227
+ # HTML answer says so. frame-src 'none': a page's own frames can't navigate
228
+ # anywhere, which a block renderer's frame's own CSP doesn't stop (a srcdoc
229
+ # frame still loads under it; docs/internals/block-renderers.md). The page's
230
+ # full CSP is a later change.
231
+ FRAME_HEADERS = { "X-Frame-Options" => "DENY", "Content-Security-Policy" => "frame-ancestors 'none'; frame-src 'none'" }.freeze
227
232
  FRAME_DESTS = %w[iframe frame embed object].freeze
228
233
 
229
234
  def unframed(response)
@@ -315,6 +320,8 @@ module Samagotchi
315
320
  ["DELETE", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_remove, :owner],
316
321
  ["POST", %r{\A/api/push/test\z}, :handle_push_test, :owner],
317
322
  ["POST", %r{\A/api/push/presence\z}, :handle_push_presence, :owner],
323
+ ["GET", %r{\A/api/block_renderers\z}, :handle_block_renderers, :owner],
324
+ ["GET", %r{\A/api/block_renderers/([^/]+)/([^/]+)\z}, :handle_block_renderer_file, :owner],
318
325
  ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
319
326
  ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
320
327
  ].freeze
@@ -1800,21 +1807,24 @@ module Samagotchi
1800
1807
  rel = rel.split("/").reject { |p| p == ".." || p.empty? }.join("/")
1801
1808
  full = File.join(@public_dir, rel)
1802
1809
  if File.file?(full) && full.start_with?(@public_dir)
1803
- body = File.binread(full)
1804
- # no-cache + a content ETag: the browser revalidates every asset
1805
- # (the ES-module imports too) and gets a cheap 304 while it is
1806
- # unchanged, so a gem upgrade + chi web restart takes effect on the
1807
- # next load instead of after max-age runs out.
1808
- etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
1809
- headers = { "Cache-Control" => "no-cache", "ETag" => etag }
1810
- return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
1811
-
1812
- [200, headers.merge("Content-Type" => mime_type(full), "Content-Length" => body.bytesize.to_s), [body]]
1810
+ revalidated_body(req, File.binread(full), mime_type(full))
1813
1811
  else
1814
1812
  not_found(path: req.path_info)
1815
1813
  end
1816
1814
  end
1817
1815
 
1816
+ # no-cache + a content ETag: the browser revalidates every asset
1817
+ # (the ES-module imports too) and gets a cheap 304 while it is
1818
+ # unchanged, so a gem upgrade + chi web restart takes effect on the
1819
+ # next load instead of after max-age runs out.
1820
+ def revalidated_body(req, body, content_type)
1821
+ etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
1822
+ headers = { "Cache-Control" => "no-cache", "ETag" => etag }
1823
+ return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
1824
+
1825
+ [200, headers.merge("Content-Type" => content_type, "Content-Length" => body.bytesize.to_s), [body]]
1826
+ end
1827
+
1818
1828
  def etag_match?(if_none_match, etag)
1819
1829
  return false if if_none_match.nil?
1820
1830
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../block_renderers"
4
+
5
+ module Samagotchi
6
+ module Web
7
+ # What the owner's page asks to draw a fenced block (App::ROUTES, :owner
8
+ # rows; a participant's page shows the source). Included in App: its helpers
9
+ # answer. docs/internals/block-renderers.md.
10
+ module BlockRendererRoutes
11
+ private
12
+
13
+ # {renderers: [{lang, bundle, files}]}
14
+ def handle_block_renderers(_req)
15
+ json_response(200, { renderers: BlockRenderers.for_installed.map(&:to_h) })
16
+ end
17
+
18
+ # One of a renderer's files. The captures are the URL's and unchecked:
19
+ # BlockRenderers.script_path never makes a path of one that isn't a
20
+ # renderer's. A file changed since the install gets an error, so the page
21
+ # keeps the block as source.
22
+ def handle_block_renderer_file(req, bundle, file)
23
+ path = BlockRenderers.script_path(bundle, file)
24
+ return not_found(path: req.path_info) unless path
25
+
26
+ revalidated_body(req, File.binread(path), "text/javascript; charset=utf-8")
27
+ rescue BlockRenderers::Invalid => e
28
+ error_response(409, "renderer_changed", e.message)
29
+ end
30
+ end
31
+ end
32
+ end