samagotchi 0.149.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/bundle-authoring.md +1 -1
  6. data/docs/cli.md +59 -24
  7. data/docs/configuration.md +6 -6
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/block-renderers.md +96 -0
  10. data/docs/internals/bridge.md +53 -9
  11. data/docs/internals/bundles.md +43 -14
  12. data/docs/internals/objectives.md +7 -2
  13. data/docs/internals/web-frontend.md +2 -2
  14. data/docs/internals/web-push.md +2 -2
  15. data/docs/internals/working-set.md +5 -2
  16. data/docs/memory.md +48 -10
  17. data/docs/plugins.md +49 -3
  18. data/docs/sessions.md +5 -4
  19. data/docs/testing.md +5 -0
  20. data/lib/samagotchi/atomic_file.rb +3 -0
  21. data/lib/samagotchi/block_renderers.rb +115 -0
  22. data/lib/samagotchi/bootstrap_command.rb +5 -2
  23. data/lib/samagotchi/bridge/card_places.rb +93 -0
  24. data/lib/samagotchi/bridge/card_store.rb +224 -82
  25. data/lib/samagotchi/bridge.rb +12 -4
  26. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  32. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  33. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  34. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  35. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  38. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  39. data/lib/samagotchi/chi_home.rb +4 -2
  40. data/lib/samagotchi/chi_shim.rb +2 -1
  41. data/lib/samagotchi/child_env.rb +10 -3
  42. data/lib/samagotchi/engine.rb +19 -1
  43. data/lib/samagotchi/git_env.rb +43 -0
  44. data/lib/samagotchi/guardrails/context.rb +2 -0
  45. data/lib/samagotchi/guardrails/targets.rb +4 -1
  46. data/lib/samagotchi/memory_bundle/asset_installer.rb +23 -3
  47. data/lib/samagotchi/memory_bundle/builder.rb +5 -3
  48. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  49. data/lib/samagotchi/memory_bundle/installed_bundle.rb +70 -6
  50. data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
  51. data/lib/samagotchi/memory_bundle/installer.rb +162 -52
  52. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  53. data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
  54. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  55. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  56. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  57. data/lib/samagotchi/memory_bundle/provenance.rb +182 -22
  58. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  59. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  61. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  62. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  63. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  64. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  65. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  67. data/lib/samagotchi/objectives_check.rb +37 -7
  68. data/lib/samagotchi/project_env.rb +3 -2
  69. data/lib/samagotchi/self_report.rb +2 -0
  70. data/lib/samagotchi/session_chain.rb +2 -7
  71. data/lib/samagotchi/session_commands.rb +15 -9
  72. data/lib/samagotchi/session_manager.rb +10 -7
  73. data/lib/samagotchi/steer.rb +17 -0
  74. data/lib/samagotchi/terminal_ui.rb +4 -4
  75. data/lib/samagotchi/testing/boundary.rb +1 -0
  76. data/lib/samagotchi/testing/minitest.rb +3 -0
  77. data/lib/samagotchi/testing/rspec.rb +3 -0
  78. data/lib/samagotchi/testing/sandbox.rb +3 -1
  79. data/lib/samagotchi/tools/delegate.rb +2 -1
  80. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  81. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  82. data/lib/samagotchi/turn_flow.rb +2 -1
  83. data/lib/samagotchi/update_command.rb +76 -28
  84. data/lib/samagotchi/update_hint.rb +4 -1
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +22 -12
  87. data/lib/samagotchi/web/auto_update.rb +8 -8
  88. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  89. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  90. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  91. data/lib/samagotchi/web/message_display.rb +3 -3
  92. data/lib/samagotchi/web/public/app.js +7 -3
  93. data/lib/samagotchi/web/public/block_frame.js +202 -0
  94. data/lib/samagotchi/web/public/block_host.js +502 -0
  95. data/lib/samagotchi/web/public/block_open.js +13 -0
  96. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  97. data/lib/samagotchi/web/public/block_source.js +37 -0
  98. data/lib/samagotchi/web/public/bubbles.js +11 -5
  99. data/lib/samagotchi/web/public/card.js +17 -10
  100. data/lib/samagotchi/web/public/cards_place.js +10 -5
  101. data/lib/samagotchi/web/public/claim_link.js +4 -2
  102. data/lib/samagotchi/web/public/details_motion.js +2 -1
  103. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  104. data/lib/samagotchi/web/public/index.html +20 -14
  105. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  106. data/lib/samagotchi/web/public/stage_model.js +5 -6
  107. data/lib/samagotchi/web/public/stage_view.js +8 -7
  108. data/lib/samagotchi/web/public/text_roots.js +6 -0
  109. data/lib/samagotchi/web/public/turn_block.js +34 -19
  110. data/lib/samagotchi/web/public/turn_html.js +4 -1
  111. data/lib/samagotchi/web/public/turn_model.js +1 -1
  112. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  113. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  114. data/lib/samagotchi/web/push/notifier.rb +7 -9
  115. data/lib/samagotchi/web/session_hub.rb +2 -1
  116. data/lib/samagotchi/web/session_show.rb +1 -1
  117. data/lib/samagotchi/web/update_ops.rb +2 -1
  118. data/lib/samagotchi/worker.rb +4 -2
  119. metadata +17 -1
