wurk 0.0.5 → 1.0.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -0
  3. data/app/controllers/wurk/api/serializers.rb +48 -2
  4. data/app/controllers/wurk/api_controller.rb +216 -1
  5. data/app/controllers/wurk/dashboard_controller.rb +20 -2
  6. data/app/controllers/wurk/extensions_controller.rb +56 -0
  7. data/app/controllers/wurk/profiles_controller.rb +68 -0
  8. data/config/routes.rb +54 -1
  9. data/exe/sidekiqswarm +8 -0
  10. data/exe/wurkswarm +23 -0
  11. data/lib/active_job/queue_adapters/wurk_adapter.rb +35 -0
  12. data/lib/generators/wurk/install/templates/wurk.rb +14 -3
  13. data/lib/sidekiq/api.rb +4 -0
  14. data/lib/sidekiq/cli.rb +9 -0
  15. data/lib/sidekiq/client.rb +4 -0
  16. data/lib/sidekiq/job.rb +4 -0
  17. data/lib/sidekiq/launcher.rb +4 -0
  18. data/lib/sidekiq/middleware/chain.rb +4 -0
  19. data/lib/sidekiq/middleware/server/statsd.rb +12 -0
  20. data/lib/sidekiq/rails.rb +10 -0
  21. data/lib/sidekiq/redis_connection.rb +4 -0
  22. data/lib/sidekiq/scheduled.rb +4 -0
  23. data/lib/sidekiq/testing.rb +4 -0
  24. data/lib/sidekiq/version.rb +4 -0
  25. data/lib/sidekiq/web.rb +4 -0
  26. data/lib/sidekiq/worker.rb +4 -0
  27. data/lib/sidekiq.rb +16 -0
  28. data/lib/wurk/batch/callbacks.rb +103 -13
  29. data/lib/wurk/batch/death_handler.rb +5 -2
  30. data/lib/wurk/batch/server_middleware.rb +35 -3
  31. data/lib/wurk/batch/status.rb +9 -0
  32. data/lib/wurk/batch.rb +23 -1
  33. data/lib/wurk/capsule.rb +20 -1
  34. data/lib/wurk/cli.rb +84 -1
  35. data/lib/wurk/client.rb +20 -17
  36. data/lib/wurk/compat.rb +44 -2
  37. data/lib/wurk/component.rb +5 -4
  38. data/lib/wurk/configuration.rb +120 -3
  39. data/lib/wurk/cron.rb +51 -9
  40. data/lib/wurk/dead_set.rb +8 -3
  41. data/lib/wurk/deploy.rb +8 -4
  42. data/lib/wurk/encryption.rb +6 -1
  43. data/lib/wurk/fetcher/reaper.rb +78 -11
  44. data/lib/wurk/fetcher/reliable.rb +14 -4
  45. data/lib/wurk/heartbeat.rb +45 -0
  46. data/lib/wurk/history.rb +174 -0
  47. data/lib/wurk/iterable_job/active_record_enumerator.rb +71 -0
  48. data/lib/wurk/iterable_job/csv_enumerator.rb +51 -0
  49. data/lib/wurk/iterable_job.rb +41 -0
  50. data/lib/wurk/iterable_job_query.rb +75 -0
  51. data/lib/wurk/job.rb +8 -0
  52. data/lib/wurk/job_record.rb +16 -1
  53. data/lib/wurk/job_set.rb +4 -4
  54. data/lib/wurk/job_util.rb +15 -6
  55. data/lib/wurk/keys.rb +10 -0
  56. data/lib/wurk/launcher.rb +35 -1
  57. data/lib/wurk/leader.rb +15 -6
  58. data/lib/wurk/limiter/bucket.rb +14 -3
  59. data/lib/wurk/limiter/concurrent.rb +1 -1
  60. data/lib/wurk/limiter/window.rb +2 -1
  61. data/lib/wurk/limiter.rb +12 -0
  62. data/lib/wurk/lua/loader.rb +10 -0
  63. data/lib/wurk/lua.rb +106 -14
  64. data/lib/wurk/metrics/history.rb +5 -0
  65. data/lib/wurk/metrics/query.rb +39 -0
  66. data/lib/wurk/metrics/queue_rollup.rb +151 -0
  67. data/lib/wurk/metrics/statsd.rb +11 -0
  68. data/lib/wurk/middleware/current_attributes.rb +29 -6
  69. data/lib/wurk/middleware/interrupt_handler.rb +5 -0
  70. data/lib/wurk/middleware/poison_pill.rb +35 -5
  71. data/lib/wurk/processor.rb +17 -8
  72. data/lib/wurk/profile_set.rb +65 -0
  73. data/lib/wurk/profiler.rb +127 -0
  74. data/lib/wurk/railtie.rb +19 -5
  75. data/lib/wurk/redis_client_adapter.rb +72 -0
  76. data/lib/wurk/redis_connection.rb +30 -0
  77. data/lib/wurk/redis_pool.rb +5 -1
  78. data/lib/wurk/scheduled.rb +42 -0
  79. data/lib/wurk/sorted_entry.rb +13 -11
  80. data/lib/wurk/stats.rb +11 -4
  81. data/lib/wurk/swarm/child_boot.rb +26 -4
  82. data/lib/wurk/swarm.rb +1 -1
  83. data/lib/wurk/transaction_aware_client.rb +69 -0
  84. data/lib/wurk/unique.rb +49 -7
  85. data/lib/wurk/version.rb +1 -1
  86. data/lib/wurk/web/batch_status.rb +42 -0
  87. data/lib/wurk/web/config.rb +219 -17
  88. data/lib/wurk/web/enterprise.rb +14 -0
  89. data/lib/wurk/web/extension.rb +348 -0
  90. data/lib/wurk/web/rack_app.rb +77 -0
  91. data/lib/wurk/web.rb +2 -0
  92. data/lib/wurk/worker/setter.rb +5 -1
  93. data/lib/wurk/worker.rb +17 -6
  94. data/lib/wurk.rb +44 -0
  95. data/vendor/assets/dashboard/assets/fa-brands-400-BP5tdqmh.woff2 +0 -0
  96. data/vendor/assets/dashboard/assets/fa-regular-400-nyy7hhHF.woff2 +0 -0
  97. data/vendor/assets/dashboard/assets/fa-solid-900-DRAAbZTg.woff2 +0 -0
  98. data/vendor/assets/dashboard/assets/index-9CFRWpfG.js +77 -0
  99. data/vendor/assets/dashboard/assets/index-CW8AFQIv.css +2 -0
  100. data/vendor/assets/dashboard/assets/wurk-logo-Vy3xW4K0.png +0 -0
  101. data/vendor/assets/dashboard/favicon.png +0 -0
  102. data/vendor/assets/dashboard/index.html +10 -3
  103. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  104. metadata +42 -3
  105. data/vendor/assets/dashboard/assets/index-D2XR0iGw.js +0 -60
  106. data/vendor/assets/dashboard/assets/index-DlPr4YXw.css +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4c0b4f7856306b0cec991a88fa89a2ba86f152598adb27b8b1e34607128c12a
