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.
@@ -0,0 +1,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'stream_weaver/university/artifacts'
5
+ require 'stream_weaver/university/progress'
6
+ require 'stream_weaver/university/scripts/growing_doc_state'
7
+
8
+ module StreamWeaver
9
+ module University
10
+ # The ONE implementation of "delete something the course created".
11
+ # `streamweaver university-cleanup` and the completion recap's own
12
+ # delete buttons both come through here -- two surfaces, one deletion
13
+ # path, so a rule proven about one is true of the other.
14
+ #
15
+ # Every delete is allowlisted, and the allowlist is not a list this
16
+ # module keeps: it is whatever `Artifacts` currently records, plus two
17
+ # deterministic knowns this module owns outright (the course's own state
18
+ # files, by exact basename; the demo canvas sessions `university-reset`
19
+ # already closes by name). A ref that is in neither raises `Refused` and
20
+ # nothing happens -- a stale button, or a caller passing a path it made
21
+ # up, both land in the same place.
22
+ #
23
+ # Being precise about how far that goes, because safety code that
24
+ # over-promises is worse than safety code that doesn't: for `session`
25
+ # and `gist` there is a SECOND guard here (the demo-session allowlist,
26
+ # the gist-URL parse), so a hand-edited manifest cannot reach past them.
27
+ # For `doc` and `org` there is no second guard -- any path the manifest
28
+ # records is deleted. The strict door for those two is `Artifacts`
29
+ # itself, which stores them expanded and only ever records a path
30
+ # something actually produced, so keep it that way.
31
+ #
32
+ # `--scan` (`scan` / `adopt_scan!`) is the one door that WIDENS that
33
+ # allowlist without a human having named the artifact, for the course
34
+ # runs that predate the manifest. It is bounded two ways. It claims only
35
+ # deterministic course-owned names (COURSE_DOC_BASENAME, the demo
36
+ # session allowlist), and it does not delete: it ADOPTS, so a scanned
37
+ # artifact becomes destroyable only by becoming an ordinary manifest
38
+ # entry, and everything proven about the path above holds for it
39
+ # unchanged. Two invariants go with it, both learned the hard way:
40
+ # discovery only ever READS (a "probe" that fetches `/canvas/:name`
41
+ # creates the session it claims to find), and `--dry-run` writes
42
+ # nothing at all -- not the manifest, not the bridge.
43
+ #
44
+ # Nothing here prompts. Confirmation belongs to the surface (the CLI's
45
+ # per-group y/N and per-gist y/N; the canvas's confirm re-push), because
46
+ # the two surfaces confirm in completely different ways and only one of
47
+ # them has a tty.
48
+ module Cleanup
49
+ # Raised when a ref is not something this course is allowed to delete.
50
+ # Deliberately an exception rather than a false return: a refusal is a
51
+ # bug or an attack, never a routine outcome to fall through.
52
+ Refused = Class.new(StandardError)
53
+
54
+ # What one delete attempt did. `ok` is false for a real failure (gh
55
+ # missing, gh errored); a file or session that was already gone is
56
+ # `ok` -- the end state the caller asked for is the end state it got.
57
+ Outcome = Struct.new(:ok, :message, keyword_init: true)
58
+
59
+ # State files the course itself writes, by exact basename. An
60
+ # allowlist, not a glob: this directory is under the user's home and a
61
+ # `Dir[dir/*]` sweep would delete whatever else ever lands there.
62
+ # `*_state.yml` is growing_doc's per-session sidecar, whose session
63
+ # half is itself allowlisted (GrowingDocState.path).
64
+ STATE_BASENAMES = %w[
65
+ progress.yml progress.yml.bak worker.json listener.pid listener.log
66
+ ].freeze
67
+
68
+ # Where those files live: whatever directory the progress ledger is
69
+ # in, so a redirected STREAMWEAVER_UNIVERSITY_PROGRESS redirects this
70
+ # too -- a spec (or a second isolated University) must never be able
71
+ # to reach the developer's real state dir.
72
+ def self.state_dir
73
+ File.dirname(Progress.path)
74
+ end
75
+
76
+ # The state files that actually exist right now, absolute paths. This
77
+ # is recomputed on every call and is the allowlist `delete_state_file!`
78
+ # checks against.
79
+ def self.state_files
80
+ dir = state_dir
81
+ return [] unless File.directory?(dir)
82
+
83
+ # The manifest's and the growing-doc sidecars' names come from the
84
+ # modules that actually write them, not from a literal here, so a
85
+ # renamed file can't leave cleanup deleting a name nothing uses.
86
+ # Filtered back to `dir` afterwards: those two honor env overrides
87
+ # of their own, and a partially-overridden environment (a spec that
88
+ # redirects the ledger but not the doc state) must not let this
89
+ # reach out of the directory it was scoped to.
90
+ names = STATE_BASENAMES + [File.basename(Artifacts.path)]
91
+ paths = names.map { |n| File.join(dir, n) } +
92
+ Artifacts.demo_session_names.map { |n| Scripts::GrowingDocState.path(n) }
93
+ paths.select { |p| File.dirname(p) == dir && File.file?(p) }.uniq
94
+ end
95
+
96
+ # The state files' own pseudo-type in `inventory`. Not one of
97
+ # Artifacts::TYPES -- these are never recorded, they are the
98
+ # deterministic known this module owns -- but they are a group the
99
+ # surfaces confirm exactly like the others, so they are keyed the
100
+ # same way.
101
+ STATE = 'state'
102
+
103
+ # The only names `--scan` will ever claim. `university-doc` is
104
+ # growing_doc's DEFAULT_DOC_NAME; `doc-demo-<stamp>` is what a save out
105
+ # of the step-4 demo session is named after its session. Anchored at
106
+ # both ends on purpose: the user's own `my-university-doc.rb` and a
107
+ # `university-doc.rb.bak` they made before editing are NOT the course's
108
+ # to delete, and a scan is the one door here with no human confirmation
109
+ # behind the naming.
110
+ COURSE_DOC_BASENAME = /\A(?:university-doc|doc-demo-[A-Za-z0-9._-]+)\.(rb|org)\z/
111
+
112
+ # Extension -> artifact type. Total by construction: the only source
113
+ # of a key here is COURSE_DOC_BASENAME's own capture group.
114
+ DOC_TYPE_BY_EXT = { 'rb' => 'doc', 'org' => 'org' }.freeze
115
+
116
+ # Where a course doc can have been saved: the canvas doc store for this
117
+ # checkout and the global fallback store. Both come from DocStore
118
+ # rather than from literals, so a redirected store is scanned and a
119
+ # directory the course never writes to is not.
120
+ def self.scan_roots
121
+ require 'stream_weaver/canvas/doc_store'
122
+ [::StreamWeaver::Canvas::DocStore.path,
123
+ ::StreamWeaver::Canvas::DocStore::DEFAULT_ROOT].compact.map { |r| File.expand_path(r) }.uniq
124
+ end
125
+
126
+ # Artifacts from a run that predates the manifest, found by
127
+ # deterministic course names ONLY. Finds; records nothing; deletes
128
+ # nothing -- `adopt_scan!` is the door that writes, and the ordinary
129
+ # manifest-allowlisted deletion path is still the only door that
130
+ # destroys.
131
+ #
132
+ # The traversal safety here is structural rather than checked: file
133
+ # candidates come from `Dir.children`, which yields bare basenames (no
134
+ # `.`, no `..`, and on this platform a basename cannot contain a
135
+ # separator), each matched whole against COURSE_DOC_BASENAME and then
136
+ # joined onto the root it came from. There is no glob, so there is no
137
+ # pattern for a crafted filename to be interpreted BY, and no recursion,
138
+ # so a nested checkout of someone else's docs is out of reach.
139
+ def self.scan
140
+ found = Artifacts::TYPES.to_h { |type| [type, []] }
141
+ .merge('gist' => scan_gists, 'session' => open_demo_sessions)
142
+
143
+ scan_roots.each do |root|
144
+ next unless File.directory?(root)
145
+
146
+ Dir.children(root).sort.each do |name|
147
+ next unless (ext = COURSE_DOC_BASENAME.match(name)&.[](1))
148
+
149
+ path = File.join(root, name)
150
+ found[DOC_TYPE_BY_EXT.fetch(ext)] << path if File.file?(path)
151
+ end
152
+ end
153
+ found
154
+ end
155
+
156
+ # Records everything `scan` found, returning only the entries that were
157
+ # new. Adoption is the whole mechanism: a scanned artifact becomes
158
+ # deletable by becoming a manifest entry like any other, so `--scan`
159
+ # widens what cleanup KNOWS about without widening what it is allowed
160
+ # to do.
161
+ def self.adopt_scan!
162
+ scan_unrecorded.flat_map do |type, refs|
163
+ refs.filter_map do |ref|
164
+ # Sessions go through record_session!, not record!, so the
165
+ # manifest's own demo-allowlist guard still gets its say on the
166
+ # way in. `open_demo_sessions` already intersects that list, so
167
+ # this is a second layer rather than the only one -- which is
168
+ # the point: the recording door is where the manifest gets to
169
+ # refuse, and no path should walk past it.
170
+ type == 'session' ? Artifacts.record_session!(ref) : Artifacts.record!(ref, type: type)
171
+ end
172
+ end
173
+ end
174
+
175
+ # What `scan` found that is not already in the manifest -- what a
176
+ # `--scan` run is actually offering to adopt, and what `--dry-run`
177
+ # reports. One definition, so the preview and the adoption cannot
178
+ # disagree about what is new.
179
+ def self.scan_unrecorded
180
+ recorded = Artifacts.all.map { |e| [e['type'], e['ref']] }
181
+ scan.each_with_object({}) do |(type, refs), out|
182
+ out[type] = refs.reject { |ref| recorded.include?([type, ref]) }
183
+ end
184
+ end
185
+
186
+ # Course gists, by the filename `gh` reports as a file-created gist's
187
+ # description -- the same names COURSE_DOC_BASENAME allows, so the
188
+ # user's own gists are never proposed. The id is re-checked through
189
+ # Artifacts so a URL that gets recorded is one Cleanup could later
190
+ # resolve.
191
+ #
192
+ # Deliberately misses one case, and it must stay missed: a gist made
193
+ # by the canvas's own Save-as-gist button is described by the doc's
194
+ # `#+TITLE:` (GistPublisher.description_for), not its filename, so no
195
+ # deterministic name identifies it. That fails SAFE -- an unfound gist
196
+ # is one the user still has -- and the fix is never to loosen this
197
+ # match, which is the only thing standing between a scan and someone's
198
+ # unrelated gists. Those get recorded the way step 5's already are:
199
+ # by `university-artifact add`, at the moment something creates them.
200
+ def self.scan_gists
201
+ gist_list_lines.filter_map do |line|
202
+ id, description = line.split("\t", 3)
203
+ next unless description.to_s.strip.match?(COURSE_DOC_BASENAME)
204
+
205
+ url = "https://gist.github.com/#{id.to_s.strip}"
206
+ url if Artifacts.gist_id(url)
207
+ end
208
+ end
209
+
210
+ # Bounded, because this runs inside a command the user is sitting in
211
+ # front of waiting to answer a prompt: a stalled network must not hang
212
+ # cleanup, it must just mean "no gists found this run".
213
+ GIST_LIST_TIMEOUT = 10
214
+
215
+ def self.gist_list_lines
216
+ return [] unless gh_available?
217
+
218
+ require 'open3'
219
+ require 'timeout'
220
+ out, status = Timeout.timeout(GIST_LIST_TIMEOUT) do
221
+ Open3.capture2('gh', 'gist', 'list', '--limit', '100')
222
+ end
223
+ status.success? ? out.lines.map(&:chomp).reject(&:empty?) : []
224
+ rescue SystemCallError, IOError, Timeout::Error
225
+ []
226
+ end
227
+
228
+ # The course demo sessions the bridge is currently serving.
229
+ #
230
+ # Read from the bridge's session LIST, never by fetching
231
+ # `/canvas/:name`: that route is `create_session` (bridge_server.rb),
232
+ # so "probing" a session that way CREATES it -- it can never answer
233
+ # false, and it would have scan conjuring empty sessions onto a live
234
+ # bridge, under `--dry-run` included. Discovery has to be a read.
235
+ #
236
+ # Intersected with the allowlist, receiver-first so the allowlist and
237
+ # not the bridge decides both what may be offered and in what order:
238
+ # whatever else the bridge is serving -- the controller canvas, the
239
+ # user's own work -- cannot come through here.
240
+ def self.open_demo_sessions
241
+ Artifacts.demo_session_names & bridge_session_names
242
+ end
243
+
244
+ # Every session name the bridge currently holds, or [] if there is no
245
+ # bridge to ask. A pure read: no session is created by asking.
246
+ def self.bridge_session_names
247
+ require 'stream_weaver/canvas/client'
248
+ require 'net/http'
249
+ require 'json'
250
+ info = ::StreamWeaver::Canvas::Client.read_bridge_info or return []
251
+
252
+ uri = URI("http://127.0.0.1:#{info[:port]}/sessions")
253
+ body = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 2) do |http|
254
+ http.get(uri.path).body
255
+ end
256
+ JSON.parse(body).filter_map { |session| session['name'] }
257
+ rescue StandardError
258
+ []
259
+ end
260
+
261
+ # Everything cleanup can offer to remove, keyed by artifact type (plus
262
+ # STATE), every group always present so an empty one is `[]` rather
263
+ # than missing. Keyed by TYPES rather than by names of its own so a
264
+ # caller cannot pair the wrong group with the wrong type -- an earlier
265
+ # shape had `:docs`/`:orgs` symbols that the CLI hand-paired with
266
+ # `'doc'`/`'org'`, and nothing but a later refusal would have caught a
267
+ # swap. File groups carry size and whether the file is still there.
268
+ # Reading this deletes nothing.
269
+ def self.inventory
270
+ grouped = Artifacts.grouped
271
+ inv = Artifacts::TYPES.each_with_object({}) do |type, out|
272
+ entries = grouped[type].to_a
273
+ out[type] = if %w[doc org].include?(type)
274
+ file_entries(entries)
275
+ else
276
+ entries.map { |e| { ref: e['ref'], step: e['step'] } }
277
+ end
278
+ end
279
+ inv[STATE] = state_files.map { |p| { ref: p, exists: true, size: File.size(p) } }
280
+ inv
281
+ end
282
+
283
+ # True when there is nothing left for cleanup to do.
284
+ def self.empty?(inv = inventory)
285
+ inv.values.all?(&:empty?)
286
+ end
287
+
288
+ # Deletes one manifest entry, by type and ref. The refusal check reads
289
+ # the manifest FRESH rather than trusting whatever the caller was
290
+ # holding: a button rendered against an older push, or a ref handed in
291
+ # by a caller that never looked, both get checked against what is
292
+ # actually recorded now.
293
+ def self.delete_entry!(type, ref)
294
+ type = type.to_s
295
+ ref = ref.to_s
296
+ unless Artifacts.all.any? { |e| e['type'] == type && e['ref'] == ref }
297
+ raise Refused, "refusing to delete #{ref.inspect}: not in the University artifact manifest"
298
+ end
299
+
300
+ outcome =
301
+ case type
302
+ when 'doc', 'org' then delete_file!(ref)
303
+ when 'gist' then delete_gist!(ref)
304
+ when 'session' then close_session!(ref)
305
+ else raise Refused, "refusing to delete #{ref.inspect}: unknown artifact type #{type.inspect}"
306
+ end
307
+
308
+ Artifacts.forget!(type, ref) if outcome.ok
309
+ outcome
310
+ end
311
+
312
+ # Deletes a list of refs of one type, reporting a refusal as its own
313
+ # not-ok outcome rather than raising through the caller. This is where
314
+ # BOTH surfaces come in -- the CLI's group confirm and the canvas's
315
+ # confirmed pending delete -- so "what a refusal does to the rest of
316
+ # the batch" has one answer: the refused ref is reported, the others
317
+ # still go. `delete_entry!` keeps raising, because a single delete
318
+ # with no batch around it has nowhere to put a report.
319
+ def self.delete_refs!(type, refs)
320
+ Array(refs).map do |ref|
321
+ begin
322
+ delete_entry!(type, ref)
323
+ rescue Refused => e
324
+ Outcome.new(ok: false, message: e.message)
325
+ end
326
+ end
327
+ end
328
+
329
+ # Every entry of one type -- what a CLI group confirm acts on.
330
+ def self.delete_type!(type)
331
+ delete_refs!(type, Artifacts.grouped[type.to_s].to_a.map { |e| e['ref'] })
332
+ end
333
+
334
+ # Deletes the course's own state files. Never touches the manifest's
335
+ # entries -- those are separate groups with their own confirmations --
336
+ # though artifacts.yml itself is one of these files, so a run that
337
+ # confirms this group last removes the record along with the rest.
338
+ def self.delete_state_files!
339
+ state_files.map { |p| delete_state_file!(p) }
340
+ end
341
+
342
+ # A single state file. Same shape of guard as delete_entry!: the
343
+ # allowlist is recomputed here, so a path that is not one of the
344
+ # course's own state files right now is refused no matter who passed
345
+ # it in.
346
+ def self.delete_state_file!(file_path)
347
+ unless state_files.include?(file_path.to_s)
348
+ raise Refused, "refusing to delete #{file_path.inspect}: not a University state file"
349
+ end
350
+
351
+ FileUtils.rm_f(file_path)
352
+ Outcome.new(ok: true, message: "removed #{file_path}")
353
+ end
354
+
355
+ # Whether gist deletion is even possible on this machine. When it
356
+ # isn't, `delete_gist!` reports the URL and leaves the manifest entry
357
+ # alone rather than pretending the gist is gone.
358
+ def self.gh_available?
359
+ ENV['PATH'].to_s.split(File::PATH_SEPARATOR).any? do |dir|
360
+ gh = File.join(dir, 'gh')
361
+ File.file?(gh) && File.executable?(gh)
362
+ end
363
+ end
364
+
365
+ def self.delete_file!(file_path)
366
+ return Outcome.new(ok: true, message: "already gone: #{file_path}") unless File.exist?(file_path)
367
+
368
+ FileUtils.rm_f(file_path)
369
+ Outcome.new(ok: true, message: "deleted #{file_path}")
370
+ end
371
+ private_class_method :delete_file!
372
+
373
+ # `gh gist delete <id> --yes`. The id is the last path segment of the
374
+ # URL, which is what `gh` wants; anything that doesn't look like a
375
+ # gist URL is refused rather than shelled out with.
376
+ def self.delete_gist!(url)
377
+ # Artifacts.gist_id, not a second pattern of this module's own: the
378
+ # door that records a gist and the door that deletes one have to
379
+ # agree on what a gist URL is, or a ref loose enough to record is
380
+ # too vague to ever delete.
381
+ id = Artifacts.gist_id(url) or
382
+ raise Refused, "refusing to delete #{url.inspect}: not a gist URL"
383
+
384
+ unless gh_available?
385
+ return Outcome.new(ok: false, message: "gh is not installed -- delete this one yourself: #{url}")
386
+ end
387
+
388
+ if system('gh', 'gist', 'delete', id, '--yes', out: File::NULL, err: File::NULL)
389
+ Outcome.new(ok: true, message: "deleted gist #{url}")
390
+ else
391
+ # A gist deleted on github.com (or by GitHub's own abuse
392
+ # detection) fails here exactly like a permissions problem does,
393
+ # and neither is worth failing the whole run over.
394
+ Outcome.new(ok: false, message: "gh could not delete #{url} (already gone, or not yours)")
395
+ end
396
+ end
397
+ private_class_method :delete_gist!
398
+
399
+ # Closing a demo session IS the delete for that type. Guarded a second
400
+ # time against the same allowlist the manifest was guarded by on the
401
+ # way in -- a hand-edited artifacts.yml must not be able to name the
402
+ # controller session, or one of the user's own.
403
+ def self.close_session!(name)
404
+ unless Artifacts.demo_session_names.include?(name.to_s)
405
+ raise Refused, "refusing to close #{name.inspect}: not a course demo session"
406
+ end
407
+
408
+ require 'stream_weaver/canvas/client'
409
+ ::StreamWeaver::Canvas::Client.send_message(
410
+ ::StreamWeaver::Canvas::Protocol::Messages.close(name)
411
+ )
412
+ Outcome.new(ok: true, message: "closed canvas session '#{name}'")
413
+ rescue ::StreamWeaver::Canvas::Client::NotRunningError,
414
+ ::StreamWeaver::Canvas::Client::ConnectionError
415
+ # No bridge means no session to close: the end state is the one the
416
+ # caller wanted, so the entry goes away with it.
417
+ Outcome.new(ok: true, message: "canvas bridge not running -- '#{name}' is already closed")
418
+ end
419
+ private_class_method :close_session!
420
+
421
+ def self.file_entries(entries)
422
+ entries.to_a.map do |e|
423
+ exists = File.exist?(e['ref'])
424
+ { ref: e['ref'], step: e['step'], exists: exists, size: exists ? File.size(e['ref']) : nil }
425
+ end
426
+ end
427
+ private_class_method :file_entries
428
+ end
429
+ end
430
+ end
@@ -631,6 +631,13 @@ module StreamWeaver
631
631
  never leave "pushing to gist..." sitting there past the point where it stopped
