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 +4 -4
- data/CHANGELOG.md +10 -1
- data/README.md +9 -0
- data/docs/canvas-panel-workflow.md +8 -0
- data/docs/university/send-to-coworker.md +28 -3
- data/lib/stream_weaver/canvas/bridge_server.rb +7 -3
- data/lib/stream_weaver/canvas/client.rb +56 -6
- data/lib/stream_weaver/canvas/code_stamp.rb +74 -0
- data/lib/stream_weaver/canvas/reader.rb +5 -1
- data/lib/stream_weaver/canvas/staleness_guard.rb +154 -0
- data/lib/stream_weaver/cli.rb +448 -5
- data/lib/stream_weaver/iterm.rb +90 -9
- data/lib/stream_weaver/org/writer.rb +1 -0
- data/lib/stream_weaver/university/artifacts.rb +250 -0
- data/lib/stream_weaver/university/canvas.rb +88 -0
- data/lib/stream_weaver/university/cleanup.rb +430 -0
- data/lib/stream_weaver/university/course.rb +7 -0
- data/lib/stream_weaver/university/listener.rb +77 -3
- data/lib/stream_weaver/university/scripts/growing_doc.rb +19 -0
- data/lib/stream_weaver/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71c5865a49a8746b8592689941d1b1bd4b0d813add49bb60ee8fab85182c1f64
|
|
4
|
+
data.tar.gz: 3c08461ec552647cb288bef820d35eaf05dd7cb79284710f9d830d9cbb25d493
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
|
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,
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|