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,211 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "json"
5
+
6
+ class TaskProgress
7
+ # A batch as JSON, one object per line:
8
+ #
9
+ # {"v":1,"log":"k7p2qd9z4m","prev":11,"seq":12,"at":1730000000.123,"ops":[[["x3k0","00af"],"set",40],[["x3k0"],"note","Reading"]]}
10
+ #
11
+ # The reading side is meant for a writer we do not trust - a subprocess parsing hostile input,
12
+ # a stream relayed from elsewhere - so nothing it sends is taken at its word: a batch with
13
+ # anything wrong in it is dropped whole, numbers are clamped, strings scrubbed and cut, paths
14
+ # bounded, and nothing in an error is ever turned into a class
15
+ module Wire
16
+ # The version of the format every batch carries as `v`. A reader takes only its own: a batch
17
+ # from a newer writer is dropped, as a garbled one would be, rather than half understood
18
+ VERSION = 1 #: Integer
19
+ # The longest line a reader takes - see the MANUAL for what every limit is for
20
+ LINE_LIMIT = 4 * 1024 * 1024 #: Integer
21
+ # Where a string is cut
22
+ TEXT_LIMIT = 2000 #: Integer
23
+ # How much of a backtrace is kept
24
+ BACKTRACE_LINES = 200 #: Integer
25
+ # How many causes of an error are kept
26
+ CAUSES = 3 #: Integer
27
+ # How deep a path may go
28
+ MAX_DEPTH = 32 #: Integer
29
+ # What a task id may be: lower case letters and digits, 16 at most
30
+ SEGMENT = /\A[0-9a-z]{1,16}\z/ #: Regexp
31
+ # The most events one batch may carry
32
+ MAX_OPS = 100_000 #: Integer
33
+ # The largest count or total - what a JavaScript number holds exactly
34
+ MAX_COUNT = 2**53 #: Integer
35
+ # What a `put` may say a task's status is
36
+ STATUSES = %w[pending active done failed canceled].freeze #: Array[String]
37
+
38
+ class << self
39
+ # The batch as a JSON-ready Hash
40
+ #: (Batch) -> Hash[Symbol, untyped]
41
+ def to_h(batch)
42
+ {v: VERSION, log: batch.log, prev: batch.prev, seq: batch.seq, at: batch.at,
43
+ ops: batch.events.map { |e| e.value.nil? ? [e.path, e.op] : [e.path, e.op, e.value] }}
44
+ end
45
+
46
+ # The batch as one line of JSON, without the newline
47
+ #: (Batch) -> String
48
+ def dump(batch) = JSON.generate(to_h(batch))
49
+
50
+ # A Batch, or nil when the line is anything but a valid one
51
+ #: (String) -> Batch?
52
+ def parse(line)
53
+ from_h(JSON.parse(line))
54
+ rescue JSON::ParserError, EncodingError
55
+ nil
56
+ end
57
+
58
+ # A Batch from its Hash form - string or symbol keys - or nil if anything in it is wrong
59
+ #: (untyped) -> Batch?
60
+ def from_h(data)
61
+ return unless data.is_a?(Hash)
62
+ data = data.transform_keys(&:to_s)
63
+ return unless data["v"] == VERSION
64
+ prev, seq, ops = data.values_at("prev", "seq", "ops")
65
+ return unless prev.is_a?(Integer) && seq.is_a?(Integer) && prev >= 0 && seq > prev
66
+ return unless ops.is_a?(Array) && ops.length <= MAX_OPS
67
+ events = ops.map { event(it) || (return nil) }
68
+ at = number(data["at"]) || 0.0
69
+ Batch.new(log: text(data["log"])&.slice(0, 64), prev:, seq:, at:, events: events.freeze)
70
+ end
71
+
72
+ # Reads batches until EOF. An overlong line is skipped, as is a garbled one, and a line
73
+ # cut off by a writer that died is never yielded
74
+ #: (IO, ?limit: Integer) { (Batch) -> void } -> void
75
+ def each_batch(io, limit: LINE_LIMIT)
76
+ io.binmode
77
+ while (line = io.gets("\n", limit))
78
+ if !line.end_with?("\n")
79
+ if line.bytesize >= limit
80
+ line = io.gets("\n", limit) until line.nil? || line.end_with?("\n")
81
+ end
82
+ next
83
+ end
84
+ batch = parse(line)
85
+ yield batch if batch
86
+ end
87
+ end
88
+
89
+ # An exception - its class, message, backtrace and causes - or a message, as an error Hash
90
+ #: (Exception | String | untyped, ?Integer) -> Hash[Symbol, untyped]
91
+ def error_to_h(error, depth = 0)
92
+ return {class: "RuntimeError", message: text(error.to_s)} unless error.is_a?(Exception)
93
+ {
94
+ class: error.class.name,
95
+ message: text(error.message),
96
+ backtrace: error.backtrace&.first(BACKTRACE_LINES),
97
+ cause: (error_to_h(error.cause, depth + 1) if error.cause && depth < CAUSES)
98
+ }.compact
99
+ end
100
+
101
+ # Every string that leaves or enters goes through here: valid UTF-8, and cut
102
+ #: (untyped) -> String?
103
+ def text(value)
104
+ return unless value.is_a?(String) || value.is_a?(Symbol)
105
+ TaskProgress.utf8(value.to_s)[0, TEXT_LIMIT]
106
+ end
107
+
108
+ private
109
+
110
+ #: (untyped) -> Event?
111
+ def event(op)
112
+ return unless op.is_a?(Array) && (2..3).cover?(op.length)
113
+ path, name, value = op
114
+ name = name.name if name.is_a?(Symbol)
115
+ return unless (path = path(path)) && name.is_a?(String)
116
+ op = OPS.find { it.name == name } or return
117
+ value = value(op, value)
118
+ return if value == :invalid
119
+ Event.new(path:, op:, value:)
120
+ end
121
+
122
+ #: (untyped) -> path?
123
+ def path(path)
124
+ return unless path.is_a?(Array) && path.length <= MAX_DEPTH
125
+ return unless path.all? { it.is_a?(String) && it.match?(SEGMENT) }
126
+ path.map { -it }.freeze
127
+ end
128
+
129
+ #: (Symbol, untyped) -> untyped
130
+ def value(op, value)
131
+ case op
132
+ when :open, :meta then attributes(value, stale: op == :meta)
133
+ when :set then number(value)&.clamp(0, 100) || :invalid
134
+ when :add then value.is_a?(Integer) ? value.clamp(-MAX_COUNT, MAX_COUNT) : :invalid
135
+ when :note then text(value)
136
+ when :say then text(value) || :invalid
137
+ when :done, :prune then nil
138
+ when :fail then value.is_a?(Hash) ? error(value) : :invalid
139
+ when :cancel then value.nil? ? nil : (text(value) || :invalid)
140
+ when :put then node(value)
141
+ end
142
+ end
143
+
144
+ #: (untyped, ?stale: bool) -> (Hash[Symbol, untyped] | :invalid)
145
+ def attributes(value, stale: false)
146
+ return :invalid unless value.is_a?(Hash)
147
+ value = value.transform_keys(&:to_s)
148
+ attrs = {} #: Hash[Symbol, untyped]
149
+ attrs[:name] = text(value["name"]).to_s if value.key?("name")
150
+ attrs[:total] = count(value["total"]) if value.key?("total")
151
+ weight = number(value["weight"])
152
+ attrs[:weight] = weight.clamp(0.0, 1e9) if weight
153
+ attrs[:expect] = count(value["expect"]) if value.key?("expect")
154
+ attrs[:indeterminate] = value["indeterminate"] == true if value.key?("indeterminate")
155
+ attrs[:cancelable] = value["cancelable"] == true if value.key?("cancelable")
156
+ attrs[:attempt] = count(value["attempt"])&.clamp(1, 1_000_000) if value.key?("attempt")
157
+ attrs[:unit] = text(value["unit"])&.slice(0, 16) if value.key?("unit")
158
+ attrs[:stale] = value["stale"] == true if stale && value.key?("stale")
159
+ attrs
160
+ end
161
+
162
+ #: (untyped) -> (Hash[Symbol, untyped] | :invalid)
163
+ def node(value)
164
+ attrs = attributes(value)
165
+ return :invalid unless attrs.is_a?(Hash)
166
+ value = value.transform_keys(&:to_s)
167
+ status = value["status"].is_a?(Symbol) ? value["status"].name : value["status"]
168
+ return :invalid unless STATUSES.include?(status)
169
+ attrs.merge(
170
+ status: status.to_sym,
171
+ count: count(value["count"]) || 0,
172
+ percent: value["percent"].is_a?(Numeric) ? value["percent"].clamp(0, 100) : 0,
173
+ message: text(value["message"]),
174
+ error: (error(value["error"]) if value["error"].is_a?(Hash)),
175
+ stale: value["stale"] == true,
176
+ opened_at: time(value["opened_at"]),
177
+ started_at: time(value["started_at"]),
178
+ finished_at: time(value["finished_at"])
179
+ )
180
+ end
181
+
182
+ #: (untyped) -> Integer?
183
+ def count(value) = (value.clamp(0, MAX_COUNT) if value.is_a?(Integer))
184
+
185
+ #: (untyped) -> Float?
186
+ def time(value) = number(value)
187
+
188
+ # Finite integers and floats only - not a Rational, nor a Complex that has no Float to give,
189
+ # nor the Infinity that `1e999` parses to, which no JSON could ever be written with again
190
+ #: (untyped) -> Float?
191
+ def number(value)
192
+ return unless value.is_a?(Integer) || value.is_a?(Float)
193
+ value.to_f.then { it if it.finite? }
194
+ end
195
+
196
+ #: (Hash[untyped, untyped], ?Integer) -> Hash[Symbol, untyped]
197
+ def error(data, depth = 0)
198
+ data = data.transform_keys(&:to_s)
199
+ extra = data.except("class", "message", "backtrace", "cause")
200
+ .select { |_, v| v == true || v == false || v.is_a?(Integer) }.first(8).to_h { |k, v| [k.to_sym, v] }
201
+ cause = data["cause"]
202
+ {
203
+ class: text(data["class"]) || "unknown",
204
+ message: text(data["message"]).to_s,
205
+ backtrace: ([*data["backtrace"]].first(BACKTRACE_LINES).filter_map { text(it) } if data.key?("backtrace")),
206
+ cause: (error(cause, depth + 1) if cause.is_a?(Hash) && depth < CAUSES)
207
+ }.compact.merge(extra.except(:class, :message, :backtrace, :cause))
208
+ end
209
+ end
210
+ end
211
+ end