@@ -9,6 +9,7 @@ require_relative "desktop"
9
9
  require_relative "gem_update"
10
10
  require_relative "installed_gem"
11
11
  require_relative "live_versions"
12
+ require_relative "memory_bundle/file_standing"
12
13
  require_relative "memory_bundle/shipped_update"
13
14
  require_relative "memory_bundle/system_bundle"
14
15
  require_relative "session"
@@ -20,8 +21,8 @@ require "json"
20
21
  module Samagotchi
21
22
  # `chi update`: brings an installed chi up to date in one go: the system
22
23
  # bundle, the shipped bundles the user installed, and the macOS helper.
23
- # Unedited things update; edited ones are kept (.md) or replaced and
24
- # reported (hooks, plugin, rules, which didn't load once edited). Live
24
+ # Unedited things update; edited ones merge or stay behind (.md) or are replaced
25
+ # and reported (hooks, plugin, rules, which didn't load once edited). Live
25
26
  # workers and a running chi web are reported, never restarted. The bundles
26
27
  # chi didn't ship are only checked, under the chi that will run them
27
28
  # (BundleCommand::InstalledCheck): what it finds is rows that fail nothing.
@@ -34,10 +35,15 @@ module Samagotchi
34
35
  Updates this chi's install: the gem (from rubygems.org), then the