632
632
  being true.
633
633
 
634
+ The moment you have that URL, record it -- and the `.org` file beside it --
635
+ so the course knows what it left behind: `streamweaver university-artifact
636
+ add <gist URL> --step 5`, then the same for the `.org` path. Everything else
637
+ records itself (the saved doc, the demo canvas sessions); a gist exists only
638
+ in your own output, and `streamweaver university-cleanup` can offer to
639
+ delete only what has been recorded.
640
+
634
641
  The last beat is NOT yours. Stop and hand it to me, and say why in plain words:
635
642
  no automated or headless browser can install a Chrome Web Store extension or
636
643
  see my logged-in Chrome, so this part is structurally out of your reach no
@@ -4,6 +4,8 @@ require 'fileutils'
4
4
  require 'rbconfig'
5
5
  require 'stream_weaver/canvas/client'
6
6
  require 'stream_weaver/canvas/scroll_top_hint'
7
+ require 'stream_weaver/university/artifacts'
8
+ require 'stream_weaver/university/cleanup'
7
9
  require 'stream_weaver/university/course'
8
10
  require 'stream_weaver/university/progress'
9
11
  require 'stream_weaver/university/runner'
@@ -101,6 +103,19 @@ module StreamWeaver
101
103
  progress.expand_step!(step)
