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
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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-
|
|
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
|