35
36
  system bundle, the bundles chi ships that you installed (chi bundle
36
37
  list), and the desktop helper (macOS; rebuilt only when its sources
37
- changed). Edited memory files are kept and reported; nothing is
38
- installed that isn't already, and old gem versions stay. The bundles
39
- chi doesn't ship are never changed: the new chi checks them and adds
40
- a row per warning or error (these don't fail the update).
38
+ changed). A memory file you edited takes the update in a text merge
39
+ (the old file goes to the bundle trash), or stays behind as it is when
40
+ no merge is possible, and the table says which and why; each run
41
+ retries a file left behind, though one an older chi marked, with a base
42
+ no installed chi shipped, merges only if the bundle takes out or rewords
43
+ no line it still holds. Nothing is
44
+ installed that isn't already, and old gem versions stay. The bundles chi doesn't ship are
45
+ never changed: the new chi checks them and adds a row per warning or
46
+ error (these don't fail the update).
41
47
  --dry-run show what would change; change nothing (with a newer
42
48
  gem to install, bundles chi doesn't ship aren't checked:
43
49
  that chi would)
@@ -76,8 +82,11 @@ module Samagotchi
76
82
 
77
83
  # A table row. info rows (live workers, chi web) say what runs; they
78
84
  # never fail. level ("warn", "error"; else nil) marks a finding on a bundle
79
- # chi didn't ship: it reports, changes nothing and fails nothing.
80
- Row = Struct.new(:component, :from, :to, :status, :failed, :kept, :level, keyword_init: true)
85
+ # chi didn't ship: it reports, changes nothing and fails nothing. kept: the
86
+ # memory files that stay behind; merged: the ones that took the update;
87
+ # trash_copy: where their old copies went, in full (the table's short: --json
88
+ # has the path); earlier: the files of the earlier merges the row names.
89
+ Row = Struct.new(:component, :from, :to, :status, :failed, :kept, :merged, :earlier, :trash_copy, :level, keyword_init: true)
81
90
 
82
91
  # What a row says after a change, and in a dry run.
83
92
  VERBS = { update: ["updated", "would update"], install: ["installed", "would install"],
@@ -95,6 +104,7 @@ module Samagotchi
95
104
  gem_update: nil, exec: nil, bundler: nil, home: ChiHome, env: ENV)
96
105
  @home = home
97
106
  @in_home = env.delete(IN_HOME_ENV) == "1"
107
+ @unattended = env[ChildEnv::UNATTENDED_UPDATE] == "1"
98
108
  # Built after the home is known: its install env goes into the home.
99
109
  @gem_update_given = gem_update
100
110
  @exec = exec || ->(argv) { Kernel.exec(*argv) }
@@ -147,7 +157,7 @@ module Samagotchi
147
157
  gem = gem_row(options)
148
158
  rows = [gem, system_bundle_row, *bundle_rows(options), *external_rows(options), desktop_row(options), *live_rows].compact
149
159
  if options[:json]
150
- @stdout.puts(JSON.generate(rows.map { |row| row.to_h.merge(failed: !!row.failed, kept: row.kept || []) }))
160
+ @stdout.puts(JSON.generate(rows.map { |row| row.to_h.merge(failed: !!row.failed, kept: row.kept || [], merged: row.merged || [], earlier: row.earlier || []) }))
151
161
  else
152
162
  print_table(rows)
153
163
  footers(options, rows).each { |line| @stdout.puts(line) }
@@ -279,17 +289,24 @@ module Samagotchi
279
289
  end
280
290
 
281
291
  def system_bundle_row
282
- result = MemoryBundle::SystemBundle.sync(dry_run: @dry_run)
292
+ system = MemoryBundle::SystemBundle
293
+ earlier = system.merge_notes
294
+ result = system.sync(dry_run: @dry_run)
295
+ # These rows have no place for the notes, which wait for one that has.
296
+ earlier = [] if %i[installed skipped failed].include?(result.status)
283
297
  row = Row.new(component: "system bundle", from: result.from, to: result.to)
298
+ notes = file_notes(row, system::BUNDLE_NAME, result.to, result.merged, result.behind, result.trash_copy, earlier: earlier)
284
299
  case result.status
285
300
  when :installed then done(row, :install)
286
- when :updated then done(row, :update, kept(row, result.kept, MemoryBundle::SystemBundle::BUNDLE_NAME))
287
- when :restored then done(row, :restore).tap { |r| r.to = nil }
288
- when :up_to_date then up_to_date(row)
289
- when :newer_installed then skipped(row, "newer than this chi's #{result.to}: left").tap { |r| r.to = nil }
301
+ when :updated then done(row, :update, notes.empty? ? nil : notes.join("; "))
302
+ when :restored then done(row, :restore, notes.empty? ? nil : notes.join("; ")).tap { |r| r.to = nil }
303
+ when :merged, :up_to_date then same_version(row, notes + result.warnings, changed: result.merged.any?)
304
+ when :newer_installed then skipped(row, ["newer than this chi's #{result.to}: left", *notes].join("; ")).tap { |r| r.to = nil }
290
305
  when :skipped then skipped(row, result.error)
291
306
  else failed(row, result.error)
292
307
  end
308
+ system.clear_merge_notes(earlier, files: result.merged) if read_by_someone?
309
+ row
293
310
  end
294
311
 
295
312
  def bundle_rows(options)
@@ -320,14 +337,50 @@ module Samagotchi
320
337
 
321
338
  def bundle_row(update)
322
339
  row = Row.new(component: update.name, from: update.from, to: update.to)
340
+ earlier = update.status == :failed ? [] : Array(update.earlier)
341
+ notes = file_notes(row, update.name, update.to || update.from, update.merged, update.behind, update.trash_copy, earlier: earlier)
323
342
  case update.status
324
343
  when :would_update, :updated
325
- notes = [adds(update.adds), kept(row, update.kept, update.name), replaced(update.replaced), *update.kept_own].compact
344
+ notes = [adds(update.adds), *notes, replaced(update.replaced), *update.kept_own].compact
326
345
  done(row, :update, notes.empty? ? nil : notes.join("; "))
327
- when :up_to_date then up_to_date(row)
328
- when :skipped then skipped(row, update.note)
329
- else failed(row, update.note)
346
+ when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note].compact, changed: Array(update.merged).any?)
347
+ when :skipped then skipped(row, [update.note, *notes].join("; "))
348
+ else failed(row, [update.note, *notes].compact.join("; "))
330
349
  end