102
104
  end
103
105
  step
106
+ when /cleanup-ask-(docs|orgs|sessions|gist-\d+)\z/
107
+ # Asks only. Nothing is deleted until a second, separate click on
108
+ # the confirmation this writes -- which is why the delete surface
109
+ # on the canvas is two clicks and not one, and why this branch
110
+ # never reaches Cleanup at all.
111
+ cleanup_ask!(Regexp.last_match(1))
112
+ true
113
+ when /cleanup-confirm\z/
114
+ cleanup_confirm!
115
+ true
116
+ when /cleanup-keep\z/
117
+ Artifacts.clear_pending!
118
+ true
104
119
  when /reset-course\z/
105
120
  # Same effect as `streamweaver university-reset -y`: back up +
106
121
  # clear the ledger, close the demo sessions the course itself
@@ -112,6 +127,45 @@ module StreamWeaver
112
127
  end
113
128
  end
114
129
 
130
+ # What each of the recap's delete buttons is asking about. Groups are
131
+ # named by artifact type; a gist is asked about ONE at a time, by
132
+ # index into the manifest's own gist list, because a gist is the one
133
+ # artifact here that leaves the machine and cannot be un-deleted.
134
+ CLEANUP_GROUPS = {
135
+ 'docs' => { kind: 'doc', label: 'the saved docs' },
136
+ 'orgs' => { kind: 'org', label: 'the exported .org files' },
137
+ 'sessions' => { kind: 'session', label: 'the course canvas sessions' }
138
+ }.freeze
139
+
140
+ # Turns a delete button into the pending confirmation the next re-push
141
+ # renders. Resolves the target to REFS here, not at confirm time: the
142
+ # user is about to be shown exactly what will go, and what goes has to
143
+ # be that same list, not whatever an index points at a click later.
144
+ def self.cleanup_ask!(target)
145
+ if (group = CLEANUP_GROUPS[target])
146
+ refs = Artifacts.grouped[group[:kind]].to_a.map { |e| e['ref'] }
147
+ return nil if refs.empty?
148
+
149
+ Artifacts.request_delete!(label: group[:label], kind: group[:kind], refs: refs)
150
+ elsif (index = target[/\Agist-(\d+)\z/, 1])
151
+ entry = Artifacts.grouped['gist'].to_a[index.to_i] or return nil
152
+ Artifacts.request_delete!(label: entry['ref'], kind: 'gist', refs: [entry['ref']])
153
+ end
154
+ end
155
+
156
+ # Performs the pending delete through Cleanup's own batch entry point
157
+ # -- the same module, allowlist AND refusal behavior
158
+ # `streamweaver university-cleanup` gets, so nothing here
159
+ # re-implements what may be deleted or what happens when something
160
+ # may not be. The messages go in the ledger for the next re-push to
161
+ # render: this runs inside a background listener nobody is watching,
162
+ # so the recap is the only place an answer can appear.
163
+ def self.cleanup_confirm!
164
+ pending = Artifacts.pending_delete or return nil
165
+ outcomes = Cleanup.delete_refs!(pending['kind'], pending['refs'])
166
+ Artifacts.record_cleanup!(outcomes.map(&:message))
167
+ end
168
+
115
169
  # The ledger write a "step is done" action makes: stamps `last_done`
