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.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../components"
3
4
  require_relative "recording_context"
4
5
  require_relative "inline"
5
6
  require_relative "source_splitter"
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ module StreamWeaver
8
+ module University
9
+ # The course's own record of what it left behind: every doc it saved,
10
+ # every org file exported from one, every gist published, every demo
11
+ # canvas session it opened. Persisted as YAML at
12
+ # `~/.streamweaver/university/artifacts.yml`, same shape and same
13
+ # env-override convention as Progress's own ledger.
14
+ #
15
+ # Invisible by design: nothing asks the user to maintain it. The pieces
16
+ # that create artifacts record them as they go (growing_doc's save,
17
+ # Listener.warm_up!'s session create), and the one thing a worker has to
18
+ # record by hand -- a gist URL, which only it ever sees -- has a CLI door
19
+ # (`streamweaver university-artifact add`) named in step 5's own prompt.
20
+ #
21
+ # It exists for exactly one consumer: cleanup. `University::Cleanup` will
22
+ # delete a thing only if this manifest currently lists it (or it is one
23
+ # of the deterministic knowns Cleanup owns outright), so the manifest is
24
+ # the allowlist, not merely an inventory. That is why `record!` is
25
+ # additive and deduped, `forget!` is called only after a delete actually
26
+ # lands, and nothing here ever deletes a file itself.
27
+ module Artifacts
28
+ DEFAULT_PATH = '~/.streamweaver/university/artifacts.yml'
29
+
30
+ # doc -- a StreamWeaver DSL file saved out of a canvas (`.rb`)
31
+ # org -- its `streamweaver org-export` sibling (`.org`)
32
+ # gist -- a published gist URL
33
+ # session -- a course demo canvas session, by name
34
+ TYPES = %w[doc org gist session].freeze
35
+
36
+ # Human names for the types, in one place: both surfaces show these
37
+ # (the CLI's inventory headings and "Kept: ..." lines, the canvas's
38
+ # group headings and delete-button labels), and two copies would let
39
+ # the same group be called two different things depending on where
40
+ # the user was standing.
41
+ LABELS = {
42
+ 'doc' => 'Saved docs',
43
+ 'org' => 'Exported .org files',
44
+ 'gist' => 'Gists',
45
+ 'session' => 'Course canvas sessions'
46
+ }.freeze
47
+
48
+ def self.label(type)
49
+ LABELS.fetch(type.to_s, type.to_s)
50
+ end
51
+
52
+ # Expanded per call, not at the constant, so a spec that redirects
53
+ # HOME is redirected here too -- same reasoning as Progress.path.
54
+ def self.path
55
+ ENV['STREAMWEAVER_UNIVERSITY_ARTIFACTS'] || File.expand_path(DEFAULT_PATH)
56
+ end
57
+
58
+ # Every recorded artifact, oldest first, as plain string-keyed hashes.
59
+ def self.all
60
+ read['entries'] || []
61
+ end
62
+
63
+ # Records one artifact. `type` is inferred from the ref when omitted
64
+ # (see .infer_type). Deduped on [type, ref] -- re-running step 4 saves
65
+ # over the same path, and one path is still one thing to delete.
66
+ # Returns the entry, or nil when the type could not be determined
67
+ # (callers surface that; a manifest entry with no type is one Cleanup
68
+ # could never dispatch on).
69
+ def self.record!(ref, type: nil, step: nil)
70
+ ref = ref.to_s
71
+ type = (type || infer_type(ref))&.to_s
72
+ return nil unless TYPES.include?(type)
73
+
74
+ # A file ref is stored absolute, always. The process that records
75
+ # one (a worker's shell, the bridge) is not the process that later
76
+ # deletes it, so a relative path would be resolved against a
77
+ # different working directory than the one it meant -- and the
78
+ # thing resolved would be deleted without anyone noticing the
79
+ # difference. Refuse a gist ref that the deleter could not resolve
80
+ # for the same reason: `Cleanup` shells `gh` with the id this
81
+ # parses out, so a URL with no id in it is a manifest entry that
82
+ # can only ever fail.
83
+ ref = File.expand_path(ref) if %w[doc org].include?(type)
84
+ return nil if type == 'gist' && gist_id(ref).nil?
85
+
86
+ data = read
87
+ entries = data['entries'] || []
88
+ existing = entries.find { |e| e['type'] == type && e['ref'] == ref }
89
+ return existing if existing
90
+
91
+ entry = {
92
+ 'type' => type,
93
+ 'ref' => ref,
94
+ 'step' => step&.to_i,
95
+ 'created_at' => Time.now.utc.iso8601
96
+ }
97
+ write(data.merge('entries' => entries + [entry]))
98
+ entry
99
+ end
100
+
101
+ # growing_doc's save path, plus the `.org` sibling `streamweaver
102
+ # org-export` writes beside it when that has already happened (step 5
103
+ # exports after the fact, so the sibling usually gets recorded by the
104
+ # worker's own `university-artifact add` instead -- both doors, one
105
+ # manifest).
106
+ def self.record_doc!(doc_path, step: nil)
107
+ return nil unless doc_path
108
+
109
+ entry = record!(doc_path, type: 'doc', step: step)
110
+ org = doc_path.to_s.sub(/\.rb\z/, '.org')
111
+ record!(org, type: 'org', step: step) if org != doc_path.to_s && File.exist?(org)
112
+ entry
113
+ end
114
+
115
+ # A course demo canvas session, by name. Guarded on the same allowlist
116
+ # `university-reset` closes by (Listener::DEMO_SESSION_NAMES): a
117
+ # session the course did not open is not the course's to record, and
118
+ # therefore never becomes something cleanup is allowed to close.
119
+ def self.record_session!(name, step: nil)
120
+ return nil unless demo_session_names.include?(name.to_s)
121
+
122
+ record!(name, type: 'session', step: step)
123
+ end
124
+
125
+ # Drops one entry -- called by Cleanup after a delete actually lands,
126
+ # never speculatively. A no-op for an entry that isn't there.
127
+ def self.forget!(type, ref)
128
+ data = read
129
+ entries = data['entries'] || []
130
+ kept = entries.reject { |e| e['type'] == type.to_s && e['ref'] == ref.to_s }
131
+ return false if kept.size == entries.size
132
+
133
+ write(data.merge('entries' => kept))
134
+ true
135
+ end
136
+
137
+ # { 'doc' => [entry, ...], ... } in TYPES order, empty groups omitted.
138
+ def self.grouped
139
+ by_type = all.group_by { |e| e['type'] }
140
+ TYPES.each_with_object({}) do |type, out|
141
+ out[type] = by_type[type] if by_type[type]&.any?
142
+ end
143
+ end
144
+
145
+ # Everything recorded for one step, in record order -- what a step
146
+ # row's "created:" line reads (display only; it deletes nothing).
147
+ def self.for_step(step_number)
148
+ all.select { |e| e['step'].to_i == step_number.to_i }
149
+ end
150
+
151
+ # The gist id `gh gist delete` wants, or nil if this is not a gist URL
152
+ # this course could act on. Lives here, beside the recording door,
153
+ # rather than in Cleanup: "is this a gist?" must have exactly ONE
154
+ # definition, or a URL loose enough to record can be too vague to
155
+ # delete -- a manifest entry that is permanently stuck. Cleanup calls
156
+ # this again on the way out as its second guard.
157
+ def self.gist_id(url)
158
+ match = url.to_s.match(%r{\Ahttps?://gist\.github\.com/(?:[^/]+/)?([0-9a-f]{6,})/?\z}i)
159
+ match && match[1]
160
+ end
161
+
162
+ # gist URL > .org path > .rb path > an allowlisted demo session name.
163
+ # Deliberately narrow: an unrecognized ref returns nil and `record!`
164
+ # refuses it rather than guessing a type Cleanup would later act on.
165
+ def self.infer_type(ref)
166
+ ref = ref.to_s
167
+ return 'gist' if gist_id(ref)
168
+ return 'org' if ref.end_with?('.org')
169
+ return 'doc' if ref.end_with?('.rb')
170
+ return 'session' if demo_session_names.include?(ref)
171
+
172
+ nil
173
+ end
174
+
175
+ # The delete a canvas click has asked for but not yet confirmed:
176
+ # { 'label' =>, 'kind' =>, 'refs' => [...] }, or nil.
177
+ #
178
+ # Lives here, in the artifacts file, rather than in Progress: it is
179
+ # state about artifacts, and Progress is the course-completion ledger.
180
+ # It has to live on disk at all for the same reason `viewing` does --
181
+ # canvas.rb is instance_eval'd fresh on every push, so nothing about
182
+ # what is currently on screen survives in memory between the click
183
+ # that asks and the re-push that renders the confirmation.
184
+ def self.pending_delete
185
+ read['pending']
186
+ end
187
+
188
+ # Asking clears whatever the last delete reported: the recap shows one
189
+ # thing at a time, and the question the user is being asked now
190
+ # supersedes the answer to the last one.
191
+ def self.request_delete!(label:, kind:, refs:)
192
+ pending = { 'label' => label.to_s, 'kind' => kind.to_s, 'refs' => Array(refs).map(&:to_s) }
193
+ write(read.merge('pending' => pending, 'last_cleanup' => nil))
194
+ pending
195
+ end
196
+
197
+ def self.clear_pending!
198
+ write(read.merge('pending' => nil))
199
+ end
200
+
201
+ # What the last confirmed delete actually did, as the lines Cleanup
202
+ # reported -- rendered in the recap so a click on the canvas says
203
+ # something, the same way a Run click's own notice band does. Cleared
204
+ # by the next question (above).
205
+ def self.last_cleanup
206
+ read['last_cleanup']
207
+ end
208
+
209
+ def self.record_cleanup!(messages)
210
+ write(read.merge('last_cleanup' => Array(messages).map(&:to_s), 'pending' => nil))
211
+ end
212
+
213
+ # Resolved lazily rather than by a top-level require: Listener records
214
+ # sessions through this module, so requiring it from here at load time
215
+ # would be a cycle. `require` is idempotent, so the cost is one hash
216
+ # lookup after the first call.
217
+ def self.demo_session_names
218
+ require 'stream_weaver/university/listener'
219
+ Listener::DEMO_SESSION_NAMES
220
+ end
221
+
222
+ def self.read
223
+ return {} unless File.exist?(path)
224
+
225
+ loaded = YAML.safe_load(File.read(path))
226
+ loaded.is_a?(Hash) ? loaded : {}
227
+ rescue Psych::SyntaxError, SystemCallError, IOError
228
+ {}
229
+ end
230
+ private_class_method :read
231
+
232
+ # Locked, because two live processes write this file: the background
233
+ # listener records a session the moment a Run click warms one up,
234
+ # while a foreground `university-cleanup` is forgetting entries it
235
+ # just deleted. Both rewrite the whole document, so without the lock
236
+ # the loser's change is dropped -- worst case a deleted artifact's
237
+ # entry comes back, which is a confusing offer to delete it again
238
+ # rather than a wrong deletion, but not something to leave to luck.
239
+ def self.write(data)
240
+ FileUtils.mkdir_p(File.dirname(path))
241
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
242
+ file.flock(File::LOCK_EX)
243
+ file.truncate(0)
244
+ file.write(YAML.dump(data))
245
+ end
246
+ end
247
+ private_class_method :write
248
+ end
249
+ end
250
+ end
@@ -43,7 +43,9 @@
43
43
  # `btn_mark_done_mark-done-3`. The mockup itself uses `submit: false` (fully
44
44
  # decorative) because it is a review artifact, not the built app.
45
45
 
46
+ require 'stream_weaver/university/artifacts'
46
47
  require 'stream_weaver/university/course'
48
+ require 'stream_weaver/university/listener'
47
49
  require 'stream_weaver/university/progress'
48
50
  require 'stream_weaver/university/runner'
49
51
 
@@ -113,6 +115,28 @@ module StreamWeaver::University::Canvas
113
115
  ever_sent = progress.requested_at(number) || (last_run && last_run['step'].to_i == number)
114
116
  ever_sent ? "Re-run" : "Run"
115
117
  end
118
+
119
+ # Which `cleanup-ask-<target>` button a whole-group delete uses, read
120
+ # back out of Listener::CLEANUP_GROUPS rather than repeated here -- the
121
+ # button this renders and the branch that handles it have to agree, and
122
+ # the handler is the one that decides what a target means. nil for `gist`
123
+ # (never deleted as a group) and for any type nobody wired a group button
124
+ # for, which the caller must treat as "render no button" rather than
125
+ # rendering a dead one.
126
+ def self.artifact_group_target(type)
127
+ StreamWeaver::University::Listener::CLEANUP_GROUPS
128
+ .find { |_target, group| group[:kind] == type.to_s }&.first
129
+ end
130
+
131
+ # "created: a.rb, doc-demo" for one step's row, or nil when that step
132
+ # created nothing. Display only -- the delete buttons live in the recap,
133
+ # where the whole list is visible at once.
134
+ def self.step_artifacts_line(step_number)
135
+ refs = StreamWeaver::University::Artifacts.for_step(step_number).map { |e| e['ref'] }
136
+ return nil if refs.empty?
137
+
138
+ "created: #{refs.join(', ')}"
139
+ end
116
140
  end
117
141
 
118
142
  _css = <<~CSS
@@ -780,6 +804,62 @@ _body = proc do
780
804
  - Run `streamweaver tutorial` for the classic component-by-component walkthrough.
781
805
  MD
782
806
 
807
+ # What the course left on the machine, and the offer to take it
808
+ # back. Buttons here only ever ASK -- every one of them writes a
809
+ # pending confirmation and the listener's own re-push renders it,
810
+ # so nothing on this canvas can delete anything in one click.
811
+ # Gists get one button each, showing the URL, because a gist is
812
+ # the only artifact here that left the machine.
813
+ artifacts = StreamWeaver::University::Artifacts.grouped
814
+ pending = StreamWeaver::University::Artifacts.pending_delete
815
+ last_cleanup = StreamWeaver::University::Artifacts.last_cleanup
816
+ unless artifacts.empty?
817
+ phrase "Artifacts", class: "uni-label"
818
+ phrase "This course created #{artifacts.values.sum(&:size)} things on your machine. " \
819
+ "`streamweaver university-cleanup` does the same from the terminal.",
820
+ class: "uni-prose"
821
+
822
+ if pending
823
+ div(class: "uni-run-notice uni-run-notice--degraded") do
824
+ phrase "Really delete #{pending['label']}?", class: "uni-run-notice__msg"
825
+ md StreamWeaver::University::Canvas.bullets(pending['refs']), class: "uni-payoff"
826
+ div(class: "uni-actions") do
827
+ button "Confirm delete", id: "cleanup-confirm", class: "uni-btn uni-btn--run"
828
+ button "Keep", id: "cleanup-keep", class: "uni-btn uni-btn--quiet"
829
+ end
830
+ end
831
+ else
832
+ if last_cleanup&.any?
833
+ div(class: "uni-run-notice uni-run-notice--sent") do
834
+ md StreamWeaver::University::Canvas.bullets(last_cleanup), class: "uni-payoff"
835
+ end
836
+ end
837
+
838
+ artifacts.each do |type, entries|
839
+ label = StreamWeaver::University::Artifacts.label(type)
840
+ target = StreamWeaver::University::Canvas.artifact_group_target(type)
841
+ phrase label, class: "uni-label"
842
+ md StreamWeaver::University::Canvas.bullets(entries.map { |e| e['ref'] }),
843
+ class: "uni-payoff"
844
+ div(class: "uni-actions") do
845
+ if type == 'gist'
846
+ entries.each_with_index do |entry, index|
847
+ button "Delete #{entry['ref']}", id: "cleanup-ask-gist-#{index}",
848
+ class: "uni-btn uni-btn--outline"
849
+ end
850
+ # No group button for a type nobody wired one for -- a
851
+ # `cleanup-ask-` id with no target in it matches no
852
+ # listener branch, so rendering it would be a button that
853
+ # silently does nothing.
854
+ elsif target
855
+ button "Delete #{label.downcase}", id: "cleanup-ask-#{target}",
856
+ class: "uni-btn uni-btn--outline"
857
+ end
858
+ end
859
+ end
860
+ end
861
+ end
862
+
783
863
  div(class: "uni-recap__foot") do
784
864
  phrase "Run or Repeat any step below to go through it again.",
785
865
  class: "uni-foot__hint"
@@ -865,6 +945,14 @@ _body = proc do
865
945
  md StreamWeaver::University::Canvas.bullets(step[:what_you_should_see]),
866
946
  class: "uni-payoff"
867
947
 
948
+ # What this step actually left behind, straight off the
949
+ # artifact manifest. Display only: deleting happens in the
950
+ # recap's Artifacts section, where the whole list is visible
951
+ # at once rather than one step's slice of it.
952
+ if (created = StreamWeaver::University::Canvas.step_artifacts_line(number))
953
+ phrase created, class: "uni-foot__hint"
954
+ end
955
+
868
956
  div(class: "uni-step__expansion-foot") do
869
957
  button "Mark step #{number} done", id: "mark-done-#{number}", class: "uni-btn uni-btn--outline"
870
958
  if number < total