350
+ # A failed row keeps them: the run didn't finish, and the next says what stands.
351
+ MemoryBundle::Provenance.new(name: update.name).clear_merge_notes(earlier, files: row.merged) if read_by_someone? && update.status != :failed
352
+ row
353
+ end
354
+
355
+ # What one bundle's memory files did, in FileStanding's words; the row keeps
356
+ # the file names and the trash copy's full line for --json. merged is what
357
+ # this run merged, earlier the files of the notes it shows.
358
+ # @return [Array<String>] the row's clauses
359
+ def file_notes(row, bundle, version, merged, behind, trash_copy, earlier: [])
360
+ merged = Array(merged)
361
+ behind = Array(behind)
362
+ row.merged = merged
363
+ row.earlier = earlier.map(&:file)
364
+ row.kept = behind.map(&:file)
365
+ @unsure_behind ||= behind.any?(&:unsure)
366
+ row.trash_copy = trash_copy
367
+ notes = MemoryBundle::FileStanding.clauses(bundle, version, merged: merged, behind: behind, earlier: earlier, dry_run: @dry_run)
368
+ trash_copy || earlier.any?(&:trash) ? [*notes, MemoryBundle::FileStanding::IN_THE_TRASH] : notes
369
+ end
370
+
371
+ # Whether a row printed now ends the notes for what it printed, this run's
372
+ # merges too. A dry run changes nothing, and an automatic update's rows may be
373
+ # read by no one (ChildEnv::UNATTENDED_UPDATE).
374
+ def read_by_someone? = !@dry_run && !@unattended
375
+
376
+ # A row at the same version: up to date only when nothing merged and nothing
377
+ # stays behind. No to, and no verb of its own: the clauses say it all.
378
+ def same_version(row, notes, changed:)
379
+ return up_to_date(row, notes.join("; ")) unless changed || row.kept.any?
380
+
381
+ row.to = nil
382
+ row.status = notes.join("; ")
383
+ row
331
384
  end
332
385
 
333
386
  def desktop_row(options)
@@ -420,6 +473,9 @@ module Samagotchi
420
473
  return "(dry run: nothing was changed)" if @dry_run
421
474
  return "some parts failed (see above)" if rows.any?(&:failed)
422
475
 
476
+ behind = rows.sum { |row| Array(row.kept).size }
477
+ return "#{MemoryBundle::FileStanding.stay_behind(behind, unsure: @unsure_behind)} (see above)" if behind.positive?
478
+
423
479
  # A finding on a bundle chi didn't ship repeats until its bundle changes; it isn't a change.
424
480
  changes = rows.reject(&:level)
425
481
  changes.all? { |r| r.status.start_with?("up to date", "skipped", "not installed", "couldn't check") } ? "everything is up to date" : "done"
@@ -434,9 +490,9 @@ module Samagotchi
434
490
  row
435
491
  end
436
492
 
437
- def up_to_date(row)
493
+ def up_to_date(row, note = nil)
438
494
  row.to = nil
439
- row.status = "up to date"
495
+ row.status = note.to_s.empty? ? "up to date" : "up to date (#{note})"
440
496
  row
441
497
  end
442
498
 
@@ -456,14 +512,6 @@ module Samagotchi
456
512
  members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
457
513
  end
458
514
 
459
- def kept(row, files, bundle)
460
- row.kept = files.dup
461
- return nil if row.kept.empty?
462
-
463
- verb = @dry_run ? "would keep" : "kept"
464
- "#{verb} your edits in #{row.kept.join(", ")}: chi bundle diff #{bundle} #{row.kept.first}"
465
- end
466
-
467
515
  def replaced(files)
468
516
  return nil if files.empty?
469
517
 
