task_progress 0.0.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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE +21 -0
  4. data/MANUAL.md +851 -0
  5. data/README.md +158 -0
  6. data/RECIPES.md +413 -0
  7. data/config/importmap.rb +17 -0
  8. data/exe/task_progress +51 -0
  9. data/js/local_tasks.d.ts +44 -0
  10. data/js/local_tasks.js +229 -0
  11. data/js/package.json +31 -0
  12. data/js/paths.d.ts +5 -0
  13. data/js/paths.js +7 -0
  14. data/js/react_task_tree.d.ts +49 -0
  15. data/js/react_task_tree.js +123 -0
  16. data/js/render_queue.d.ts +24 -0
  17. data/js/render_queue.js +79 -0
  18. data/js/row_view.d.ts +25 -0
  19. data/js/row_view.js +69 -0
  20. data/js/stimulus_controller.d.ts +10 -0
  21. data/js/stimulus_controller.js +137 -0
  22. data/js/task_progress_clock.d.ts +2 -0
  23. data/js/task_progress_clock.js +33 -0
  24. data/js/task_tree.css +51 -0
  25. data/js/task_tree.d.ts +111 -0
  26. data/js/task_tree.js +444 -0
  27. data/lib/task_progress/assertions.rb +49 -0
  28. data/lib/task_progress/batch.rb +23 -0
  29. data/lib/task_progress/branch.rb +74 -0
  30. data/lib/task_progress/buffered_output.rb +63 -0
  31. data/lib/task_progress/cancellations_app.rb +76 -0
  32. data/lib/task_progress/event.rb +46 -0
  33. data/lib/task_progress/execution_log_output.rb +183 -0
  34. data/lib/task_progress/fold.rb +18 -0
  35. data/lib/task_progress/html_renderer.rb +153 -0
  36. data/lib/task_progress/job_serializer.rb +92 -0
  37. data/lib/task_progress/journal.rb +35 -0
  38. data/lib/task_progress/log.rb +263 -0
  39. data/lib/task_progress/memory_cache.rb +31 -0
  40. data/lib/task_progress/memory_stream_store.rb +142 -0
  41. data/lib/task_progress/mount.rb +448 -0
  42. data/lib/task_progress/ndjson_output.rb +24 -0
  43. data/lib/task_progress/node.rb +52 -0
  44. data/lib/task_progress/poll.rb +87 -0
  45. data/lib/task_progress/publishing_output.rb +30 -0
  46. data/lib/task_progress/ractor_output.rb +23 -0
  47. data/lib/task_progress/rails_defaults.rb +46 -0
  48. data/lib/task_progress/railtie.rb +91 -0
  49. data/lib/task_progress/recording_output.rb +51 -0
  50. data/lib/task_progress/redis_stream_store.rb +131 -0
  51. data/lib/task_progress/squasher.rb +59 -0
  52. data/lib/task_progress/sse.rb +114 -0
  53. data/lib/task_progress/state.rb +319 -0
  54. data/lib/task_progress/stream_output.rb +222 -0
  55. data/lib/task_progress/stream_reader.rb +76 -0
  56. data/lib/task_progress/summary_output.rb +37 -0
  57. data/lib/task_progress/terminal_output.rb +241 -0
  58. data/lib/task_progress/token.rb +58 -0
  59. data/lib/task_progress/tracked_io.rb +68 -0
  60. data/lib/task_progress/transaction.rb +41 -0
  61. data/lib/task_progress/trees_app.rb +66 -0
  62. data/lib/task_progress/turbo_helper.rb +18 -0
  63. data/lib/task_progress/turbo_sse.rb +103 -0
  64. data/lib/task_progress/turbo_stream_output.rb +61 -0
  65. data/lib/task_progress/version.rb +7 -0
  66. data/lib/task_progress/wire.rb +211 -0
  67. data/lib/task_progress.rb +1036 -0
  68. data/sig/generated/task_progress/assertions.rbs +36 -0
  69. data/sig/generated/task_progress/batch.rbs +33 -0
  70. data/sig/generated/task_progress/branch.rbs +47 -0
  71. data/sig/generated/task_progress/buffered_output.rbs +38 -0
  72. data/sig/generated/task_progress/cancellations_app.rbs +41 -0
  73. data/sig/generated/task_progress/event.rbs +20 -0
  74. data/sig/generated/task_progress/execution_log_output.rbs +93 -0
  75. data/sig/generated/task_progress/fold.rbs +25 -0
  76. data/sig/generated/task_progress/html_renderer.rbs +71 -0
  77. data/sig/generated/task_progress/journal.rbs +25 -0
  78. data/sig/generated/task_progress/log.rbs +140 -0
  79. data/sig/generated/task_progress/memory_cache.rbs +28 -0
  80. data/sig/generated/task_progress/memory_stream_store.rbs +83 -0
  81. data/sig/generated/task_progress/mount.rbs +200 -0
  82. data/sig/generated/task_progress/ndjson_output.rbs +18 -0
  83. data/sig/generated/task_progress/node.rbs +66 -0
  84. data/sig/generated/task_progress/poll.rbs +50 -0
  85. data/sig/generated/task_progress/publishing_output.rbs +24 -0
  86. data/sig/generated/task_progress/ractor_output.rbs +17 -0
  87. data/sig/generated/task_progress/rails_defaults.rbs +33 -0
  88. data/sig/generated/task_progress/recording_output.rbs +41 -0
  89. data/sig/generated/task_progress/redis_stream_store.rbs +75 -0
  90. data/sig/generated/task_progress/squasher.rbs +20 -0
  91. data/sig/generated/task_progress/sse.rbs +73 -0
  92. data/sig/generated/task_progress/state.rbs +165 -0
  93. data/sig/generated/task_progress/stream_output.rbs +142 -0
  94. data/sig/generated/task_progress/stream_reader.rbs +46 -0
  95. data/sig/generated/task_progress/summary_output.rbs +19 -0
  96. data/sig/generated/task_progress/terminal_output.rbs +117 -0
  97. data/sig/generated/task_progress/token.rbs +41 -0
  98. data/sig/generated/task_progress/tracked_io.rbs +48 -0
  99. data/sig/generated/task_progress/transaction.rbs +33 -0
  100. data/sig/generated/task_progress/trees_app.rbs +40 -0
  101. data/sig/generated/task_progress/turbo_helper.rbs +14 -0
  102. data/sig/generated/task_progress/turbo_sse.rbs +69 -0
  103. data/sig/generated/task_progress/turbo_stream_output.rbs +51 -0
  104. data/sig/generated/task_progress/version.rbs +6 -0
  105. data/sig/generated/task_progress/wire.rbs +102 -0
  106. data/sig/generated/task_progress.rbs +553 -0
  107. metadata +178 -0
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # Redraws the tree in place on a terminal, from a ticker thread of its own so spinners turn
6
+ # while nothing reports. A finished subtree folds into its one line, and when the tree is
7
+ # taller than the screen the finished lines go first, then the ones not started. When the
8
+ # root finishes the tree is drawn one last time and left on screen - or erased, with `erase: true`.
9
+ # A line a task `puts` goes above the tree and stays there
10
+ #
11
+ # With `interval: nil` there is no thread and every batch redraws, which is what tests want
12
+ class TerminalOutput
13
+ SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze #: Array[String]
14
+ BAR = 20 #: Integer
15
+ DEFAULT_SIZE = [24, 80].freeze #: [Integer, Integer]
16
+ private_constant :SPINNER, :BAR, :DEFAULT_SIZE
17
+
18
+ # @rbs @io: IO | StringIO
19
+ # @rbs @interval: (Integer | Float)?
20
+ # @rbs @erase: bool
21
+ # @rbs @clock: ^() -> Float
22
+ # @rbs @lock: Thread::Mutex
23
+ # @rbs @drawn: Integer
24
+ # @rbs @frame: Integer
25
+ # @rbs @log: Log?
26
+ # @rbs @closed: bool
27
+ # @rbs @ticker: Thread?
28
+ # @rbs @dirty: bool
29
+
30
+ # Draws on `io` every `interval` seconds - or on every batch, with `interval: nil`. With `erase: true` the tree is cleared once the root finishes
31
+ #: (?IO | StringIO, ?interval: (Integer | Float)?, ?erase: bool, ?clock: ^() -> Float) -> void
32
+ def initialize(io = $stderr, interval: 0.1, erase: false, clock: -> { Process.clock_gettime(Process::CLOCK_REALTIME) })
33
+ @io = io
34
+ @interval = interval
35
+ @erase = erase
36
+ @clock = clock
37
+ @lock = Mutex.new
38
+ @drawn = 0
39
+ @frame = 0
40
+ @log = nil
41
+ @closed = false
42
+ @ticker = nil
43
+ end
44
+
45
+ # Takes one batch, see Log#add_output
46
+ #: (Batch, Log) -> void
47
+ def call(batch, log)
48
+ said = batch.events.filter_map { it.value if it.op == :say } #: Array[String]
49
+ @lock.synchronize do
50
+ @log = log
51
+ return said.each { @io.puts(it) } if @closed
52
+ print_above(said) unless said.empty?
53
+ if log.snapshot[[]]&.finished?
54
+ finish
55
+ elsif @interval.nil? || !said.empty?
56
+ draw
57
+ @ticker ||= Thread.new { tick } if @interval
58
+ else
59
+ @dirty = true
60
+ @ticker ||= Thread.new { tick }
61
+ end
62
+ end
63
+ end
64
+
65
+ # Draws the tree one last time and stops the ticker
66
+ #: () -> void
67
+ def close
68
+ @lock.synchronize { finish if @log && !@closed }
69
+ @ticker&.join
70
+ end
71
+
72
+ # The lines for a snapshot, without the terminal control around them
73
+ #: (State, ?width: Integer, ?height: Integer) -> Array[String]
74
+ def lines(snapshot, width: columns, height: rows)
75
+ shown = [] #: Array[[path?, Node | Fold | Integer]]
76
+ snapshot.rows do |path, node|
77
+ next if path.length.positive? && hidden?(snapshot, path)
78
+ shown << [path, node]
79
+ end
80
+ shown = trim(shown, height - 1)
81
+ shown.map { |path, node| line(snapshot, path, node, width) }
82
+ end
83
+
84
+ private
85
+
86
+ #: () -> void
87
+ def tick
88
+ loop do
89
+ sleep(@interval || 0.1)
90
+ @lock.synchronize do
91
+ return if @closed
92
+ @frame += 1
93
+ draw
94
+ end
95
+ end
96
+ end
97
+
98
+ #: () -> void
99
+ def finish
100
+ @closed = true
101
+ @erase ? clear : draw
102
+ end
103
+
104
+ #: () -> void
105
+ def draw
106
+ log = @log or return
107
+ text = lines(log.snapshot)
108
+ @io.write("#{up}#{text.join("\n")}\n")
109
+ @io.flush
110
+ @drawn = text.length
111
+ end
112
+
113
+ #: (Array[String]) -> void
114
+ def print_above(said)
115
+ @io.write("#{up}#{said.join("\n")}\n")
116
+ @drawn = 0
117
+ end
118
+
119
+ #: () -> void
120
+ def clear
121
+ @io.write(up)
122
+ @io.flush
123
+ @drawn = 0
124
+ end
125
+
126
+ #: () -> String
127
+ def up = @drawn.zero? ? "" : "\e[#{@drawn}A\e[J"
128
+
129
+ # Under a finished node only what failed shows, so a failure leaves the way down to it
130
+ #: (State, path) -> bool
131
+ def hidden?(snapshot, path)
132
+ (0...path.length).any? { snapshot[path.take(it)]&.finished? && snapshot[path.take(it + 1)]&.status != :failed }
133
+ end
134
+
135
+ # What fits in `room` lines - the last of them saying how many more there are
136
+ #: (Array[[path?, Node | Fold | Integer]], Integer) -> Array[[path?, Node | Fold | Integer]]
137
+ def trim(shown, room)
138
+ room = [room, 2].max
139
+ %i[done pending].each do |status|
140
+ break if shown.length <= room
141
+ excess = shown.length - room
142
+ shown = shown.each_with_index.reject { |(_, node), i| i.positive? && node.is_a?(Node) && node.status == status && (excess -= 1) >= 0 }.map(&:first)
143
+ end
144
+ return shown if shown.length <= room
145
+ more = [nil, shown.length - room + 1] #: [path?, Node | Fold | Integer]
146
+ shown.first(room - 1) + [more]
147
+ end
148
+
149
+ #: (State, path?, Node | Fold | Integer, Integer) -> String
150
+ def line(snapshot, path, node, width)
151
+ return " … #{node} more" if path.nil? || node.is_a?(Integer)
152
+ indent = " " * path.length
153
+ return "#{indent}#{(node.status == :done) ? "✓" : "·"} #{node.name}"[0, width - 1].to_s if node.is_a?(Fold)
154
+ parts = ["#{indent}#{icon(node)} #{node.name}"] #: Array[String?]
155
+ parts << measure(snapshot, path, node)
156
+ parts << pace(node) if node.status == :active && node.children.empty?
157
+ parts << elapsed(node)
158
+ parts << "attempt #{node.attempt}" if node.attempt.to_i > 1
159
+ parts << ((node.status == :failed) ? node.error&.dig(:message) : node.message)
160
+ parts.compact.reject(&:empty?).join(" ")[0, width - 1].to_s
161
+ end
162
+
163
+ #: (Node) -> String
164
+ def icon(node)
165
+ case node.status
166
+ when :done then "✓"
167
+ when :failed then "✗"
168
+ when :canceled then "⊘"
169
+ when :pending then "·"
170
+ else node.stale ? "?" : SPINNER.fetch(@frame % SPINNER.length)
171
+ end
172
+ end
173
+
174
+ #: (State, path, Node) -> String?
175
+ def measure(snapshot, path, node)
176
+ return nil if node.status == :done && path.length.positive?
177
+ percent = snapshot.percent(path)
178
+ if node.children.empty? && node.indeterminate
179
+ counted = ((node.unit == "bytes") ? TaskProgress.human_bytes(node.count) : node.count.to_s) if node.count.positive?
180
+ # A barber pole while it runs, a full bar once it is through
181
+ [(node.status == :active) ? stripes : bar(percent), counted].compact.join(" ")
182
+ elsif node.children.empty? && (total = node.total)
183
+ count = (node.unit == "bytes") ? "#{TaskProgress.human_bytes(node.count)} / #{TaskProgress.human_bytes(total)}" : "#{node.count}/#{total}"
184
+ "#{bar(percent)} #{count}"
185
+ else
186
+ format("%s %3d%%", bar(percent), percent)
187
+ end
188
+ end
189
+
190
+ # Diagonal stripes that crawl one cell along every frame - for work with no measure
191
+ #: () -> String
192
+ def stripes = Array.new(BAR) { ((it - @frame) % 6 < 3) ? "╱" : "░" }.join
193
+
194
+ #: (Integer) -> String
195
+ def bar(percent)
196
+ filled = BAR * percent / 100
197
+ "#{"█" * filled}#{"░" * (BAR - filled)}"
198
+ end
199
+
200
+ # The rate over the whole run so far, and the time left at that rate. Steadier than a
201
+ # rate over the last few ticks, which jumps about with every pause in the work
202
+ #: (Node) -> String?
203
+ def pace(node)
204
+ started = node.started_at or return nil
205
+ seconds = @clock.call - started
206
+ return nil unless seconds >= 1 && node.count.positive?
207
+ rate = node.count / seconds
208
+ per = if node.unit == "bytes"
209
+ "#{TaskProgress.human_bytes(rate)}/s"
210
+ else
211
+ format((rate < 10) ? "%.1f/s" : "%d/s", rate)
212
+ end
213
+ total = node.total
214
+ return per unless total && total > node.count
215
+ "#{per} ETA #{TaskProgress.duration((total - node.count) / rate)}"
216
+ end
217
+
218
+ #: (Node) -> String?
219
+ def elapsed(node)
220
+ started = node.started_at or return nil
221
+ TaskProgress.duration((node.finished_at || @clock.call) - started)
222
+ end
223
+
224
+ #: () -> Integer
225
+ def columns = size[1]
226
+
227
+ #: () -> Integer
228
+ def rows = size[0]
229
+
230
+ #: () -> [Integer, Integer]
231
+ def size
232
+ io = @io
233
+ return DEFAULT_SIZE unless io.is_a?(IO) # a StringIO has no screen
234
+ require "io/console"
235
+ rows, cols = io.winsize
236
+ (rows.positive? && cols.positive?) ? [rows, cols] : DEFAULT_SIZE
237
+ rescue NoMethodError, SystemCallError
238
+ DEFAULT_SIZE
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "openssl"
5
+
6
+ class TaskProgress
7
+ # The right to see a tree and to ask for its tasks to be canceled, signed, and good for a while.
8
+ # The server issues one when it hands out a page - after it has decided the page may show that
9
+ # tree - and checks it when the page comes back for the tree or with a cancellation. So neither
10
+ # can be forged without the secret, or used after the token expires, and the tree's own key never
11
+ # has to reach a page. Which tasks may be canceled is for `cancelable` to say.
12
+ #
13
+ # tokens = TaskProgress::Token.new(ENV.fetch("TASK_PROGRESS_SECRET"))
14
+ # token = tokens.issue(import.key, expires_in: 3600) # into the page
15
+ # tokens.verify(token) # => import.key, when the page comes back
16
+ class Token
17
+ # @rbs @secret: String
18
+ # @rbs @clock: ^() -> Integer
19
+
20
+ # Signs with `secret`, which must be at least 32 bytes
21
+ #: (String, ?clock: ^() -> Integer) -> void
22
+ def initialize(secret, clock: -> { Time.now.to_i })
23
+ raise ArgumentError, "The secret must be at least 32 bytes" if secret.to_s.bytesize < 32
24
+ @secret = secret.to_s
25
+ @clock = clock
26
+ end
27
+
28
+ # A token for `subject` - a log id, a run, whatever the tokens are checked against - good for `expires_in` seconds
29
+ #: (String, ?expires_in: Integer) -> String
30
+ def issue(subject, expires_in: 3600)
31
+ payload = encode("#{subject}\n#{@clock.call + expires_in}")
32
+ "#{payload}.#{sign(payload)}"
33
+ end
34
+
35
+ # What the token was issued for, or nil if it is forged, mangled or expired
36
+ #: (String?) -> String?
37
+ def verify(token)
38
+ payload, signature = token.to_s.split(".", 2)
39
+ return unless payload && signature && signature.bytesize == sign(payload).bytesize
40
+ return unless OpenSSL.fixed_length_secure_compare(signature, sign(payload))
41
+ subject, expires = decode(payload).split("\n", 2)
42
+ subject if expires && Integer(expires, exception: false)&.>=(@clock.call)
43
+ rescue ArgumentError
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ #: (String) -> String
50
+ def sign(payload) = encode(OpenSSL::HMAC.digest("SHA256", @secret, "task_progress.token\n#{payload}"))
51
+
52
+ #: (String) -> String
53
+ def encode(bytes) = [bytes].pack("m0").tr("+/", "-_").delete("=")
54
+
55
+ #: (String) -> String
56
+ def decode(text) = (text + "=" * (-text.length % 4)).tr("-_", "+/").unpack1("m0").to_s.force_encoding(Encoding::UTF_8)
57
+ end
58
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ begin
5
+ require "progressive_io"
6
+ rescue LoadError
7
+ raise LoadError, "TaskProgress#track_io needs the progressive_io gem - add `gem \"progressive_io\"` to your Gemfile"
8
+ end
9
+
10
+ class TaskProgress
11
+ # A ProgressiveIO that advances a task by how far it has been read. Readers seek (ZipKit reads
12
+ # the end of an archive first), so only the furthest position counts. The task moves in steps,
13
+ # a thousandth of the total by default - a loop of small reads over a big file would otherwise
14
+ # commit a batch per read, and every output and browser would get each of them
15
+ class TrackedIO < ::ProgressiveIO
16
+ STEP_WITHOUT_TOTAL = 64 * 1024 #: Integer
17
+ private_constant :STEP_WITHOUT_TOTAL
18
+
19
+ # @rbs @task: TaskProgress
20
+ # @rbs @total: Integer?
21
+ # @rbs @step: Integer
22
+ # @rbs @furthest: Integer
23
+ # @rbs @reported: Integer
24
+
25
+ # Advances `task` as `io` is read, in steps of `step` bytes - see TaskProgress#track_io, which makes one
26
+ #: (IO, TaskProgress, ?total: Integer?, ?step: Integer?) -> void
27
+ def initialize(io, task, total: nil, step: nil)
28
+ @task = task
29
+ @total = total
30
+ @step = step || (total ? [total / 1000, 1].max : STEP_WITHOUT_TOTAL)
31
+ @furthest = 0
32
+ @reported = 0
33
+ super(io) { |pos| moved_to(pos) }
34
+ end
35
+
36
+ # ProgressiveIO lets these through unreported, and IO.copy_stream reads anything that is not
37
+ # an IO with readpartial
38
+ # Reads the way IO#readpartial does, and reports how far it got
39
+ #: (*untyped, **untyped) -> untyped
40
+ def readpartial(*, **) = super.tap { notify_read }
41
+
42
+ # Reads the way IO#read_nonblock does, and reports how far it got
43
+ #: (*untyped, **untyped) -> untyped
44
+ def read_nonblock(*, **) = super.tap { notify_read }
45
+
46
+ # Reads the way IO#sysread does, and reports how far it got
47
+ #: (*untyped, **untyped) -> untyped
48
+ def sysread(*, **) = super.tap { notify_read }
49
+
50
+ # Reports what was read since the last step
51
+ #: () -> void
52
+ def flush_progress
53
+ return if @furthest == @reported || @task.finished?
54
+ @task.advance(@furthest - @reported)
55
+ @reported = @furthest
56
+ end
57
+
58
+ private
59
+
60
+ #: (Integer) -> void
61
+ def moved_to(pos)
62
+ return if pos <= @furthest
63
+ @furthest = pos
64
+ total = @total
65
+ flush_progress if @furthest - @reported >= @step || (total && @furthest >= total)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # What a block given to Log#transaction adds its events through. Each one is applied to the
6
+ # tree as it comes, and journaled, so the whole transaction can be undone if the block raises
7
+ class Transaction # :nodoc:
8
+ # What it has added so far, in order
9
+ attr_reader :events #: Array[Event]
10
+ # What it changed, to undo it by
11
+ attr_reader :journal #: Journal
12
+ # The time it opened - every event in it happens then
13
+ attr_reader :at #: Float
14
+ # The tree as the transaction has it so far
15
+ attr_reader :state #: State
16
+
17
+ # @rbs @random: Random
18
+
19
+ # For changing `state` at `at`, drawing task ids from `random` - see Log#transaction, which opens one
20
+ #: (State, Float, Random) -> void
21
+ def initialize(state, at, random)
22
+ @state = state
23
+ @at = at
24
+ @random = random
25
+ @events = []
26
+ @journal = Journal.new
27
+ end
28
+
29
+ # Applies the event, and adds it to the batch
30
+ #: (Event) -> self
31
+ def <<(event)
32
+ @state.apply(event, at: @at, journal: @journal)
33
+ @events << event
34
+ self
35
+ end
36
+
37
+ # A path for a new child of `path` - see State#new_child
38
+ #: (path) -> path
39
+ def new_child(path) = @state.new_child(path, @random)
40
+ end
41
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "json"
5
+
6
+ class TaskProgress
7
+ # A Rack app that serves handed-off trees to pages, by a Token rather than by the tree's key -
8
+ # what TaskProgress.for_page hands out links to. The Railtie mounts it at `/task_progress/trees`.
9
+ #
10
+ # GET /:token/events Server-Sent Events, see SSE
11
+ # GET /:token/poll the tree, polled or long-polled, see Poll
12
+ # POST /:token/cancellations path=x3k0-00af (a form, or JSON)
13
+ # 202 {"path": ..., "state": "requested"} asked of whoever works on it
14
+ # 200 {"path": ..., "state": "moot"} nothing left to cancel - no such task, or it ended
15
+ # 409 the task is not cancelable
16
+ # 403 a token that is forged, mangled or expired
17
+ # 404 a tree that is gone, or anything else
18
+ #
19
+ # Issuing the token is the authorization: it happens in an action of yours, which has decided
20
+ # the page may see that tree. The key itself never leaves the server
21
+ class TreesApp
22
+ # @rbs @tokens: Token
23
+ # @rbs @stream_store: untyped
24
+
25
+ # Checks tokens with `tokens`, and reads trees from `stream_store` - TaskProgress.stream_store
26
+ # unless given
27
+ #: (tokens: Token, ?stream_store: untyped) -> void
28
+ def initialize(tokens:, stream_store: nil)
29
+ @tokens = tokens
30
+ @stream_store = stream_store
31
+ end
32
+
33
+ # Serves one request
34
+ #: (Hash[String, untyped]) -> [Integer, Hash[String, String], untyped]
35
+ def call(env)
36
+ _, token, action = env["PATH_INFO"].to_s.split("/", 3)
37
+ key = @tokens.verify(token) or return respond(403, {error: "invalid or expired token"})
38
+ stream_store = @stream_store || TaskProgress.stream_store
39
+ case [env["REQUEST_METHOD"], action]
40
+ in ["GET", "events"] then SSE.response(key, stream_store:)
41
+ in ["GET", "poll"] then Poll.response(key, env, stream_store:)
42
+ in ["POST", "cancellations"] then cancel(key, env, stream_store)
43
+ else respond(404, {error: "not found"})
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ #: (String, Hash[String, untyped], untyped) -> [Integer, Hash[String, String], Array[String]]
50
+ def cancel(key, env, stream_store)
51
+ path = CancellationsApp.params(env)["path"].to_s.split("-")
52
+ return respond(422, {error: "malformed path"}) unless path.length <= Wire::MAX_DEPTH && path.all? { it.match?(Wire::SEGMENT) }
53
+ outcome = TaskProgress.fetch(key, stream_store:).request_cancel(path, "Canceled from the page")
54
+ case outcome
55
+ when :refused then respond(409, {error: "not cancelable"})
56
+ when :moot then respond(200, {path: path.join("-"), state: "moot"})
57
+ else respond(202, {path: path.join("-"), state: "requested"})
58
+ end
59
+ rescue KeyError
60
+ respond(404, {error: "no such tree"})
61
+ end
62
+
63
+ #: (Integer, Hash[Symbol, untyped]) -> [Integer, Hash[String, String], Array[String]]
64
+ def respond(status, body) = [status, {"content-type" => "application/json"}, [JSON.generate(body)]]
65
+ end
66
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # View helpers, in every view once the gem is in a Rails app
6
+ module TurboHelper
7
+ # The tree a job broadcasts with `TaskProgress.broadcast(record, ...)`, kept up to date: drawn
8
+ # as it stood at its last frame, and subscribed to every frame after that. Needs turbo-rails,
9
+ # and `task_tree.css` on the page - plus `task_progress_clock.js`, for elapsed times to tick
10
+ #
11
+ # <%= task_progress_tree @csv_import %>
12
+ #: (untyped, ?store: untyped) -> String
13
+ def task_progress_tree(record, store: ::Rails.cache) # steep:ignore
14
+ drawn = store.read(RailsDefaults.key(record)) || %(<div id="#{ERB::Util.html_escape(RailsDefaults.id(record))}" class="tree"></div>)
15
+ safe_join([turbo_stream_from(record, :task_progress), drawn.html_safe]) # steep:ignore
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "cgi/escape"
5
+
6
+ class TaskProgress
7
+ # A Rack body streaming a tree as Turbo Streams over Server-Sent Events, for a page that shows
8
+ # it with `<turbo-stream-source src="...">` - Turbo with no Action Cable, and no JavaScript of
9
+ # your own. Every frame replaces the whole tree by morphing, at most every `interval` seconds
10
+ # however fast the tree changes; a reconnect starts with the whole tree, so nothing is ever
11
+ # missed for good. Given a subtask, it streams that branch alone.
12
+ #
13
+ # TaskProgress::TurboSSE.response(task, renderer:, source: "progress-source")
14
+ #
15
+ # Once the task finishes it sends the last frame, removes the element with the id `source` -
16
+ # the `<turbo-stream-source>`, so the browser does not reconnect to a finished stream - and ends.
17
+ # Without a `source` the browser reconnects every `reconnect` seconds, and gets the last frame
18
+ # each time. Given a key, it follows the tree handed off there, as SSE does
19
+ class TurboSSE
20
+ # What a Turbo SSE response is sent with - the same as an SSE one
21
+ HEADERS = SSE::HEADERS #: Hash[String, String]
22
+
23
+ # @rbs @task: TaskProgress
24
+ # @rbs @renderer: TurboStreamOutput::_Renderer
25
+ # @rbs @interval: Integer | Float
26
+ # @rbs @heartbeat: Integer | Float
27
+ # @rbs @source: String?
28
+ # @rbs @reconnect: Integer | Float
29
+ # @rbs @close: bool
30
+
31
+ # A whole Rack response: status, headers and this body - or a 404, for a key with nothing at it
32
+ #: (TaskProgress | String, **untyped) -> [Integer, Hash[String, String], TurboSSE | Array[String]]
33
+ def self.response(source, **)
34
+ [200, HEADERS.dup, new(source, **)]
35
+ rescue KeyError
36
+ [404, {"content-type" => "text/plain"}, ["Nothing to follow"]]
37
+ end
38
+
39
+ # Streams `task`, drawn by `renderer` - an HTMLRenderer, or anything that answers `id` and
40
+ # `render(snapshot, from)`, `from` being the task's path. `source` is the id of the
41
+ # `<turbo-stream-source>` to remove at the end
42
+ #: (TaskProgress | String, renderer: TurboStreamOutput::_Renderer, ?stream_store: untyped, ?interval: Integer | Float, ?heartbeat: Integer | Float, ?reconnect: Integer | Float, ?source: String?) -> void
43
+ def initialize(task, renderer:, stream_store: TaskProgress.stream_store, interval: 0.25, heartbeat: 15, reconnect: 2, source: nil)
44
+ @close = task.is_a?(String)
45
+ @task = task.is_a?(String) ? TaskProgress.follow(task, stream_store:) : task
46
+ @reconnect = reconnect
47
+ @renderer = renderer
48
+ @interval = interval
49
+ @heartbeat = heartbeat
50
+ @source = source
51
+ end
52
+
53
+ # Yields the stream a chunk at a time, until the task finishes - as a Rack body does
54
+ #: () { (String) -> void } -> void
55
+ def each
56
+ queue = Thread::Queue.new
57
+ output = @task.add_output(replay: false) { |batch, _log| queue << batch }
58
+ yield "retry: #{(@reconnect * 1000).round}\n\n"
59
+ yield message(frame)
60
+ until ended?
61
+ next yield(": ping\n\n") unless queue.pop(timeout: @heartbeat)
62
+ queue.clear # one frame for all of them: it draws the tree as it stands
63
+ yield message(frame)
64
+ sleep(@interval) unless ended?
65
+ end
66
+ yield message(frame)
67
+ source = @source
68
+ yield message(%(<turbo-stream action="remove" target="#{CGI.escapeHTML(source)}"></turbo-stream>)) if source
69
+ ensure
70
+ @task.remove_output(output) if output
71
+ close
72
+ end
73
+
74
+ # Stops following a tree it followed by its key - Rack calls it when the response is over
75
+ #: () -> void
76
+ def close
77
+ @task.log.close if @close
78
+ end
79
+
80
+ private
81
+
82
+ # Finished, or pruned away - which is as finished as it will ever be
83
+ #: () -> bool
84
+ def ended?
85
+ node = @task.log.snapshot[@task.path]
86
+ node.nil? || node.finished?
87
+ end
88
+
89
+ #: () -> String
90
+ def frame
91
+ snapshot = @task.log.snapshot
92
+ TurboStreamOutput.frame(@renderer.id, render(snapshot))
93
+ end
94
+
95
+ #: (State) -> String
96
+ def render(snapshot) = @renderer.render(snapshot, @task.path)
97
+
98
+ # An unnamed event - what `<turbo-stream-source>` listens for. A line of SSE ends at a CR as
99
+ # much as at a LF, and task names and messages - curl's and ffmpeg's, say - are full of CRs
100
+ #: (String) -> String
101
+ def message(html) = "#{html.split(/\r\n|\r|\n/).map { "data: #{it}\n" }.join}\n"
102
+ end
103
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "cgi/escape"
5
+
6
+ class TaskProgress
7
+ # Draws the whole tree as HTML on every batch, as a Turbo Stream that replaces it in the page by
8
+ # morphing, and hands that to `to` - in Rails, Turbo::StreamsChannel, which reaches every
9
+ # page subscribed with `turbo_stream_from`, whichever process the tree lives in. Put it behind a
10
+ # BufferedOutput, as TaskProgress.broadcast does, and a spammy job is drawn a few times a second.
11
+ #
12
+ # The whole tree, every time: a Turbo Stream has no seq and no gaps to notice, so a page that
13
+ # missed a frame - or joined late - is set right by the next one. Morphing touches only what
14
+ # differs, so a bar's width eases rather than jumps.
15
+ #
16
+ # With a `store`, the latest frame is also written under `key`, for a page to draw the tree
17
+ # before the first broadcast reaches it - see TurboHelper#task_progress_tree
18
+ class TurboStreamOutput
19
+ # @rbs!
20
+ # interface _Renderer
21
+ # def render: (State snapshot, ?path from) -> String
22
+ # def id: () -> String
23
+ # end
24
+ #
25
+ # interface _Store
26
+ # def read: (String key) -> untyped
27
+ # def write: (String key, untyped value, **untyped) -> void
28
+ # def delete: (String key) -> void
29
+ # end
30
+
31
+ # The store key the latest frame is written under
32
+ attr_reader :key #: String
33
+
34
+ # @rbs @renderer: _Renderer
35
+ # @rbs @to: ^(String) -> void
36
+ # @rbs @store: _Store?
37
+
38
+ # Draws with `renderer`, and hands each frame - a `<turbo-stream>` element - to `to`
39
+ #: (renderer: _Renderer, to: ^(String) -> void, ?store: _Store?, ?key: String) -> void
40
+ def initialize(renderer:, to:, store: nil, key: "task_progress/#{renderer.id}")
41
+ @renderer = renderer
42
+ @to = to
43
+ @store = store
44
+ @key = key
45
+ end
46
+
47
+ # Draws the tree as it stands - the batch only says it changed
48
+ #: (Batch, Log) -> void
49
+ def call(_batch, log)
50
+ html = @renderer.render(log.snapshot)
51
+ @store&.write(@key, html)
52
+ @to.call(TurboStreamOutput.frame(@renderer.id, html))
53
+ end
54
+
55
+ # A Turbo Stream that replaces the element `target` with `html`, morphing it
56
+ #: (String, String) -> String
57
+ def self.frame(target, html)
58
+ %(<turbo-stream action="replace" method="morph" target="#{CGI.escapeHTML(target)}"><template>#{html}</template></turbo-stream>)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # The gem's version
6
+ VERSION = "0.0.1" #: String
7
+ end