stream_weaver 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 27698ae0fafbcbb9fbbea4b2f18f2215b43fc13e8c04acfe0b01a6bec722ab3c
4
- data.tar.gz: 43b573368d680cd1c663b8e8c8935e883db51dd605cd25434424f94be2d19c29
3
+ metadata.gz: 71c5865a49a8746b8592689941d1b1bd4b0d813add49bb60ee8fab85182c1f64
4
+ data.tar.gz: 3c08461ec552647cb288bef820d35eaf05dd7cb79284710f9d830d9cbb25d493
5
5
  SHA512:
6
- metadata.gz: 2035acdd6c8d7c98111b67ac8c3e6f3df008440cdf61072ac65749395b78c0ee05f63f9c80ec901c4e9496afe445e5edaeca4e3164600ba37513a61d85356dfb
7
- data.tar.gz: '080f167accb1d0244c7bab518f236e3ddd4ddc04066abe23b2a8459fc36be8631096e863eb5820256cef5ecd72471e4342e51cafa1041ce889f2c09ca23ac6de'
6
+ metadata.gz: 70eefa999d7ab6b8569e15f4713e60ff29693855ca789c2be276fb3d1211ede33abb819d8af3c096098ec7ac1eb1304ddc5c60da02de5ca4e2df7bd1d71dc417
7
+ data.tar.gz: 9452bac8a36c5769132e0eddac60289128b311fd515e1185f3e428c64123e661721db0d2e3eaf644ca1c080df6b63d5c1d730c035211f86e884d33b7cf2c47ef
data/CHANGELOG.md CHANGED
@@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.3.1] - 2026-09-11
9
+
10
+ ### Added
11
+ - **Canvas bridge auto-heal** - every canvas command now detects a bridge left running on older code (after a gem upgrade or a dev `rake install`) and transparently restarts it, preserving all sessions via snapshot/restore. One line on stderr says it happened; `SW_NO_AUTO_RESTART=1` opts out. Ends the "stale bridge" class of 500s.
12
+ - **University artifact lifecycle — the course cleans up after itself** - StreamWeaver University now keeps a manifest of everything it creates (saved docs, exported `.org` files, gists, demo canvas sessions) and can take it all back. `streamweaver university-cleanup` prints the grouped inventory and confirms per group — gists one at a time, showing each URL, since a gist is the only artifact that left your machine — with `--dry-run` to look without being asked anything; the completion recap grew the same offer as buttons, where a delete always takes two clicks (the first only asks, and the confirmation names exactly what will go). `streamweaver university-stop` puts the course down without throwing anything away: it stops the listener, closes the demo canvas sessions and the two iTerm2 windows `get-started` opened, and keeps your progress. Recording is invisible — docs and sessions record themselves; `streamweaver university-artifact add <ref>` is the one manual door, for the gist URL only your agent ever sees. Nothing outside the manifest is ever deleted: a ref it does not record is refused, sessions are re-checked against the same allowlist `university-reset` closes by, and the course's own state files are matched by exact name rather than swept out of a directory in your home. `--scan` covers the runs that predate the manifest: it finds artifacts by the course's own deterministic names only (`university-doc` / `doc-demo-<stamp>` files in the canvas doc stores, gists whose file is one of those, and the demo sessions the bridge is actually serving) and *adopts* them into the manifest rather than deleting anything directly, so the same allowlisted deletion path — and every guard on it — still governs what can go. `--scan --dry-run` previews and writes nothing at all. `streamweaver university-artifact remove <ref>` drops an entry from the manifest without touching the thing it names, so an adoption you did not want is undone in one command.
13
+
14
+ ### Fixed
15
+ - **Canvas bridge 500s on multibyte content when spawned from a locale-less env** - a bridge (or the University listener) spawned detached from a parent process whose env lacked `LANG`/`LC_ALL`/`LC_CTYPE` — e.g. the staleness auto-heal's restart — booted with `Encoding.default_external` falling back to `US-ASCII`, so any push containing an em-dash, checkmark, or other multibyte text raised `Encoding::CompatibilityError` on every render. Both spawn paths now force UTF-8 at boot (`-E UTF-8` plus an explicit encoding preamble) and pass a sane locale to anything they shell out to.
16
+ - **No more "Web Browser" profile dependency for the iTerm2 canvas window** - a fresh macOS/iTerm2 install (only the "Default" profile present) previously failed `open_browser_window`/`panel`'s split with `INVALID_PROFILE_NAME`, and the old remediation text wrongly told a tester on iTerm2 3.6+ to "update iTerm2" when the real fix was to manually create a profile. StreamWeaver no longer needs a saved profile at all: it turns any pane into a browser pane itself by overriding `"Custom Command" => "Browser"` in the split's `profile_customizations` (the exact property iTerm2's own built-in "Web Browser" profile is defined with). The `browser_profile` dependency probe, its dependency-report row, and the "profile not found" remediation hints are removed as no longer applicable.
17
+ - **Dependency check now names iTerm2's Browser Plugin and its Advanced setting, the two prerequisites the profile override can't bypass** - a second field report showed a tester who'd installed the plugin still couldn't get browser panes: iTerm2's own docs confirm the Profile Type / browser feature is gated on the separately-downloaded Browser Plugin (`iterm2.com/browser-plugin.html`) and an "Enable browser-style profiles" Advanced setting (ships enabled). `get-started`'s dependency report gained two more advisory probes (`ITerm.browser_plugin_available?`, detected the same way iTerm2 itself locates the plugin, by bundle identifier via `mdfind`; `ITerm.browser_style_profiles_enabled?`, reading the Advanced setting's preference key directly) plus matching remediation text and a targeted fallback hint when the controller window can't open.
9
18
 
10
19
  ## [0.3.0] - 2026-09-04
11
20
 
data/README.md CHANGED
@@ -27,6 +27,7 @@ That's it. No HTML. No CSS. No JavaScript. No webpack.
27
27
  A few things worth being explicit about:
28
28
 
29
29
  - `streamweaver panel` and the worker-tab automation it drives are **macOS + iTerm2 only** — they script iTerm2's Python API.
30
+ - The premier `get-started` experience also needs iTerm2 3.6.0 or newer — that's the release that added native browser panes, which is what the canvas window renders in, plus iTerm2's Browser Plugin (a one-time download — iTerm2 offers it in Settings, or grab it from https://iterm2.com/browser-plugin.html and drop it in /Applications). No saved profile setup is required: StreamWeaver turns any pane into a browser pane itself (a profile-customization override), so there's nothing to create or rename in Settings → Profiles. An older iTerm2 without browser-pane support still runs `get-started`; the canvas window just falls back to a plain browser tab.
30
31
  - `canvas-read` is a plain local web server. It's the most portable piece of StreamWeaver and doesn't need iTerm2, Chrome, or `gh` — see [docs/canvas-read.md](docs/canvas-read.md).
31
32
  - The Chrome extension and the `gh` CLI are enhancers for *sharing* docs (Gist links that render nicely), not requirements for reading them locally.
32
33
  - Never assume a port. Every command that starts a server prints the URL it actually bound — StreamWeaver auto-increments past busy ports.
@@ -141,6 +142,14 @@ through a short interactive course, next to your own terminal. On macOS + iTerm2
141
142
  experience; anywhere else it falls back to a browser tab automatically (or jump straight there with
142
143
  `streamweaver get-started --degraded`). See [Platform support](#platform-support) above.
143
144
 
145
+ The course tracks everything it creates -- the docs it saves, the `.org` files exported from them,
146
+ the gists published, the demo canvas sessions it opens -- and takes it all back on request:
147
+
148
+ ```bash
149
+ streamweaver university-cleanup # confirm group by group; gists one at a time
150
+ streamweaver university-cleanup --scan # also adopt artifacts from a run before the manifest
151
+ ```
152
+
144
153
  Prefer to explore on your own first?
145
154
 
146
155
  ```bash
@@ -36,6 +36,14 @@ streamweaver canvas-close myapp
36
36
  | `canvas-reset <name>` | Reset session state (keep connections) |
37
37
  | `canvas-list` | List all canvas sessions |
38
38
 
39
+ ## Upgrading StreamWeaver under a running bridge
40
+
41
+ The canvas bridge is a long-lived process holding its classes in memory, so a `gem update` or `rake install` underneath it leaves it serving the old code -- pushed canvas source calling a brand-new method used to fail with a bare `NameError`. It now heals itself: the bridge stamps the StreamWeaver version and a fingerprint of the code it loaded into `~/.streamweaver/canvas.pid` at boot, every canvas command compares that stamp against its own before talking to the bridge, and on a mismatch it restarts the bridge in place -- snapshot, stop, start, restore -- so open sessions keep their content, then carries on with the command you actually ran. You see one line on stderr (stdout stays clean for `canvas-wait`'s JSON): `StreamWeaver: canvas bridge was running older code — restarting it (sessions preserved)…`. It speaks up again, on the same stream, if anything about that claim needs qualifying: a session it could not read before stopping the old bridge, a restore that failed, or a restarted bridge that landed on a different port -- which means the tab you have open is pointing at a dead one and wants a reload.
42
+
43
+ Two things it deliberately will not do. It never restarts twice within a minute: a second mismatch that soon means two callers disagree about which code is current -- a dev checkout and the installed gem, typically -- and restarting again would just hand the bridge back and forth, taking your sessions with it each time. And it does not notice a single edited file deep in a dev checkout, because the fingerprint tracks `stream_weaver.rb`, which an install rewrites and a targeted edit does not. Both cases print a warning naming `streamweaver canvas-restart`, which is the answer.
44
+
45
+ Set `SW_NO_AUTO_RESTART=1` to be warned instead of healed everywhere; the command then continues against the old bridge.
46
+
39
47
  ## Multi-Step Workflows
40
48
 
41
49
  Use `canvas_continue` to show a spinner after submit instead of "You can close this window":
@@ -20,14 +20,17 @@ Two versions below, depending on who's getting it. Pick one and paste it as-is.
20
20
  > The full experience puts the course in an iTerm2 window of its own and your agent in a fresh tab
21
21
  > beside your work -- set iTerm2 up first, then install the gem and run the door command.
22
22
 
23
- **1. Prerequisites** (macOS + iTerm2 only -- skip if you're on another OS or terminal, you'll just
24
- get a browser tab instead):
23
+ **1. Prerequisites** (macOS + iTerm2 3.6.0+ only -- skip if you're on another OS, terminal, or an
24
+ older iTerm2, you'll just get a browser tab instead):
25
25
 
26
26
  ```bash
27
27
  gem install iterm2_ruby
28
28
  ```
29
29
 
30
- Then in iTerm2: **Settings → General → Magic → Enable Python API**.
30
+ Then in iTerm2: **Settings → General → Magic → Enable Python API**, and install the **iTerm2 Browser Plugin** when Settings offers it (or from https://iterm2.com/browser-plugin.html into /Applications) — it powers the canvas window. iTerm2 3.6.0 (September 2025)
31
+ is the version that added native browser panes, which is what the canvas window renders in --
32
+ no profile setup needed, StreamWeaver turns any pane into a browser pane itself. An older iTerm2
33
+ without that support just falls back to a browser tab automatically.
31
34
 
32
35
  **2. Install the gem:**
33
36
 
@@ -75,3 +78,25 @@ The gist link already renders as readable text without the extension -- the exte
75
78
  look exactly like it did in the original canvas: sidebar navigation, callouts, cards, tables, and
76
79
  live Mermaid diagrams, all in the browser, no install beyond the one-click extension. The extension
77
80
  is Chrome/Chromium only -- other browsers just see the plain org text, which is still readable.
81
+
82
+ ## Cleaning up afterwards
83
+
84
+ The course tracks everything it creates -- saved docs, exported `.org` files, published gists, and
85
+ the demo canvas sessions it opens -- so you never have to remember what a run left behind:
86
+
87
+ ```bash
88
+ streamweaver university-artifact list # what the course has created
89
+ streamweaver university-cleanup # offer to take it all back
90
+ streamweaver university-cleanup --dry-run # show the same inventory, delete nothing
91
+ streamweaver university-cleanup --scan # also adopt artifacts from a run before the manifest existed
92
+ streamweaver university-artifact remove <ref> # drop one from the list, leaving the file itself alone
93
+ ```
94
+
95
+ Cleanup confirms group by group, and gists **one at a time showing each URL** -- a gist is the only
96
+ thing here that left your machine. Mind this page's own subject when you answer: the link you sent
97
+ your coworker IS one of those gists, and deleting it breaks the link for them. Keep it until they've
98
+ read it.
99
+
100
+ `university-cleanup` and `university-reset` compose and do different jobs: cleanup deletes what the
101
+ course created, reset starts the course over (progress and demo sessions). Run both, in either
102
+ order, and nothing of the course is left on the machine.
@@ -5,6 +5,7 @@ require 'json'
5
5
  require 'socket'
6
6
  require 'fileutils'
7
7
  require_relative 'protocol'
8
+ require_relative 'code_stamp'
8
9
  require_relative 'scroll_top_hint'
9
10
  require_relative 'session'
10
11
  require_relative 'bridge'
@@ -288,7 +289,7 @@ module StreamWeaver
288
289
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css">
289
290
  <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
290
291
  <!-- Mermaid zoom/pan engine — always present so canvas-push with mermaid works on first push -->
291
- <script>#{File.read(File.join(__dir__, '..', 'assets', 'js', 'sw-mermaid-zoom.js'))}</script>
292
+ <script>#{File.read(File.join(__dir__, '..', 'assets', 'js', 'sw-mermaid-zoom.js'), encoding: 'UTF-8')}</script>
292
293
  <!-- Kick off CDN fetch immediately so mermaid is ready before first push arrives -->
293
294
  <script>if (window.swMermaidPreload) window.swMermaidPreload();</script>
294
295
  </head>
@@ -613,10 +614,13 @@ module StreamWeaver
613
614
  # For HTTP polling mode, browsers will get updates on next poll
614
615
  end
615
616
 
616
- # Write PID file
617
+ # Write PID file. The version/code stamp records which StreamWeaver
618
+ # code this process loaded, so a later CLI invocation can tell that the
619
+ # bridge it is about to talk to predates an install (disc-171).
617
620
  def self.write_pid_file
618
621
  FileUtils.mkdir_p(File.dirname(pid_file_path))
619
- File.write(pid_file_path, "pid=#{Process.pid}\nport=#{@port || DEFAULT_PORT}\n")
622
+ File.write(pid_file_path,
623
+ "pid=#{Process.pid}\nport=#{@port || DEFAULT_PORT}\n#{CodeStamp.pid_file_stanza}")
620
624
  end
621
625
 
622
626
  # Cleanup on shutdown
@@ -4,6 +4,8 @@ require 'socket'
4
4
  require 'json'
5
5
  require 'fileutils'
6
6
  require_relative 'protocol'
7
+ require_relative 'code_stamp'
8
+ require_relative 'staleness_guard'
7
9
 
8
10
  module StreamWeaver
9
11
  module Canvas
@@ -17,7 +19,37 @@ module StreamWeaver
17
19
  PID_FILE_PATH = File.expand_path('~/.streamweaver/canvas.pid')
18
20
  DEFAULT_TIMEOUT = 300 # 5 minutes
19
21
 
22
+ # A bridge spawned detached (e.g. the staleness heal's restart) inherits
23
+ # whatever LANG/LC_ALL its parent process happened to have. When those
24
+ # are missing -- true for some launchers -- Ruby's Encoding.default_external
25
+ # falls back to US-ASCII, and any push containing multibyte characters
26
+ # blows up render_canvas_page with Encoding::CompatibilityError, 500ing
27
+ # every request for that session. This preamble is the entry-level fix:
28
+ # force UTF-8 before the bridge does its first read, independent of the
29
+ # env it was spawned with. Shared with the university listener's spawn
30
+ # (also detached, also multibyte-capable), so it lives here once.
31
+ ENCODING_PREAMBLE = <<~RUBY
32
+ Encoding.default_external = Encoding::UTF_8
33
+ Encoding.default_internal = nil
34
+ RUBY
35
+
20
36
  class << self
37
+ UTF8_LOCALE = 'en_US.UTF-8'
38
+
39
+ # Belt-and-suspenders alongside ENCODING_PREAMBLE: forces the child
40
+ # LANG/LC_ALL to a UTF-8 locale so shelled-out tools (e.g. gist
41
+ # publishing's `gh` calls) inherit sane behavior too. Only preserves
42
+ # the caller's own value when it's already UTF-8 -- a caller running
43
+ # under LANG=C is exactly the case this exists to fix, so passing
44
+ # that through unchanged would defeat the point.
45
+ def utf8_locale_env
46
+ %w[LANG LC_ALL].to_h { |key| [key, utf8_locale?(ENV[key]) ? ENV[key] : UTF8_LOCALE] }
47
+ end
48
+
49
+ def utf8_locale?(locale)
50
+ locale.to_s.match?(/utf-?8\z/i)
51
+ end
52
+
21
53
  # STREAMWEAVER_CANVAS_SOCKET / _PID redirect the whole bridge --
22
54
  # socket, pid file, and (because the bridge is spawned with the
23
55
  # environment it inherits) the server half too. This exists so a
@@ -62,6 +94,7 @@ module StreamWeaver
62
94
  # @param timeout [Integer] Timeout in seconds
63
95
  # @return [Hash, nil] Response message or nil
64
96
  def send_message(message, timeout: 5)
97
+ StalenessGuard.ensure_current!
65
98
  raise NotRunningError, "Canvas bridge is not running" unless bridge_running?
66
99
 
67
100
  socket = UNIXSocket.new(socket_path)
@@ -93,6 +126,7 @@ module StreamWeaver
93
126
  # @param timeout [Integer] Timeout in seconds
94
127
  # @return [Hash, nil] Event message or nil
95
128
  def send_and_wait(message, event_type:, timeout: DEFAULT_TIMEOUT)
129
+ StalenessGuard.ensure_current!
96
130
  raise NotRunningError, "Canvas bridge is not running" unless bridge_running?
97
131
 
98
132
  socket = UNIXSocket.new(socket_path)
@@ -138,6 +172,7 @@ module StreamWeaver
138
172
  # @param name [String, nil] session to filter on; nil yields all
139
173
  # @yield [Hash] each event message
140
174
  def each_event(name = nil)
175
+ StalenessGuard.ensure_current!
141
176
  raise NotRunningError, "Canvas bridge is not running" unless bridge_running?
142
177
 
143
178
  socket = UNIXSocket.new(socket_path)
@@ -170,6 +205,8 @@ module StreamWeaver
170
205
  # Start the bridge process if not running
171
206
  # @return [Hash] { pid: Integer, port: Integer }
172
207
  def ensure_bridge_running
208
+ StalenessGuard.ensure_current!
209
+
173
210
  if bridge_running?
174
211
  info = read_bridge_info
175
212
  # Verify HTTP server is actually responding
@@ -206,8 +243,11 @@ module StreamWeaver
206
243
  # Get the lib path
207
244
  lib_path = File.expand_path('../..', __dir__)
208
245
 
209
- # Create startup script
246
+ # Create startup script. ENCODING_PREAMBLE goes first, before the
247
+ # requires, so nothing the bridge loads or renders can run under
248
+ # the wrong default_external.
210
249
  script = <<~RUBY
250
+ #{ENCODING_PREAMBLE}
211
251
  $LOAD_PATH.unshift('#{lib_path}')
212
252
  require 'stream_weaver'
213
253
  require 'stream_weaver/canvas/bridge_server'
@@ -221,9 +261,11 @@ module StreamWeaver
221
261
  # Log file
222
262
  log_file = File.join(File.dirname(pid_file_path), 'canvas.log')
223
263
 
224
- # Spawn the bridge process
225
- pid = spawn(
226
- RbConfig.ruby, script_file,
264
+ # Spawn the bridge process. -E fixes the interpreter's own default
265
+ # encodings before ENCODING_PREAMBLE even runs; utf8_locale_env
266
+ # fixes the locale for anything the bridge shells out to.
267
+ pid = Process.spawn(
268
+ *bridge_spawn_command(script_file),
227
269
  out: [log_file, 'a'],
228
270
  err: [log_file, 'a'],
229
271
  pgroup: true
@@ -253,8 +295,16 @@ module StreamWeaver
253
295
  read_bridge_info || { pid: pid, port: BridgeServer::DEFAULT_PORT }
254
296
  end
255
297
 
298
+ # The argv (env hash + command) start_bridge hands to Process.spawn.
299
+ # Broken out so a spec can assert on it directly instead of stubbing
300
+ # Process.spawn and waiting out the readiness poll above.
301
+ def bridge_spawn_command(script_file)
302
+ [utf8_locale_env, RbConfig.ruby, '-E', 'UTF-8', script_file]
303
+ end
304
+
256
305
  # Read bridge info from PID file
257
- # @return [Hash] { pid: Integer, port: Integer }
306
+ # @return [Hash] { pid: Integer, port: Integer, version: String, code: String }
307
+ # version/code are nil for a bridge started before the stamp existed
258
308
  def read_bridge_info
259
309
  return nil unless File.exist?(pid_file_path)
260
310
 
@@ -262,7 +312,7 @@ module StreamWeaver
262
312
  pid = content[/pid=(\d+)/, 1]&.to_i
263
313
  port = content[/port=(\d+)/, 1]&.to_i || Bridge::DEFAULT_PORT
264
314
 
265
- { pid: pid, port: port }
315
+ { pid: pid, port: port }.merge(CodeStamp.parse(content))
266
316
  end
267
317
 
268
318
  # Stop the bridge process
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../version'
4
+
5
+ module StreamWeaver
6
+ module Canvas
7
+ # Identity of the StreamWeaver code a process has loaded: stamped into the
8
+ # bridge's pid file at boot, re-computed by every CLI invocation that talks
9
+ # to the bridge, and compared by StalenessGuard.ensure_current!.
10
+ #
11
+ # A canvas bridge is long-lived and keeps its classes in memory, so a
12
+ # `rake install` or `gem update` underneath it leaves it serving code the
13
+ # caller no longer has -- pushed canvas source calls a method the running
14
+ # bridge has never heard of and NameErrors (disc-171, hit three times
15
+ # live). Version alone does not catch it: the trap fires hardest on a
16
+ # same-version reinstall, which is why the mtime of the loaded
17
+ # stream_weaver.rb is half the stamp.
18
+ #
19
+ # It deliberately fingerprints that one file rather than walking lib/: any
20
+ # install rewrites stream_weaver.rb along with everything else, so every
21
+ # install is caught, while editing a file deeper in lib/ in a dev checkout
22
+ # is not and still wants an explicit `canvas-restart`.
23
+ module CodeStamp
24
+ module_function
25
+
26
+ def version
27
+ StreamWeaver::VERSION
28
+ end
29
+
30
+ # The stream_weaver.rb above this file -- resolved from __dir__ rather
31
+ # than $LOADED_FEATURES so a process that required only the canvas
32
+ # subtree still fingerprints the library it is actually running.
33
+ def library_path
34
+ File.expand_path('../../stream_weaver.rb', __dir__)
35
+ end
36
+
37
+ def fingerprint
38
+ fingerprint_for(library_path)
39
+ end
40
+
41
+ def fingerprint_for(path)
42
+ return nil unless File.file?(path)
43
+
44
+ "#{path}:#{File.mtime(path).to_i}"
45
+ rescue SystemCallError
46
+ nil
47
+ end
48
+
49
+ # Appended to the bridge's pid file; the existing pid=/port= lines are
50
+ # parsed by regex, so extra lines are invisible to older readers.
51
+ def pid_file_stanza
52
+ "version=#{version}\ncode=#{fingerprint}\n"
53
+ end
54
+
55
+ def parse(content)
56
+ { version: content[/^version=(.*)$/, 1], code: content[/^code=(.*)$/, 1] }
57
+ end
58
+
59
+ # True when the stamp read from a running bridge's pid file does not
60
+ # describe the code THIS process loaded. No stamp at all means a bridge
61
+ # started before this existed -- precisely the upgrade case, so it counts
62
+ # as stale. Unknowable on our own side (no library file to stat) never
63
+ # counts: a false positive restarts a developer's live bridge mid-session,
64
+ # which is worse than the staleness it would be curing.
65
+ def stale?(stamped)
66
+ mine = fingerprint
67
+ return false if mine.nil?
68
+
69
+ stamped ||= {}
70
+ stamped[:version] != version || stamped[:code] != mine
71
+ end
72
+ end
73
+ end
74
+ end
@@ -200,7 +200,11 @@ module StreamWeaver
200
200
  end
201
201
  end
202
202
 
203
- MERMAID_ZOOM_JS = File.read(File.expand_path('../assets/js/sw-mermaid-zoom.js', __dir__))
203
+ # Explicit encoding rather than relying on default_external: this loads
204
+ # at class-load time in whatever process requires reader.rb, not just
205
+ # a bridge booted via Client.start_bridge's UTF-8 fix, and the file
206
+ # itself is always UTF-8 regardless of the host process's locale.
207
+ MERMAID_ZOOM_JS = File.read(File.expand_path('../assets/js/sw-mermaid-zoom.js', __dir__), encoding: 'UTF-8')
204
208
  private_constant :MERMAID_ZOOM_JS
205
209
 
206
210
  configure do
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'stringio'
5
+ require_relative 'code_stamp'
6
+
7
+ module StreamWeaver
8
+ module Canvas
9
+ # Detects a canvas bridge left running on code the caller no longer has,
10
+ # and restarts it in place (disc-171).
11
+ #
12
+ # A bridge outlives the shells that talk to it and holds its classes in
13
+ # memory, so `rake install` or `gem update` underneath one leaves it
14
+ # serving the old code: pushed canvas source calls a brand-new method and
15
+ # the bridge dies with a bare NameError, with nothing on screen saying
16
+ # why. Client calls ensure_current! before every socket conversation, so
17
+ # every command that reaches the bridge is covered by one check rather
18
+ # than N call sites.
19
+ #
20
+ # The whole design leans away from acting: a spurious restart takes down
21
+ # every session the developer has open in a browser, which is worse than
22
+ # the staleness it would be curing. Hence the once-per-process latch, the
23
+ # cross-process cooldown, and CodeStamp.stale?'s refusal to guess.
24
+ module StalenessGuard
25
+ # Long enough that a checkout/gem pair alternating on one bridge stops
26
+ # after the first restart instead of thrashing on every command; short
27
+ # enough that a genuine second upgrade in the same session still heals.
28
+ COOLDOWN_SECONDS = 60
29
+
30
+ module_function
31
+
32
+ def pending?
33
+ !@checked
34
+ end
35
+
36
+ # For canvas-stop and canvas-restart, which are the manual spelling of
37
+ # this same cure -- healing inside them is a restart within a restart.
38
+ def disable!
39
+ @checked = true
40
+ end
41
+
42
+ # Specs only: the latch is process-global and RSpec is one process.
43
+ def reset!
44
+ @checked = false
45
+ end
46
+
47
+ def ensure_current!
48
+ return unless pending?
49
+
50
+ @checked = true
51
+
52
+ return unless Client.bridge_running?
53
+ return unless CodeStamp.stale?(Client.read_bridge_info)
54
+
55
+ if auto_restart_disabled?
56
+ warn_only("canvas bridge is running older code")
57
+ elsif recently_healed?
58
+ # Two installs cannot land inside a minute; a second mismatch this
59
+ # soon means two callers disagree about which code is current
60
+ # (a dev checkout and the installed gem, say), and restarting again
61
+ # would just hand the bridge back and forth forever. Naming
62
+ # canvas-restart here would be advice to resume the fight, so this
63
+ # branch points at the off switch instead.
64
+ $stderr.puts "StreamWeaver: canvas bridge still looks like older code, but was just restarted — two installs disagree about which code is current; set SW_NO_AUTO_RESTART=1 to stop the restarts."
65
+ else
66
+ heal!
67
+ end
68
+ end
69
+
70
+ def auto_restart_disabled?
71
+ !ENV['SW_NO_AUTO_RESTART'].to_s.empty?
72
+ end
73
+
74
+ def warn_only(reason)
75
+ $stderr.puts "StreamWeaver: #{reason} — run `streamweaver canvas-restart` to pick it up."
76
+ end
77
+
78
+ # Announced on stderr, not stdout: canvas-wait and friends emit JSON for
79
+ # a script to parse, and a line of narration in front of it is a parse
80
+ # error on the other end.
81
+ #
82
+ # The restart lives in the CLI, which owns snapshot/restore; required
83
+ # here rather than at the top of the file because the CLI requires the
84
+ # canvas client back. Its narration is swallowed so the heal costs the
85
+ # caller one line; anything it could not preserve, or could not do at
86
+ # all, still speaks -- with the manual command.
87
+ def heal!
88
+ require_relative '../cli'
89
+
90
+ $stderr.puts "StreamWeaver: canvas bridge was running older code — restarting it (sessions preserved)…"
91
+ record_heal!
92
+
93
+ result = silently { StreamWeaver::CLI.restart_bridge_preserving_sessions }
94
+ report(result)
95
+ result
96
+ rescue StandardError => e
97
+ # A cure that cannot finish must not take down the command it was
98
+ # trying to help: the caller only wanted to push a canvas.
99
+ $stderr.puts "StreamWeaver: automatic bridge restart failed (#{e.class}: #{e.message}) — run `streamweaver canvas-restart`."
100
+ { ok: false, dir: nil, unconfirmed: [] }
101
+ end
102
+
103
+ # Everything that qualifies the "sessions preserved" the announcement
104
+ # already promised: a session the snapshot never read, a bridge that came
105
+ # back on a different port (every open tab is now pointing at a dead one
106
+ # -- canvas-restart warns about this in stars, and an automatic restart
107
+ # owes at least as much), and a restore that did not finish.
108
+ def report(result)
109
+ unconfirmed = result[:unconfirmed] || []
110
+ unless unconfirmed.empty?
111
+ $stderr.puts "StreamWeaver: could not capture #{unconfirmed.join(', ')} before the restart — snapshot at #{result[:dir]}"
112
+ end
113
+
114
+ if result[:old_port] && result[:port] && result[:old_port] != result[:port]
115
+ $stderr.puts "StreamWeaver: bridge moved from port #{result[:old_port]} to #{result[:port]} — reload any canvas tab you have open."
116
+ end
117
+
118
+ return if result[:ok]
119
+
120
+ $stderr.puts "StreamWeaver: automatic bridge restart did not complete — snapshot at #{result[:dir]}; run `streamweaver canvas-restart` if sessions look wrong."
121
+ end
122
+
123
+ def silently
124
+ original = $stdout
125
+ $stdout = StringIO.new
126
+ yield
127
+ ensure
128
+ $stdout = original
129
+ end
130
+
131
+ # Written BEFORE the restart, so a second process arriving mid-heal
132
+ # backs off rather than snapshotting a half-restored bridge.
133
+ def heal_marker_path
134
+ File.join(File.dirname(Client.pid_file_path), 'canvas.heal')
135
+ end
136
+
137
+ def recently_healed?
138
+ Time.now - File.mtime(heal_marker_path) < COOLDOWN_SECONDS
139
+ rescue SystemCallError
140
+ false
141
+ end
142
+
143
+ # Silent on failure by design: an unwritable marker only costs the next
144
+ # invocation its cooldown, and the heal itself is worth doing anyway --
145
+ # there is nothing here to tell the operator to act on.
146
+ def record_heal!
147
+ FileUtils.mkdir_p(File.dirname(heal_marker_path))
148
+ File.write(heal_marker_path, Time.now.to_i.to_s)
149
+ rescue SystemCallError
150
+ nil
151
+ end
152
+ end
153
+ end
154
+ end