@@ -43,7 +43,10 @@ module Samagotchi
43
43
  end
44
44
 
45
45
  def message(version, shipped_dir:, helper:)
46
- bundles = MemoryBundle::ShippedUpdate.plan(shipped_dir: shipped_dir).count { |r| r.status == :would_update }
46
+ # A retry that would merge is an update too; one that only records a reason isn't.
47
+ bundles = MemoryBundle::ShippedUpdate.plan(shipped_dir: shipped_dir).count do |r|
48
+ r.status == :would_update || (r.status == :would_merge && r.merged.any?)
49
+ end
47
50
  parts = []
48
51
  parts << (bundles == 1 ? "1 bundle" : "#{bundles} bundles") if bundles.positive?
49
52
  parts << "the desktop helper" if helper&.stale?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.149.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
 
@@ -197,7 +197,7 @@ module Samagotchi
197
197
  end
198
198
 
199
199
  # A newer chi this didn't install, settled: a run of its own from here.
200
- # The terminal's table already told what `chi update` kept.
200
+ # The terminal's table already told what `chi update` merged or left behind.
201
201
  def adopt_installed
202
202
  return unless @restart == "idle" && @seen
203
203
 
@@ -215,10 +215,10 @@ module Samagotchi
215
215
  return Log.info(:web, "auto_update_deferred", version: version, why: result) if result.is_a?(Symbol)
216
216
 
217
217
  rows = result.rows
218
- record = AutoUpdateRecord.new(version: version, from: VERSION, at: @clock.call.iso8601, kept: kept_files(rows),
219
- phase: "installed")
218
+ record = AutoUpdateRecord.new(version: version, from: VERSION, at: @clock.call.iso8601, kept: files_in(rows, "kept"),
219
+ merged: files_in(rows, "merged"), phase: "installed")
220
220
  if result.done?
221
- Log.info(:web, "auto_update_installed", version: version, kept: record.kept.size)
221
+ Log.info(:web, "auto_update_installed", version: version, kept: record.kept.size, merged: record.merged.size)
222
222
  installed(record)
223
223
  else
224
224
  failed(record, rows)
@@ -232,7 +232,7 @@ module Samagotchi
232
232
  return carry_on(save(record)) unless @restart == "never"
233
233
 
234
234
  save(record.with(phase: "done", pushed: true))
235
- @hub.publish_notice(kind: "installed", version: record.version, kept: record.kept.size)
235
+ @hub.publish_notice(kind: "installed", version: record.version, kept: record.kept.size, merged: record.merged.size)
236
236
  end
237
237
 
238
238
  # The next step of a run on record.
@@ -328,7 +328,7 @@ module Samagotchi
328
328
  record = restart_workers(record) || record
329
329
  Log.info(:web, "auto_update_done", version: record.version, restarted: record.restarted, left: record.left)
330
330
  save(record.with(phase: record.left_to_try?(@clock.call, RETRY_FOR) ? RETRYING : "done", pushed: true))
331
- @hub.publish_notice(kind: "updated", version: record.version, kept: record.kept.size,
331
+ @hub.publish_notice(kind: "updated", version: record.version, kept: record.kept.size, merged: record.merged.size,
332
332
  restarted: record.restarted, left: record.left)
333
333
  end
334
334
 
@@ -366,8 +366,8 @@ module Samagotchi
366
366
  installed: gem_installed)
367
367
  end
368
368
 
369
- def kept_files(rows)
370
- rows.flat_map { |row| Array(row["kept"]).map(&:to_s) }.uniq
369
+ def files_in(rows, field)
370
+ rows.flat_map { |row| Array(row[field]).map(&:to_s) }.uniq
371
371
  end
372
372
  end
373
373
  end
@@ -13,8 +13,10 @@ module Samagotchi
13
13
  # +phase+: installed, workers, web (AutoUpdate::OPEN_PHASES), retrying,
14
14
  # done or failed. +restarted+ and +left+ count the stale workers (`workers` in
15
15
  # the file); +at+ is when the install ended, an ISO 8601 string.
