cogworker 0.1.0

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/exe/cogworker +6 -0
  3. data/exe/cogworkerswarm +6 -0
  4. data/lib/cogworker/basic_fetch.rb +27 -0
  5. data/lib/cogworker/cli.rb +64 -0
  6. data/lib/cogworker/client.rb +61 -0
  7. data/lib/cogworker/component.rb +19 -0
  8. data/lib/cogworker/config.rb +84 -0
  9. data/lib/cogworker/config_loader.rb +22 -0
  10. data/lib/cogworker/heartbeat.rb +150 -0
  11. data/lib/cogworker/history/middleware.rb +18 -0
  12. data/lib/cogworker/history/storage.rb +81 -0
  13. data/lib/cogworker/history.rb +33 -0
  14. data/lib/cogworker/job.rb +75 -0
  15. data/lib/cogworker/job_record.rb +24 -0
  16. data/lib/cogworker/job_util.rb +60 -0
  17. data/lib/cogworker/launcher.rb +93 -0
  18. data/lib/cogworker/logging.rb +18 -0
  19. data/lib/cogworker/manager.rb +71 -0
  20. data/lib/cogworker/middleware/chain.rb +64 -0
  21. data/lib/cogworker/periodic/claim.lua +27 -0
  22. data/lib/cogworker/periodic/entry.rb +24 -0
  23. data/lib/cogworker/periodic/manager.rb +28 -0
  24. data/lib/cogworker/periodic/release_middleware.rb +27 -0
  25. data/lib/cogworker/periodic/ticker.rb +123 -0
  26. data/lib/cogworker/process.rb +36 -0
  27. data/lib/cogworker/process_set.rb +29 -0
  28. data/lib/cogworker/processor.rb +129 -0
  29. data/lib/cogworker/prometheus/exporter.rb +62 -0
  30. data/lib/cogworker/queue.rb +60 -0
  31. data/lib/cogworker/redis_connection.rb +37 -0
  32. data/lib/cogworker/redis_keys.rb +32 -0
  33. data/lib/cogworker/scheduled.rb +67 -0
  34. data/lib/cogworker/signals.rb +15 -0
  35. data/lib/cogworker/stats.rb +47 -0
  36. data/lib/cogworker/status/client_middleware.rb +19 -0
  37. data/lib/cogworker/status/server_middleware.rb +31 -0
  38. data/lib/cogworker/status/storage.rb +30 -0
  39. data/lib/cogworker/status/worker.rb +27 -0
  40. data/lib/cogworker/status.rb +40 -0
  41. data/lib/cogworker/swarm.rb +169 -0
  42. data/lib/cogworker/testing.rb +109 -0
  43. data/lib/cogworker/unique_jobs/client_middleware.rb +31 -0
  44. data/lib/cogworker/unique_jobs/release_middleware.rb +30 -0
  45. data/lib/cogworker/unique_jobs.rb +32 -0
  46. data/lib/cogworker/version.rb +5 -0
  47. data/lib/cogworker/web/action.rb +63 -0
  48. data/lib/cogworker/web/application.rb +62 -0
  49. data/lib/cogworker/web/assets/ag-grid/ag-grid-community.min.js +1 -0
  50. data/lib/cogworker/web/assets/ag-grid/ag-grid.min.css +7 -0
  51. data/lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css +2 -0
  52. data/lib/cogworker/web/assets/chart.umd.min.js +13 -0
  53. data/lib/cogworker/web/assets/htmx.min.js +1 -0
  54. data/lib/cogworker/web/assets/tailwind.css +1 -0
  55. data/lib/cogworker/web/layout.rb +352 -0
  56. data/lib/cogworker/web/router.rb +27 -0
  57. data/lib/cogworker/web/routes/busy.rb +99 -0
  58. data/lib/cogworker/web/routes/dead.rb +93 -0
  59. data/lib/cogworker/web/routes/history.rb +226 -0
  60. data/lib/cogworker/web/routes/periodic.rb +67 -0
  61. data/lib/cogworker/web/routes/queues.rb +101 -0
  62. data/lib/cogworker/web/routes/retries.rb +89 -0
  63. data/lib/cogworker/web/routes/save_session.rb +21 -0
  64. data/lib/cogworker/web/routes/scheduled.rb +49 -0
  65. data/lib/cogworker/web/routes/stats.rb +298 -0
  66. data/lib/cogworker/web/views.rb +25 -0
  67. data/lib/cogworker/web.rb +257 -0
  68. data/lib/cogworker/work.rb +19 -0
  69. data/lib/cogworker/work_set.rb +23 -0
  70. data/lib/cogworker/worker.rb +5 -0
  71. data/lib/cogworker/workers.rb +8 -0
  72. data/lib/cogworker.rb +114 -0
  73. metadata +300 -0
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Cogworker
6
+ class Web
7
+ module Routes
8
+ # The 6 job-counter cards, the "Runs per day" Chart.js graph, and a
9
+ # Redis INFO summary.
10
+ module Stats
11
+ COUNTERS_CONTENT_ID = 'stats-counters-content'
12
+ REDIS_CONTENT_ID = 'stats-redis-content'
13
+ # The 6 job-counter colors are the same ones `Layout.stat_chip` uses
14
+ # for the compact bar shown on every page — one shared mapping
15
+ # (`Layout::JOB_STAT_ACCENTS`) so the two never drift apart.
16
+ CARD_ACCENTS = Layout::JOB_STAT_ACCENTS.merge(
17
+ 'Version' => 'text-gray-700 dark:text-gray-300', 'Uptime (days)' => 'text-gray-700 dark:text-gray-300',
18
+ 'Connections' => 'text-gray-700 dark:text-gray-300', 'Memory Usage' => 'text-gray-700 dark:text-gray-300',
19
+ 'Peak Memory Usage' => 'text-gray-700 dark:text-gray-300'
20
+ ).freeze
21
+ # `INFO` field name => card label. Pulled from the flat Hash
22
+ # `Cogworker::Stats#redis_info` returns (same field names the
23
+ # `redis` gem always uses, regardless of Redis version) — a field
24
+ # missing from a given server/deployment renders as "n/a" rather
25
+ # than raising.
26
+ REDIS_INFO_FIELDS = {
27
+ 'redis_version' => 'Version', 'uptime_in_days' => 'Uptime (days)',
28
+ 'connected_clients' => 'Connections', 'used_memory_human' => 'Memory Usage',
29
+ 'used_memory_peak_human' => 'Peak Memory Usage'
30
+ }.freeze
31
+ # Period switcher options for the "Runs per day" chart —
32
+ # `params['period']` (a plain query string, read fresh on every
33
+ # request; see `registered` below) selects one of these by key.
34
+ # Ordered as displayed, shortest first.
35
+ PERIODS = {
36
+ 'week' => { 'label' => 'Week', 'days' => 7 },
37
+ 'month' => { 'label' => 'Month', 'days' => 30 },
38
+ '3months' => { 'label' => '3 Months', 'days' => 90 },
39
+ '6months' => { 'label' => '6 Months', 'days' => 182 }
40
+ }.freeze
41
+ DEFAULT_PERIOD = 'month'
42
+ CHART_SUCCESS_COLOR = '#16a34a'
43
+ CHART_FAILED_COLOR = '#dc2626'
44
+ CHART_CANVAS_ID = 'runs-chart-canvas'
45
+ CHART_CONTAINER_HEIGHT_PX = 220
46
+ # Vendored under assets/ (see CLAUDE.md's "Fully offline" section —
47
+ # served locally, not fetched from a CDN), the same way AG_GRID_ASSETS
48
+ # is in `routes/history.rb`.
49
+ CHART_ASSET = 'assets/chart.umd.min.js'
50
+
51
+ module_function
52
+
53
+ def registered(app)
54
+ renderer = lambda do
55
+ # A plain query param, not htmx state — the period switcher
56
+ # below is a normal `<a href>` (full page reload), exactly like
57
+ # `Routes::History`'s status filter links.
58
+ period = Stats.resolve_period(params['period'])
59
+ body = Stats.page_body(period, request.script_name)
60
+ if hx_request?
61
+ body
62
+ else
63
+ Layout.wrap('Stats', body, script_name: request.script_name, show_stats_bar: false,
64
+ extra_head: Stats.chart_head(request.script_name))
65
+ end
66
+ end
67
+
68
+ app.get('/', &renderer)
69
+ app.get('/stats', &renderer)
70
+
71
+ # Polled by `Layout.stats_bar` (the global counter strip shown
72
+ # under the header on every page, not just here) — always just
73
+ # this small fragment, never a full page.
74
+ app.get('/stats/bar') { Layout.stats_bar_content }
75
+
76
+ # The job-counter and Redis grids each poll their own small
77
+ # fragment independently (see `page_body`) — neither depends on
78
+ # `period`, so unlike the chart there's no query string to carry.
79
+ app.get('/stats/counters') { Stats.counters_grid }
80
+ app.get('/stats/redis') { Stats.redis_grid(Cogworker::Stats.new.redis_info) }
81
+
82
+ # Polled directly by the chart's own inline script (`chart`,
83
+ # below) via `fetch` — NOT an htmx target, and deliberately not a
84
+ # full HTML fragment: returning just the plotted numbers lets the
85
+ # chart patch its existing Chart.js instance's data in place
86
+ # (`chart.update()`) instead of tearing down and recreating the
87
+ # `<canvas>`/instance on every tick, which is what an htmx
88
+ # innerHTML-swapped fragment would force. Same idea as
89
+ # `Routes::History`'s `/history/data` for its AG Grid.
90
+ app.get('/stats/chart_data') do
91
+ period = Stats.resolve_period(params['period'])
92
+ [200, { 'content-type' => 'application/json' }, [JSON.generate(Stats.chart_data_payload(period))]]
93
+ end
94
+ end
95
+
96
+ def resolve_period(raw)
97
+ PERIODS.key?(raw) ? raw : DEFAULT_PERIOD
98
+ end
99
+
100
+ def chart_head(script_name)
101
+ %(<script src="#{Layout.path(script_name, CHART_ASSET)}"></script>)
102
+ end
103
+
104
+ # The counters and Redis grids are each their own independently
105
+ # htmx-polled fragment (`Layout.poll_div`) — plain, stateless HTML,
106
+ # cheap to fully replace every tick. The "Runs per day" section in
107
+ # between is a normal, *unpolled* part of the page: its own chart
108
+ # keeps itself live via `/stats/chart_data` instead (see
109
+ # `registered` above and `chart` below), so re-rendering this whole
110
+ # method's output on a poll would rebuild widgets that don't need
111
+ # rebuilding — the counters/Redis grids are the only pieces actually
112
+ # meant to be swapped wholesale on every tick.
113
+ def page_body(period, script_name)
114
+ stats = Cogworker::Stats.new
115
+ counters_poll = Layout.poll_div(COUNTERS_CONTENT_ID, script_name, 'stats/counters', counters_grid(stats))
116
+ runs_chart = Layout.section('Runs per day', runs_per_day_section(period, script_name))
117
+ redis_poll = Layout.section('Redis', Layout.poll_div(REDIS_CONTENT_ID, script_name, 'stats/redis',
118
+ redis_grid(stats.redis_info)))
119
+ counters_poll + runs_chart + redis_poll
120
+ end
121
+
122
+ def counters_grid(stats = Cogworker::Stats.new)
123
+ values = {
124
+ 'Enqueued' => stats.enqueued, 'Processed' => stats.processed, 'Failed' => stats.failed,
125
+ 'Retries' => stats.retry_size, 'Scheduled' => stats.scheduled_size, 'Dead' => stats.dead_size
126
+ }
127
+ cards = values.map { |label, value| card(label, value) }.join
128
+ %(<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">#{cards}</div>)
129
+ end
130
+
131
+ def redis_grid(info)
132
+ cards = REDIS_INFO_FIELDS.map { |field, label| card(label, info[field] || 'n/a') }.join
133
+ %(<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">#{cards}</div>)
134
+ end
135
+
136
+ def runs_per_day_section(period, script_name)
137
+ period_switcher(script_name, period) + chart_bubble(period, script_name)
138
+ end
139
+
140
+ def period_switcher(script_name, current_period)
141
+ links = PERIODS.map do |key, opts|
142
+ period_link(script_name, key, opts['label'], active: key == current_period)
143
+ end.join
144
+ %(<div class="mb-4 flex gap-2">#{links}</div>)
145
+ end
146
+
147
+ def period_link(script_name, key, label, active:)
148
+ classes = if active
149
+ 'bg-indigo-600 text-white'
150
+ else
151
+ 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
152
+ end
153
+ href = Layout.path(script_name, "stats?period=#{key}")
154
+ %(<a href="#{href}" class="px-3 py-1 rounded-md text-sm font-medium #{classes}">#{Layout.h(label)}</a>)
155
+ end
156
+
157
+ # Wraps the chart in the same "bubble" card look as the job-counter/
158
+ # Redis stat cards (`card`, below).
159
+ def chart_bubble(period, script_name)
160
+ %(<div class="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-4">#{chart(
161
+ period, script_name
162
+ )}</div>)
163
+ end
164
+
165
+ # Success/failed counts for `period`, keyed exactly as Chart.js
166
+ # wants them — `labels`/`fullDates` line up index-for-index with
167
+ # `success`/`failed`. Shared between the initial render (`chart`,
168
+ # baked into the page) and `/stats/chart_data` (what the chart's own
169
+ # poll re-fetches from then on), so the two can never drift apart.
170
+ def chart_data_payload(period)
171
+ days_count = PERIODS.fetch(period, PERIODS[DEFAULT_PERIOD])['days']
172
+ # Fully qualified: a bare `History::Storage` from inside
173
+ # `Routes::Stats` would resolve, via lexical nesting, to
174
+ # `Cogworker::Web::Routes::History` first (this module's sibling
175
+ # route file) — the same gotcha `routes/history.rb` itself
176
+ # documents — not to the top-level `Cogworker::History`.
177
+ counts = Cogworker::History::Storage.daily_counts(days_count)
178
+ days = day_labels(days_count)
179
+ {
180
+ 'labels' => days.map { |d| d[5..] },
181
+ 'fullDates' => days,
182
+ 'success' => days.map { |d| (counts[d] || {})['success'] || 0 },
183
+ 'failed' => days.map { |d| (counts[d] || {})['failed'] || 0 }
184
+ }
185
+ end
186
+
187
+ # A small two-line chart (success/failed per day) rendered by
188
+ # Chart.js (vendored, loaded via `chart_head` — see CLAUDE.md's
189
+ # "Fully offline" section, not fetched from a CDN), not a hand-rolled
190
+ # SVG: a first attempt at a DIY SVG chart used
191
+ # `preserveAspectRatio="none"` to stretch full width, which visibly
192
+ # distorted the plotted lines/points on any card wider than its
193
+ # aspect ratio, and its styling looked noticeably rougher than a
194
+ # maintained charting library's own defaults (real user report on
195
+ # both counts). `responsive: true` + `maintainAspectRatio: false`
196
+ # fills the fixed-height wrapper div at its full width, at any
197
+ # screen size, without distortion — Chart.js sizes its own
198
+ # `<canvas>` (including devicePixelRatio) via `ResizeObserver`.
199
+ #
200
+ # This section is NOT inside any `Layout.poll_div` (see `page_body`
201
+ # above) — the `<canvas>`/`new Chart(...)` below render exactly once
202
+ # per page load. An earlier version instead re-rendered this whole
203
+ # chart (canvas included) on every htmx poll, which meant creating a
204
+ # brand-new Chart.js instance every tick; skipping `.destroy()` on
205
+ # the previous one (easy to miss, since the *canvas* really was
206
+ # gone) leaked one more zombie instance per poll — real, observed
207
+ # root cause of the chart eventually breaking under live updates.
208
+ # Rather than track and destroy instances across swaps, the fix here
209
+ # is to not recreate the widget at all: the chart keeps itself
210
+ # current by polling `/stats/chart_data` on its own (`refreshChart`
211
+ # below, gated by the same `window.cogworkerLiveUpdate` toggle every
212
+ # other tab's poll respects) and patching the *existing* instance's
213
+ # data in place, exactly like `Routes::History`'s AG Grid does via
214
+ # its own `refreshRows`/`/history/data`.
215
+ def chart(period, script_name)
216
+ payload = chart_data_payload(period)
217
+ data_url = Layout.path(script_name, "stats/chart_data?period=#{period}")
218
+
219
+ <<~HTML
220
+ <div style="position: relative; height: #{CHART_CONTAINER_HEIGHT_PX}px; width: 100%;">
221
+ <canvas id="#{CHART_CANVAS_ID}"></canvas>
222
+ </div>
223
+ <script>
224
+ (function () {
225
+ var dataUrl = #{Layout.json_for_script(data_url)};
226
+ var fullDates = #{Layout.json_for_script(payload['fullDates'])};
227
+ var chart = new Chart(document.getElementById(#{Layout.json_for_script(CHART_CANVAS_ID)}), {
228
+ type: 'line',
229
+ data: {
230
+ labels: #{Layout.json_for_script(payload['labels'])},
231
+ datasets: [
232
+ { label: 'Success', data: #{Layout.json_for_script(payload['success'])},
233
+ borderColor: #{Layout.json_for_script(CHART_SUCCESS_COLOR)},
234
+ backgroundColor: #{Layout.json_for_script(CHART_SUCCESS_COLOR)},
235
+ tension: 0.3, pointRadius: 2, borderWidth: 2 },
236
+ { label: 'Failed', data: #{Layout.json_for_script(payload['failed'])},
237
+ borderColor: #{Layout.json_for_script(CHART_FAILED_COLOR)},
238
+ backgroundColor: #{Layout.json_for_script(CHART_FAILED_COLOR)},
239
+ tension: 0.3, pointRadius: 2, borderWidth: 2 }
240
+ ]
241
+ },
242
+ options: {
243
+ responsive: true,
244
+ maintainAspectRatio: false,
245
+ interaction: { mode: 'index', intersect: false },
246
+ scales: {
247
+ x: { ticks: { color: '#6b7280', maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, grid: { display: false } },
248
+ y: { beginAtZero: true, ticks: { color: '#6b7280', precision: 0 }, grid: { color: 'rgba(107, 114, 128, 0.15)' } }
249
+ },
250
+ plugins: {
251
+ legend: { labels: { color: '#6b7280' } },
252
+ tooltip: { callbacks: { title: function (items) { return fullDates[items[0].dataIndex]; } } }
253
+ }
254
+ }
255
+ });
256
+
257
+ function refreshChart() {
258
+ if (!window.cogworkerLiveUpdate) return;
259
+ fetch(dataUrl, { headers: { 'Accept': 'application/json' } })
260
+ .then(function (r) { return r.ok ? r.json() : null; })
261
+ .then(function (data) {
262
+ if (!data) return;
263
+ fullDates = data.fullDates;
264
+ chart.data.labels = data.labels;
265
+ chart.data.datasets[0].data = data.success;
266
+ chart.data.datasets[1].data = data.failed;
267
+ chart.update();
268
+ })
269
+ .catch(function () {});
270
+ }
271
+ setInterval(refreshChart, #{Cogworker::Web.live_update_interval * 1000});
272
+ })();
273
+ </script>
274
+ HTML
275
+ end
276
+
277
+ # `days_count` consecutive UTC calendar-day strings ending today —
278
+ # matches the UTC bucketing `Storage.daily_counts` uses, so lookups
279
+ # by day string always line up.
280
+ def day_labels(days_count)
281
+ now = Time.now.utc
282
+ (days_count - 1).downto(0).map { |offset| (now - (offset * 86_400)).strftime('%Y-%m-%d') }
283
+ end
284
+
285
+ def card(label, value)
286
+ <<~HTML
287
+ <div class="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-4">
288
+ <div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">#{Layout.h(label)}</div>
289
+ <div class="mt-1 text-2xl font-bold #{CARD_ACCENTS.fetch(label, '')}">#{Layout.h(value)}</div>
290
+ </div>
291
+ HTML
292
+ end
293
+ end
294
+ end
295
+ end
296
+ end
297
+
298
+ Cogworker::Web.register(Cogworker::Web::Routes::Stats)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class Web
5
+ # Reads a built-in view template by name. An extension that wants its
6
+ # own template file can use the same pattern (read the file itself,
7
+ # then hand `erb` the resulting String) — `Action#erb` accepts either a
8
+ # Symbol (looked up here) or a raw ERB-source String directly.
9
+ module Views
10
+ VIEWS_PATH = File.join(__dir__, 'views')
11
+
12
+ class << self
13
+ def read(name)
14
+ cache[name] ||= File.read(File.join(VIEWS_PATH, "#{name}.erb"))
15
+ end
16
+
17
+ private
18
+
19
+ def cache
20
+ @cache ||= {}
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'rack/session/cookie'
5
+ require 'rack/static'
6
+ require 'securerandom'
7
+
8
+ module Cogworker
9
+ # Rack entry point: `map('/mount-point') { run Cogworker::Web }`.
10
+ #
11
+ # A plain, reopenable class on purpose — `safe_request?` is meant to be
12
+ # overridden by whoever mounts this (e.g. to let one specific external
13
+ # callback path through a same-origin check) without needing any hook
14
+ # mechanism from this gem: `class Cogworker::Web; def self.safe_request?(env);
15
+ # ...; end; end` just works, the same way monkey-patching any Ruby class
16
+ # does. Note the `self.` — `safe_request?` is defined inside `class << self`
17
+ # below (it's called as `Cogworker::Web.safe_request?`, not on an
18
+ # instance), so reopening it as a plain instance method is a silent no-op.
19
+ class Web
20
+ # Every built-in tab lives under here; touching each constant once
21
+ # forces Zeitwerk to load that file (and run its
22
+ # `Cogworker::Web.register(...)` bottom-of-file side effect) — nothing
23
+ # else ever references `Routes::Queues` etc. by name, so without this
24
+ # they would simply never load.
25
+ BUILT_IN_ROUTE_NAMES = %i[Queues Busy Retries Scheduled Periodic Dead History Stats SaveSession].freeze
26
+ DEFAULT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
27
+ DEFAULT_HISTORY_PER_PAGE = 25
28
+ DEFAULT_LIVE_UPDATE_INTERVAL = 3
29
+
30
+ # htmx/Tailwind/AG Grid are vendored under here (not fetched from a CDN)
31
+ # so the Web UI works with no internet access at all — see `Layout`'s
32
+ # `<script>`/`<link>` tags and `Routes::History#ag_grid_head`, all of
33
+ # which build their `src`/`href` as `path(script_name, 'assets/...')`,
34
+ # same as every other in-app link. `root:` is this directory's *parent*
35
+ # (not `assets/` itself) because `Rack::Static` appends the full,
36
+ # unstripped request path (e.g. `/assets/htmx.min.js`) onto `root`.
37
+ ASSETS_ROOT = File.join(__dir__, 'web')
38
+
39
+ # Guards every request's dispatch while reloading is enabled — a plain
40
+ # class-level constant (not lazily memoized via `||=`, which would
41
+ # itself race) so it exists before any request can possibly reach it.
42
+ # See `call`/`dispatch` below for why this has to wrap the *entire*
43
+ # request, not just `reload!`.
44
+ RELOAD_MUTEX = Mutex.new
45
+
46
+ class << self
47
+ # While `COGWORKER_RELOAD=true`, the whole request — the reload
48
+ # itself *and* the route handling that follows — runs inside
49
+ # `RELOAD_MUTEX`, not just the `Cogworker::LOADER.reload` call.
50
+ # `Zeitwerk::Loader#reload`'s `unload` step removes every constant it
51
+ # manages *process-wide* for the duration of the reload, not just
52
+ # from this thread's point of view — so a second thread mid-`app.call`
53
+ # (already past its own `reload!`, now executing a route that
54
+ # references `Layout`/`WorkSet`/etc.) can have those constants yanked
55
+ # out from under it and raise `NameError`, even though it never
56
+ # touched `reload!` concurrently itself. Only wrapping `reload!` (an
57
+ # earlier version of this fix) stopped the *permanent* `Zeitwerk::
58
+ # SetupRequired` wedge but not this — both were caught by
59
+ # `spec/cogworker/web_reload_spec.rb`'s concurrent-requests spec,
60
+ # which spawns several threads hammering `.call` at once. Outside
61
+ # `COGWORKER_RELOAD` (i.e. every real worker-adjacent Web process)
62
+ # `reloading?` is false and this adds no locking at all.
63
+ def call(env)
64
+ if reloading?
65
+ RELOAD_MUTEX.synchronize do
66
+ reload!
67
+ dispatch(env)
68
+ end
69
+ else
70
+ dispatch(env)
71
+ end
72
+ end
73
+
74
+ # Extends the Rack middleware stack this Web app itself runs behind
75
+ # (session cookie, an auth middleware wrapping the whole app) —
76
+ # distinct from mounting external middleware *outside* this app via
77
+ # plain `Rack::Builder`/`map`, which also works and needs nothing from
78
+ # here.
79
+ def use(middleware, *args)
80
+ middlewares << [middleware, args]
81
+ @app = nil
82
+ end
83
+
84
+ def register(extension, name: extension.to_s, tab: nil, index: nil)
85
+ Cogworker::Web::Application.register(extension)
86
+ tabs[tab] = index if tab && index
87
+ name
88
+ end
89
+
90
+ # Public, mutable: `Cogworker::Web.tabs['History'] = 'history'` is how
91
+ # a registered extension actually gets a nav entry.
92
+ def tabs
93
+ @tabs ||= {}
94
+ end
95
+
96
+ def session_secret
97
+ @session_secret ||= SecureRandom.hex(32)
98
+ end
99
+
100
+ # How every `Layout.time_tag` timestamp is rendered — a Ruby
101
+ # `Time#strftime` pattern, reused as-is client-side (the same tokens:
102
+ # %Y %m %d %H %M %S %B %b %A %a %p), just evaluated against the
103
+ # browser's local time instead of the server's UTC fallback. Set this
104
+ # from your own init file/config.ru, e.g.
105
+ # `Cogworker::Web.time_format = '%d.%m.%Y %H:%M'`.
106
+ def time_format
107
+ @time_format ||= DEFAULT_TIME_FORMAT
108
+ end
109
+
110
+ attr_writer :time_format, :history_per_page, :live_update_interval, :prometheus_exporter_enabled
111
+
112
+ # Whether `GET /metrics` (`Cogworker::Prometheus::Exporter`, mounted
113
+ # automatically by `load_routes!` below like any other built-in) is
114
+ # actually served — checked at request time by the exporter's own
115
+ # route, not here, so this can be set any time before a request comes
116
+ # in, same as `time_format`/`live_update_interval`. Set this to
117
+ # `false` from your own init file/config.ru to opt back out, e.g. if
118
+ # you'd rather scrape metrics through a separate, unauthenticated
119
+ # mount and don't want `/metrics` reachable behind this one at all.
120
+ def prometheus_exporter_enabled
121
+ return true unless defined?(@prometheus_exporter_enabled)
122
+
123
+ @prometheus_exporter_enabled
124
+ end
125
+
126
+ # Rows per page on the History tab. Retention depth (how many entries
127
+ # exist to page through at all) is a separate, gem-wide setting:
128
+ # `Cogworker::History.max_entries`.
129
+ def history_per_page
130
+ @history_per_page ||= DEFAULT_HISTORY_PER_PAGE
131
+ end
132
+
133
+ # How often (in seconds) every auto-refreshing tab polls while the
134
+ # global live-update toggle is on — both the htmx-polled Busy/Stats/
135
+ # Queues tabs (`Layout.poll_div`'s `hx-trigger="every Ns [...]"`) and
136
+ # History's own AG Grid `refreshRows()` JS poll share this one value,
137
+ # so there's a single knob rather than one per tab. Set this from your
138
+ # own init file/config.ru, e.g. `Cogworker::Web.live_update_interval = 10`.
139
+ def live_update_interval
140
+ @live_update_interval ||= DEFAULT_LIVE_UPDATE_INTERVAL
141
+ end
142
+
143
+ # A minimal same-origin check (default-open for safe/read-only HTTP
144
+ # methods, otherwise requires `Sec-Fetch-Site: same-origin`).
145
+ # Reopen this method to carve out an exception for a specific
146
+ # cross-site callback path (e.g. an SSO provider's POST redirect).
147
+ def safe_request?(env)
148
+ return true if safe_method?(env['REQUEST_METHOD'])
149
+
150
+ env['HTTP_SEC_FETCH_SITE'] == 'same-origin'
151
+ end
152
+
153
+ def safe_method?(method)
154
+ %w[GET HEAD OPTIONS].include?(method)
155
+ end
156
+
157
+ def reloading?
158
+ Cogworker::LOADER.reloading_enabled?
159
+ end
160
+
161
+ # Only ever called from `.call` above (always already holding
162
+ # `RELOAD_MUTEX` — see there for why), i.e. only while handling a Web
163
+ # UI HTTP request — never from a job-processing thread. Note this
164
+ # reloads Zeitwerk's *entire* managed tree (there's one loader for the
165
+ # whole gem), not just lib/cogworker/web/**: editing an engine file
166
+ # (Processor, Config, ...) while a dev Web UI process is up will also
167
+ # pick up those changes on the next request. That's harmless for a
168
+ # process that only ever serves HTTP and never runs `Launcher`, but is
169
+ # exactly why this must never be wired into `exe/cogworker`.
170
+ #
171
+ # `Cogworker::Web` itself is *not* meaningfully reloadable: Rack
172
+ # captures a single reference to this class object once, at `run
173
+ # Cogworker::Web` boot time, and calls that same object's `.call`
174
+ # forever — editing this file's own methods (`call`, `build_app`, ...)
175
+ # has no effect without a real process restart. What *does* refresh
176
+ # every request is everything this class only ever reaches through a
177
+ # fresh, fully-qualified lookup: `@app` is rebuilt every time (so it
178
+ # re-resolves `Cogworker::Web::Application`, not a memoized stale
179
+ # reference to whatever object used to be there), and that in turn
180
+ # re-executes `Routes::*`/`Layout`/`Router`/`Action` fresh, so their
181
+ # bare cross-references to each other stay internally consistent.
182
+ def reload!
183
+ @tabs = {}
184
+ @app = nil
185
+ Cogworker::LOADER.reload
186
+ load_routes!
187
+ end
188
+
189
+ # Fully-qualified references throughout, not bare `Application`/
190
+ # `Routes`: right after `Zeitwerk::Loader#reload`, a bare constant
191
+ # lookup relying on lexical nesting can fail to find a freshly
192
+ # re-armed autoload even though the constant is genuinely there —
193
+ # `Cogworker::Web::Routes` resolves correctly where a bare `Routes`
194
+ # (searched via Module.nesting from inside this method) sometimes
195
+ # raises `NameError` immediately after a reload. Fully-qualifying
196
+ # sidesteps it entirely.
197
+ def load_routes!
198
+ BUILT_IN_ROUTE_NAMES.each { |name| Cogworker::Web::Routes.const_get(name) }
199
+ # Different namespace (Cogworker::Prometheus::Exporter, not
200
+ # Cogworker::Web::Routes::*) so it can't join BUILT_IN_ROUTE_NAMES
201
+ # above, but touching the constant is the same Zeitwerk-autoload
202
+ # trick either way: it runs that file's own bottom-of-file
203
+ # `Cogworker::Web.register(...)` exactly like a route file would.
204
+ # Previously this constant was only ever touched by an app's own
205
+ # init code remembering to reference it — forgetting to do so left
206
+ # `/metrics` returning a plain 404 with no indication why the route
207
+ # was missing.
208
+ # Mounting it always and gating on `prometheus_exporter_enabled`
209
+ # instead (checked inside the route itself, not here) fixes that
210
+ # while keeping the opt-out.
211
+ Cogworker::Prometheus::Exporter
212
+ end
213
+
214
+ private
215
+
216
+ def dispatch(env)
217
+ return forbidden(env) unless safe_request?(env)
218
+
219
+ app.call(env)
220
+ end
221
+
222
+ def forbidden(_env)
223
+ [403, { 'content-type' => 'text/plain' }, ['Forbidden']]
224
+ end
225
+
226
+ def middlewares
227
+ @middlewares ||= []
228
+ end
229
+
230
+ def app
231
+ @app ||= build_app
232
+ end
233
+
234
+ def build_app
235
+ builder = Rack::Builder.new
236
+ builder.use(Rack::Session::Cookie, secret: session_secret, key: 'cogworker.session')
237
+ # No `cache_control:` (no `immutable`/long `max-age`): these files
238
+ # are plain, unfingerprinted paths that *do* change — every time
239
+ # this gem's Tailwind bundle gets rebuilt, or on any gem upgrade —
240
+ # and `immutable` previously told browsers to keep serving a stale
241
+ # cached copy under the old URL for up to a year with no
242
+ # revalidation at all (caught by hand: a real edit to tailwind.css
243
+ # didn't show up in an already-open tab until a hard reload).
244
+ # `Rack::Static`/`Rack::Files` already sends `Last-Modified` and
245
+ # honors conditional GETs by default, which is all that's needed
246
+ # here — a browser still avoids a full re-download when nothing
247
+ # changed, but always gets fresh content the moment something did.
248
+ builder.use(Rack::Static, urls: ['/assets'], root: ASSETS_ROOT)
249
+ middlewares.each { |mw, args| builder.use(mw, *args) }
250
+ builder.run(Cogworker::Web::Application)
251
+ builder.to_app
252
+ end
253
+ end
254
+ end
255
+ end
256
+
257
+ Cogworker::Web.load_routes!
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # One in-flight job on one process/thread.
5
+ class Work
6
+ attr_reader :process_id, :thread_id
7
+
8
+ def initialize(process_id, thread_id, hash)
9
+ @process_id = process_id
10
+ @thread_id = thread_id
11
+ @hash = hash
12
+ end
13
+
14
+ def queue = @hash['queue']
15
+ def run_at = @hash['run_at']
16
+ # already a Hash: nested by the outer JSON.parse in WorkSet#each
17
+ def job = @hash['payload']
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Cogworker
6
+ class WorkSet
7
+ include Enumerable
8
+
9
+ def each
10
+ ProcessSet.new.each do |process|
11
+ identity = process['identity']
12
+ workers = Cogworker.config.redis { |c| c.hgetall(RedisKeys.workers(identity)) }
13
+ workers.each do |tid, raw|
14
+ yield identity, tid, Work.new(identity, tid, JSON.parse(raw))
15
+ end
16
+ end
17
+ end
18
+
19
+ def size
20
+ count { true }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ Worker = Job
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # TZ 3.4 describes Workers and WorkSet with the identical
5
+ # (process_id, thread_id, work) contract, so this is a bare alias, not a
6
+ # second implementation to keep in sync.
7
+ Workers = WorkSet
8
+ end