4
- data.tar.gz: 4ece7a94d1ddba50dbcb6af3b6e17c6346c7ef90642d5839d1b87cf4a12eb7e7
3
+ metadata.gz: 774727bf58b55972fbc4ace533c90803233ad6e4a42d6e8b8cec0f464972bae3
4
+ data.tar.gz: e06ddec0e9138bb50be07a50184d46b500affe8f9212d5aaa290be75b35a73da
5
5
  SHA512:
6
- metadata.gz: ae6268ba18a442b30c692a3017cb6bd5a8b14fa136d5f6ae9edcf3cf5d96df1ac36bc1f5449a78f8d5f237ef70d69720c7b6b3c7a0d8730bdf8ce9ac979c9d0c
7
- data.tar.gz: 2269d08c147405889c154e7ed995c346b972c19f0fcb40b42263bf498469b1bc33e297ab1e28f569d7162e3d6b208c3804134d74fbcc4c127e41ebe335b5ef14
6
+ metadata.gz: e0f7924f14f94fc5f4143be95389f461c988d2772cb05d4502a77d9959431420d4392a5dc7d9d390038fa372da0b492b4f0d095bb566d6abc0fc4a6bf3fa5605
7
+ data.tar.gz: 8cc09f2445d8e75f40cdc06ac84597748de5e2a7cbfd99ec9ad062e88ca5df03f7be7e117c221533c796304a6b90e6e47555f26d307a04a44b550b570449ceeb
data/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  <div align="center">
12
12
 
