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
data/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # TaskProgress
2
+
3
+ Progress for task trees: in one process, across threads, Fibers, Ractors and subprocesses, and out to a terminal or a browser.
4
+
5
+ Think progress bars, but properly, for complex task trees that the user needs to be updated on. Fit for both the web and the CLI.
6
+
7
+ The progress is not a mutable structure but an _event log_ which allows task progress collection to be distributed. Every change goes through one lock, which numbers it and commits it as a batch. Outputs receive the batches in order: a terminal renderer, a pipe, an SSE stream, a Sossego stream. A batch is one transaction. On a pipe it is one line, in a stream one event or row, so a reader gets all of it or none of it.
8
+
9
+ Any run of batches can be squashed into one, and a reader that joins late starts from that squash. This also lends itself perfectly to redraw-the-world UIs like React - but using it with Stimulus is a breeze as well.
10
+
11
+ <div>
12
+ <picture>
13
+ <source media="(prefers-color-scheme: dark)" srcset="docs/bench-dark.png">
14
+ <img alt="The bench (bin/dev) mid-job: an import with worker threads and an engine subprocess mounted under its own node, drawn by the Stimulus and React renderers side by side above the stream inspector" src="docs/bench-light.png" width="100%">
15
+ </picture>
16
+ </div>
17
+
18
+ The [MANUAL](MANUAL.md) has everything, and [RECIPES](RECIPES.md) whole setups to copy from - a Rails job polled from a page, subprocesses drawn in one terminal, a Rails action streaming to React. This page has what you need first.
19
+
20
+ ## Install
21
+
22
+ ```ruby
23
+ gem "task_progress", github: "julik/task_progress"
24
+ ```
25
+
26
+ Ruby 3.4 or newer. No runtime dependencies. Typed: RBS signatures ship in `sig/generated`, and the code is checked against them with Steep.
27
+
28
+ ## A bar
29
+
30
+ ```ruby
31
+ bar = TaskProgress.bar("Importing", total: rows.size)
32
+ rows.each { import(it) && bar.tick }
33
+
34
+ TaskProgress.bar("Importing", total: rows.size).iterate(rows) { |row| import(row) } # or let it do the counting
35
+ ```
36
+
37
+ ```
38
+ ⠹ Importing ██████████░░░░░░░░░░ 21/40 19/s ETA 1.0s 1.1s
39
+ ```
40
+
41
+ It finishes at its total, and is finished for you when the program exits. `bar.puts("Skipped row 12")` prints a line above it that stays.
42
+
43
+ ## A tree
44
+
45
+ ```ruby
46
+ TaskProgress.cli("Import") do |root|
47
+ download = root.subtask("Download", total: io.size, unit: "bytes")
48
+ rows = root.subtask("Rows", total: rows.size, weight: 3, cancelable: true)
49
+
50
+ download.run { copy(download.track_io(io)) }
51
+ rows.run { rows.each { import(it) && rows.tick } }
52
+ end
53
+ ```
54
+
55
+ A block is a task's work: done when it returns, failed with the error when it raises. A parent reads 100% only once it and everything under it are done. On a terminal the tree redraws in place; piped or on CI it writes an execution log instead - one finished line per thing worth saying, never a redraw.
56
+
57
+ ## With Turbo
58
+
59
+ In a Rails app with turbo-rails, a job draws its tree in every page that shows it - from whichever process it runs in, with no JavaScript of your own. The tree is drawn for a record, here a `CsvImport` - any Active Record model will do, the one whose page shows the progress:
60
+
61
+ ```ruby
62
+ class ImportJob < ApplicationJob
63
+ def perform(csv_import)
64
+ TaskProgress.broadcast(csv_import, "Importing") do |root|
65
+ root.subtask("Rows", total: csv_import.rows.size, cancelable: true) do |rows|
66
+ csv_import.rows.each { import_row(it) && rows.tick }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ ```
72
+
73
+ ```erb
74
+ <%# app/views/csv_imports/show.html.erb %>
75
+ <%= task_progress_tree @csv_import %>
76
+ ```
77
+
78
+ The job draws the whole tree as HTML a few times a second, and Action Cable takes it to every page, where Turbo morphs it in. Cancel buttons are plain forms. Nothing of Rails is needed for the rest of the gem: outside Rails, `to:` says where frames go, and `TurboSSE` streams them to a `<turbo-stream-source>`.
79
+
80
+ ## In a web app
81
+
82
+ Stream any task - the whole tree, or one branch of it - as Server-Sent Events:
83
+
84
+ ```ruby
85
+ # Rails
86
+ headers.merge!(TaskProgress::SSE::HEADERS)
87
+ self.response_body = TaskProgress::SSE.new(task)
88
+ ```
89
+
90
+ and draw it with the browser client that ships in `js/` (`TaskProgress.js_path`): plain ES modules, no build step, with a Stimulus controller and a React component on top.
91
+
92
+ ```html
93
+ <div data-controller="task-tree" data-task-tree-url-value="/jobs/42/progress"></div>
94
+ ```
95
+
96
+ Where SSE does not get through, `TaskProgress::Poll` answers plain GETs - with a 304 while nothing changed - and `followPollingAt` polls it. Work that runs in the browser itself can be grafted under a server task with `LocalTasks`.
97
+
98
+ ## Getting the browser client
99
+
100
+ The JS ships inside the gem, at the gem's version, and is not on npm. Take it from the gem in one of three ways:
101
+
102
+ - **Rails with importmap-rails:** nothing to do. The Railtie serves `js/` at `/task_progress/js` and pins it in your import map as `task_progress`, `task_progress/react`, `task_progress/stimulus`, `task_progress/local_tasks`, `task_progress/row_view` and `task_progress/clock`. Add `<link rel="stylesheet" href="/task_progress/js/task_tree.css">` for the styles.
103
+ - **A bundler - Vite, esbuild, Inertia:** copy it into your app, and install the copy as a package:
104
+
105
+ ```
106
+ $ bin/rails task_progress:vendor # or `task_progress vendor` outside Rails
107
+ $ npm install ./vendor/javascript/task_progress
108
+ ```
109
+
110
+ Then `import { TaskTreeView } from "task_progress/react"` and `import "task_progress/task_tree.css"`, with TypeScript declarations included. Commit the copy, and vendor again after updating the gem - Rails warns at boot while the copy is from another version.
111
+ - **Anything else on Rack:** serve `TaskProgress.js_path` under any prefix. The modules import each other by relative path, and need no build step.
112
+
113
+ React and Stimulus are not bundled: the components import `react`, `react-dom/client` and `@hotwired/stimulus` by name, from your app.
114
+
115
+ ## Handing work off
116
+
117
+ Create the tree here, and let a job write to it there - through Redis:
118
+
119
+ ```ruby
120
+ TaskProgress.stream_store = TaskProgress::RedisStreamStore.new(RedisClient.config(url: ENV["REDIS_URL"]).new_pool)
121
+
122
+ progress = TaskProgress.handoff("Importing", cancelable: true)
123
+ ImportJob.perform_later(csv_import, progress) # the task is an argument like any other
124
+
125
+ def perform(csv_import, progress) # ...and arrives adopted: perform is its work
126
+ progress.subtask("Rows", total: csv_import.rows.size) { |rows| ... }
127
+ end
128
+
129
+ TaskProgress::SSE.response(progress.key) # a page's stream, anywhere that reaches Redis
130
+ ```
131
+
132
+ `csv_import` is the same `CsvImport` record as above; `progress` is the tree. `TaskProgress.for_page(progress.key)` is everything a page needs - links with a signed token in them, never the key, and the tree as it stands - for a Stimulus value or an Inertia prop: `<TaskTreeView progress={progress} onComplete={() => router.reload()} />`, with the client from `rails task_progress:vendor` - or pinned by itself, with importmap-rails. The page shows the job queued before a worker has it. A worker that dies goes stale, then fails; a retry starts the tree over as attempt 2. `task_progress watch KEY` draws it in a terminal.
133
+
134
+ ## Across processes
135
+
136
+ ```ruby
137
+ engine = root.mount("Engine", weight: 3)
138
+ engine.spawn(RbConfig.ruby, "engine.rb") # engine.rb uses TaskProgress.cli, and reports up a pipe
139
+ ```
140
+
141
+ The child's tree lands under the mount, and a cancel goes down to it the same way. Ractors work too, on Ruby 4.0.
142
+
143
+ ## Canceling
144
+
145
+ ```ruby
146
+ task.cancel!("Not needed") # from any thread
147
+ root.request_cancel(path, "From the browser") # as a request: :canceled, :forwarded, :moot or :refused
148
+ ```
149
+
150
+ Whoever works on a canceled task next gets a `TaskProgress::Canceled`, which `run` takes as the end of that work. Blocking work can use `on_cancel { }` to unblock. The renderers show a cancel button on tasks marked `cancelable: true`, and `TaskProgress::Token` signs the right to press it.
151
+
152
+ ## The bench
153
+
154
+ `bin/dev` starts a bench on http://localhost:9292: pick a scenario and watch it in both renderers side by side, with a stream inspector. Cancel tasks, cut the stream to watch it resume, or join a job late. The terminal demo is `ruby -Ilib examples/cli_demo.rb`.
155
+
156
+ ## License
157
+
158
+ MIT
data/RECIPES.md ADDED
@@ -0,0 +1,413 @@
1
+ # TaskProgress recipes
2
+
3
+ Whole setups to copy from, start to end. The [README](README.md) has the short version, and the [MANUAL](MANUAL.md) everything else.
4
+
5
+ - [Relaying a command's progress: ffmpeg](#relaying-a-commands-progress-ffmpeg)
6
+ - [A video transcoded in a background job, polled from a Rails page](#a-video-transcoded-in-a-background-job-polled-from-a-rails-page)
7
+ - [The same video on an Inertia page, with React](#the-same-video-on-an-inertia-page-with-react)
8
+ - [Videos transcoded in subprocesses, drawn in one terminal](#videos-transcoded-in-subprocesses-drawn-in-one-terminal)
9
+ - [A Rails action that streams its own progress to React](#a-rails-action-that-streams-its-own-progress-to-react)
10
+ - [Upload progress, all in the browser](#upload-progress-all-in-the-browser)
11
+
12
+ Every Rails recipe leans on what the Railtie sets up by itself: the browser client served at `/task_progress/js`, trees as job arguments, and the `task_progress_tree` helper.
13
+
14
+ ## Relaying a command's progress: ffmpeg
15
+
16
+ Most long work is somebody else's program, and most such programs say how far along they are - ffmpeg among the most talkative. Read what it says, and move a task along with it. The two recipes after this one use it as it is.
17
+
18
+ ```ruby
19
+ # lib/transcoder.rb
20
+ require "open3"
21
+
22
+ # Runs ffmpeg, and moves `task` along through the seconds of the input as ffmpeg gets through
23
+ # them. ffmpeg writes its progress as key=value lines to wherever `-progress` says - here, its
24
+ # stdout. Canceling the task asks ffmpeg to stop, the way a person at its terminal would: with a "q"
25
+ module Transcoder
26
+ Failed = Class.new(StandardError)
27
+
28
+ def self.run(input, output, task, *options)
29
+ duration = Float(IO.popen(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", input], &:read))
30
+ task.total = duration.ceil
31
+ command = ["ffmpeg", "-hide_banner", "-y", "-i", input, *options, "-progress", "pipe:1", "-nostats", output]
32
+ Open3.popen3(*command) do |stdin, stdout, stderr, wait|
33
+ said = Thread.new { stderr.read rescue "" } # where ffmpeg says what went wrong
34
+ task.on_cancel { stdin.write("q") rescue nil }
35
+ speed = nil
36
+ stdout.each_line do |line|
37
+ key, value = line.chomp.split("=", 2)
38
+ case key
39
+ when "speed" then speed = value.strip
40
+ when "out_time_us"
41
+ seconds = value.to_i / 1_000_000
42
+ task.advance(seconds - task.count, message: ("#{speed} realtime" if speed)) if seconds > task.count
43
+ end
44
+ end
45
+ raise Failed, said.value.lines.last(3).join.strip unless wait.value.success?
46
+ end
47
+ end
48
+ end
49
+ ```
50
+
51
+ ```ruby
52
+ TaskProgress.cli("clip.mov") do |root|
53
+ root.subtask("720p", cancelable: true) do |task|
54
+ Transcoder.run("clip.mov", "clip-720p.mp4", task, "-vf", "scale=-2:720", "-c:v", "libx264")
55
+ end
56
+ end
57
+ ```
58
+
59
+ ```
60
+ ⠋ clip.mov ██████████████░░░░░░ 70% 0.5s
61
+ ⠋ 720p ██████████████░░░░░░ 14/20 0.5s 15.1x realtime
62
+ ```
63
+
64
+ The task counts seconds of video, so the bar comes for free - and after the first second, the rate and the time left too. It finishes by itself at its total. Canceled, ffmpeg is told to stop, and the next `advance` raises `TaskProgress::Canceled`, which ends the work quietly. If ffmpeg fails, the task fails with what ffmpeg last said:
65
+
66
+ ```
67
+ ✗ clip.mov › 720p: Error opening output /nonexistent/clip-720p.mp4: No such file or directory
68
+ ```
69
+
70
+ Anything else that prints its progress goes the same way: `rsync --info=progress2`, `curl --progress-bar`, `pg_restore --verbose`, a `docker build`. Read its lines, find the number, and `advance` or `update(percent)`.
71
+
72
+ ## A video transcoded in a background job, polled from a Rails page
73
+
74
+ A video is uploaded, a job transcodes it, and the page asks every second how it is doing, with a plain GET - no WebSockets, no SSE, nothing a proxy can get in the way of. Nothing is sent while nothing changes: an unchanged tree is a 304.
75
+
76
+ **1. Where trees live.** Jobs run in other processes, so the tree goes through Redis:
77
+
78
+ ```ruby
79
+ # Gemfile
80
+ gem "task_progress"
81
+ gem "redis-client"
82
+
83
+ # config/initializers/task_progress.rb
84
+ TaskProgress.stream_store = TaskProgress::RedisStreamStore.new(RedisClient.config(url: ENV.fetch("REDIS_URL", "redis://localhost:6379")).new_pool(size: 10))
85
+ ```
86
+
87
+ In development with the `:async` queue adapter, jobs run in the same process as the pages, and you can leave this out - the stream is then kept in memory.
88
+
89
+ **2. Remember which tree is whose.** A tree is named by its key:
90
+
91
+ ```ruby
92
+ add_column :videos, :progress_key, :string
93
+ ```
94
+
95
+ **3. Hand the tree to the job.**
96
+
97
+ ```ruby
98
+ # app/controllers/videos_controller.rb
99
+ class VideosController < ApplicationController
100
+ def create
101
+ video = Video.create!(video_params) # has_one_attached :original, has_many_attached :renditions
102
+ progress = TaskProgress.handoff("Transcoding #{video.original.filename}", cancelable: true)
103
+ video.update!(progress_key: progress.key)
104
+ TranscodeJob.perform_later(video, progress)
105
+ redirect_to video
106
+ end
107
+
108
+ def show
109
+ @video = current_user.videos.find(params[:id])
110
+ end
111
+ end
112
+ ```
113
+
114
+ The tree is pending from the moment it is handed off, so the page shows the video queued before any worker has it. No routes or actions for the progress itself: the Railtie mounts them, and the page gets links to them from `TaskProgress.for_page` - links with a signed token in them, never the tree's key. Handing them out in `show`, which only finds the user's own videos, is the authorization.
115
+
116
+ **4. Do the work.** The job gets the tree as an argument, already taken up. Its `perform` is the tree's work: done when it returns, failed with the error when it raises. Each size is a task of its own, moved along by [the ffmpeg relay](#relaying-a-commands-progress-ffmpeg):
117
+
118
+ ```ruby
119
+ # app/jobs/transcode_job.rb
120
+ class TranscodeJob < ApplicationJob
121
+ SIZES = {"1080p" => [], "720p" => ["-vf", "scale=-2:720"], "480p" => ["-vf", "scale=-2:480"]}
122
+ retry_on Transcoder::Failed, attempts: 2
123
+
124
+ def perform(video, progress)
125
+ video.original.open do |original|
126
+ SIZES.each do |size, options|
127
+ output = "#{Dir.tmpdir}/#{video.id}-#{size}.mp4"
128
+ progress.subtask(size, weight: 2, cancelable: true) do |task|
129
+ Transcoder.run(original.path, output, task, *options, "-c:v", "libx264", "-preset", "fast")
130
+ end
131
+ progress.subtask("Storing #{size}") { video.renditions.attach(io: File.open(output), filename: "#{size}.mp4") }
132
+ end
133
+ end
134
+ end
135
+ end
136
+ ```
137
+
138
+ Storing is a task too, without a measure: it counts for little (weight 1 against the transcode's 2) and shows a spinner while it runs. A retry starts the tree over, and the page shows "attempt 2". A worker that dies without a word goes stale on the page after a few seconds, and fails after a minute. Canceling "720p" from the page stops ffmpeg and ends that task - the job goes on with 480p; cancel the root to stop the job, without failing it.
139
+
140
+ **5. Draw it.** With importmap-rails, the Railtie has pinned `task_progress/stimulus` already.
141
+
142
+ ```js
143
+ // app/javascript/controllers/index.js
144
+ import TaskTreeController from "task_progress/stimulus"
145
+ application.register("task-tree", TaskTreeController)
146
+ ```
147
+
148
+ ```erb
149
+ <%# app/views/videos/show.html.erb %>
150
+ <link rel="stylesheet" href="/task_progress/js/task_tree.css">
151
+ <div class="tree" data-controller="task-tree"
152
+ data-task-tree-progress-value="<%= TaskProgress.for_page(@video.progress_key).to_json %>"
153
+ data-task-tree-poll-value="1"></div>
154
+ ```
155
+
156
+ The tree is drawn at once, from the snapshot `for_page` puts in. Then it polls every second (`poll-value`), draws once a frame at most, and stops once the video is through. Its cancel buttons ask where `for_page` says.
157
+
158
+ To have each poll wait for news instead - long polling - set `data-task-tree-wait-value="25"` in place of the poll value; every waiting poll holds a server thread, so size Puma for the pages you expect open at once. Leave both out, and it follows the tree over Server-Sent Events, falling back to polling by itself where those do not get through.
159
+
160
+ ## The same video on an Inertia page, with React
161
+
162
+ The job and the controller's `create` are [the ones above](#a-video-transcoded-in-a-background-job-polled-from-a-rails-page). What changes is the page: a React component that Inertia renders, and that reloads the video once its renditions are there.
163
+
164
+ **1. The browser client, vendored from the gem.**
165
+
166
+ ```
167
+ $ bin/rails task_progress:vendor
168
+ $ npm install ./vendor/javascript/task_progress
169
+ ```
170
+
171
+ The first copies the gem's `js/` into `vendor/javascript/task_progress`, as a package named `task_progress` with TypeScript declarations; the second links it into `node_modules`, for Vite to resolve like any other. React is a peer dependency - the one your app has. Commit the vendored copy, and run the task again after updating the gem - Rails warns at boot when the copy is from another version of the gem.
172
+
173
+ **2. One prop.**
174
+
175
+ ```ruby
176
+ # app/controllers/videos_controller.rb
177
+ def show
178
+ video = current_user.videos.find(params[:id])
179
+ render inertia: "Videos/Show", props: {
180
+ video: video.as_json(methods: :rendition_urls),
181
+ progress: -> { TaskProgress.for_page(video.progress_key) if video.progress_key }
182
+ }
183
+ end
184
+ ```
185
+
186
+ `for_page` hands out the links to the tree's events, polling and cancellations - with a signed token in them, good for an hour - and the tree as it stands, for the first render. As a lambda, Inertia leaves it out of a partial reload that does not ask for it.
187
+
188
+ **3. The component.**
189
+
190
+ ```tsx
191
+ // app/frontend/pages/Videos/Show.tsx
192
+ import { router } from "@inertiajs/react"
193
+ import { TaskTreeView, type Progress } from "task_progress/react"
194
+ import "task_progress/task_tree.css"
195
+
196
+ export default function Show({ video, progress }: { video: Video; progress: Progress | null }) {
197
+ return (
198
+ <>
199
+ <VideoPlayer video={video} />
200
+ {progress && (
201
+ <div className="tree">
202
+ <TaskTreeView progress={progress} onComplete={() => router.reload({ only: ["video"] })} />
203
+ </div>
204
+ )}
205
+ </>
206
+ )
207
+ }
208
+ ```
209
+
210
+ It draws the snapshot straight away - server-side rendering included - follows the tree over Server-Sent Events, and falls back to polling by itself where those do not get through. When the root is done, `onComplete` reloads `video`, and the player has its renditions; `onFail` is there for the other outcome. Navigating away closes the stream.
211
+
212
+ To draw it your own way - with your design system's components, say - take the rows instead:
213
+
214
+ ```tsx
215
+ import { useTaskTree } from "task_progress/react"
216
+
217
+ const { rows, cancel } = useTaskTree(progress, { onComplete: () => router.reload({ only: ["video"] }) })
218
+ return rows.map((row) => (
219
+ <ProgressRow key={row.key} depth={row.depth} name={row.name} percent={row.percent} striped={row.indeterminate}
220
+ status={row.status} message={row.message} onCancel={row.cancelable ? () => cancel(row) : undefined} />
221
+ ))
222
+ ```
223
+
224
+ ## Videos transcoded in subprocesses, drawn in one terminal
225
+
226
+ A script that transcodes a pile of videos into two sizes each, a few videos at a time, each in a Ruby process of its own - and draws the lot as one tree. Every process reports up a pipe; Ctrl-C cancels everything and stops every ffmpeg, and a process that crashes fails its own branch and the run with it.
227
+
228
+ **The worker** is an ordinary command-line program, using [the ffmpeg relay](#relaying-a-commands-progress-ffmpeg). Run on its own, it draws its own tree:
229
+
230
+ ```ruby
231
+ # transcode.rb
232
+ require "task_progress"
233
+ require_relative "lib/transcoder"
234
+
235
+ input = ARGV.fetch(0)
236
+ base = File.basename(input, ".*")
237
+ TaskProgress.cli(File.basename(input)) do |root|
238
+ root.subtask("1080p", cancelable: true) { |t| Transcoder.run(input, "#{base}-1080p.mp4", t, "-c:v", "libx264", "-preset", "fast") }
239
+ root.subtask("720p", cancelable: true) { |t| Transcoder.run(input, "#{base}-720p.mp4", t, "-vf", "scale=-2:720", "-c:v", "libx264", "-preset", "fast") }
240
+ end
241
+ ```
242
+
243
+ **The parent** mounts one worker per video. ffmpeg uses several cores of its own, so it runs half as many at once as there are cores:
244
+
245
+ ```ruby
246
+ # transcode_all.rb
247
+ require "task_progress"
248
+ require "etc"
249
+
250
+ files = ARGV
251
+ TaskProgress.cli("Transcode #{files.length} files", cancelable: true) do |root|
252
+ queue = Queue.new
253
+ files.each { queue << [it, root.mount(File.basename(it), cancelable: true)] }
254
+ queue.close
255
+
256
+ workers = [Etc.nprocessors / 2, files.length].min.clamp(1, nil).times.map do
257
+ Thread.new do
258
+ while (file, mount = queue.pop)
259
+ mount.spawn(RbConfig.ruby, File.expand_path("transcode.rb", __dir__), file)
260
+ end
261
+ end
262
+ end
263
+ workers.each(&:join)
264
+ end
265
+ ```
266
+
267
+ ```
268
+ $ ruby transcode_all.rb *.mov
269
+ ```
270
+
271
+ Every video shows from the start, waiting its turn. `TaskProgress.cli` in the worker sees it was started by `spawn` and writes its batches up the pipe instead of drawing; `spawn` returns once the worker exits. On a terminal the tree redraws in place. Piped, or on CI, it writes an execution log instead:
272
+
273
+ ```
274
+ [ 10% 0.6s] Transcode 2 files › a.mov › 1080p 4/20
275
+ [ 99% 2.4s] ✓ Transcode 2 files › b.mov › 1080p 1.7s
276
+ [ 99% 4.7s] ✓ Transcode 2 files › a.mov 4.1s
277
+ [ 99% 4.7s] ✓ Transcode 2 files › b.mov 4.1s
278
+ [100% 4.7s] ✓ Transcode 2 files 4.1s
279
+ ```
280
+
281
+ ffmpeg is a process of its own already, so the relay works from threads in one Ruby process just as well. A Ruby process per video is what to reach for when the Ruby side does real work of its own, or might fall over. Workers on other machines report the same way through Redis: hand each one a tree with `TaskProgress.handoff`, `adopt` it in the worker, and mount it in the parent with `root.mount(name).follow_stream(TaskProgress.stream_store, key)`. `task_progress watch KEY` draws any one of them from yet another terminal.
282
+
283
+ ## A Rails action that streams its own progress to React
284
+
285
+ A report that takes a minute to build, built while the page watches: the request does the work, and its response is the progress, as Server-Sent Events. Nothing to queue, nothing to store - when the report is ready, the stream ends.
286
+
287
+ **1. The action.** `ActionController::Live` runs it on a thread of its own and sends what it writes as it writes it. The work goes on another thread, and the stream follows it:
288
+
289
+ ```ruby
290
+ # config/routes.rb
291
+ get "/reports/:id/build" => "reports#build", as: :build_report
292
+
293
+ # app/controllers/reports_controller.rb
294
+ class ReportsController < ApplicationController
295
+ include ActionController::Live
296
+
297
+ def build
298
+ report = Report.find(params[:id])
299
+ response.headers.merge!(TaskProgress::SSE::HEADERS)
300
+ root = TaskProgress.new("Building #{report.name}", cancelable: true)
301
+ work = Thread.new do
302
+ Rails.application.executor.wrap do
303
+ root.run { |r| report.build!(progress: r) }
304
+ end
305
+ end
306
+ TaskProgress::SSE.new(root).each { response.stream.write(it) }
307
+ rescue ActionController::Live::ClientDisconnected
308
+ root.cancel!("Nobody is watching any more")
309
+ ensure
310
+ work&.join
311
+ response.stream.close
312
+ end
313
+ end
314
+ ```
315
+
316
+ ```ruby
317
+ # app/models/report.rb
318
+ def build!(progress:)
319
+ queries = progress.subtask("Queries", total: sections.size)
320
+ sections.each { |section| section.query! && queries.tick }
321
+ progress.subtask("PDF", indeterminate: true) { render_pdf! }
322
+ end
323
+ ```
324
+
325
+ The tree is sent whole first, then every change as it happens, and the stream ends once the root has finished. A page that goes away is noticed at the next write - a change, or the ping every 15 seconds - and cancels the work. The work is the request's own: each connection builds the report anew, so link to the stream only where you mean to start it. Every open stream holds a Puma thread.
326
+
327
+ **2. Load the client.**
328
+
329
+ ```
330
+ $ bin/importmap pin react react-dom/client
331
+ ```
332
+
333
+ The Railtie pins `task_progress/react` itself. In an app with a bundler, vendor it instead - see [the Inertia recipe](#the-same-video-on-an-inertia-page-with-react).
334
+
335
+ **3. Draw it.** In a React app:
336
+
337
+ ```jsx
338
+ import { TaskTreeView } from "task_progress/react"
339
+
340
+ export function ReportBuilder({ report }) {
341
+ const [building, setBuilding] = useState(false)
342
+ if (!building) return <button onClick={() => setBuilding(true)}>Build the report</button>
343
+ return <div className="tree"><TaskTreeView url={`/reports/${report.id}/build`} /></div>
344
+ }
345
+ ```
346
+
347
+ Or on a page that is not a React app, with no JSX and no build step:
348
+
349
+ ```erb
350
+ <link rel="stylesheet" href="/task_progress/js/task_tree.css">
351
+ <div class="tree" id="report-progress"></div>
352
+ <script type="module">
353
+ import { createTaskTreeRoot } from "task_progress/react"
354
+ createTaskTreeRoot(document.getElementById("report-progress")).render({ url: "<%= build_report_path(@report) %>" })
355
+ </script>
356
+ ```
357
+
358
+ `useTaskTree(url)` hands you the tree to draw your own way: `tree.rows()` is what to draw, and `rowView(tree, row)` from `row_view.js` what each row says. A tree of thousands of tasks folds what is done and what waits into a row each, so a big report draws as a handful of rows.
359
+
360
+ ## Upload progress, all in the browser
361
+
362
+ Files going up from a form, a bar for each, with no tree on the server and nothing to set up there. The tree lives in the page: `LocalTasks` keeps it, `trackUpload` follows an `XMLHttpRequest` - `fetch` reports nothing about a body going up - and the renderers draw it as it changes.
363
+
364
+ **React:**
365
+
366
+ ```jsx
367
+ import { useState } from "react"
368
+ import { LocalTasks, trackUpload } from "task_progress/local_tasks"
369
+ import { TaskTreeView } from "task_progress/react"
370
+
371
+ function upload(files) {
372
+ const uploads = new LocalTasks("Uploading", { cancelable: true })
373
+ for (const file of files) {
374
+ const xhr = new XMLHttpRequest()
375
+ xhr.open("PUT", `/attachments/${encodeURIComponent(file.name)}`)
376
+ trackUpload(xhr, uploads.root.subtask(file.name, { unit: "bytes", cancelable: true }), { abortOnCancel: true })
377
+ xhr.send(file)
378
+ }
379
+ return uploads
380
+ }
381
+
382
+ export function Uploader() {
383
+ const [uploads, setUploads] = useState(null)
384
+ return <>
385
+ <input type="file" multiple onChange={(e) => setUploads(upload(e.target.files))} />
386
+ {uploads && <div className="tree"><TaskTreeView local={uploads} /></div>}
387
+ </>
388
+ }
389
+ ```
390
+
391
+ **Stimulus** - the same `upload(files)`, drawn by the `task-tree` controller through an outlet:
392
+
393
+ ```html
394
+ <input type="file" multiple data-controller="uploader" data-action="uploader#upload" data-uploader-task-tree-outlet="#uploads">
395
+ <div class="tree" id="uploads" data-controller="task-tree"></div>
396
+ ```
397
+
398
+ ```js
399
+ // app/javascript/controllers/uploader_controller.js
400
+ export default class extends Controller {
401
+ static outlets = ["task-tree"]
402
+ upload(event) { this.taskTreeOutlet.show(upload(event.target.files)) }
403
+ }
404
+ ```
405
+
406
+ That is all of it. What you get:
407
+
408
+ - A row per file, with bytes sent, the rate and the time left, under a row for the lot. Put `task_tree.css` on the page for them.
409
+ - A file is done when the server answers 2xx, not when the last byte has gone out. Any other answer fails it with the status, and so do a network error and a timeout.
410
+ - Every cancel button works here, with nothing on the server to ask: a file's button aborts its request, and the top one aborts them all.
411
+ - The top row finishes when you say so. To have it end with the files, collect `task.whenFinished()` from each `trackUpload` and close it once all have ended: `Promise.all(endings).then(() => uploads.root.done())`. It resolves with how the task ended - `"done"`, `"failed"` or `"canceled"` - if you want a failure to fail the lot.
412
+
413
+ Uploading to S3, or to Active Storage's direct uploads, is the same `XMLHttpRequest`, with another URL and its headers. When the files are a step in a job the server tracks, graft the uploads under that job's task instead - see [Tasks that run in the browser](MANUAL.md#uploads) in the MANUAL.
@@ -0,0 +1,17 @@
1
+ # Drawn into the app's import map by the Railtie when importmap-rails is in use, before the app's
2
+ # own config/importmap.rb - so a pin of the same name there wins. The files are pinned where the
3
+ # Railtie serves them as they are: through the asset pipeline they would be fingerprinted, and
4
+ # the relative imports between them would point at names it does not serve
5
+ js = Rails.application.config.task_progress.js_path
6
+
7
+ {
8
+ "task_progress" => "task_tree.js",
9
+ "task_progress/react" => "react_task_tree.js",
10
+ "task_progress/stimulus" => "stimulus_controller.js",
11
+ "task_progress/local_tasks" => "local_tasks.js",
12
+ "task_progress/row_view" => "row_view.js",
13
+ "task_progress/clock" => "task_progress_clock.js"
14
+ }.each do |name, file|
15
+ # Not preloaded: the React and Stimulus parts import what only some pages have
16
+ pin name, to: "#{js}/#{file}", preload: false, integrity: false
17
+ end
data/exe/task_progress ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require_relative "../lib/task_progress"
6
+
7
+ url = ENV.fetch("REDIS_URL", "redis://localhost:6379")
8
+ # In a Rails app, where the Railtie looks for it too
9
+ default_dir = File.exist?("config/application.rb") ? "vendor/javascript/task_progress" : "task_progress"
10
+ parser = OptionParser.new do |o|
11
+ o.banner = <<~TEXT
12
+ Usage: task_progress watch KEY [--redis URL]
13
+ task_progress vendor [DIR]
14
+
15
+ watch Draws a tree handed off with TaskProgress.handoff - running anywhere that reaches
16
+ the same Redis - until it finishes. Exits 0 if it was complete, 1 if not. Ctrl-C
17
+ stops watching, and leaves the work alone.
18
+ vendor Copies the browser client into DIR (#{default_dir}), replacing what an earlier
19
+ vendoring left there, for a bundler to take from - `npm install ./DIR` makes it
20
+ the `task_progress` package.
21
+
22
+ TEXT
23
+ o.on("--redis URL", "Where the tree is kept (REDIS_URL, or #{url})") { url = it }
24
+ end
25
+ command, arg = parser.parse(ARGV)
26
+
27
+ case command
28
+ when "vendor"
29
+ dir = arg || default_dir
30
+ begin
31
+ files = TaskProgress.vendor_js(dir)
32
+ rescue ArgumentError => e
33
+ abort e.message
34
+ end
35
+ puts "The task_progress client #{TaskProgress::VERSION} is in #{dir}: #{files.join(", ")}"
36
+ when "watch"
37
+ abort(parser.help) unless arg
38
+ begin
39
+ require "redis-client"
40
+ rescue LoadError
41
+ abort "task_progress watch needs the redis-client gem"
42
+ end
43
+ stream_store = TaskProgress::RedisStreamStore.new(RedisClient.config(url:).new_pool(size: 2))
44
+ begin
45
+ exit(TaskProgress.watch(arg, stream_store:).complete? ? 0 : 1)
46
+ rescue KeyError => e
47
+ abort e.message
48
+ end
49
+ else
50
+ abort(parser.help)
51
+ end