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,76 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "json"
5
+ require "uri"
6
+
7
+ class TaskProgress
8
+ # A Rack app that takes the cancel buttons HTMLRenderer draws, for a tree that lives in another
9
+ # process - a job broadcasting with TaskProgress.broadcast. It cannot cancel anything itself:
10
+ # it checks the request's token, and leaves the request in the store the job watches. The job
11
+ # decides, as it does for any request (see TaskProgress#request_cancel), and the cancel comes
12
+ # back in its next frame.
13
+ #
14
+ # A Turbo form submission is answered with an empty Turbo Stream, which changes nothing on the
15
+ # page - the frame that follows does. Anything else gets 202 and a little JSON
16
+ class CancellationsApp
17
+ # How long a request waits for its run to take it up
18
+ REQUEST_TTL = 600 #: Integer
19
+ # @rbs @store: TurboStreamOutput::_Store
20
+ # @rbs @tokens: Token
21
+
22
+ # Leaves requests in `store`, for tokens `tokens` issued
23
+ #: (store: TurboStreamOutput::_Store, tokens: Token) -> void
24
+ def initialize(store:, tokens:)
25
+ @store = store
26
+ @tokens = tokens
27
+ end
28
+
29
+ # Takes a POST with `path` - the task's ids joined with a dash - and `token`, as a form or JSON
30
+ #: (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
31
+ def call(env)
32
+ return respond(405, {error: "POST a cancellation"}) unless env["REQUEST_METHOD"] == "POST"
33
+ params = CancellationsApp.params(env)
34
+ key = @tokens.verify(params["token"]&.to_s) or return respond(403, {error: "invalid or expired token"})
35
+ path = params["path"].to_s.split("-")
36
+ return respond(422, {error: "malformed path"}) unless path.length <= Wire::MAX_DEPTH && path.all? { it.match?(Wire::SEGMENT) }
37
+ # A request nobody takes up - the run ended first - is forgotten after a while
38
+ @store.write(CancellationsApp.request_key(key, path), "Canceled from the browser", expires_in: REQUEST_TTL)
39
+ return [200, {"content-type" => "text/vnd.turbo-stream.html"}, [""]] if env["HTTP_ACCEPT"].to_s.include?("turbo-stream")
40
+ respond(202, {path: path.join("-"), state: "requested"})
41
+ end
42
+
43
+ # Where a request to cancel the task at `path` waits, for the tree stored under `key`
44
+ #: (String, path) -> String
45
+ def self.request_key(key, path) = "#{key}/cancel/#{path.join("-")}"
46
+
47
+ # The request's parameters, from a form or JSON
48
+ #: (Hash[String, untyped]) -> Hash[String, untyped]
49
+ def self.params(env) # :nodoc:
50
+ # Mounted in Rails, a middleware (Rack::MethodOverride, looking for `_method`) has read a
51
+ # form's body already - and left it parsed here
52
+ form = env["rack.request.form_hash"]
53
+ return form if form.is_a?(Hash)
54
+ input = env["rack.input"]
55
+ input.rewind if input.respond_to?(:rewind)
56
+ body = input&.read(4096).to_s
57
+ if env["CONTENT_TYPE"].to_s.include?("json")
58
+ parsed = begin
59
+ JSON.parse(body)
60
+ rescue
61
+ nil
62
+ end
63
+ parsed.is_a?(Hash) ? parsed : {}
64
+ else
65
+ URI.decode_www_form(body).to_h
66
+ end
67
+ rescue ArgumentError
68
+ {}
69
+ end
70
+
71
+ private
72
+
73
+ #: (Integer, Hash[Symbol, untyped]) -> [Integer, Hash[String, String], Array[String]]
74
+ def respond(status, body) = [status, {"content-type" => "application/json"}, [JSON.generate(body)]]
75
+ end
76
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # One change to one node. `path` leads to the node by its ancestors' ids - `[]` is the root,
6
+ # `["x3k0", "00af"]` a child of the root's child "x3k0". Each id is four random base36
7
+ # characters, unique among its siblings and never the task's name: names repeat, and they
8
+ # may say more than should leave the process
9
+ #
10
+ # The ops:
11
+ # open {name, total, weight, expect, indeterminate, unit} - a new node
12
+ # meta any of open's keys, and `stale` - attributes changed after the fact
13
+ # set a percent, 0..100
14
+ # add a delta to the node's count
15
+ # note a message
16
+ # done nil
17
+ # fail an error hash, see Wire.error_to_h
18
+ # cancel a reason, or nil - the work was called off, and did not fail
19
+ # prune nil - drop every descendant of the node
20
+ # put a whole node, see Node#to_h - what a squashed log is made of
21
+ # say a line to print, which changes nothing in the tree - so a squash drops it, the
22
+ # way a terminal's scrollback is gone once a program exits
23
+ # @rbs skip
24
+ Event = Data.define(:path, :op, :value)
25
+
26
+ # @rbs!
27
+ # class Event < Data
28
+ # attr_reader path: path
29
+ # attr_reader op: Symbol
30
+ # attr_reader value: untyped
31
+ # def self.new: (path: path, op: Symbol, ?value: untyped) -> instance
32
+ # def with: (?path: path, ?op: Symbol, ?value: untyped) -> instance
33
+ # end
34
+
35
+ class Event
36
+ # An event for the node at `path` - its path is frozen, so the event can be shared
37
+ #: (path: path, op: Symbol, ?value: untyped) -> void
38
+ def initialize(path:, op:, value: nil)
39
+ # RBS has no keywords for Data#initialize, which takes them all the same
40
+ super(path: path.frozen? ? path : path.dup.freeze, op:, value:) # steep:ignore
41
+ end
42
+ end
43
+
44
+ # Every op there is - see Event
45
+ OPS = %i[open meta set add note done fail cancel prune put say].freeze #: Array[Symbol]
46
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # For a CI log, or anything else that is read top to bottom after the fact: one finished
6
+ # line per thing worth saying, never a redraw, each line led by the overall percent and
7
+ # the time since the root opened.
8
+ #
9
+ # [ 42% 1m02s] Import › Rows: row 12000
10
+ # [ 50% 1m10s] ✓ Import › Rows 8.1s
11
+ # [ 50% 1m11s] ✗ Import › Engine: watermarking failed
12
+ # [ 50% 1m12s] ⊘ Import › Publish: Ctrl-C
13
+ #
14
+ # A task finishing, failing or being canceled, and a line it `puts`, go out at once and every
15
+ # time - though of a task and the ones under it ending in one batch, only the task says so. The
16
+ # rest - notes and counts - are only ever the latest news, so it says at most one of those
17
+ # per `interval`, the newest. With nothing to say for `heartbeat` it says it is still
18
+ # going, so a CI runner watching for output does not take a long quiet step for a hang
19
+ class ExecutionLogOutput
20
+ # @rbs @io: IO | StringIO
21
+ # @rbs @interval: Integer | Float
22
+ # @rbs @heartbeat: Integer | Float
23
+ # @rbs @clock: ^() -> Float
24
+ # @rbs @lock: Thread::Mutex
25
+ # @rbs @wake: Thread::ConditionVariable
26
+ # @rbs @log: Log?
27
+ # @rbs @pending: [path, String?]?
28
+ # @rbs @said_at: Float
29
+ # @rbs @closed: bool
30
+ # @rbs @thread: Thread?
31
+ # @rbs @ended: Set[path]
32
+
33
+ # Writes to `io`: news at most every `interval` seconds, and that it is still going after `heartbeat` seconds of quiet
34
+ #: (?IO | StringIO, ?interval: Integer | Float, ?heartbeat: Integer | Float, ?clock: ^() -> Float) -> void
35
+ def initialize(io = $stderr, interval: 3.0, heartbeat: 60.0, clock: -> { Process.clock_gettime(Process::CLOCK_REALTIME) })
36
+ @io = io
37
+ @interval = interval
38
+ @heartbeat = heartbeat
39
+ @clock = clock
40
+ @lock = Mutex.new
41
+ @wake = ConditionVariable.new
42
+ @log = nil
43
+ @pending = nil
44
+ @said_at = -Float::INFINITY
45
+ @closed = false
46
+ @thread = nil
47
+ @ended = Set.new
48
+ end
49
+
50
+ # Takes one batch, see Log#add_output
51
+ #: (Batch, Log) -> void
52
+ def call(batch, log)
53
+ @lock.synchronize do
54
+ return if @closed
55
+ @log = log
56
+ @said_at = @clock.call - @interval if @said_at.infinite? # the first news goes out at once
57
+ snapshot = log.snapshot
58
+ events = batch.events.map { ending_put(it) || it }
59
+ endings = events.filter_map { it.path if ENDINGS.include?(it.op) }
60
+ events.each do |event|
61
+ case event.op
62
+ when *ENDINGS
63
+ # News from inside a task that has ended is old news
64
+ @pending = nil if @pending&.first&.take(event.path.length) == event.path
65
+ next unless @ended.add?(event.path)
66
+ next if endings.any? { it.length < event.path.length && event.path.take(it.length) == it }
67
+ say(snapshot, ending(snapshot, event))
68
+ when :say then say(snapshot, "#{names(snapshot, event.path)}: #{event.value}")
69
+ when :note then @pending = [event.path, event.value] if event.value
70
+ when :set, :add then @pending = [event.path, nil] unless @pending&.first == event.path
71
+ end
72
+ end
73
+ # Older news than the ending. Judged by what came, never the snapshot, which can run ahead
74
+ # of delivery - and close before the batch that ends the root has been said
75
+ return close_locked(flush: false) if @ended.include?([])
76
+ say_pending if @pending && @clock.call - @said_at >= @interval
77
+ @thread ||= Thread.new { run }
78
+ @wake.signal
79
+ end
80
+ end
81
+
82
+ # Says what is still pending, and stops
83
+ #: () -> void
84
+ def close
85
+ @lock.synchronize { close_locked }
86
+ @thread&.join
87
+ end
88
+
89
+ private
90
+
91
+ #: () -> void
92
+ def run
93
+ @lock.synchronize do
94
+ until @closed
95
+ due = @pending ? @said_at + @interval : @said_at + @heartbeat
96
+ wait = due - @clock.call
97
+ next @wake.wait(@lock, wait) if wait.positive?
98
+ next say_pending if @pending
99
+ snapshot = @log&.snapshot or next
100
+ say(snapshot, "#{snapshot[[]]&.name}: still going")
101
+ end
102
+ end
103
+ end
104
+
105
+ #: (?flush: bool) -> void
106
+ def close_locked(flush: true)
107
+ return if @closed
108
+ say_pending if flush && @pending && @log
109
+ @closed = true
110
+ @wake.signal
111
+ end
112
+
113
+ #: () -> void
114
+ def say_pending
115
+ pending = @pending or return
116
+ path, message = pending
117
+ @pending = nil
118
+ snapshot = @log&.snapshot or return
119
+ node = snapshot[path] or return
120
+ say(snapshot, message ? "#{names(snapshot, path)}: #{message}" : "#{names(snapshot, path)}#{count(node)}")
121
+ end
122
+
123
+ #: (State, String) -> void
124
+ def say(snapshot, text)
125
+ root = snapshot[[]]
126
+ elapsed = root ? @clock.call - root.opened_at : 0
127
+ @io.puts(format("[%3d%% %6s] %s", snapshot.percent, TaskProgress.duration(elapsed), text))
128
+ @io.flush
129
+ @said_at = @clock.call
130
+ end
131
+
132
+ ENDINGS = %i[done fail cancel].freeze #: Array[Symbol]
133
+ PUT_ENDINGS = {done: :done, failed: :fail, canceled: :cancel}.freeze #: Hash[Symbol, Symbol]
134
+ private_constant :ENDINGS, :PUT_ENDINGS
135
+
136
+ # A squashed batch - from BufferedOutput, a stream, a mount catching up - says a task ended by
137
+ # putting it whole, finished. Said once, like the ending it stands for
138
+ #: (Event) -> Event?
139
+ def ending_put(event)
140
+ op = PUT_ENDINGS[event.value[:status]] if event.op == :put
141
+ return unless op && !@ended.include?(event.path)
142
+ Event.new(path: event.path, op:, value: if op == :fail
143
+ event.value[:error]
144
+ else
145
+ ((op == :cancel) ? event.value[:message] : nil)
146
+ end)
147
+ end
148
+
149
+ #: (State, Event) -> String
150
+ def ending(snapshot, event)
151
+ names = names(snapshot, event.path)
152
+ case event.op
153
+ when :done then "✓ #{names}#{took(snapshot[event.path])}"
154
+ when :fail then "✗ #{names}: #{event.value&.dig(:message)}"
155
+ else "⊘ #{names}#{": #{event.value}" if event.value}"
156
+ end
157
+ end
158
+
159
+ # The task's name, after the names of every task it is under
160
+ #: (State, path) -> String
161
+ def names(snapshot, path) = (0..path.length).filter_map { snapshot[path.take(it)]&.name }.join(" › ")
162
+
163
+ #: (Node) -> String
164
+ def count(node)
165
+ total = node.total
166
+ return "" unless total || node.count.positive?
167
+ bytes = node.unit == "bytes"
168
+ shown = bytes ? TaskProgress.human_bytes(node.count) : node.count.to_s
169
+ total = (bytes ? TaskProgress.human_bytes(total) : total.to_s) if total
170
+ " #{[shown, total].compact.join("/")}"
171
+ end
172
+
173
+ # A task that reported nothing before it finished started when it finished, as far as the
174
+ # tree knows - so it took as long as it was open
175
+ #: (Node?) -> String
176
+ def took(node)
177
+ return "" unless node && (finished = node.finished_at)
178
+ started = node.started_at
179
+ from = (started && started < finished) ? started : node.opened_at
180
+ " #{TaskProgress.duration(finished - from)}"
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # Siblings drawn as one row, see below
6
+ Fold = Data.define(
7
+ :status, #: Symbol
8
+ :count #: Integer
9
+ )
10
+
11
+ # Siblings drawn as one row - "✓ 9,812 more done", "· 340 more waiting" - so a task with ten
12
+ # thousand children draws as a handful of rows. See State#rows, which makes them
13
+ class Fold
14
+ # What the row says
15
+ #: () -> String
16
+ def name = "#{count.to_s.gsub(/\B(?=(\d{3})+(?!\d))/, ",")} more #{(status == :done) ? "done" : "waiting"}"
17
+ end
18
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "cgi/escape"
5
+
6
+ class TaskProgress
7
+ # Draws a tree as HTML - the same rows, with the same classes, as the browser client's renderers
8
+ # draw, so `task_tree.css` styles both and a page can move from one to the other unchanged.
9
+ # This is what TurboStreamOutput sends, and what TurboSSE streams.
10
+ #
11
+ # Elapsed times are `<task-progress-clock>` elements (`task_progress_clock.js`), which tick on
12
+ # between frames; everything else is as it was when the tree was drawn. A cancelable task gets
13
+ # a cancel button that is a plain form, posting the task's path and `cancel_token` to
14
+ # `cancel_url` - so it needs no JavaScript, and Turbo submits it without leaving the page.
15
+ #
16
+ # To draw rows your own way, subclass it and override `row`, or hand TurboStreamOutput anything
17
+ # that answers `id` and `render(snapshot, from = [])` - say, an object that renders a partial
18
+ class HTMLRenderer
19
+ # A circle with a cross in it, drawn in the row's text color - the same in every font
20
+ CANCEL_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="8" cy="8" r="6.25" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' #: String
21
+
22
+ GLYPHS = {done: "✓", failed: "✗", canceled: "⊘", pending: "·"}.freeze #: Hash[Symbol, String]
23
+ private_constant :GLYPHS
24
+
25
+ # The id of the element the tree is drawn in - what a Turbo Stream targets
26
+ attr_reader :id #: String
27
+
28
+ # @rbs @cancel_url: String?
29
+ # @rbs @cancel_token: String?
30
+ # @rbs @clock: ^() -> Float
31
+
32
+ # Draws into an element with the id `id`. Without a `cancel_url`, no task offers a cancel
33
+ # button. `clock` is for tests to pin down
34
+ #: (id: String, ?cancel_url: String?, ?cancel_token: String?, ?clock: ^() -> Float) -> void
35
+ def initialize(id:, cancel_url: nil, cancel_token: nil, clock: -> { Process.clock_gettime(Process::CLOCK_REALTIME) })
36
+ @id = id
37
+ @cancel_url = cancel_url
38
+ @cancel_token = cancel_token
39
+ @clock = clock
40
+ end
41
+
42
+ # The whole tree in its element - or the subtree at `from`, drawn as a tree of its own
43
+ #: (State, ?path) -> String
44
+ def render(snapshot, from = [])
45
+ rows = snapshot.rows(from).map do |path, node|
46
+ depth = path.length - from.length
47
+ node.is_a?(Fold) ? fold(path, node, depth:) : row(snapshot, path, node, depth:)
48
+ end
49
+ %(<div id="#{h(@id)}" class="tree" data-task-progress-at="#{@clock.call}">#{rows.join}</div>)
50
+ end
51
+
52
+ # One task's row, `depth` levels in
53
+ #: (State, path, Node, ?depth: Integer) -> String
54
+ def row(snapshot, path, node, depth: path.length)
55
+ status = (node.status == :done && !snapshot.complete?(path)) ? :active : node.status
56
+ glyph = GLYPHS[status]
57
+ icon = glyph ? "<span>#{glyph}</span>" : '<span class="spin"></span>'
58
+ indeterminate = !!(node.children.empty? && node.indeterminate && node.status == :active)
59
+ percent = snapshot.percent(path)
60
+ classes = ["row", status, ("stale" if node.stale)].compact.join(" ")
61
+ <<~HTML.delete("\n")
62
+ <div class="#{classes}" id="#{h(@id)}-#{h(path.empty? ? "root" : path.join("-"))}" data-path="#{h(path.join("-"))}">
63
+ <div class="label" style="padding-left: #{depth * 18}px">
64
+ <span class="icon">#{icon}</span><span class="name">#{h(node.name)}</span><span class="message">#{h(message(node))}</span>
65
+ </div>
66
+ <div class="bar#{" indeterminate" if indeterminate}"><span style="width: #{percent}%"></span></div>
67
+ #{cancel(path, node)}
68
+ <div class="measure">#{measure(path, node, percent, indeterminate)}</div>
69
+ </div>
70
+ HTML
71
+ end
72
+
73
+ # Siblings folded into one row, see State#rows - drawn as a row like any other, so the
74
+ # columns line up
75
+ #: (path, Fold, ?depth: Integer) -> String
76
+ def fold(path, fold, depth: path.length)
77
+ done = fold.status == :done
78
+ <<~HTML.delete("\n")
79
+ <div class="row folded #{done ? "done" : "pending"}" id="#{h(@id)}-#{h(path.join("-"))}" data-path="#{h(path.join("-"))}">
80
+ <div class="label" style="padding-left: #{depth * 18}px">
81
+ <span class="icon"><span>#{GLYPHS[done ? :done : :pending]}</span></span><span class="name">#{h(fold.name)}</span><span class="message"></span>
82
+ </div>
83
+ <div class="bar"><span style="width: #{done ? 100 : 0}%"></span></div>
84
+ <span class="cancel-slot"></span>
85
+ <div class="measure"></div>
86
+ </div>
87
+ HTML
88
+ end
89
+
90
+ private
91
+
92
+ #: (String?) -> String
93
+ def h(text) = CGI.escapeHTML(text.to_s)
94
+
95
+ #: (Node) -> String
96
+ def message(node)
97
+ said = (node.status == :failed) ? node.error&.dig(:message) : [pace(node), node.message].compact.reject(&:empty?).join(" · ")
98
+ [("attempt #{node.attempt}" if node.attempt.to_i > 1), said].compact.reject(&:empty?).join(" · ")
99
+ end
100
+
101
+ # The rate over the run so far, and the time left at it
102
+ #: (Node) -> String?
103
+ def pace(node)
104
+ started = node.started_at
105
+ return nil unless node.status == :active && node.children.empty? && started && node.count.positive?
106
+ seconds = @clock.call - started
107
+ return nil if seconds < 1
108
+ rate = node.count / seconds
109
+ per = if node.unit == "bytes"
110
+ "#{TaskProgress.human_bytes(rate)}/s"
111
+ else
112
+ ((rate < 10) ? format("%.1f/s", rate) : "#{rate.floor}/s")
113
+ end
114
+ total = node.total
115
+ return per unless total && total > node.count
116
+ "#{per}, #{TaskProgress.duration((total - node.count) / rate)} left"
117
+ end
118
+
119
+ #: (path, Node, Integer, bool) -> String
120
+ def measure(path, node, percent, indeterminate)
121
+ total = node.total
122
+ if node.children.empty? && total
123
+ (node.unit == "bytes") ? "#{TaskProgress.human_bytes(node.count)} / #{TaskProgress.human_bytes(total)}" : "#{node.count} / #{total}"
124
+ elsif indeterminate
125
+ clock(node)
126
+ else
127
+ "#{percent}%"
128
+ end
129
+ end
130
+
131
+ # Ticks on in the browser between frames, from the time the tree was drawn
132
+ #: (Node) -> String
133
+ def clock(node)
134
+ started = node.started_at or return ""
135
+ seconds = [(node.finished_at || @clock.call) - started, 0].max
136
+ ended = %( finished="#{node.finished_at}") if node.finished_at
137
+ %(<task-progress-clock started="#{started}" drawn="#{@clock.call}"#{ended}>#{format("%.1fs", seconds)}</task-progress-clock>)
138
+ end
139
+
140
+ # The cancel column is there on every row, so the bars line up
141
+ #: (path, Node) -> String
142
+ def cancel(path, node)
143
+ return '<span class="cancel-slot"></span>' unless @cancel_url && node.cancelable && !node.finished?
144
+ <<~HTML.delete("\n")
145
+ <form class="cancel-slot" action="#{h(@cancel_url)}" method="post" data-turbo="true">
146
+ <input type="hidden" name="path" value="#{h(path.join("-"))}">
147
+ <input type="hidden" name="token" value="#{h(@cancel_token)}">
148
+ <button type="submit" class="cancel" aria-label="Cancel #{h(node.name)}">#{CANCEL_ICON}</button>
149
+ </form>
150
+ HTML
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ActiveJob's own serializers name Duration, DateTime and the rest as they load, and only Rails
4
+ # loads those first - so outside Rails all of ActiveSupport has to come along
5
+ require "active_support/all"
6
+ require "active_job"
7
+
8
+ class TaskProgress
9
+ # Lets a tree from TaskProgress.handoff go to an ActiveJob as an argument like any other - no
10
+ # ids to pass, nothing to look up:
11
+ #
12
+ # progress = TaskProgress.handoff("Importing", cancelable: true)
13
+ # ImportJob.perform_later(csv_import, progress)
14
+ #
15
+ # class ImportJob < ApplicationJob
16
+ # def perform(csv_import, progress)
17
+ # progress.subtask("Rows", total: csv_import.rows.size) { |rows| ... }
18
+ # end
19
+ # end
20
+ #
21
+ # It goes into the queue as its key. Out of the queue it comes adopted - see TaskProgress.adopt -
22
+ # and the job's work is the root's: done when `perform` returns, failed when it raises, and a
23
+ # cancel stops it without failing the job. A retry adopts it anew, as attempt 2, and starts
24
+ # it over. The Railtie installs all of this; without Rails, call `install` once ActiveJob loads.
25
+ #
26
+ # Only a root that was handed off can go: a task that lives in this process alone has nowhere
27
+ # for a worker elsewhere to write to
28
+ class JobSerializer < ActiveJob::Serializers::ObjectSerializer
29
+ # Teaches ActiveJob to take trees as arguments, and runs every job that gets one as the tree's work
30
+ def self.install
31
+ ActiveJob::Serializers.add_serializers(self)
32
+ ActiveJob::Base.prepend(Performing)
33
+ ActiveJob::Base.around_perform do |job, perform|
34
+ TaskProgress.working_on(job.arguments) do
35
+ perform.call
36
+ # A `before_perform` that halts stops the chain inside this one: the work never ran
37
+ job.task_progress_halted!
38
+ end
39
+ end
40
+ end
41
+
42
+ # Arguments are taken up as they are deserialized, which is before `around_perform` - and a job
43
+ # can stop in between: an argument that cannot be found, a `before_perform` that halts. Every
44
+ # tree taken up for a job is seen to the end once the job is through, whatever became of it,
45
+ # so none of them is left beating for work that is not being done
46
+ module Performing
47
+ # A callback halted the job - ActiveSupport says so through this hook
48
+ def halted_callback_hook(*)
49
+ @task_progress_halted = true
50
+ super
51
+ end
52
+
53
+ # Fails the trees the job took up, if a callback halted it before its work
54
+ def task_progress_halted!
55
+ return unless @task_progress_halted
56
+ arguments.flat_map { it.is_a?(Hash) ? it.values : [it] }.grep(TaskProgress).each do |root|
57
+ root.fail!("The job stopped before it began its work") unless root.finished?
58
+ end
59
+ end
60
+
61
+ # Runs the job, and ends what it took up and did not end
62
+ def perform_now
63
+ (Thread.current[:task_progress_adopted] ||= []).push([])
64
+ super
65
+ ensure
66
+ adopted = Thread.current[:task_progress_adopted]&.pop || []
67
+ adopted.each do |root|
68
+ root.fail!($! || "The job stopped before it began its work") unless root.finished?
69
+ root.log.close
70
+ end
71
+ end
72
+ end
73
+
74
+ # Whether it is a TaskProgress
75
+ def serialize?(argument) = argument.is_a?(TaskProgress)
76
+
77
+ # Its key, and nothing else
78
+ def serialize(task)
79
+ key = task.key or raise ActiveJob::SerializationError,
80
+ "#{task.inspect} lives in this process alone - hand a tree to a job with TaskProgress.handoff"
81
+ super("key" => key)
82
+ end
83
+
84
+ # The tree at the key, adopted
85
+ def deserialize(hash)
86
+ TaskProgress.adopt(hash.fetch("key")).tap { Thread.current[:task_progress_adopted]&.last&.push(it) }
87
+ end
88
+
89
+ # What it serializes
90
+ def klass = TaskProgress
91
+ end
92
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # The changes one transaction made, so that it can be undone whole
6
+ class Journal # :nodoc:
7
+ # @rbs @saved: Hash[path, [Node, Integer]?]
8
+
9
+ #: () -> void
10
+ def initialize = @saved = {}
11
+
12
+ # Remembers the node as it was before the transaction first touched it - nil for one it opened.
13
+ # A shallow copy, and how many children it had: a transaction only ever adds children at the
14
+ # end, or swaps the list for a new one, so that is enough to put the list back - and a parent
15
+ # of a hundred thousand tasks is not copied whole every time one of them moves
16
+ #: (path, Node?) -> void
17
+ def touch(path, node)
18
+ @saved[path] = (node && [node.dup, node.children.length]) unless @saved.key?(path)
19
+ end
20
+
21
+ # Every path it touched, opened or removed
22
+ #: () -> Array[path]
23
+ def paths = @saved.keys
24
+
25
+ #: (Hash[path, Node]) -> void
26
+ def rollback(nodes)
27
+ @saved.each do |path, saved|
28
+ next nodes.delete(path) unless saved
29
+ node, length = saved
30
+ node.children.slice!(length..)
31
+ nodes[path] = node
32
+ end
33
+ end
34
+ end
35
+ end