16
- AutoUpdateRecord = Data.define(:version, :from, :at, :kept, :phase, :restarted, :left, :detail, :pushed) do
17
- def initialize(version:, phase:, from: nil, at: nil, kept: [], restarted: 0, left: 0, detail: nil, pushed: false)
16
+ # +kept+ and +merged+ name the edited memory files that stayed behind and
17
+ # that took the update (chi update --json's).
18
+ AutoUpdateRecord = Data.define(:version, :from, :at, :kept, :merged, :phase, :restarted, :left, :detail, :pushed) do
19
+ def initialize(version:, phase:, from: nil, at: nil, kept: [], merged: [], restarted: 0, left: 0, detail: nil, pushed: false)
18
20
  super
19
21
  end
20
22
 
@@ -30,14 +32,14 @@ module Samagotchi
30
32
 
31
33
  workers = hash["workers"].is_a?(Hash) ? hash["workers"] : {}
32
34
  new(version: version, phase: phase, from: hash["from"], at: hash["at"],
33
- kept: Array(hash["kept"]).grep(String), restarted: workers["restarted"].to_i, left: workers["left"].to_i,
35
+ kept: Array(hash["kept"]).grep(String), merged: Array(hash["merged"]).grep(String), restarted: workers["restarted"].to_i, left: workers["left"].to_i,
34
36
  detail: hash["detail"], pushed: hash["pushed"] == true)
35
37
  end
36
38
 
37
39
  # What goes in the file, for .from_h to read back.
38
40
  # @return [Hash{String => Object}]
39
41
  def to_store
40
- { "version" => version, "from" => from, "at" => at, "kept" => kept, "phase" => phase,
42
+ { "version" => version, "from" => from, "at" => at, "kept" => kept, "merged" => merged, "phase" => phase,
41
43
  "workers" => { "restarted" => restarted, "left" => left }, "detail" => detail,
42
44
  "pushed" => pushed ? true : nil }.compact
43
45
  end
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "nokogiri"
4
4
  require "uri"
5
+ require_relative "../block_renderers"
5
6
 
6
7
  module Samagotchi
7
8
  module Web
@@ -17,6 +18,12 @@ module Samagotchi
17
18
  ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
18
19
  DROP_ELEMENTS = %w[iframe object script style svg].freeze
19
20
  ALLOWED_PROTOCOLS = %w[http https mailto].freeze
21
+ # Both Commonmarker calls take these: the parse that reads the fences has
22
+ # to see the blocks #render's own parse does.
23
+ OPTIONS = {
24
+ render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
25
+ extension: { header_ids: nil }
26
+ }.freeze
20
27
 
21
28
  def initialize(enabled: false)
22
29
  @enabled = enabled
@@ -46,10 +53,7 @@ module Samagotchi
46
53
  def render(markdown)
47
54
  return nil unless available?
48
55
 
49
- html = Commonmarker.to_html(markdown.to_s, options: {
50
- render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
51
- extension: { header_ids: nil }
52
- }, plugins: {
56
+ html = Commonmarker.to_html(markdown.to_s, options: OPTIONS, plugins: {
53
57
  # An empty theme makes syntect emit scope classes (`keyword`, `string`, ...)
54
58
  # instead of inline colours from one fixed theme, so the page's light and
55
59
  # dark palettes colour code blocks (the --code-* tokens in index.html).
@@ -57,7 +61,7 @@ module Samagotchi
57
61
  })
58
62
  return nil unless html
59
63
 
60
- sanitize(html)
64
+ sanitize(html, fence_languages(markdown.to_s))
61
65
  end
62
66
 
63
67
  private
@@ -77,7 +81,35 @@ module Samagotchi
77
81
  @commonmarker_installed = Gem::Specification.find_all_by_name("commonmarker").any?
78
82
  end
79
83
 
80
- def sanitize(html)
84
+ # Every code block's language, in document order (nil for an indented block
85
+ # or a bare fence): the highlighter drops it from the HTML, the parse tree
86
+ # has it. [] for a text with no fence: its blocks have no language.
87
+ def fence_languages(markdown)
88
+ return [] unless markdown.include?("```") || markdown.include?("~~~")
89
+
90
+ languages = []
91
+ Commonmarker.parse(markdown, options: OPTIONS).walk do |node|
92
+ languages << language_of(node.fence_info) if node.type == :code_block
93
+ end
94
+ languages
95
+ end
96
+
97
+ def language_of(info)
98
+ word = info.to_s.split.first.to_s.downcase
99
+ word if word.match?(BlockRenderers::LANG)
100
+ end
101
+
102
+ # After the walk, which drops every attribute it doesn't know. Blocks and
103
+ # languages pair by position; a count that differs says the two parses
104
+ # disagree, and then no block gets one (it shows as source).
105
+ def mark_languages(fragment, languages)
106
+ blocks = fragment.css("pre")
107
+ return unless blocks.size == languages.size
108
+
109
+ blocks.zip(languages) { |pre, language| pre["data-lang"] = language if language }
110
+ end
111
+
112
+ def sanitize(html, languages)
81
113
  fragment = Nokogiri::HTML5.fragment(html)
82
114
  fragment.traverse do |node|
83
115
  next unless node.element?
@@ -90,6 +122,7 @@ module Samagotchi
90
122
  sanitize_attributes(node)
91
123
  end
92
124
  end
125
+ mark_languages(fragment, languages)
93
126
  fragment.to_html
94
127
  end
95
128
 
@@ -59,7 +59,7 @@ module Samagotchi
59
59
  return nil unless saved
60
60
 
61
61
  messages = Array(session.messages)
62
- since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
62
+ since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_opening?(m) }
63
63
  { text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
64
64
  rescue StandardError
65
65
  nil
@@ -288,8 +288,8 @@ module Samagotchi
288
288
  end
289
289
 
290
290
  # As JSON has them (string keys and values), like a snapshot's.
291
- def saved_cards(id)
292
- JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
291
+ def saved_cards(id, messages: nil)
292
+ JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir), messages: messages)))
293
293
  rescue StandardError
