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,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
|