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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/MANUAL.md +851 -0
- data/README.md +158 -0
- data/RECIPES.md +413 -0
- data/config/importmap.rb +17 -0
- data/exe/task_progress +51 -0
- data/js/local_tasks.d.ts +44 -0
- data/js/local_tasks.js +229 -0
- data/js/package.json +31 -0
- data/js/paths.d.ts +5 -0
- data/js/paths.js +7 -0
- data/js/react_task_tree.d.ts +49 -0
- data/js/react_task_tree.js +123 -0
- data/js/render_queue.d.ts +24 -0
- data/js/render_queue.js +79 -0
- data/js/row_view.d.ts +25 -0
- data/js/row_view.js +69 -0
- data/js/stimulus_controller.d.ts +10 -0
- data/js/stimulus_controller.js +137 -0
- data/js/task_progress_clock.d.ts +2 -0
- data/js/task_progress_clock.js +33 -0
- data/js/task_tree.css +51 -0
- data/js/task_tree.d.ts +111 -0
- data/js/task_tree.js +444 -0
- data/lib/task_progress/assertions.rb +49 -0
- data/lib/task_progress/batch.rb +23 -0
- data/lib/task_progress/branch.rb +74 -0
- data/lib/task_progress/buffered_output.rb +63 -0
- data/lib/task_progress/cancellations_app.rb +76 -0
- data/lib/task_progress/event.rb +46 -0
- data/lib/task_progress/execution_log_output.rb +183 -0
- data/lib/task_progress/fold.rb +18 -0
- data/lib/task_progress/html_renderer.rb +153 -0
- data/lib/task_progress/job_serializer.rb +92 -0
- data/lib/task_progress/journal.rb +35 -0
- data/lib/task_progress/log.rb +263 -0
- data/lib/task_progress/memory_cache.rb +31 -0
- data/lib/task_progress/memory_stream_store.rb +142 -0
- data/lib/task_progress/mount.rb +448 -0
- data/lib/task_progress/ndjson_output.rb +24 -0
- data/lib/task_progress/node.rb +52 -0
- data/lib/task_progress/poll.rb +87 -0
- data/lib/task_progress/publishing_output.rb +30 -0
- data/lib/task_progress/ractor_output.rb +23 -0
- data/lib/task_progress/rails_defaults.rb +46 -0
- data/lib/task_progress/railtie.rb +91 -0
- data/lib/task_progress/recording_output.rb +51 -0
- data/lib/task_progress/redis_stream_store.rb +131 -0
- data/lib/task_progress/squasher.rb +59 -0
- data/lib/task_progress/sse.rb +114 -0
- data/lib/task_progress/state.rb +319 -0
- data/lib/task_progress/stream_output.rb +222 -0
- data/lib/task_progress/stream_reader.rb +76 -0
- data/lib/task_progress/summary_output.rb +37 -0
- data/lib/task_progress/terminal_output.rb +241 -0
- data/lib/task_progress/token.rb +58 -0
- data/lib/task_progress/tracked_io.rb +68 -0
- data/lib/task_progress/transaction.rb +41 -0
- data/lib/task_progress/trees_app.rb +66 -0
- data/lib/task_progress/turbo_helper.rb +18 -0
- data/lib/task_progress/turbo_sse.rb +103 -0
- data/lib/task_progress/turbo_stream_output.rb +61 -0
- data/lib/task_progress/version.rb +7 -0
- data/lib/task_progress/wire.rb +211 -0
- data/lib/task_progress.rb +1036 -0
- data/sig/generated/task_progress/assertions.rbs +36 -0
- data/sig/generated/task_progress/batch.rbs +33 -0
- data/sig/generated/task_progress/branch.rbs +47 -0
- data/sig/generated/task_progress/buffered_output.rbs +38 -0
- data/sig/generated/task_progress/cancellations_app.rbs +41 -0
- data/sig/generated/task_progress/event.rbs +20 -0
- data/sig/generated/task_progress/execution_log_output.rbs +93 -0
- data/sig/generated/task_progress/fold.rbs +25 -0
- data/sig/generated/task_progress/html_renderer.rbs +71 -0
- data/sig/generated/task_progress/journal.rbs +25 -0
- data/sig/generated/task_progress/log.rbs +140 -0
- data/sig/generated/task_progress/memory_cache.rbs +28 -0
- data/sig/generated/task_progress/memory_stream_store.rbs +83 -0
- data/sig/generated/task_progress/mount.rbs +200 -0
- data/sig/generated/task_progress/ndjson_output.rbs +18 -0
- data/sig/generated/task_progress/node.rbs +66 -0
- data/sig/generated/task_progress/poll.rbs +50 -0
- data/sig/generated/task_progress/publishing_output.rbs +24 -0
- data/sig/generated/task_progress/ractor_output.rbs +17 -0
- data/sig/generated/task_progress/rails_defaults.rbs +33 -0
- data/sig/generated/task_progress/recording_output.rbs +41 -0
- data/sig/generated/task_progress/redis_stream_store.rbs +75 -0
- data/sig/generated/task_progress/squasher.rbs +20 -0
- data/sig/generated/task_progress/sse.rbs +73 -0
- data/sig/generated/task_progress/state.rbs +165 -0
- data/sig/generated/task_progress/stream_output.rbs +142 -0
- data/sig/generated/task_progress/stream_reader.rbs +46 -0
- data/sig/generated/task_progress/summary_output.rbs +19 -0
- data/sig/generated/task_progress/terminal_output.rbs +117 -0
- data/sig/generated/task_progress/token.rbs +41 -0
- data/sig/generated/task_progress/tracked_io.rbs +48 -0
- data/sig/generated/task_progress/transaction.rbs +33 -0
- data/sig/generated/task_progress/trees_app.rbs +40 -0
- data/sig/generated/task_progress/turbo_helper.rbs +14 -0
- data/sig/generated/task_progress/turbo_sse.rbs +69 -0
- data/sig/generated/task_progress/turbo_stream_output.rbs +51 -0
- data/sig/generated/task_progress/version.rbs +6 -0
- data/sig/generated/task_progress/wire.rbs +102 -0
- data/sig/generated/task_progress.rbs +553 -0
- 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
|