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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class Web
5
+ module Routes
6
+ # Lists jobs that exhausted their retries, with delete and retry
7
+ # (requeue) actions.
8
+ module Dead
9
+ CONTENT_ID = 'dead-content'
10
+
11
+ module_function
12
+
13
+ def registered(app)
14
+ app.get('/dead') do
15
+ content = Dead.render_content(request.script_name)
16
+ if hx_request?
17
+ content
18
+ else
19
+ Layout.wrap('Dead', Layout.poll_div(CONTENT_ID, request.script_name, 'dead', content),
20
+ script_name: request.script_name)
21
+ end
22
+ end
23
+
24
+ app.post('/dead/delete') do
25
+ Cogworker.config.redis { |c| c.zrem(RedisKeys::DEAD, params['raw']) }
26
+ Dead.respond(self)
27
+ end
28
+
29
+ app.post('/dead/delete_all') do
30
+ Cogworker.config.redis { |c| c.del(RedisKeys::DEAD) }
31
+ Dead.respond(self)
32
+ end
33
+
34
+ # Puts the job back on its original queue for one more attempt —
35
+ # same "graduate out of this ZSET, back onto cogworker:queue:<q>"
36
+ # move `Routes::Retries#retry_now` does for `cogworker:retry`,
37
+ # and `Cogworker::Scheduled#graduate` does for both `cogworker:
38
+ # schedule`/`cogworker:retry`. `zrem` winning (not losing) is what
39
+ # actually gates the requeue: without it, two browser tabs (or a
40
+ # slow double-click) retrying the same entry at once could both
41
+ # see it and each push a copy. NOTE: for a single (non-Array)
42
+ # member, the `redis` gem's `#zrem` returns a **Boolean**, not the
43
+ # raw `1`/`0` integer reply — `== 1` would always be false here
44
+ # (`true == 1` is `false` in Ruby); check truthiness instead, the
45
+ # same way `Cogworker::Scheduled#graduate`'s `next unless won` does.
46
+ app.post('/dead/retry') do
47
+ raw = params['raw']
48
+ Cogworker.config.redis do |c|
49
+ if c.zrem(RedisKeys::DEAD, raw)
50
+ job = JSON.parse(raw)
51
+ c.sadd(RedisKeys::QUEUES, job['queue'])
52
+ c.lpush(RedisKeys.queue(job['queue']), raw)
53
+ end
54
+ end
55
+ Dead.respond(self)
56
+ end
57
+ end
58
+
59
+ # After an action, htmx gets the refreshed fragment swapped into
60
+ # #dead-content in place; a plain form submission (no JS) falls back
61
+ # to a normal redirect back to the full page.
62
+ def respond(action)
63
+ if action.hx_request?
64
+ render_content(action.request.script_name)
65
+ else
66
+ action.redirect(Layout.path(action.request.script_name, 'dead'))
67
+ end
68
+ end
69
+
70
+ def render_content(script_name)
71
+ # Newest DiedAt first — `zrevrange` (`cogworker:dead`'s score is
72
+ # the epoch it died at), not `zrange`.
73
+ entries = Cogworker.config.redis { |c| c.zrevrange(RedisKeys::DEAD, 0, -1, withscores: true) }
74
+ delete_path = Layout.path(script_name, 'dead/delete')
75
+ retry_path = Layout.path(script_name, 'dead/retry')
76
+ rows = entries.map do |raw, score|
77
+ job = JSON.parse(raw)
78
+ [job['jid'], Layout.h(job['class']), Layout.time_tag(score),
79
+ Layout.form_button(retry_path, 'raw', raw, 'retry', hx_target: "##{CONTENT_ID}", variant: :primary),
80
+ Layout.form_button(delete_path, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)]
81
+ end
82
+ delete_all_path = Layout.path(script_name, 'dead/delete_all')
83
+ delete_all_button = Layout.action_button(delete_all_path, 'delete all', hx_target: "##{CONTENT_ID}",
84
+ variant: :danger)
85
+ %(<div class="mb-3 flex justify-end">#{delete_all_button}</div>) +
86
+ Layout.table(%w[JID Class DiedAt Retry Delete], rows, empty_message: 'No dead jobs.')
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ Cogworker::Web.register(Cogworker::Web::Routes::Dead)
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Cogworker
6
+ class Web
7
+ module Routes
8
+ # NOTE: always spelled `Cogworker::History::*` below, fully
9
+ # qualified — a bare `History::Storage` from in here would resolve
10
+ # (via lexical nesting) to *this* module first, which has no
11
+ # `Storage`, not to the top-level `Cogworker::History`.
12
+ module History
13
+ STATUSES = %w[all success failed].freeze
14
+ # Vendored under assets/ag-grid/ (pinned to Community v32.3.9 — see
15
+ # CLAUDE.md) rather than fetched from jsdelivr, so this tab works
16
+ # fully offline like the rest of the Web UI.
17
+ AG_GRID_ASSETS = %w[ag-grid/ag-grid-community.min.js ag-grid/ag-grid.min.css
18
+ ag-grid/ag-theme-alpine.min.css].freeze
19
+
20
+ module_function
21
+
22
+ def registered(app)
23
+ app.get('/history') do
24
+ status = History::STATUSES.include?(params['status']) ? params['status'] : 'all'
25
+ content = Routes::History.render_content(request.script_name, status)
26
+ if hx_request?
27
+ content
28
+ else
29
+ Layout.wrap('History', content, script_name: request.script_name,
30
+ extra_head: Routes::History.ag_grid_head(request.script_name))
31
+ end
32
+ end
33
+
34
+ # Polled from the browser (see `grid_script`'s `refreshRows`) to
35
+ # keep the grid live without a full-page/htmx fragment reload,
36
+ # which would tear down and rebuild the AG Grid instance (and lose
37
+ # its sort/filter/scroll state) on every tick.
38
+ app.get('/history/data') do
39
+ status = History::STATUSES.include?(params['status']) ? params['status'] : 'all'
40
+ entries, = Cogworker::History::Storage.page(status, 1, Cogworker::History.max_entries)
41
+ [200, { 'content-type' => 'application/json' }, [JSON.generate(entries)]]
42
+ end
43
+ end
44
+
45
+ def ag_grid_head(script_name)
46
+ js, grid_css, theme_css = AG_GRID_ASSETS.map { |rel| Layout.path(script_name, "assets/#{rel}") }
47
+ <<~HTML
48
+ <script src="#{js}"></script>
49
+ <link rel="stylesheet" href="#{grid_css}">
50
+ <link rel="stylesheet" href="#{theme_css}">
51
+ HTML
52
+ end
53
+
54
+ # Loads up to `Cogworker::History.max_entries` rows for the chosen
55
+ # filter in one shot and hands them to AG Grid, which does its own
56
+ # client-side sorting/per-column filtering/quick-search/pagination
57
+ # from there — retention (`max_entries`) already bounds this to a
58
+ # size AG Grid handles comfortably, so there's no need for the
59
+ # gem's own server-side paging on top of it.
60
+ def render_content(script_name, status)
61
+ entries, = Cogworker::History::Storage.page(status, 1, Cogworker::History.max_entries)
62
+ filters(script_name, status) + grid(entries, script_name, status)
63
+ end
64
+
65
+ def filters(script_name, current_status)
66
+ links = STATUSES.map { |status| filter_link(script_name, status, active: status == current_status) }.join
67
+ %(<div class="mb-4 flex gap-2">#{links}</div>)
68
+ end
69
+
70
+ def filter_link(script_name, status, active:)
71
+ classes = if active
72
+ 'bg-indigo-600 text-white'
73
+ else
74
+ 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
75
+ end
76
+ href = Layout.path(script_name, "history?status=#{status}")
77
+ %(<a href="#{href}" class="px-3 py-1 rounded-md text-sm font-medium #{classes}">#{Layout.h(status.capitalize)}</a>)
78
+ end
79
+
80
+ def grid(entries, script_name, status)
81
+ <<~HTML
82
+ <div id="history-grid" class="ag-theme-alpine" style="height: 70vh; width: 100%;"></div>
83
+
84
+ <dialog id="history-backtrace-dialog" class="rounded-lg p-0 max-w-2xl w-[90vw] bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
85
+ <div class="p-4">
86
+ <div class="flex justify-between items-center mb-3">
87
+ <h3 class="font-semibold">Backtrace</h3>
88
+ <button type="button" onclick="this.closest('dialog').close()"
89
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">✕</button>
90
+ </div>
91
+ <pre id="history-backtrace-content" class="text-xs whitespace-pre-wrap max-h-[60vh] overflow-y-auto bg-gray-50 dark:bg-gray-950 p-3 rounded border border-gray-200 dark:border-gray-800"></pre>
92
+ </div>
93
+ </dialog>
94
+
95
+ #{grid_script(entries, script_name, status)}
96
+ HTML
97
+ end
98
+
99
+ def grid_script(entries, script_name, status)
100
+ data_url = Layout.path(script_name, "history/data?status=#{status}")
101
+ <<~HTML
102
+ <script>
103
+ (function () {
104
+ var dataUrl = #{Layout.json_for_script(data_url)};
105
+ var rowData = #{Layout.json_for_script(entries)};
106
+ var backtraces = {};
107
+ // The dialog leads with the error itself (class + message)
108
+ // and only then the backtrace — the grid's own Error column
109
+ // can be too narrow/truncated to read the full message, and
110
+ // clicking through to "just the backtrace" without it loses
111
+ // the one thing you're usually trying to look up.
112
+ function indexBacktraces() {
113
+ backtraces = {};
114
+ rowData.forEach(function (row) {
115
+ if (!row.backtrace) return;
116
+ var header = (row.error_class || 'Error') + ': ' + (row.error_message || '');
117
+ backtraces[row.jid] = header + '\\n\\n' + row.backtrace.join('\\n');
118
+ });
119
+ }
120
+ indexBacktraces();
121
+
122
+ window.cogworkerShowHistoryBacktrace = function (jid) {
123
+ document.getElementById('history-backtrace-content').textContent = backtraces[jid] || '(no backtrace)';
124
+ document.getElementById('history-backtrace-dialog').showModal();
125
+ };
126
+
127
+ function statusCellRenderer(p) {
128
+ var ok = p.value === 'success';
129
+ var classes = ok
130
+ ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
131
+ : 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300';
132
+ return '<span class="px-2 py-0.5 rounded-full text-xs font-medium ' + classes + '">' + p.value + '</span>';
133
+ }
134
+
135
+ function errorValueGetter(p) {
136
+ return p.data.error_class ? (p.data.error_class + ': ' + p.data.error_message) : '';
137
+ }
138
+
139
+ function errorCellRenderer(p) {
140
+ if (!p.data.backtrace) return p.value || '';
141
+ var span = document.createElement('span');
142
+ span.textContent = p.value;
143
+ span.className = 'text-red-700 dark:text-red-400 underline cursor-pointer';
144
+ span.title = 'Click to view backtrace';
145
+ span.addEventListener('click', function () { window.cogworkerShowHistoryBacktrace(p.data.jid); });
146
+ return span;
147
+ }
148
+
149
+ // "2h 34m 23s 23ms" — each unit above the smallest one
150
+ // present only shows up once it (or a bigger one) is
151
+ // non-zero, so a typical sub-second job just reads "23ms"
152
+ // rather than "0h 0m 0s 23ms".
153
+ function formatDuration(ms) {
154
+ ms = Math.max(0, Math.round(ms));
155
+ var h = Math.floor(ms / 3600000);
156
+ var m = Math.floor((ms % 3600000) / 60000);
157
+ var s = Math.floor((ms % 60000) / 1000);
158
+ var msRemainder = ms % 1000;
159
+ var parts = [];
160
+ if (h > 0) parts.push(h + 'h');
161
+ if (h > 0 || m > 0) parts.push(m + 'm');
162
+ if (h > 0 || m > 0 || s > 0) parts.push(s + 's');
163
+ parts.push(msRemainder + 'ms');
164
+ return parts.join(' ');
165
+ }
166
+
167
+ var columnDefs = [
168
+ { field: 'finished_at', headerName: 'Finished', sort: 'desc', minWidth: 170,
169
+ valueFormatter: function (p) { return window.cogworkerFormatTime(new Date(p.value * 1000)); } },
170
+ { field: 'class', headerName: 'Class' },
171
+ { field: 'queue', headerName: 'Queue' },
172
+ { field: 'jid', headerName: 'JID', minWidth: 160 },
173
+ { field: 'args', headerName: 'Args', minWidth: 200,
174
+ valueFormatter: function (p) { return JSON.stringify(p.value); } },
175
+ { field: 'status', headerName: 'Status', cellRenderer: statusCellRenderer, maxWidth: 120 },
176
+ { headerName: 'Duration', maxWidth: 160,
177
+ // `valueGetter` stays a plain millisecond Integer — AG
178
+ // Grid sorts/filters on that raw value, `valueFormatter`
179
+ // only changes what's *displayed*, so numeric sort order
180
+ // ("23ms" before "1m 5s") stays correct regardless of
181
+ // formatting.
182
+ valueGetter: function (p) { return Math.round((p.data.finished_at - p.data.started_at) * 1000); },
183
+ valueFormatter: function (p) { return formatDuration(p.value); } },
184
+ { headerName: 'Error', minWidth: 260, valueGetter: errorValueGetter, cellRenderer: errorCellRenderer }
185
+ ];
186
+
187
+ var gridApi = agGrid.createGrid(document.getElementById('history-grid'), {
188
+ columnDefs: columnDefs,
189
+ rowData: rowData,
190
+ defaultColDef: { sortable: true, filter: true, resizable: true, flex: 1 },
191
+ pagination: true,
192
+ paginationPageSize: #{Cogworker::Web.history_per_page},
193
+ paginationPageSizeSelector: [10, 25, 50, 100]
194
+ });
195
+
196
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
197
+ document.getElementById('history-grid').classList.add('ag-theme-alpine-dark');
198
+ }
199
+
200
+ // AG Grid isn't htmx-swapped (unlike Busy/Stats/Queues), so
201
+ // it needs its own poll — gated by the same global toggle —
202
+ // that replaces just `rowData` in place rather than
203
+ // reloading the fragment and tearing the grid instance down.
204
+ function refreshRows() {
205
+ if (!window.cogworkerLiveUpdate) return;
206
+ fetch(dataUrl, { headers: { 'Accept': 'application/json' } })
207
+ .then(function (r) { return r.ok ? r.json() : null; })
208
+ .then(function (data) {
209
+ if (!data) return;
210
+ rowData = data;
211
+ indexBacktraces();
212
+ gridApi.setGridOption('rowData', rowData);
213
+ })
214
+ .catch(function () {});
215
+ }
216
+ setInterval(refreshRows, #{Cogworker::Web.live_update_interval * 1000});
217
+ })();
218
+ </script>
219
+ HTML
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ Cogworker::Web.register(Cogworker::Web::Routes::History)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fugit'
4
+ require 'json'
5
+
6
+ module Cogworker
7
+ class Web
8
+ module Routes
9
+ # Read-only: lists every registered `config.periodic { |mgr|
10
+ # mgr.register(...) }` entry and when it last/next fires. Sourced
11
+ # entirely from Redis (`periodic:schedule`/`periodic:last_slot:<pjid>`),
12
+ # not from any in-process `Periodic::Manager` — those are only
13
+ # published once an actual worker process's `Periodic::Ticker` has
14
+ # booted (see `Ticker#publish_schedule!`), so a Web-UI-only process
15
+ # (no worker ever started) shows the empty state here, same as Busy
16
+ # shows no processes with no worker running.
17
+ module Periodic
18
+ CONTENT_ID = 'periodic-content'
19
+
20
+ module_function
21
+
22
+ def registered(app)
23
+ app.get('/periodic') do
24
+ content = Routes::Periodic.render_content
25
+ if hx_request?
26
+ content
27
+ else
28
+ Layout.wrap('Periodic', Layout.poll_div(CONTENT_ID, request.script_name, 'periodic', content),
29
+ script_name: request.script_name)
30
+ end
31
+ end
32
+ end
33
+
34
+ def render_content
35
+ schedule = Cogworker.config.redis { |c| c.hgetall(RedisKeys::PERIODIC_SCHEDULE) }
36
+ rows = schedule.map { |pjid, raw| row_for(pjid, JSON.parse(raw)) }
37
+ Layout.table(%w[Class Cron Args NextRun LastRun Unique], rows,
38
+ empty_message: 'Nothing registered yet (no worker process has booted the periodic ' \
39
+ 'scheduler — periodic jobs are published to Redis by ' \
40
+ 'Periodic::Ticker on worker startup, not by the Web UI process).')
41
+ end
42
+
43
+ def row_for(pjid, entry)
44
+ [Layout.h(entry['class']), Layout.h(entry['cron']), Layout.h(entry['args'].to_json),
45
+ next_run_tag(entry['cron']), last_run_tag(pjid), unique_cell(entry['unique'])]
46
+ end
47
+
48
+ def next_run_tag(cron)
49
+ Layout.time_tag(Fugit::Cron.parse(cron)&.next_time(Time.now)&.to_t)
50
+ rescue StandardError
51
+ Layout.h('invalid cron')
52
+ end
53
+
54
+ def last_run_tag(pjid)
55
+ last_slot = Cogworker.config.redis { |c| c.get(RedisKeys.periodic_last_slot(pjid)) }
56
+ last_slot ? Layout.time_tag(last_slot.to_f) : %(<span class="text-gray-400 italic">never</span>)
57
+ end
58
+
59
+ def unique_cell(unique)
60
+ unique.nil? || unique.empty? ? '' : Layout.badge(unique, variant: :warning)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ Cogworker::Web.register(Cogworker::Web::Routes::Periodic)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class Web
5
+ module Routes
6
+ # Lists every known queue with its size/latency, and a per-queue drill
7
+ # down page listing that queue's pending jobs.
8
+ module Queues
9
+ CONTENT_ID = 'queues-content'
10
+ QUEUE_CONTENT_ID = 'queue-content'
11
+
12
+ module_function
13
+
14
+ def registered(app)
15
+ app.get('/queues') do
16
+ content = Queues.render_content(request.script_name)
17
+ if hx_request?
18
+ content
19
+ else
20
+ Layout.wrap('Queues', Layout.poll_div(CONTENT_ID, request.script_name, 'queues', content),
21
+ script_name: request.script_name)
22
+ end
23
+ end
24
+
25
+ app.get('/queues/:name') do
26
+ name = url_params('name')
27
+ content = Queues.render_queue_content(name, request.script_name)
28
+ if hx_request?
29
+ content
30
+ else
31
+ Layout.wrap("Queue: #{Layout.h(name)}",
32
+ Layout.poll_div(QUEUE_CONTENT_ID, request.script_name, "queues/#{name}", content),
33
+ script_name: request.script_name)
34
+ end
35
+ end
36
+
37
+ # `raw` is the exact JSON string the job entry was pushed with —
38
+ # same "raw" identity `Routes::Dead`/`Routes::Retries` already key
39
+ # their own per-row delete off — so `Queue#delete` can `LREM` it
40
+ # back out of the list.
41
+ app.post('/queues/:name/delete') do
42
+ name = url_params('name')
43
+ Cogworker::Queue.new(name).delete(params['raw'])
44
+ Queues.respond(self, name)
45
+ end
46
+
47
+ app.post('/queues/:name/delete_all') do
48
+ name = url_params('name')
49
+ Cogworker::Queue.new(name).clear
50
+ Queues.respond(self, name)
51
+ end
52
+ end
53
+
54
+ # After an action, htmx gets the refreshed fragment swapped into
55
+ # #queue-content in place; a plain form submission (no JS) falls
56
+ # back to a normal redirect back to the queue's own page.
57
+ def respond(action, name)
58
+ if action.hx_request?
59
+ render_queue_content(name, action.request.script_name)
60
+ else
61
+ action.redirect(Layout.path(action.request.script_name, "queues/#{name}"))
62
+ end
63
+ end
64
+
65
+ def render_content(script_name)
66
+ names = Cogworker.config.redis { |c| c.smembers(RedisKeys::QUEUES) }.sort
67
+ rows = names.map do |name|
68
+ q = Cogworker::Queue.new(name)
69
+ link = Layout.path(script_name, "queues/#{Layout.h(name)}")
70
+ [%(<a class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium" href="#{link}">#{Layout.h(name)}</a>),
71
+ q.size, q.latency.round(2)]
72
+ end
73
+ Layout.table(%w[Name Size Latency], rows, empty_message: 'No queues yet — push a job to create one.')
74
+ end
75
+
76
+ def render_queue_content(name, script_name)
77
+ delete_path = Layout.path(script_name, "queues/#{name}/delete")
78
+ rows = Cogworker::Queue.new(name).map do |r|
79
+ [r.jid, Layout.h(r.klass), Layout.h(r.args.to_s),
80
+ Layout.form_button(delete_path, 'raw', r.value, 'delete', hx_target: "##{QUEUE_CONTENT_ID}",
81
+ variant: :danger)]
82
+ end
83
+ delete_all_button(name, script_name, rows.empty?) +
84
+ Layout.table(%w[JID Class Args Delete], rows, empty_message: 'This queue is empty.')
85
+ end
86
+
87
+ # Nothing to bulk-delete once the queue is already empty.
88
+ def delete_all_button(name, script_name, empty)
89
+ return '' if empty
90
+
91
+ delete_all_path = Layout.path(script_name, "queues/#{name}/delete_all")
92
+ button = Layout.action_button(delete_all_path, 'delete all', hx_target: "##{QUEUE_CONTENT_ID}",
93
+ variant: :danger)
94
+ %(<div class="mb-3 flex justify-end">#{button}</div>)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ Cogworker::Web.register(Cogworker::Web::Routes::Queues)
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class Web
5
+ module Routes
6
+ # Lists jobs currently scheduled for retry, with delete and
7
+ # retry-now (skip the backoff) actions.
8
+ module Retries
9
+ CONTENT_ID = 'retries-content'
10
+
11
+ module_function
12
+
13
+ def registered(app)
14
+ app.get('/retries') do
15
+ content = Retries.render_content(request.script_name)
16
+ if hx_request?
17
+ content
18
+ else
19
+ Layout.wrap('Retries', Layout.poll_div(CONTENT_ID, request.script_name, 'retries', content),
20
+ script_name: request.script_name)
21
+ end
22
+ end
23
+
24
+ app.post('/retries/delete') do
25
+ Cogworker.config.redis { |c| c.zrem(RedisKeys::RETRY, params['raw']) }
26
+ Retries.respond(self)
27
+ end
28
+
29
+ app.post('/retries/retry_now') do
30
+ raw = params['raw']
31
+ Cogworker.config.redis do |c|
32
+ # For a single (non-Array) member, the `redis` gem's `#zrem`
33
+ # returns a **Boolean**, not the raw `1`/`0` integer reply —
34
+ # `== 1` is always false here (`true == 1` is `false` in
35
+ # Ruby); check truthiness instead, matching
36
+ # `Cogworker::Scheduled#graduate`'s `next unless won`.
37
+ if c.zrem(RedisKeys::RETRY, raw)
38
+ job = JSON.parse(raw)
39
+ c.sadd(RedisKeys::QUEUES, job['queue'])
40
+ c.lpush(RedisKeys.queue(job['queue']), raw)
41
+ end
42
+ end
43
+ Retries.respond(self)
44
+ end
45
+ end
46
+
47
+ # After an action, htmx gets the refreshed fragment swapped into
48
+ # #retries-content in place; a plain form submission (no JS) falls
49
+ # back to a normal redirect back to the full page.
50
+ def respond(action)
51
+ if action.hx_request?
52
+ render_content(action.request.script_name)
53
+ else
54
+ action.redirect(Layout.path(action.request.script_name, 'retries'))
55
+ end
56
+ end
57
+
58
+ def render_content(script_name)
59
+ entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::RETRY, 0, -1) }
60
+ rows = entries.map do |raw|
61
+ job = JSON.parse(raw)
62
+ [job['jid'], Layout.h(job['class']), error_cell(job), delete_form(script_name, raw),
63
+ retry_now_form(script_name, raw)]
64
+ end
65
+ Layout.table(%w[JID Class Error Delete RetryNow], rows, empty_message: 'No retries pending.')
66
+ end
67
+
68
+ def error_cell(job)
69
+ <<~HTML
70
+ <div class="font-mono text-xs text-red-700 dark:text-red-400">#{Layout.h(job['error_class'])}</div>
71
+ <div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs">#{Layout.h(job['error_message'])}</div>
72
+ HTML
73
+ end
74
+
75
+ def delete_form(script_name, raw)
76
+ action = Layout.path(script_name, 'retries/delete')
77
+ Layout.form_button(action, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)
78
+ end
79
+
80
+ def retry_now_form(script_name, raw)
81
+ action = Layout.path(script_name, 'retries/retry_now')
82
+ Layout.form_button(action, 'raw', raw, 'retry now', hx_target: "##{CONTENT_ID}", variant: :primary)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ Cogworker::Web.register(Cogworker::Web::Routes::Retries)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class Web
5
+ module Routes
6
+ # Reserves POST /save_session as a real, always-present route — in
7
+ # practice an external auth middleware wrapping this app (via `Web.use`)
8
+ # typically intercepts this exact path itself before it ever reaches
9
+ # here (that's the whole point of it being a stable, bare, un-prefixed
10
+ # path an auth callback can bypass same-origin checks for). This is
11
+ # just a harmless default for when nothing else claims the path.
12
+ module SaveSession
13
+ def self.registered(app)
14
+ app.post('/save_session') { [200, { 'content-type' => 'text/plain' }, ['OK']] }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ Cogworker::Web.register(Cogworker::Web::Routes::SaveSession)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class Web
5
+ module Routes
6
+ # Lists jobs pushed with `perform_in`/`perform_at`, still waiting for
7
+ # their run-at time, with a delete action.
8
+ module Scheduled
9
+ CONTENT_ID = 'scheduled-content'
10
+
11
+ module_function
12
+
13
+ def registered(app)
14
+ app.get('/scheduled') do
15
+ content = Scheduled.render_content(request.script_name)
16
+ if hx_request?
17
+ content
18
+ else
19
+ Layout.wrap('Scheduled', Layout.poll_div(CONTENT_ID, request.script_name, 'scheduled', content),
20
+ script_name: request.script_name)
21
+ end
22
+ end
23
+
24
+ app.post('/scheduled/delete') do
25
+ Cogworker.config.redis { |c| c.zrem(RedisKeys::SCHEDULE, params['raw']) }
26
+ if hx_request?
27
+ Scheduled.render_content(request.script_name)
28
+ else
29
+ redirect Layout.path(request.script_name, 'scheduled')
30
+ end
31
+ end
32
+ end
33
+
34
+ def render_content(script_name)
35
+ entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::SCHEDULE, 0, -1, withscores: true) }
36
+ delete_path = Layout.path(script_name, 'scheduled/delete')
37
+ rows = entries.map do |raw, score|
38
+ job = JSON.parse(raw)
39
+ [job['jid'], Layout.h(job['class']), Layout.time_tag(score),
40
+ Layout.form_button(delete_path, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)]
41
+ end
42
+ Layout.table(%w[JID Class RunAt Delete], rows, empty_message: 'Nothing scheduled.')
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ Cogworker::Web.register(Cogworker::Web::Routes::Scheduled)