294
294
  []
295
295
  end
@@ -71,6 +71,7 @@ import { createAnnotateBar } from "./annotate_bar.js";
71
71
  import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
72
72
  import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
73
73
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
74
+ import { createBlockHost } from "./block_host.js";
74
75
  import { copyText, installCopy } from "./copy.js";
75
76
  import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
76
77
  import { installDetailsMotion } from "./details_motion.js";
@@ -1188,8 +1189,9 @@ function renderSessionView(data, { kept = [] } = {}) {
1188
1189
  restoreCommandBubbles(historyEl, kept);
1189
1190
  // A failed turn's prompt went back to the user: shown after the
1190
1191
  // conversation, marked failed, as it was live (prompt_restored).
1191
- if (data.failed_turn) {
1192
- makeUserBubble(data.failed_turn.prompt, { state: "failed" }).dataset.failedTurn = "1";
1192
+ const failedPrompt = data.failed_turn ? makeUserBubble(data.failed_turn.prompt, { state: "failed" }) : null;
1193
+ if (failedPrompt) {
1194
+ failedPrompt.dataset.failedTurn = "1";
1193
1195
  addEndBubble(failedTurnText(data.failed_turn));
1194
1196
  }
1195
1197
  seedSeenFromDom();
@@ -1203,7 +1205,8 @@ function renderSessionView(data, { kept = [] } = {}) {
1203
1205
  // A live worker's last cards and between-turns notices, where they
1204
1206
  // arrived; the running turn's go into its step once it is drawn.
1205
1207
  const snapshotCards = splitSnapshotCards(data.cards, pageCapabilities);
1206
- placeSnapshotCards(snapshotCards.placed);
1208
+ // Above a failed turn's prompt: it came after them, and it is no turn.
1209
+ placeSnapshotCards(snapshotCards.placed, { before: failedPrompt });
1207
1210
  // The recap saved with the session (also a stopped one's), else a live
1208
1211
  // worker's from before saved_recap existed. After the cards: the last
1209
1212
  // turn's check-in notice goes to the end, and the recap closes the page.
@@ -2117,6 +2120,7 @@ historyEl.addEventListener("click", (e) => {
2117
2120
  });
2118
2121
  turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
2119
2122
  turnRoots().forEach((root) => installCopy(root));
2123
+ createBlockHost({ roots: turnRoots(), sessionId: () => store.selected });
2120
2124
  // A steps block, a thinking block and the recap open and close by hand
2121
2125
  // with their height animated (details_motion.js), in the history and on
2122
2126
  // the stage.