116
170
  # (rendered as an inline confirmation band) and collapses whichever
117
171
  # row was expanded, so a stale expansion never sits open under the
@@ -181,7 +235,13 @@ module StreamWeaver
181
235
  # one just gets "Session not found" back, which is not a reason to
182
236
  # skip the rest. Shared by the canvas's own Reset button
183
237
  # (`handle_token`, above) and `streamweaver university-reset`.
184
- def self.close_demo_sessions!
238
+ #
239
+ # `clear_state:` is what separates a reset from a stop.
240
+ # `university-stop` closes the same sessions but must NOT forget
241
+ # growing_doc's picks: stopping the course is "put it down", and the
242
+ # doc the user comes back to has to be the doc they left. Reset is the
243
+ # one that means "start over", so it keeps the default.
244
+ def self.close_demo_sessions!(clear_state: true)
185
245
  DEMO_SESSION_NAMES.each do |name|
186
246
  begin
187
247
  ::StreamWeaver::Canvas::Client.send_message(
@@ -194,7 +254,7 @@ module StreamWeaver
194
254
  # otherwise a reset course still remembers last run's picks the
195
255
  # next time its script runs. A no-op (FileUtils.rm_f) for every
196
256
  # name but doc-demo's, which never had state to begin with.
197
- ::StreamWeaver::University::Scripts::GrowingDocState.clear(name)
257
+ ::StreamWeaver::University::Scripts::GrowingDocState.clear(name) if clear_state
198
258
  end
199
259
  end
200
260
 
@@ -220,6 +280,11 @@ module StreamWeaver
220
280
  ::StreamWeaver::Canvas::Client.send_message(
221
281
  ::StreamWeaver::Canvas::Protocol::Messages.create(demo.name, theme: demo.theme)
222
282
  )
283
+ # This create is the first moment the course owns that session, so
284
+ # it is where the artifact manifest learns about it -- before the
285
+ # worker's own push, and regardless of whether the worker ever
286
+ # reaches one.
287
+ Artifacts.record_session!(demo.name, step: step_number)
223
288
  ::StreamWeaver::Canvas::Client.send_message(
224
289
  ::StreamWeaver::Canvas::Protocol::Messages.push(demo.name, warm_up_dsl(step), source_dir: nil)
225
290
  )
@@ -403,11 +468,20 @@ module StreamWeaver
403
468
  FileUtils.mkdir_p(File.dirname(log_path))
404
469
  FileUtils.mkdir_p(File.dirname(pid_path))
405
470
 
471
+ # Detached the same way the canvas bridge is (client.rb's
472
+ # start_bridge), and vulnerable to the same encoding hole: spawned
473
+ # from a parent whose env lacks LANG/LC_ALL, Ruby's default_external
474
+ # falls back to US-ASCII and a multibyte canvas doc blows up the
475
+ # first read. Same two-part fix -- ::Canvas::Client::ENCODING_PREAMBLE
476
+ # ahead of the entry point, -E for the interpreter itself, and a
477
+ # sane locale for anything this process shells out to.
406
478
  pid = Process.spawn(
479
+ ::StreamWeaver::Canvas::Client.utf8_locale_env,
407
480
  RbConfig.ruby,
481
+ '-E', 'UTF-8',
408
482
  "-I#{File.expand_path('../..', __dir__)}",
409
483
  '-r', 'stream_weaver/university/listener',
410
- '-e', "StreamWeaver::University::Listener.run!(session_name: #{session_name.inspect})",
484
+ '-e', "#{::StreamWeaver::Canvas::Client::ENCODING_PREAMBLE}\nStreamWeaver::University::Listener.run!(session_name: #{session_name.inspect})",
411
485
  out: [log_path, 'a'], err: %i[child out], pgroup: true
412
486
  )
413
487
  Process.detach(pid)
@@ -66,6 +66,7 @@
66
66
  # clobbered it back out.
67
67
 
68
68
  require 'stream_weaver/canvas/client'
69
+ require 'stream_weaver/university/artifacts'
69
70
  require_relative 'growing_doc_state'
70
71
 
71
72
  module StreamWeaver
@@ -79,6 +80,11 @@ module StreamWeaver
79
80
  # dependency.
80
81
  DEFAULT_DOC_NAME = 'university-doc'
81
82
 
83
+ # Which course step this script IS, for the artifact manifest's
84
+ # per-step grouping. Named here rather than passed in: the script is
85
+ # step 4's demo and nothing else runs it.
86
+ STEP = 4
87
+
82
88
  OPENING = <<~RUBY
83
89
  doc_header(
84
90
  eyebrow: "StreamWeaver University · Step 4",
@@ -491,6 +497,14 @@ module StreamWeaver
491
497
  ::StreamWeaver::Canvas::Client.send_message(
492
498
  ::StreamWeaver::Canvas::Protocol::Messages.create(session_name, layout: :fluid, theme: :doc)
493
499
  )
500
+ # Records itself in the course's artifact manifest as it goes, so
501
+ # `university-cleanup` can offer to close this session (and, below,
502
+ # delete the file this run saves) without anyone having to
503
+ # remember either one existed. Refused outright for a session name
504
+ # that isn't one of the course's own -- someone running this
505
+ # script against their own session has not created a course
506
+ # artifact (Artifacts.record_session!).
507
+ ::StreamWeaver::University::Artifacts.record_session!(session_name, step: STEP)
494
508
 
495
509
  toc = []
496
510
  body = +''
@@ -532,6 +546,11 @@ module StreamWeaver
532
546
  "streamweaver canvas-wait #{session_name}"
533
547
  elsif save
534
548
  path = save_doc(bridge, session_name, doc_name)
549
+ # Recorded only for a save that actually reported a path -- an
550
+ # entry the manifest can't resolve to a file is one cleanup
551
+ # could never act on. Picks up the `.org` sibling too if step 5
552
+ # has already exported one beside it.
553
+ ::StreamWeaver::University::Artifacts.record_doc!(path, step: STEP) if path
535
554
  puts(save_message(path, doc_name, extend_ok))
536
555
  end
537
556
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StreamWeaver
4
- VERSION = "0.3.0".freeze
4
+ VERSION = "0.3.1".freeze
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stream_weaver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Forrest Chang
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-04 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sinatra
@@ -427,6 +427,7 @@ files:
427
427
  - lib/stream_weaver/canvas/bridge.rb
428
428
  - lib/stream_weaver/canvas/bridge_server.rb
429
429
  - lib/stream_weaver/canvas/client.rb
430
+ - lib/stream_weaver/canvas/code_stamp.rb
430
431
  - lib/stream_weaver/canvas/doc_roots.rb
431
432
  - lib/stream_weaver/canvas/doc_store.rb
432
433
  - lib/stream_weaver/canvas/gist_publisher.rb
@@ -439,6 +440,7 @@ files:
439
440
  - lib/stream_weaver/canvas/save_doc_widget.rb
440
441
  - lib/stream_weaver/canvas/scroll_top_hint.rb
441
442
  - lib/stream_weaver/canvas/session.rb
443
+ - lib/stream_weaver/canvas/staleness_guard.rb
442
444
  - lib/stream_weaver/cli.rb
443
445
  - lib/stream_weaver/component_assets.rb
444
446
  - lib/stream_weaver/component_registry.rb
@@ -550,7 +552,9 @@ files:
550
552
  - lib/stream_weaver/theme.rb
551
553
  - lib/stream_weaver/theme/auto_mode.rb
552
554
  - lib/stream_weaver/theme/presets.rb
555
+ - lib/stream_weaver/university/artifacts.rb
553
556
  - lib/stream_weaver/university/canvas.rb
557
+ - lib/stream_weaver/university/cleanup.rb
554
558
  - lib/stream_weaver/university/course.rb
555
559
  - lib/stream_weaver/university/demos.rb
556
560
  - lib/stream_weaver/university/demos/counter.rb