13
+ [![Live Demo](https://img.shields.io/badge/live%20demo-wurk.demo.developerz.ai-22c55e?logo=googlechrome&logoColor=white)](https://wurk.demo.developerz.ai/wurk)
13
14
  [![Gem Version](https://img.shields.io/gem/v/wurk.svg)](https://rubygems.org/gems/wurk)
14
15
  [![CI](https://github.com/developerz-ai/wurk/actions/workflows/test.yml/badge.svg)](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
15
16
  [![Coverage gate](https://img.shields.io/badge/coverage%20gate-line%20%E2%89%A590%25-brightgreen.svg)](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
@@ -54,6 +55,7 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
54
55
 
55
56
  ## Documentation
56
57
 
58
+ - **[Website](https://developerz-ai.github.io/wurk/)** · **[Wiki / full docs](https://github.com/developerz-ai/wurk/wiki)** — the pitch, install, and the complete guide.
57
59
  - **[Getting started & architecture](https://github.com/developerz-ai/wurk/blob/main/docs/idea/01-overview.md)** — how the swarm, manager, fetcher, and processor fit together.
58
60
  - **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
59
61
  - **API reference (parity specs):** [Sidekiq OSS](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-free.md) · [Pro](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-pro.md) · [Enterprise](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-ent.md) — the authoritative surface Wurk matches exactly.
@@ -139,6 +141,8 @@ Knobs: `health_check(port:, bind: "0.0.0.0", ready_window: 30)`. In swarm mode o
139
141
 
140
142
  `bundle install && restart`. Wurk reads and writes the same Redis schema, so a rolling deploy can run Sidekiq and Wurk against the same Redis during the cutover. Third-party gems (sidekiq-cron, sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures, sidekiq-throttled, …) are exercised by running their own upstream suites against Wurk in the [`ecosystem` CI job](https://github.com/developerz-ai/wurk/blob/main/.github/workflows/ecosystem.yml) (see [`test/ecosystem/`](https://github.com/developerz-ai/wurk/tree/main/test/ecosystem)).
141
143
 
144
+ Full walkthrough — config side-by-side, the Redis key/`sidekiq_options` mapping, known incompatibilities, and a one-page cutover checklist: **[docs/migrate-from-sidekiq.md](https://github.com/developerz-ai/wurk/blob/main/docs/migrate-from-sidekiq.md)**.
145
+
142
146
  ## Contributing
143
147
 
144
148
  Issues and pull requests are welcome — see **[CONTRIBUTING.md](https://github.com/developerz-ai/wurk/blob/main/CONTRIBUTING.md)** for the dev setup, test layers, and conventions, and **[SECURITY.md](https://github.com/developerz-ai/wurk/blob/main/SECURITY.md)** to report a vulnerability.
@@ -34,8 +34,13 @@ module Wurk
34
34
  { name: summary.name, size: summary.size, latency: summary.latency, paused: summary.paused? }
35
35
  end
36
36
 
37
+ # Host-registered custom job-info rows (spec §25.2) ride along as
38
+ # `custom_rows` for the SPA's job-detail modal (see Config#job_info_pairs,
39
+ # which gates on registration so the common no-extension case is free).
40
+ # Divergence: wurk evaluates these during job-list serialization — the SPA
41
+ # renders job detail client-side — not in a dedicated server detail view.
37
42
  def job_record(record)
38
- {
43
+ base = {
39
44
  jid: record.jid,
40
45
  klass: record.display_class,
41
46
  args: record.display_args,
@@ -43,6 +48,8 @@ module Wurk
43
48
  enqueued_at: record.enqueued_at&.to_f,
44
49
  created_at: record.created_at&.to_f
45
50
  }
51
+ rows = ::Wurk::Web.config.job_info_pairs(record)
52
+ rows.empty? ? base : base.merge(custom_rows: rows)
46
53
  end
47
54
 
48
55
  def sorted_entry(entry)
@@ -51,7 +58,8 @@ module Wurk
51
58
  at: entry.at.to_f,
52
59
  error_class: entry['error_class'],
53
60
  error_message: entry['error_message'],
54
- retry_count: entry['retry_count']
61
+ retry_count: entry['retry_count'],
62
+ error_backtrace: entry.error_backtrace
55
63
  )
56
64
  end
57
65
 
@@ -67,6 +75,10 @@ module Wurk
67
75
  quiet: process.stopping?,
68
76
  rss: process['rss'],
69
77
  rtt_us: process['rtt_us'],
78
+ started_at: process['started_at'],
79
+ cpu_model: process['cpu_model'],
80
+ cores: process['cores'],
81
+ memory_total_kb: process['memory_total_kb'],
70
82
  labels: process.labels,
71
83
  queues: process.queues,
72
84
  version: process.version,
@@ -74,6 +86,20 @@ module Wurk
74
86
  }
75
87
  end
76
88
 
89
+ # One in-flight job (WorkSet row) for the Busy page's process detail.
90
+ def work_row(process_id, thread_id, work)
91
+ record = work.job
92
+ {
93
+ process_id: process_id,
94
+ thread_id: thread_id,
95
+ queue: work.queue,
96
+ klass: record.display_class,
97
+ args: record.display_args,
98
+ jid: record.jid,
99
+ run_at: work.run_at.to_f
100
+ }
101
+ end
102
+
77
103
  def limiter_row(name, meta)
78
104
  {
79
105
  name: name,
@@ -119,6 +145,13 @@ module Wurk
119
145
  { at: row[:at], processed: row[:p], failed: row[:f], runtime_ms: row[:ms] }
120
146
  end
121
147
 
148
+ # One queue's size/latency gauge series (Wurk::Metrics::Query.queue_history).
149
+ # `points` are oldest→newest; `at` is epoch seconds at the bucket start,
150
+ # `size` is queue depth, `latency` is head-of-line wait in seconds.
151
+ def queue_history_series(row)
152
+ { name: row[:name], points: row[:points].map { |p| { at: p[:at], size: p[:size], latency: p[:latency] } } }
153
+ end
154
+
122
155
  def parse_options(raw)
123
156
  return {} if raw.nil? || raw.to_s.empty?
124
157
 
@@ -126,6 +159,19 @@ module Wurk
126
159
  rescue ::JSON::ParserError
127
160
  {}
128
161
  end
162
+
163
+ # One profile row for the Profiles pane. `key` drives the view/data links.
164
+ def profile_record(rec)
165
+ {
166
+ key: rec.key,
167
+ jid: rec.jid,
168
+ token: rec.token,
169
+ type: rec.type,
170
+ size: rec.size,
171
+ elapsed: rec.elapsed,
172
+ started_at: rec.started_at&.to_i
173
+ }
174
+ end
129
175
  end
130
176
  end
131
177
  end
@@ -24,13 +24,30 @@ module Wurk
24
24
 
25
25
  skip_forgery_protection only: %i[
26
26
  stream reset_limiter pause_cron unpause_cron enqueue_cron
27
+ clear_queue delete_queue_job pause_queue unpause_queue
28
+ retries_bulk retries_all retry_job
29
+ scheduled_bulk scheduled_all scheduled_job
30
+ dead_bulk dead_all dead_job
31
+ quiet_process stop_process
27
32
  ]
28
33
 
34
+ # Per-set action whitelists. Maps the SPA's action name to the
35
+ # SortedEntry/JobSet method. Anything not listed 400s — keeps the bulk/
36
+ # single dispatchers from reaching arbitrary methods off a request param.
37
+ RETRY_ACTIONS = { 'retry' => :retry, 'delete' => :delete, 'kill' => :kill }.freeze
38
+ SCHEDULED_ACTIONS = { 'delete' => :delete, 'add_to_queue' => :add_to_queue }.freeze
39
+ DEAD_ACTIONS = { 'retry' => :retry, 'delete' => :delete }.freeze
40
+
29
41
  # Boot-time flags the SPA reads once to shape the UI (e.g. hide destructive
30
42
  # actions and show the read-only banner). Always a GET, so it stays
31
43
  # reachable while read-only mode blocks mutations.
32
44
  def meta
33
- render json: { read_only: ::Wurk::Web.config.read_only? }
45
+ config = ::Wurk::Web.config
46
+ render json: {
47
+ read_only: config.read_only?,
48
+ read_only_message: config.read_only_message,
49
+ custom_tabs: config.custom_tabs
50
+ }
34
51
  end
35
52
 
36
53
  def stats
@@ -51,14 +68,97 @@ module Wurk
51
68
  }
52
69
  end
53
70
 
71
+ # Empties one queue (UNLINK list + drop from the `queues` set).
72
+ def clear_queue
73
+ ::Wurk::Queue.new(params[:name].to_s).clear
74
+ render json: { ok: true }
75
+ end
76
+
77
+ # Removes a single job from a queue by jid. LREM matches exact bytes, so we
78
+ # locate the record (Queue#find_job) and let it delete its own value.
79
+ def delete_queue_job
80
+ record = ::Wurk::Queue.new(params[:name].to_s).find_job(params[:jid].to_s)
81
+ return render(json: { error: 'unknown job' }, status: :not_found) unless record
82
+
83
+ render json: { ok: true, deleted: record.delete }
84
+ end
85
+
86
+ # Pause/unpause a queue (Pro §6, §10.1). Idempotent; returns the resulting
87
+ # state so the SPA can update its toggle without a refetch round-trip.
88
+ def pause_queue
89
+ ::Wurk::Queue.new(params[:name].to_s).pause!
90
+ render json: { ok: true, paused: true }
91
+ end
92
+
93
+ def unpause_queue
94
+ ::Wurk::Queue.new(params[:name].to_s).unpause!
95
+ render json: { ok: true, paused: false }
96
+ end
97
+
54
98
  def retries = render_sorted_set(::Wurk::RetrySet.new)
55
99
  def scheduled = render_sorted_set(::Wurk::ScheduledSet.new)
56
100
  def dead = render_sorted_set(::Wurk::DeadSet.new)
57
101
 
102
+ # --- Retry set mutations -------------------------------------------------
103
+ def retry_job = single_entry_action(::Wurk::RetrySet.new, RETRY_ACTIONS, params[:cmd])
104
+ def retries_bulk = bulk_entry_action(::Wurk::RetrySet.new, RETRY_ACTIONS)
105
+
106
+ def retries_all
107
+ set = ::Wurk::RetrySet.new
108
+ count = case params[:cmd].to_s
109
+ when 'retry' then set.retry_all
110
+ when 'kill' then set.kill_all
111
+ when 'delete' then clear_set(set)
112
+ else return render(json: { error: 'unknown action' }, status: :bad_request)
113
+ end
114
+ render json: { ok: true, count: count }
115
+ end
116
+
117
+ # --- Scheduled set mutations ---------------------------------------------
118
+ def scheduled_job = single_entry_action(::Wurk::ScheduledSet.new, SCHEDULED_ACTIONS, params[:cmd])
119
+ def scheduled_bulk = bulk_entry_action(::Wurk::ScheduledSet.new, SCHEDULED_ACTIONS)
120
+
121
+ def scheduled_all
122
+ set = ::Wurk::ScheduledSet.new
123
+ count = case params[:cmd].to_s
124
+ when 'delete' then clear_set(set)
125
+ when 'add_to_queue' then drain_set(set, :add_to_queue)
126
+ else return render(json: { error: 'unknown action' }, status: :bad_request)
127
+ end
128
+ render json: { ok: true, count: count }
129
+ end
130
+
131
+ # --- Dead set mutations --------------------------------------------------
132
+ def dead_job = single_entry_action(::Wurk::DeadSet.new, DEAD_ACTIONS, params[:cmd])
133
+ def dead_bulk = bulk_entry_action(::Wurk::DeadSet.new, DEAD_ACTIONS)
134
+
135
+ def dead_all
136
+ set = ::Wurk::DeadSet.new
137
+ count = case params[:cmd].to_s
138
+ when 'retry' then set.retry_all
139
+ when 'delete' then clear_set(set)
140
+ else return render(json: { error: 'unknown action' }, status: :bad_request)
141
+ end
142
+ render json: { ok: true, count: count }
143
+ end
144
+
58
145
  def processes
59
146
  render json: ::Wurk::ProcessSet.new.map { |p| ::Wurk::Api::Serializers.process_row(p) }
60
147
  end
61
148
 
149
+ # Currently-executing jobs across the cluster (WorkSet), oldest first.
150
+ # The Busy page's process-detail modal filters client-side by process_id.
151
+ def workers
152
+ render json: ::Wurk::WorkSet.new.map { |pid, tid, work| ::Wurk::Api::Serializers.work_row(pid, tid, work) }
153
+ end
154
+
155
+ # Busy-page controls: SIGTSTP (quiet — drop fetch, drain in-flight) and
156
+ # SIGTERM (stop — graceful shutdown). Both are async; the target notices on
157
+ # its next heartbeat (≤10s). `identity` absent or "all" signals every live
158
+ # process.
159
+ def quiet_process = signal_processes(:quiet!)
160
+ def stop_process = signal_processes(:stop!)
161
+
62
162
  def batches
63
163
  set = ::Wurk::BatchSet.new
64
164
  page = ::Wurk::Api::Pagination.window(params)
@@ -134,6 +234,34 @@ module Wurk
134
234
  render json: { error: e.message }, status: :bad_request
135
235
  end
136
236
 
237
+ # Ent §5.3 Historical snapshots from the capped `history:metrics` stream.
238
+ # `?limit=N` (default 1000) most-recent points, oldest→newest, each
239
+ # `{at:, processed:, failures:, …}`. Fields are read generically so a
240
+ # migrated Sidekiq Ent stream renders as-is.
241
+ def history_snapshots
242
+ limit = ::Wurk::Api::Pagination.clamp_int(
243
+ params[:limit], 1, ::Wurk::History::STREAM_CAP, ::Wurk::History::STREAM_DEFAULT_LIMIT
244
+ )
245
+ render json: { snapshots: ::Wurk::Web::Enterprise::Historical.snapshots(limit: limit) }
246
+ end
247
+
248
+ # Per-queue size/latency gauge time-series for the Metrics/Historical tab.
249
+ # `:bucket` is 1m/5m/1h; `?window=24h` (s/m/h/d) is clamped to the bucket's
250
+ # retention; optional `?queue=<name>` narrows to one queue. Each queue's
251
+ # `points` are Recharts-ready.
252
+ def queue_history
253
+ window = parse_window(params[:window])
254
+ queues = params[:queue].present? ? [params[:queue].to_s] : nil
255
+ series = ::Wurk::Web::Enterprise::Historical.queue_history(params[:bucket].to_s, window: window, queues: queues)
256
+ render json: {
257
+ bucket: params[:bucket].to_s,
258
+ window: window,
259
+ queues: series.map { |row| ::Wurk::Api::Serializers.queue_history_series(row) }
260
+ }
261
+ rescue ::ArgumentError => e
262
+ render json: { error: e.message }, status: :bad_request
263
+ end
264
+
137
265
  def search
138
266
  substr = params[:substr].to_s
139
267
  return render(json: { substr: substr, total: 0, hits: [] }) if substr.empty?
@@ -142,6 +270,13 @@ module Wurk
142
270
  render json: { substr: substr, total: hits.size, hits: hits }
143
271
  end
144
272
 
273
+ # Profiles list (v8.0+). The SPA links each row to /profiles/:key (view)
274
+ # and /profiles/:key/data (raw blob). Newest first.
275
+ def profiles
276
+ records = ::Wurk::ProfileSet.new.map { |rec| ::Wurk::Api::Serializers.profile_record(rec) }
277
+ render json: records.sort_by { |r| -(r[:started_at] || 0) }
278
+ end
279
+
145
280
  # SSE: one `event: stats` per tick with a fresh Stats snapshot. Caps at
146
281
  # `STREAM_MAX_DURATION` so a stale browser tab can't tie a Rails worker
147
282
  # forever — the client reconnects automatically when the stream closes.
@@ -159,6 +294,86 @@ module Wurk
159
294
 
160
295
  private
161
296
 
297
+ # Resolves a single entry by "<score>|<jid>" key and applies a whitelisted
298
+ # action. 400 on an unknown action, 404 when the key matches nothing (e.g.
299
+ # the entry was already retried/deleted from another tab).
300
+ def single_entry_action(set, actions, cmd)
301
+ method = actions[cmd.to_s]
302
+ return render(json: { error: 'unknown action' }, status: :bad_request) unless method
303
+
304
+ entries = entries_for(set, [params[:key]])
305
+ return render(json: { error: 'unknown job' }, status: :not_found) if entries.empty?
306
+
307
+ entries.each { |entry| entry.public_send(method) }
308
+ render json: { ok: true, count: entries.size }
309
+ end
310
+
311
+ # Bulk variant: `keys[]` + a single `cmd` applied to every resolved entry.
312
+ def bulk_entry_action(set, actions)
313
+ method = actions[params[:cmd].to_s]
314
+ return render(json: { error: 'unknown action' }, status: :bad_request) unless method
315
+
316
+ count = 0
317
+ keys = Array(params[:keys]).map(&:to_s).uniq
318
+ entries_for(set, keys).each do |entry|
319
+ entry.public_send(method)
320
+ count += 1
321
+ end
322
+ render json: { ok: true, count: count }
323
+ end
324
+
325
+ # Sends `method` (:quiet! / :stop!) to one process by identity, or to every
326
+ # live process when identity is blank/"all". Embedded processes are skipped
327
+ # (they raise on quiet!/stop! — there's no separate process to signal). 404s
328
+ # when a named identity isn't in the live set.
329
+ def signal_processes(method)
330
+ identity = params[:identity].to_s
331
+ if identity.empty? || identity == 'all'
332
+ count = ::Wurk::ProcessSet.new.reject(&:embedded?).each { |p| p.public_send(method) }.size
333
+ return render(json: { ok: true, count: count })
334
+ end
335
+
336
+ process = ::Wurk::ProcessSet[identity]
337
+ return render(json: { error: 'unknown process' }, status: :not_found) unless process
338
+
339
+ process.public_send(method) unless process.embedded?
340
+ render json: { ok: true, count: process.embedded? ? 0 : 1 }
341
+ end
342
+
343
+ # Maps "<score>|<jid>" keys to live SortedEntry objects via score-bracketed
344
+ # fetch (exact float match, narrowed by jid) — avoids depending on float→
345
+ # string round-tripping between JS and Ruby. Skips malformed/empty keys.
346
+ def entries_for(set, keys)
347
+ keys.flat_map do |key|
348
+ score, jid = key.to_s.split('|', 2)
349
+ next [] if jid.nil? || jid.empty?
350
+
351
+ set.fetch(score.to_f, jid)
352
+ end
353
+ end
354
+
355
+ # UNLINKs the whole set, returning the count removed (read before clearing
356
+ # so the response reports what was deleted).
357
+ def clear_set(set)
358
+ total = set.size
359
+ set.clear
360
+ total
361
+ end
362
+
363
+ # Drains a set by applying `method` to every entry until empty. Used for
364
+ # scheduled "add to queue all", where each call removes the entry and would
365
+ # otherwise shift the paged iterator's indices mid-scan.
366
+ def drain_set(set, method)
367
+ count = 0
368
+ until set.size.zero?
369
+ set.each do |entry|
370
+ entry.public_send(method)
371
+ count += 1
372
+ end
373
+ end
374
+ count
375
+ end
376
+
162
377
  def render_sorted_set(set)
163
378
  page = ::Wurk::Api::Pagination.window(params)
164
379
  total = set.size
@@ -15,7 +15,14 @@ module Wurk
15
15
  # Vite dev server (default :5173) so contributors get HMR without
16
16
  # rebuilding the bundle on every change.
17
17
  class DashboardController < ApplicationController
18
- VITE_DEV_URL = 'http://localhost:5173/'
18
+ # Vite serves the dev shell under its `base` (vite.config.ts), which is the
19
+ # same path the engine mounts assets at — so derive these from
20
+ # AssetMount::PREFIX to keep the Ruby side in lockstep. Fetching the server
21
+ # root instead returns Vite's "did you mean /wurk-assets/" hint page, not the
22
+ # shell (issue #181).
23
+ VITE_DEV_HOST = 'http://localhost:5173'
24
+ VITE_ASSET_BASE = "#{::Wurk::Engine::AssetMount::PREFIX}/".freeze # "/wurk-assets/"
25
+ VITE_DEV_URL = "#{VITE_DEV_HOST}#{VITE_ASSET_BASE}".freeze
19
26
  INDEX_REL_PATH = ['vendor', 'assets', 'dashboard', 'index.html'].freeze
20
27
 
21
28
  def index
@@ -30,12 +37,23 @@ module Wurk
30
37
 
31
38
  def fetch_vite_dev_shell
32
39
  uri = ::URI.parse(VITE_DEV_URL)
33
- ::Net::HTTP.get(uri)
40
+ rewrite_dev_asset_urls(::Net::HTTP.get(uri))
34
41
  rescue ::StandardError => e
35
42
  raise "Wurk dashboard: cannot reach Vite dev server at #{VITE_DEV_URL} " \
36
43
  "(#{e.class}: #{e.message}). Run `bin/rake frontend:dev` from the gem root."
37
44
  end
38
45
 
46
+ # The shell's entry URLs (`src="/wurk-assets/@vite/client"`,
47
+ # `src="/wurk-assets/src/main.tsx"`) are base-absolute, but the page is
48
+ # served from the host origin (e.g. :3000), where that path only maps to the
49
+ # built bundle. Point them back at the Vite dev server so the browser loads
50
+ # the modules + HMR client straight from Vite (CORS-enabled in dev). Vite's
51
+ # `server.origin` covers runtime-generated asset URLs but not these entry
52
+ # tags, so rewrite them here.
53
+ def rewrite_dev_asset_urls(html)
54
+ html.gsub(/(["'])#{::Regexp.escape(VITE_ASSET_BASE)}/, "\\1#{VITE_DEV_URL}")
55
+ end
56
+
39
57
  def read_built_index
40
58
  path = ::Wurk::Engine.root.join(*INDEX_REL_PATH)
41
59
  unless path.exist?
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wurk/web/extension'
4
+
5
+ module Wurk
6
+ # Serves third-party Web extensions (#187): `ext/:name/*subpath` runs the
7
+ # registered extension's matched route and returns its rendered HTML for the
8
+ # SPA's Extension page to embed; `ext-assets/:name/*file` serves files from
9
+ # the extension's `asset_paths`.
10
+ #
11
+ # Inherits DashboardController so an /ext/* URL that is actually the SPA's
12
+ # client-side route (no extension registered under that name — e.g. a browser
13
+ # refresh on /wurk/ext/<tab>/) falls through to the SPA shell instead of 404.
14
+ class ExtensionsController < DashboardController
15
+ # Extension forms carry no Rails CSRF token. Parity with Sidekiq's own
16
+ # CSRF model instead (spec §25.1): unsafe methods must be same-origin per
17
+ # Sec-Fetch-Site; cross-site requests are denied. GETs are unaffected.
18
+ skip_forgery_protection
19
+ before_action :verify_same_origin!, unless: -> { request.get? || request.head? }
20
+
21
+ def show
22
+ result = Web::Extension::Renderer.call(
23
+ name: params[:name], method: request.request_method,
24
+ subpath: "/#{params[:subpath]}", env: request.env, mount: request.script_name
25
+ )
26
+ return index unless result # not an extension → SPA client route; let React handle it
27
+
28
+ respond_with(result)
29
+ end
30
+
31
+ def asset
32
+ file, cache_for = Web::Extension::Renderer.asset_file(params[:name], params[:file])
33
+ return head :not_found unless file
34
+
35
+ response.headers['Cache-Control'] = "public, max-age=#{cache_for}"
36
+ send_file file, disposition: 'inline'
37
+ end
38
+
39
+ private
40
+
41
+ def respond_with((status, headers, body))
42
+ return redirect_to(headers['Location'], allow_other_host: false) if status == 302
43
+
44
+ # Extension output is host-registered server code, same trust model as
45
+ # Sidekiq::Web rendering its extensions — not user input.
46
+ render html: body.html_safe, layout: false, status: status
47
+ end
48
+
49
+ # Spec §25.1: unsafe methods require `Sec-Fetch-Site: same-origin` — a
50
+ # missing header is denied too, like stock Sidekiq (a non-browser client
51
+ # must spoof the header deliberately; a cookie-carrying browser can't).
52
+ def verify_same_origin!
53
+ head :forbidden unless request.headers['Sec-Fetch-Site'] == 'same-origin'
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module Wurk
7
+ # Profiles pane non-JSON endpoints (spec §25.4):
8
+ #
9
+ # GET /profiles/:key/data → the stored gzipped gecko JSON, streamed with a
10
+ # gzip Content-Encoding (Firefox profiler pulls
11
+ # this when given a `from-url` source).
12
+ # GET /profiles/:key → upload the profile to the Firefox profiler
13
+ # store and 302 to its public view URL.
14
+ #
15
+ # `:key` is "<token>-<jid>". The JSON list lives at /api/profiles.
16
+ class ProfilesController < ApplicationController
17
+ # CSRF protection is for state-changing form posts; these are GET reads.
18
+ skip_forgery_protection
19
+
20
+ def data
21
+ blob = profile_blob(params[:key])
22
+ return head(:not_found) unless blob
23
+
24
+ response.headers['Content-Encoding'] = 'gzip'
25
+ send_data blob, type: 'application/json', disposition: 'inline'
26
+ end
27
+
28
+ def show
29
+ blob = profile_blob(params[:key])
30
+ return head(:not_found) unless blob
31
+
32
+ hash = upload_to_profiler(blob)
33
+ return head(:bad_gateway) unless hash
34
+
35
+ redirect_to(format(::Wurk::Web.config.profile_view_url, hash), allow_other_host: true)
36
+ end
37
+
38
+ private
39
+
40
+ def profile_blob(key)
41
+ Wurk.redis { |conn| conn.call('HGET', key, 'data') }
42
+ end
43
+
44
+ # POSTs the gzipped profile to the Firefox profiler's compressed-store.
45
+ # Returns the public hash (used to build the view URL) or nil on failure.
46
+ def upload_to_profiler(gzipped)
47
+ uri = URI.parse(::Wurk::Web.config.profile_store_url)
48
+ res = post_gzip(uri, gzipped)
49
+ res.is_a?(Net::HTTPSuccess) ? res.body.to_s.strip : nil
50
+ rescue StandardError => e
51
+ Wurk.configuration.handle_exception(e, context: 'Wurk::ProfilesController#upload')
52
+ nil
53
+ end
54
+
55
+ def post_gzip(uri, body)
56
+ req = Net::HTTP::Post.new(uri)
57
+ req['Content-Encoding'] = 'gzip'
58
+ req['Content-Type'] = 'application/json'
59
+ req.body = body
60
+ # Explicit timeouts so a slow/unreachable profiler can't tie up the Rails
61
+ # request thread for Ruby's ~60s defaults.
62
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
63
+ open_timeout: 5, read_timeout: 15) do |http|
64
+ http.request(req)
65
+ end
66
+ end
67
+ end
68
+ end
data/config/routes.rb CHANGED
@@ -8,11 +8,43 @@ Wurk::Engine.routes.draw do
8
8
  get 'meta', to: 'api#meta'
9
9
  get 'stats', to: 'api#stats'
10
10
  get 'queues', to: 'api#queues'
11
- get 'queues/:name', to: 'api#queue', as: :api_queue
11
+ get 'queues/:name', to: 'api#queue', as: :api_queue, constraints: { name: %r{[^/]+} }
12
+ post 'queues/:name/clear', to: 'api#clear_queue', as: :api_clear_queue, constraints: { name: %r{[^/]+} }
13
+ post 'queues/:name/delete', to: 'api#delete_queue_job', as: :api_delete_queue_job, constraints: { name: %r{[^/]+} }
14
+ # Per-queue Pause/Unpause (Pro §6, §10.1): toggles membership of the `paused`
15
+ # SET that fetchers consult. Read-only mode 403s these via Authorization.
16
+ post 'queues/:name/pause', to: 'api#pause_queue', as: :api_pause_queue, constraints: { name: %r{[^/]+} }
17
+ post 'queues/:name/unpause', to: 'api#unpause_queue', as: :api_unpause_queue, constraints: { name: %r{[^/]+} }
18
+
19
+ # Job-set mutations (retry/delete/kill/requeue/clear). The SPA posts to
20
+ # these; the Authorization middleware 403s every non-GET in read-only mode,
21
+ # so they need no extra guard. `:key` is "<score>|<jid>" (Wurk::SortedEntry#id);
22
+ # `all/:cmd` operates over the whole set; the bare collection POST is a bulk
23
+ # action over `keys[]`. Spec: docs/target/sidekiq-free.md §25.4.
24
+ # `all/:cmd` is intentionally unconstrained: the controller validates `:cmd`
25
+ # against the per-set whitelist and returns a JSON 400 for an unknown action,
26
+ # matching the single/bulk contract. A route-level constraint would turn that
27
+ # into a 404 route miss instead.
12
28
  get 'retries', to: 'api#retries'
29
+ post 'retries', to: 'api#retries_bulk', as: :api_retries_bulk
30
+ post 'retries/all/:cmd', to: 'api#retries_all', as: :api_retries_all
31
+ post 'retries/:key', to: 'api#retry_job', as: :api_retry_job, constraints: { key: %r{[^/]+} }
13
32
  get 'scheduled', to: 'api#scheduled'
33
+ post 'scheduled', to: 'api#scheduled_bulk', as: :api_scheduled_bulk
34
+ post 'scheduled/all/:cmd', to: 'api#scheduled_all', as: :api_scheduled_all
35
+ post 'scheduled/:key', to: 'api#scheduled_job', as: :api_scheduled_job, constraints: { key: %r{[^/]+} }
14
36
  get 'dead', to: 'api#dead'
37
+ post 'dead', to: 'api#dead_bulk', as: :api_dead_bulk
38
+ post 'dead/all/:cmd', to: 'api#dead_all', as: :api_dead_all
39
+ post 'dead/:key', to: 'api#dead_job', as: :api_dead_job, constraints: { key: %r{[^/]+} }
15
40
  get 'processes', to: 'api#processes'
41
+ get 'workers', to: 'api#workers'
42
+ # Busy-page process control (quiet/stop). `identity` rides in the body (it
43
+ # contains ':' and '.', awkward as a path segment); absent or "all" signals
44
+ # every live process. Read-only mode 403s these via the Authorization
45
+ # middleware. Spec: docs/target/sidekiq-free.md §25.4 (POST /busy).
46
+ post 'busy/quiet', to: 'api#quiet_process', as: :api_quiet_process
47
+ post 'busy/stop', to: 'api#stop_process', as: :api_stop_process
16
48
  get 'batches', to: 'api#batches'
17
49
  get 'batches/:bid', to: 'api#batch', as: :api_batch
18
50
  get 'limiters', to: 'api#limiters'
@@ -24,11 +56,32 @@ Wurk::Engine.routes.draw do
24
56
  get 'cron/:lid/history', to: 'api#cron_history', as: :api_cron_history
25
57
  get 'metrics', to: 'api#metrics'
26
58
  get 'metrics/:klass', to: 'api#metrics_for_job', as: :api_metrics_for_job, constraints: { klass: %r{[^/]+} }
59
+ get 'history/snapshots', to: 'api#history_snapshots', as: :api_history_snapshots
27
60
  get 'history/:bucket', to: 'api#history', as: :api_history
61
+ get 'queue-history/:bucket', to: 'api#queue_history', as: :api_queue_history
28
62
  get 'search', to: 'api#search'
63
+ get 'profiles', to: 'api#profiles'
29
64
  get 'stream', to: 'api#stream' # SSE
30
65
  end
31
66
 
67
+ # Profiles (v8.0+) — not under /api: `:key/data` streams the gzipped gecko
68
+ # blob with a gzip Content-Encoding, and `:key` POST-uploads the profile to
69
+ # the Firefox profiler then 302s to its public view. `:key` is "<token>-<jid>".
70
+ # The `/data` route is declared first so it wins over the bare `:key` match.
71
+ # Spec: docs/target/sidekiq-free.md §25.4.
72
+ get 'profiles/:key/data', to: 'profiles#data', as: :profile_data, constraints: { key: %r{[^/]+} }
73
+ get 'profiles/:key', to: 'profiles#show', as: :profile, constraints: { key: %r{[^/]+} }
74
+
75
+ # Third-party Web extensions (#187): server-rendered route + assets. The SPA's
76
+ # Extension page fetches `ext/:name/<subpath>` and embeds the returned HTML;
77
+ # `format: false` keeps dots in subpaths/filenames out of Rails' format logic.
78
+ # An /ext URL with no matching registered extension falls through to the SPA
79
+ # shell inside the controller (it's the SPA's own /ext/:tab client route).
80
+ match 'ext/:name(/*subpath)', to: 'extensions#show', via: %i[get post put patch delete], as: :extension,
81
+ format: false, defaults: { subpath: '' }, constraints: { name: %r{[^/]+} }
82
+ get 'ext-assets/:name/*file', to: 'extensions#asset', as: :extension_asset,
83
+ format: false, constraints: { name: %r{[^/]+} }
84
+
32
85
  # SPA catch-all — let React Router handle the rest.
33
86
  get '*path', to: 'dashboard#index', constraints: ->(req) { req.format == :html }
34
87
  end
data/exe/sidekiqswarm ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Drop-in alias for `wurkswarm`. Sidekiq Enterprise apps invoke `sidekiqswarm`
5
+ # in their Procfile / systemd unit; this keeps that exact command working after
6
+ # a one-line gem swap. Same behavior — forks the worker swarm from a preloaded
7
+ # parent. Spec: docs/target/sidekiq-ent.md §7.
8
+ load File.expand_path('wurkswarm', __dir__)