wurk 1.8.0 → 1.9.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -9
  3. data/app/controllers/wurk/api_controller.rb +49 -10
  4. data/config/routes.rb +20 -12
  5. data/lib/active_job/queue_adapters/wurk_adapter.rb +8 -2
  6. data/lib/sidekiq/rails.rb +5 -0
  7. data/lib/wurk/api/read_only.rb +1 -1
  8. data/lib/wurk/batch/server_middleware.rb +27 -0
  9. data/lib/wurk/capsule.rb +57 -2
  10. data/lib/wurk/cli.rb +4 -2
  11. data/lib/wurk/client.rb +16 -11
  12. data/lib/wurk/compat.rb +12 -2
  13. data/lib/wurk/configuration.rb +86 -7
  14. data/lib/wurk/encryption.rb +47 -8
  15. data/lib/wurk/fetcher/private_list_key.rb +19 -2
  16. data/lib/wurk/fetcher/reaper.rb +36 -12
  17. data/lib/wurk/fetcher/unparseable_keys.rb +22 -7
  18. data/lib/wurk/fetcher.rb +6 -1
  19. data/lib/wurk/health.rb +5 -1
  20. data/lib/wurk/job_logger.rb +1 -1
  21. data/lib/wurk/job_util.rb +4 -1
  22. data/lib/wurk/launcher.rb +4 -3
  23. data/lib/wurk/lua.rb +1 -14
  24. data/lib/wurk/metrics/dashboard_series.rb +21 -2
  25. data/lib/wurk/metrics/history.rb +13 -5
  26. data/lib/wurk/metrics/query.rb +9 -2
  27. data/lib/wurk/metrics/statsd.rb +8 -0
  28. data/lib/wurk/middleware/chain.rb +85 -13
  29. data/lib/wurk/pool_checkout.rb +1 -1
  30. data/lib/wurk/queue_slot.rb +19 -9
  31. data/lib/wurk/rails_boot.rb +17 -6
  32. data/lib/wurk/railtie.rb +7 -3
  33. data/lib/wurk/redis_connection.rb +4 -2
  34. data/lib/wurk/unique.rb +11 -8
  35. data/lib/wurk/version.rb +1 -1
  36. data/lib/wurk/web/config.rb +42 -9
  37. data/lib/wurk/web/enterprise.rb +4 -3
  38. data/lib/wurk/web/extension.rb +74 -39
  39. data/lib/wurk.rb +5 -2
  40. data/vendor/assets/dashboard/assets/{ArgsValue-DsPb_KZ-.js → ArgsValue-DDqX6XWH.js} +1 -1
  41. data/vendor/assets/dashboard/assets/{BatchDetail-BCIXJOpZ.js → BatchDetail-lIytcn2C.js} +1 -1
  42. data/vendor/assets/dashboard/assets/{Batches-IjaQfXW6.js → Batches-BEumt-1O.js} +1 -1
  43. data/vendor/assets/dashboard/assets/{Busy-CaQrf1Ve.js → Busy-Vk9HmZcN.js} +1 -1
  44. data/vendor/assets/dashboard/assets/{Cron-B18EHn_Y.js → Cron-CWcM-Cfr.js} +1 -1
  45. data/vendor/assets/dashboard/assets/{Dashboard-DdT5ZaUg.js → Dashboard-Bgv1kusv.js} +1 -1
  46. data/vendor/assets/dashboard/assets/{Dead-DWIy-Mu8.js → Dead-Di5Rct2H.js} +1 -1
  47. data/vendor/assets/dashboard/assets/Extension-LZY5-imc.js +1 -0
  48. data/vendor/assets/dashboard/assets/FilterBox-BDo-7IAy.js +2 -0
  49. data/vendor/assets/dashboard/assets/{FlowDetail-DnexL72n.js → FlowDetail-CehwRLTt.js} +1 -1
  50. data/vendor/assets/dashboard/assets/{FlowState-Cr4aX6-d.js → FlowState-glK2Tnf8.js} +1 -1
  51. data/vendor/assets/dashboard/assets/{Flows-DO6EimbZ.js → Flows-cgy-Fsbp.js} +1 -1
  52. data/vendor/assets/dashboard/assets/{JobDetailModal-aPjgq-U9.js → JobDetailModal-Cuk3jruU.js} +1 -1
  53. data/vendor/assets/dashboard/assets/{Limiters-DxpwWCNe.js → Limiters-Ci8G2yZG.js} +1 -1
  54. data/vendor/assets/dashboard/assets/Metrics-CQFUeZTR.js +1 -0
  55. data/vendor/assets/dashboard/assets/{NotFound-BfP9uAV9.js → NotFound-CYjhuDsI.js} +1 -1
  56. data/vendor/assets/dashboard/assets/{PageHeader-DbC97afG.js → PageHeader-DjIAFTyX.js} +1 -1
  57. data/vendor/assets/dashboard/assets/{Profiles-BcaHreMe.js → Profiles-DTowqIRg.js} +1 -1
  58. data/vendor/assets/dashboard/assets/{Queues-ZlBc5tyD.js → Queues-DUGRPjME.js} +1 -1
  59. data/vendor/assets/dashboard/assets/{Retries-Bg3nkeiL.js → Retries-SJ3BaKC0.js} +1 -1
  60. data/vendor/assets/dashboard/assets/{Scheduled-BrMZPZGl.js → Scheduled-DxSRpmFZ.js} +1 -1
  61. data/vendor/assets/dashboard/assets/{Search-B5X5pGht.js → Search-bBbi5JNK.js} +1 -1
  62. data/vendor/assets/dashboard/assets/{charts-uDyf4PN0.js → charts-Bg2YBcaD.js} +1 -1
  63. data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +1 -0
  64. data/vendor/assets/dashboard/assets/index-BOTVSP9M.js +141 -0
  65. data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +1 -0
  66. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-O3-5rMk3.js → useResetPageOnEmpty-BRPNyV2M.js} +1 -1
  67. data/vendor/assets/dashboard/assets/{useSort-xfnwjQAu.js → useSort-B-BLJik7.js} +1 -1
  68. data/vendor/assets/dashboard/assets/{utils-BIKHwNKB.js → utils-CGyrKpTh.js} +1 -1
  69. data/vendor/assets/dashboard/index.html +3 -3
  70. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  71. metadata +30 -44
  72. data/vendor/assets/dashboard/assets/Extension-BjBUWhHf.js +0 -1
  73. data/vendor/assets/dashboard/assets/FilterBox-DwH3PXXt.js +0 -1
  74. data/vendor/assets/dashboard/assets/Metrics-C3JL1GIH.js +0 -1
  75. data/vendor/assets/dashboard/assets/i18n-CJeYfeVt.js +0 -1
  76. data/vendor/assets/dashboard/assets/index-B36QCAf-.css +0 -1
  77. data/vendor/assets/dashboard/assets/index-DQujgZgH.js +0 -141
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d5734527cfa0b903c07b143f7eb0b821fca970cd98a8773f16cbdc12dcbb43c
4
- data.tar.gz: e3e610d19a29b71e071896a45710a807928123bb0c530348fb809dafb101cfe3
3
+ metadata.gz: e5540591682172d88b9972b1cfc33cfca88c9c88d823c0b50fe80cb3556e8c08
4
+ data.tar.gz: a19e9d8e68fd2f9f93131cfad4f845595bc95f45aacb65047da452da52916cdd
5
5
  SHA512:
6
- metadata.gz: c08daf7f94145029bcfd74e49c4426c8881effdce3aa65329453c5e7c2930ed87d93ac6ea85423ea8ee6a9df3465152837ebac692abfff3376623e69198209bc
7
- data.tar.gz: a8374380a15e20e599374bdd7afe6e911a3364b6f1acb532fca2ece894d31d8a113baf4375773a7b94ae2fb0a4a1da98767ca16667b705a53c8f58fdde9ad9d0
6
+ metadata.gz: 0d264b8039051e56d2f6cec5aaebb4ce1f99d62ad498a247bece1b6464c316685f43562a76c87ec46f31e846ff6cb6d536f51a11b6d9d06348c32c999d4f2be5
7
+ data.tar.gz: 9f695bfc7da4e5ad9e24e6874431a115b80bf3dab52f175a11b8f383d0586694b2d611f3137b6698eb896c1c2107ef18e97d33b7877a1a4fd3af60f380f0f47a
data/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
  <div align="center">
12
12
 
13
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)
14
+ [![AI-maintained](https://img.shields.io/badge/maintained%20by-developerz.ai-8a2be2.svg)](#ai-maintained)
14
15
  [![Gem Version](https://img.shields.io/gem/v/wurk.svg)](https://rubygems.org/gems/wurk)
15
16
  [![CI](https://github.com/developerz-ai/wurk/actions/workflows/test.yml/badge.svg)](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
16
17
  [![Coverage gate](https://img.shields.io/badge/coverage%20gate-line%20%2B%20branch%20%E2%89%A590%25-brightgreen.svg)](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
@@ -23,11 +24,13 @@ Wurk is wire-compatible with Sidekiq — same Redis keys, same job JSON, same Ru
23
24
 
24
25
  **In production:** Wurk runs the background work at [developerz.ai](https://developerz.ai) and at partner deployments, across many servers, on the fork-based swarm described below. It is not a preview. Throughput numbers from those deployments are not published yet; what has been measured is in [docs/benchmarks.md](docs/benchmarks.md).
25
26
 
26
- **At scale:** Wurk is built for fleets, not just for one box. Kubernetes `/live` + `/ready` probes are a config line, not a sidecar; a bearer-scoped [HTTP API](docs/api-http.md) lets non-Ruby services enqueue and inspect; [OpenTelemetry](docs/telemetry.md) traces propagate client → server; per-queue [global concurrency caps](docs/rate-limiting.md) hold cluster-wide limits; and monitoring is the dashboard you already mount — live SSE, charts, per-job progress, no separate stack to run. See [Wurk extras](#wurk-extras).
27
+ **At scale:** Wurk is built for fleets, not just for one box. Kubernetes `/live` + `/ready` probes are a config line, not a sidecar; a bearer-scoped [HTTP API](docs/api-http.md) lets non-Ruby services enqueue and inspect; [OpenTelemetry](docs/telemetry.md) traces propagate client → server; per-queue [global concurrency caps](docs/rate-limiting.md) hold cluster-wide limits; and monitoring is the dashboard you already mount — live SSE, charts, no separate stack to run. See [Wurk extras](#wurk-extras).
28
+
29
+ **AI-managed:** Wurk is a 100% AI-managed project. Every author's issues and pull requests, org members' included, and dependency updates, are triaged, reviewed, merged or closed by the [developerz.ai](https://developerz.ai) maintainer agent, which asks a human only for security reports and license questions, and cuts a release when a shipped fix has waited for one. New features are still built by us, humans and AI together — see [CONTRIBUTING.md](CONTRIBUTING.md) and [AI-maintained](#ai-maintained). The agent always identifies itself as a bot: it works publicly as `developerz-ai[bot]` and marks every comment it posts as bot-made. Its work and its rules are public too — browse the [repository](https://github.com/developerz-ai/wurk) and read the committed policy in [.dz/maintainer/maintainer.yml](.dz/maintainer/maintainer.yml).
27
30
 
28
31
  **On Sidekiq:** Wurk implements Sidekiq's API because it is a genuinely good API. Sidekiq is human-maintained and funds that work through its paid tiers; Wurk is AI-maintained, which is what lets the same surface be free software. Wurk is independent and not affiliated with or endorsed by Sidekiq or its maintainers — see [Why Wurk exists](#why-wurk-exists).
29
32
 
30
- **On speed:** Wurk is not currently faster than stock Sidekiq — it runs at roughly 0.87×–1.02× depending on workload shape, with parity on CPU and I/O but still behind on framework overhead (noop) and boot time. Numbers, method, and the reproduction command are in [docs/benchmarks.md](docs/benchmarks.md); run them yourself with `rake bench:vs_sidekiq`.
33
+ **On speed:** The latest published paired measurement (2026-08-07) put Wurk at roughly 0.87×–1.02× of stock Sidekiq depending on workload shape: parity on CPU and I/O, behind on framework overhead (noop) and boot time. This is dated evidence, not a throughput guarantee for today's gem. Numbers, method, and the reproduction command are in [docs/benchmarks.md](docs/benchmarks.md); run them yourself with `rake bench:vs_sidekiq`.
31
34
 
32
35
  **Also from us:** [Ultimate](https://github.com/developerz-ai/ultimate) — the AI-first full-stack framework, for a weekend pet project or the system a company runs on. What a whole stack looks like when the one writing the code is an agent, not a human: [`bunx create-ultimate myapp`](#also-from-developerzai-ultimate).
33
36
 
@@ -95,7 +98,7 @@ Sidekiq has no equivalent for any of these — they aren't parity, they're new s
95
98
 
96
99
  | Extra | What it does | Give up if you migrate back to Sidekiq |
97
100
  |---|---|---|
98
- | **[Job status, progress & results](docs/job-status.md)** | Opt-in `sidekiq_options track: true` persists a `status:<jid>` row — state, coalesced progress writes, the return value (size-capped, withheld under encryption) | `Wurk::Status` reads/writes and the dashboard's per-job progress bar |
101
+ | **[Job status, progress & results](docs/job-status.md)** | Opt-in `sidekiq_options track: true` persists a `status:<jid>` row — state, coalesced progress writes, the return value (size-capped, withheld under encryption) | `Wurk::Status` reads/writes and the `/v1` API's job-status reader (`GET /jobs/:jid`) |
99
102
  | **[HTTP producer + observe API](docs/api-http.md)** | A bearer-token-scoped `/v1` JSON API — enqueue, bulk-enqueue, inspect queues/jobs/swarm — mountable standalone, nested in the engine, or via the `wurk api` CLI | The whole `/v1` surface; non-Ruby producers lose their enqueue/inspect path |
100
103
  | **[OpenTelemetry tracing](docs/telemetry.md)** | W3C `traceparent`/`tracestate` propagated client → server, one span per attempt, linked (not force-parented) across long delays | Distributed traces across your job graph |
101
104
  | **[Flows — DAG-on-batches](docs/flows.md)** | `Wurk::Flow` chains and fans batches out/in with dependency edges, piped results between nodes, cycle/depth/width limits | The DAG builder, `pipe:` result-passing, `Flow.abandon` |
@@ -110,7 +113,7 @@ AI dashboard panes — anomaly detection, natural-language queries, error triage
110
113
 
111
114
  ## Benchmarks
112
115
 
113
- **Wurk is not faster than stock Sidekiq today.** Here is where it actually stands, measured rather than claimed — wurk 1.5.0 vs sidekiq 8.1.6, ruby 3.4.7, local Redis 7.4.10, 5000 jobs/run, 12 runs per topology, paired per-run ratios.
116
+ **The latest published comparison does not show Wurk ahead of stock Sidekiq.** Measured 2026-08-07 — wurk 1.4.0 vs sidekiq 8.1.6, ruby 3.4.7, local Redis 7.4.10, 5000 jobs/run, 12 runs per topology, paired per-run ratios. These are historical results, not a fresh measurement of today's gem.
114
117
 
115
118
  <picture>
116
119
  <source media="(prefers-color-scheme: dark)" srcset="docs/assets/bench-throughput-dark.svg">
@@ -131,8 +134,8 @@ Method, per-invocation records, workload definitions, and the separate `rake ben
131
134
  ## Documentation
132
135
 
133
136
  - **[Website](https://developerz-ai.github.io/wurk/)** · **[Wiki](https://github.com/developerz-ai/wurk/wiki)** — the pitch, install, and short orientation guides; the complete reference is [`docs/`](https://github.com/developerz-ai/wurk/tree/main/docs), linked from every wiki page.
134
- - **[API reference (YARD)](https://developerz-ai.github.io/wurk/api/)** — generated docs for the public classes (`Wurk::Worker`, `Wurk::Client`, `Wurk::Configuration`, `Wurk::Batch`, `Wurk::Limiter`, `Wurk::Unique`, and the `Sidekiq::*` aliases). Machine-readable map for AI agents: **[llms.txt](https://developerz-ai.github.io/wurk/llms.txt)**.
135
- - **[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.
137
+ - **[API reference (YARD)](https://developerz-ai.github.io/wurk/api/)** — generated from all of `lib/` (per `.yardopts`), so it documents internal classes (`Wurk::Manager`, `Wurk::Fetcher`, `Wurk::Processor`, `Wurk::Swarm`, the `Wurk::API::*` modules, …) as well as public ones; appearing in it does not make something supported public API. The supported public surface is defined by the parity specs ([`docs/target/sidekiq-free.md`](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)). Machine-readable map for AI agents: **[llms.txt](https://developerz-ai.github.io/wurk/llms.txt)**.
138
+ - **[Getting started & architecture](https://github.com/developerz-ai/wurk/blob/main/docs/idea/02-architecture.md)** — how the swarm, manager, fetcher, and processor fit together.
136
139
  - **[Starting the worker](https://github.com/developerz-ai/wurk/blob/main/docs/running.md)** — Rails auto-start, the `wurk`/`wurkswarm` runners, and running standalone without Rails.
137
140
  - **[Configuration reference](https://github.com/developerz-ai/wurk/blob/main/docs/configuration.md)** — every option, env var, YAML key, and CLI flag, with precedence and pool sizing.
138
141
  - **[Deploying](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md)** — systemd, Capistrano, Heroku, Docker, Kubernetes, rolling restarts, memory limits.
@@ -162,6 +165,8 @@ Method, per-invocation records, workload definitions, and the separate `rake ben
162
165
  | Ruby | `>= 3.2.0` |
163
166
  | Redis | `>= 7.0.0` |
164
167
 
168
+ The full test suite also runs as a non-required `compat` workflow job across Ruby 3.2.x and the newest Ruby on the current Rails series, the oldest supported Rails series on the newest Ruby, and Valkey 8 as a drop-in store alongside Redis 7.x.
169
+
165
170
  JRuby, TruffleRuby, and Windows fall back to threads-only mode (no fork) — behaviorally equivalent to stock Sidekiq.
166
171
 
167
172
  ## Running the workers
@@ -276,16 +281,16 @@ There is no native Prometheus `/metrics` endpoint — the StatsD export or the `
276
281
 
277
282
  Infrastructure this basic should be free software. A Rails app shouldn't need a licence key to get reliable fetch, batches, rate limiting, or cron — those are table stakes, not a premium tier, and the free-software tradition is that the best tools belong to everyone who runs them.
278
283
 
279
- What has made that hard is maintenance: someone has to be paid to do it. Sidekiq funds a decade of *human* maintenance through its paid tiers, which is an honest trade. Wurk makes a different one — it is maintained **AI-first**: implementation, parity suite, docs, and benchmarks are written and kept current by AI agents under human review. A fix, a doc update, or a version bump is no longer somebody's week, which is what makes it practical to:
284
+ What has made that hard is maintenance: someone has to be paid to do it. Sidekiq funds a decade of *human* maintenance through its paid tiers, which is an honest trade. Wurk makes a different one — it is maintained **by AI**: every author's issues and pull requests, the team's own included, and dependency updates, are handled end to end by a maintainer agent, and the implementation, parity suite, docs, and benchmarks are written and kept current by AI agents working with the team, which still builds the features and cuts the releases. A fix, a doc update, or a version bump is no longer somebody's week, which is what makes it practical to:
280
285
 
281
286
  - ship the entire Pro + Enterprise surface with no tier, no flag gate, and no license check;
282
287
  - keep parity honest mechanically rather than by hand — an independently written parity oracle suite, pinned to a documented Sidekiq revision, plus sidekiq-cron's upstream suite running against Wurk on every push ([docs/idea/14-ecosystem-compat.md](https://github.com/developerz-ai/wurk/blob/main/docs/idea/14-ecosystem-compat.md) tracks the rest);
283
288
  - keep adding surface Sidekiq doesn't have — the [Wurk extras](#wurk-extras) above landed as one release;
284
- - hold ourselves to published numbers instead of adjectives — the suite runs against stock Sidekiq every release and ships the results [as measured](docs/benchmarks.md), including the unflattering ones.
289
+ - hold ourselves to published numbers instead of adjectives — the measured comparison against stock Sidekiq is published [as measured](docs/benchmarks.md), including the unflattering ones, and `rake bench:vs_sidekiq` reproduces it. It is a published measurement, not a per-release CI run.
285
290
 
286
291
  DHH makes the broader version of this argument in [Let the agents democratize open source](https://world.hey.com/dhh/let-the-agents-democratize-open-source-9fd630a9): open source fought for everyone's right to change the software they run, and refusing agent-written code re-erects the gate it spent decades tearing down — "all programmers are equal, but some programmers are more equal than others." His subject is contributions; ours is maintenance, which is the same economics from the other end. The reason a licence key guards batches and cron is not that the code is precious, it's that somebody had to be paid to keep it working. Drop that cost and the tier stops being necessary.
287
292
 
288
- What makes it work in practice is that the agents run inside machinery built to check them. The oracles, upstream suites, and published numbers above are gates, not decoration, and the release gate has never once let an unverified gem reach RubyGems. Agents supply the pace, the gates supply the certainty — and when a gate does catch something, the fix is to make that class of mistake structurally impossible rather than to slow the agents down. The release lane derives its own tag from `Wurk::VERSION` so the two can't drift apart; [RELEASE.md](https://github.com/developerz-ai/wurk/blob/main/RELEASE.md) walks through it.
293
+ What makes it work in practice is that the agents run inside machinery built to check them. The parity oracle above is a merge-blocking gate, not decoration; sidekiq-cron's upstream suite runs against Wurk in CI and reports on PRs, but it is not a required check and never gates a merge; the `rake bench` PR regression check flags deltas past a noise-adjusted threshold as an advisory comment; and the published benchmark numbers are one-off historical measurements, reproduced on demand rather than enforced in CI. The release gate has never once let an unverified gem reach RubyGems. Agents supply the pace, the gates supply the certainty — and when a gate does catch something, the fix is to make that class of mistake structurally impossible rather than to slow the agents down. The release lane derives its own tag from `Wurk::VERSION` so the two can't drift apart; [RELEASE.md](https://github.com/developerz-ai/wurk/blob/main/RELEASE.md) walks through it.
289
294
 
290
295
  Wurk is MIT and stays that way. If what you need is a commercial support contract and a human on the other end of an email, buying that is a perfectly good answer.
291
296
 
@@ -316,6 +321,21 @@ Every framework of the last fifteen years optimised for a human typing the code.
316
321
 
317
322
  TypeScript end to end, MIT, and as measured about its own claims as this file is: what is proven, what is not, and what it refuses to claim yet are all in [its README](https://github.com/developerz-ai/ultimate#readme).
318
323
 
324
+ ## AI-maintained
325
+
326
+ **Wurk is fully maintained by AI**: by [developerz.ai](https://developerz.ai), the way a human maintainer looks after an open-source project, for every author, org members included (owner ruling, 2026-10-23). The developerz-ai org comes back with new ideas once or twice a year. In between, the project is still looked after:
327
+
328
+ | Who opens it | What happens |
329
+ |---|---|
330
+ | you, an issue | triaged; asked for a reproduction when one is missing; a real bug is fixed and released |
331
+ | you, a pull request | reviewed (CodeRabbit and the developerz.ai reviewer), driven to green, merged |
332
+ | Dependabot | driven to green and merged |
333
+ | an issue or pull request by anyone with write access (org members, collaborators) | handled like anyone's: triaged, reviewed, driven to green, merged |
334
+ | an outside change to CI, release or publish automation | declined: members maintain the pipeline that publishes the gem |
335
+ | a breaking change | the bot's call, not a human's; a change that breaks the drop-in Sidekiq contract is declined, since a revert cannot take that promise back |
336
+
337
+ Releases follow the work, not a calendar. A weekday checkup asks whether a shipping fix has merged and nobody has released it. When one has, the bot files a release request, its coding fleet opens the `Wurk::VERSION` bump PR, and merging that PR publishes the gem ([RELEASE.md](RELEASE.md)). The bot's policy is committed in [`.dz/maintainer/maintainer.yml`](.dz/maintainer/maintainer.yml).
338
+
319
339
  ## Contributing
320
340
 
321
341
  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.
@@ -238,11 +238,23 @@ module Wurk
238
238
  render json: { lid: params[:lid].to_s, history: ::Wurk::Web::Enterprise::Periodic.history(params[:lid].to_s) }
239
239
  end
240
240
 
241
+ # `?minutes=` (default 60) is served raw — the Query layer routes ≤480 to
242
+ # per-minute buckets and >480 onto the 10-minute buckets, raising
243
+ # WindowTooWide (400 below) past the 30-day ceiling; a non-positive window
244
+ # raises ArgumentError there and 400s the same way. `?hours=` is clamped
245
+ # to MAX_HOURS (default 24 when present) and always takes the 10-minute
246
+ # path. The response echoes the REQUESTED window so the SPA's label
247
+ # matches the served period.
241
248
  def metrics
242
- minutes = ::Wurk::Api::Pagination.clamp_int(params[:minutes], 1, ::Wurk::Metrics::Query::MAX_MINUTES, 60)
243
- rows = ::Wurk::Web::Enterprise::Historical.top(minutes: minutes, class_filter: params[:substr])
244
- render json: { minutes: minutes, top_jobs: rows.map { |(klass, totals)| ::Wurk::Api::Serializers.metric_row(klass, totals) } }
245
- rescue ::Wurk::Metrics::Query::WindowTooWide => e
249
+ minutes, hours = metrics_window_raw(params)
250
+ rows = ::Wurk::Web::Enterprise::Historical.top(minutes: minutes, hours: hours, class_filter: params[:substr])
251
+ payload = { minutes: hours ? hours * 60 : minutes,
252
+ top_jobs: rows.map { |(klass, totals)| ::Wurk::Api::Serializers.metric_row(klass, totals) } }
253
+ # hours is echoed only when the request used it, so a minutes request
254
+ # keeps the two-key shape (minutes + top_jobs) it has always had.
255
+ payload[:hours] = hours if hours
256
+ render json: payload
257
+ rescue ::ArgumentError, ::Wurk::Metrics::Query::WindowTooWide => e
246
258
  render json: { error: e.message }, status: :bad_request
247
259
  end
248
260
 
@@ -387,8 +399,10 @@ module Wurk
387
399
 
388
400
  keys = Array(params[:keys]).map(&:to_s).uniq
389
401
  # Each key costs a ZRANGEBYSCORE plus per-entry mutations; an uncapped
390
- # list lets one POST issue tens of thousands of round-trips. The UI
391
- # selects at most a page (200).
402
+ # list lets one POST issue tens of thousands of round-trips. The UI's
403
+ # per-row selections accumulate across pages (toggleAll works against
404
+ # the visible page's keys only) and the bulk POST sends the whole Set,
405
+ # so this cap — not the page size — is the bound.
392
406
  return render(json: { error: 'too many keys (max 1000)' }, status: :bad_request) if keys.size > 1000
393
407
 
394
408
  entries = entries_for(set, keys)
@@ -515,12 +529,37 @@ module Wurk
515
529
  Integer(match[1]) * HISTORY_WINDOW_UNITS.fetch(match[2].empty? ? 's' : match[2])
516
530
  end
517
531
 
532
+ # `?hours=` wins over `?minutes=`; minutes > MAX_MINUTES (480) promote onto
533
+ # the 10-minute buckets in the Query layer, so the clamp ceiling is the
534
+ # 30-day equivalent (MAX_HOURS * 60 minutes), not 480. Absent both, the
535
+ # window defaults to 60 minutes.
518
536
  def metrics_window(params)
519
537
  pagination = ::Wurk::Api::Pagination
520
- minutes = pagination.clamp_int(params[:minutes], 1, ::Wurk::Metrics::Query::MAX_MINUTES, 60) if params[:minutes]
521
- hours = pagination.clamp_int(params[:hours], 1, ::Wurk::Metrics::Query::MAX_HOURS, 24) if params[:hours]
522
- minutes ||= 60 if hours.nil?
523
- [minutes, hours]
538
+ max_hours = ::Wurk::Metrics::Query::MAX_HOURS
539
+ if params[:hours]
540
+ [nil, pagination.clamp_int(params[:hours], 1, max_hours, 24)]
541
+ else
542
+ [pagination.clamp_int(params[:minutes], 1, max_hours * 60, 60), nil]
543
+ end
544
+ end
545
+
546
+ # #metrics variant: `?minutes=` goes through raw so a window past the
547
+ # 30-day ceiling (MAX_HOURS * 60) reaches the Query layer and raises
548
+ # WindowTooWide — the endpoint answers 400 rather than silently serving
549
+ # a shorter window than the SPA's label claims. Unparseable values fall
550
+ # back to the 60-minute default; `?hours=` is clamped as above.
551
+ def metrics_window_raw(params)
552
+ pagination = ::Wurk::Api::Pagination
553
+ max_hours = ::Wurk::Metrics::Query::MAX_HOURS
554
+ if params[:hours]
555
+ [nil, pagination.clamp_int(params[:hours], 1, max_hours, 24)]
556
+ elsif params[:minutes]
557
+ [Integer(params[:minutes], 10), nil]
558
+ else
559
+ [60, nil]
560
+ end
561
+ rescue ::ArgumentError, ::TypeError
562
+ [60, nil]
524
563
  end
525
564
  end
526
565
  end
data/config/routes.rb CHANGED
@@ -16,7 +16,7 @@ Wurk::Engine.routes.draw do
16
16
  post 'queues/:name/pause', to: 'api#pause_queue', as: :api_pause_queue, constraints: { name: %r{[^/]+} }
17
17
  post 'queues/:name/unpause', to: 'api#unpause_queue', as: :api_unpause_queue, constraints: { name: %r{[^/]+} }
18
18
 
19
- # Job-set mutations (retry/delete/kill/requeue/clear). The SPA posts to
19
+ # Job-set mutations (retry/delete/kill/add_to_queue/clear). The SPA posts to
20
20
  # these; the Authorization middleware 403s every non-GET in read-only mode,
21
21
  # so they need no extra guard. `:key` is "<score>|<jid>" (Wurk::SortedEntry#id);
22
22
  # `all/:cmd` operates over the whole set; the bare collection POST is a bulk
@@ -50,10 +50,10 @@ Wurk::Engine.routes.draw do
50
50
  post 'dead/:key', to: 'api#dead_job', as: :api_dead_job, constraints: { key: %r{[^/]+} }
51
51
  get 'processes', to: 'api#processes'
52
52
  get 'workers', to: 'api#workers'
53
- # Busy-page process control (quiet/stop). `identity` rides in the body (it
54
- # contains ':' and '.', awkward as a path segment); absent or "all" signals
55
- # every live process. Read-only mode 403s these via the Authorization
56
- # middleware. Spec: docs/target/sidekiq-free.md §25.4 (POST /busy).
53
+ # Wurk uses split POST /api/busy/quiet and /api/busy/stop requests, not free
54
+ # spec §25.4 POST /busy with identity/quiet/stop params. Request-shape parity
55
+ # is unsupported; see docs/idea/parity-divergences.md#busy-control-http-request-shape-parity-is-unsupported.
56
+ # Read-only mode 403s these via the Authorization middleware.
57
57
  post 'busy/quiet', to: 'api#quiet_process', as: :api_quiet_process
58
58
  post 'busy/stop', to: 'api#stop_process', as: :api_stop_process
59
59
  # Flows (Wurk::Flow) read the way the batches below them do — a paged index
@@ -96,13 +96,21 @@ Wurk::Engine.routes.draw do
96
96
  # version reaches the app and comes back as the same `unsupported_api_version`
97
97
  # problem document the other two mounts answer with.
98
98
  #
99
- # Nested here it also inherits the engine's middleware — the host's
100
- # `Wurk::Web.use` chain and the read-only gate — so a dashboard behind Devise
101
- # keeps gating this path too. A machine client that cannot pass a browser
102
- # login wants mode 2 instead: `mount Wurk::API => '/wurk-api'` in the host's
103
- # own routes, declared BEFORE the engine when its path extends the engine's —
104
- # Rails matches a mounted app by bare string prefix, so `mount Wurk::Engine =>
105
- # '/wurk'` declared first swallows every `/wurk-api/...` request.
99
+ # Nested here it also inherits both engine middlewares — the host's
100
+ # `Wurk::Web.use` chain, then `Wurk::Web::Authorization`
101
+ # (lib/wurk/engine.rb) — so a dashboard behind Devise keeps gating this path
102
+ # too. The `Wurk::Web.configure` authorization hook (403 on falsey,
103
+ # sidekiq-ent §9.2) and the read-only gate both live in Authorization, and
104
+ # the hook runs before the machine-API handoff (lib/wurk/web/config.rb):
105
+ # the host hook gates `/v1` paths too, and a valid machine bearer token does
106
+ # NOT bypass it — bearer auth lives only inside `Wurk::API::App#handle`
107
+ # (lib/wurk/api/app.rb), downstream of both middlewares, so a session-bound
108
+ # hook (`env['warden']&.user`) 403s before the token is ever consulted. A
109
+ # machine client that cannot pass a browser login wants mode 2 instead:
110
+ # `mount Wurk::API => '/wurk-api'` in the host's own routes, declared BEFORE
111
+ # the engine when its path extends the engine's — Rails matches a mounted app
112
+ # by bare string prefix, so `mount Wurk::Engine => '/wurk'` declared first
113
+ # swallows every `/wurk-api/...` request.
106
114
  mount Wurk::API => Wurk::API::ENGINE_MOUNT, constraints: ->(request) { Wurk::API.serves?(request.path_info) }
107
115
 
108
116
  # Profiles (v8.0+) — not under /api: `:key/data` streams the gzipped gecko
@@ -32,8 +32,14 @@ begin
32
32
 
33
33
  callback = -> { @@stopping = true } # rubocop:disable Style/ClassVars
34
34
 
35
- Wurk.configure_client { |config| config.on(:quiet, &callback) }
36
- Wurk.configure_server { |config| config.on(:quiet, &callback) }
35
+ # Registered on the configuration directly, not through
36
+ # Wurk.configure_client / Wurk.configure_server: one of the two gates
37
+ # always passes (configure_client while server? is false,
38
+ # configure_server once it is true), so the hook registered either way.
39
+ # Going through a gate would spend the one-time skip warning on Wurk's
40
+ # own internal call and log a spurious WARN — the warning the host's
41
+ # own skipped initializer needs.
42
+ Wurk.configuration.on(:quiet, &callback)
37
43
 
38
44
  # Defer enqueue until the surrounding DB transaction commits so we
39
45
  # never push a job whose row hasn't landed. Matches Sidekiq.
data/lib/sidekiq/rails.rb CHANGED
@@ -6,5 +6,10 @@
6
6
  # alone, no ActionDispatch — so this maps to the railtie, NOT the dashboard
7
7
  # engine (a wurk-native extra; `require "wurk/rails"` for that). Sidekiq::Web
8
8
  # stays mountable as a plain Rack app either way, exactly like upstream.
9
+ # A first require without ActiveJob preloaded needs ActiveSupport's core
10
+ # extensions before rails/railtie. Requiring rails for that bootstrap would
11
+ # also load ActionDispatch, breaking the railtie-only integration boundary.
9
12
  require 'wurk'
13
+ require 'active_support'
14
+ require 'active_support/core_ext'
10
15
  require 'wurk/railtie'
@@ -52,7 +52,7 @@ module Wurk
52
52
  Problem.render(
53
53
  Problem::READ_ONLY,
54
54
  status: 403,
55
- detail: 'This Wurk deployment is read-only; it answers GET and HEAD only.',
55
+ detail: "This Wurk deployment is read-only; it answers #{SAFE_METHODS.join(', ')} only.",
56
56
  instance: request.path
57
57
  )
58
58
  end
@@ -41,6 +41,29 @@ module Wurk
41
41
 
42
42
  CALLBACK_JOB = 'Wurk::Batch::CallbackJob'
43
43
 
44
+ # The middleware this one must stay OUTSIDE of — the ordering anchor
45
+ # `Chain#add` consults before appending.
46
+ #
47
+ # Expiry SKIPS an expired job by returning without yielding: inside the
48
+ # batch onion the batch's `ack_success` never runs, the batch's pending
49
+ # never drops and the batch never completes. The Limiter turns an
50
+ # over-limit job into a `Limiter::Rescheduled` (a JobRetry::Skip): inside
51
+ # the batch onion the batch's generic `rescue StandardError` books a
52
+ # spurious failure for a job that never ran.
53
+ #
54
+ # A plain `add` appends, so re-registering this middleware (a
55
+ # `configure_server` block, a test helper) would move it inside both and
56
+ # break the batch's accounting. The anchor keeps it outside.
57
+ #
58
+ # Resolved at call time, not at load time: Expiry is loaded after this
59
+ # file (lib/wurk.rb), so naming the constant here would raise NameError.
60
+ def self.outer_of
61
+ anchors = []
62
+ anchors << Wurk::Middleware::Expiry if defined?(Wurk::Middleware::Expiry)
63
+ anchors << Wurk::Limiter::ServerMiddleware if defined?(Wurk::Limiter::ServerMiddleware)
64
+ anchors
65
+ end
66
+
44
67
  def call(_worker, job, _queue, &)
45
68
  bid = job['bid']
46
69
  return yield unless bid
@@ -108,4 +131,8 @@ module Wurk
108
131
  end
109
132
  end
110
133
 
134
+ # Auto-registered on the global server chain. The `outer_of` anchor above
135
+ # keeps this middleware outside Expiry and the Limiter wherever it is
136
+ # registered again later; this registration itself appends as usual, because
137
+ # neither anchor is registered in the chain yet.
111
138
  Wurk.configuration.server_middleware.add(Wurk::Batch::ServerMiddleware)
data/lib/wurk/capsule.rb CHANGED
@@ -40,6 +40,8 @@ module Wurk
40
40
  @pools = {}
41
41
  @client_chain = nil
42
42
  @server_chain = nil
43
+ @server_middleware_seen = []
44
+ @prepared = false
43
45
  @watchdog = nil
44
46
  end
45
47
 
@@ -102,10 +104,38 @@ module Wurk
102
104
  # hook opened across the fork. Idempotent.
103
105
  def prepare_shared!
104
106
  client_middleware
105
- server_middleware
107
+ refresh_server_middleware!
108
+ # A second boot (a launcher run twice) finds the capsule frozen — the
109
+ # flag is already set, so it is not written again.
110
+ @prepared ||= true
106
111
  self
107
112
  end
108
113
 
114
+ # True once this capsule has been prepared to run jobs — `prepare_shared!`
115
+ # in the swarm parent before the fork, `prepare!` in the child at boot.
116
+ # Only then is the memoized chain the one the workers run, and only then
117
+ # is it final: a middleware registered on the Configuration afterwards can
118
+ # never reach it.
119
+ def prepared?
120
+ @prepared
121
+ end
122
+
123
+ # True when this capsule's server middleware chain has already been
124
+ # snapshotted and that snapshot does not carry `klass`.
125
+ #
126
+ # The snapshot is the copy `server_middleware` memoized; a middleware
127
+ # registered on the Configuration after that copy was taken can never
128
+ # reach this capsule — which is what Wurk::Encryption.enable has to
129
+ # detect before it seals arguments the workers could not open.
130
+ #
131
+ # Pure read: it never materializes the chain and has no side effects.
132
+ def server_middleware_missing?(klass)
133
+ chain = @server_chain
134
+ return false unless chain
135
+
136
+ !chain.exists?(klass)
137
+ end
138
+
109
139
  def client_middleware
110
140
  chain = (@client_chain ||= @config.client_middleware.copy_for(self))
111
141
  yield chain if block_given?
@@ -116,7 +146,11 @@ module Wurk
116
146
  # copy_for(self) — not dup — binds the chain's `@config` to this capsule,
117
147
  # so middleware that reach for `redis_pool`/`redis`/`logger` resolve them
118
148
  # instead of hitting `nil` (a plain dup leaves @config nil).
119
- chain = (@server_chain ||= @config.server_middleware.copy_for(self))
149
+ chain = (@server_chain ||= begin
150
+ config_chain = @config.server_middleware
151
+ @server_middleware_seen = config_chain.entries.map(&:klass)
152
+ config_chain.copy_for(self)
153
+ end)
120
154
  yield chain if block_given?
121
155
  chain
122
156
  end
@@ -187,6 +221,27 @@ module Wurk
187
221
 
188
222
  private
189
223
 
224
+ # The Configuration's chain may have grown since this capsule copied it —
225
+ # a middleware registered after the copy was taken would otherwise be
226
+ # missing from the chain the workers run. Track classes already seen rather
227
+ # than just those still present, so capsule-local removals stay removed.
228
+ def refresh_server_middleware!
229
+ copy = server_middleware
230
+ return copy if frozen?
231
+
232
+ @config.server_middleware.entries.each do |entry|
233
+ next if @server_middleware_seen.include?(entry.klass)
234
+
235
+ @server_middleware_seen << entry.klass
236
+ next if copy.exists?(entry.klass)
237
+
238
+ rebound = entry.dup
239
+ rebound.config = self
240
+ copy.entries << rebound
241
+ end
242
+ copy
243
+ end
244
+
190
245
  # Drop-in fetch pluggability (spec §5 / §4.1): instantiate
191
246
  # `config[:fetch_class]` when the host set one — a custom or Pro fetcher —
192
247
  # else the default reliable BLMOVE fetcher (`Sidekiq::BasicFetch`). A
data/lib/wurk/cli.rb CHANGED
@@ -138,8 +138,10 @@ module Wurk
138
138
  # Mark server mode BEFORE the app loads so `configure_server` blocks in
139
139
  # the required initializer actually fire (they gate on `config.server?`).
140
140
  # Matches Sidekiq, which sets `Sidekiq.server = true` before requiring
141
- # the app in its CLI. Skipping this silently drops server middleware,
142
- # error handlers, and lifecycle hooks registered via configure_server.
141
+ # the app in its CLI. Skipping this drops server middleware, error
142
+ # handlers, and lifecycle hooks registered via configure_server, and each
143
+ # skip logs one WARN naming the gate (`configure_server` / `server? is
144
+ # false`).
143
145
  enter_server_mode
144
146
  boot_application if boot_app
145
147
  self_read, self_write = ::IO.pipe
data/lib/wurk/client.rb CHANGED
@@ -87,17 +87,17 @@ module Wurk
87
87
  payload['jid']
88
88
  end
89
89
 
90
- # @param items [Hash] keys: class, args (Array<Array>), at?, spread_interval?, batch_size?, jid?
90
+ # @param items [Hash] String keys only: class, args (Array<Array>), at?, spread_interval?, batch_size?, jid?
91
91
  # @return [Array<String, nil>] jids in submission order; nil entries mark middleware-halted jobs.
92
92
  def push_bulk(items)
93
- args = items['args'] || items[:args]
93
+ args = items['args']
94
94
  validate_bulk_shape!(items, args)
95
95
  return [] if args.empty?
96
96
 
97
97
  base = bulk_base(items)
98
98
  reject_bulk_collapse!(base)
99
99
  at_values = expand_at(items, args.size)
100
- batch_sz = items['batch_size'] || items[:batch_size] || (at_values ? SCHEDULED_BATCH_SIZE : DEFAULT_BATCH_SIZE)
100
+ batch_sz = items['batch_size'] || (at_values ? SCHEDULED_BATCH_SIZE : DEFAULT_BATCH_SIZE)
101
101
  flush_bulk(args, at_values, base, batch_sz)
102
102
  end
103
103
 
@@ -183,14 +183,19 @@ module Wurk
183
183
  end
184
184
 
185
185
  def validate_bulk_shape!(items, args)
186
+ raise ArgumentError, "Bulk items must be a Hash with 'class' and 'args' keys: `#{items}`" unless items.is_a?(Hash)
187
+
188
+ symbol_keys = items.keys.grep(Symbol)
189
+ # Symbol keys raise here exactly like #push does (spec §31.1) and are
190
+ # never stringified, so every read below can rely on String keys only.
191
+ raise ArgumentError, "Bulk push keys must be Strings: `#{symbol_keys.inspect}`" unless symbol_keys.empty?
186
192
  raise ArgumentError, "Bulk arguments must be an Array of Arrays: `#{args.inspect}`" unless valid_bulk_args?(args)
187
193
  raise ArgumentError, "Job 'jid' is only allowed with a single-job bulk" if explicit_jid?(items) && args.size > 1
188
194
  raise ArgumentError, "Cannot pass both 'at' and 'spread_interval'" if conflicting_schedule?(items)
189
195
  end
190
196
 
191
197
  def conflicting_schedule?(items)
192
- (items.key?('at') || items.key?(:at)) &&
193
- (items.key?('spread_interval') || items.key?(:spread_interval))
198
+ items.key?('at') && items.key?('spread_interval')
194
199
  end
195
200
 
196
201
  def valid_bulk_args?(args)
@@ -198,11 +203,11 @@ module Wurk
198
203
  end
199
204
 
200
205
  def explicit_jid?(items)
201
- items.key?('jid') || items.key?(:jid)
206
+ items.key?('jid')
202
207
  end
203
208
 
204
209
  def bulk_base(items)
205
- base = items.transform_keys(&:to_s)
210
+ base = items.dup
206
211
  %w[args at spread_interval batch_size].each { |k| base.delete(k) }
207
212
  base
208
213
  end
@@ -246,9 +251,9 @@ module Wurk
246
251
  end
247
252
 
248
253
  def expand_at(items, count)
249
- return expand_spread(items, count) unless items.key?('at') || items.key?(:at)
254
+ return expand_spread(items, count) unless items.key?('at')
250
255
 
251
- at = items['at'] || items[:at]
256
+ at = items['at']
252
257
  case at
253
258
  when Array
254
259
  raise ArgumentError, "'at' array size must match args" unless at.size == count
@@ -263,9 +268,9 @@ module Wurk
263
268
  end
264
269
 
265
270
  def expand_spread(items, count)
266
- return nil unless items.key?('spread_interval') || items.key?(:spread_interval)
271
+ return nil unless items.key?('spread_interval')
267
272
 
268
- spread = items['spread_interval'] || items[:spread_interval]
273
+ spread = items['spread_interval']
269
274
  raise ArgumentError, "'spread_interval' must be positive Numeric" unless spread.is_a?(Numeric) && spread.positive?
270
275
 
271
276
  window = [spread.to_f, SPREAD_INTERVAL_FLOOR].max
data/lib/wurk/compat.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Sidekiq aliases. This is the drop-in contract — every public Wurk::*
4
- # class is exposed under its Sidekiq::* name. Never break.
4
+ # class is exposed under its Sidekiq::* name except two deliberate
5
+ # exclusions, `Sidekiq::Cron` and `Sidekiq::Status` (#204): those namespaces
6
+ # belong to the third-party sidekiq-cron / sidekiq-status gems, not to
7
+ # Sidekiq itself. Never break the rest.
5
8
  #
6
9
  # Spec: docs/target/sidekiq-{free,pro,ent}.md.
7
10
 
@@ -14,7 +17,14 @@ require_relative 'loader'
14
17
  # {Wurk.configure_server}; `Sidekiq::Batch`, `Sidekiq::Limiter`,
15
18
  # `Sidekiq::Client`, `Sidekiq::Pro::Web`, `Sidekiq::Enterprise` all resolve to
16
19
  # their Wurk implementations. This alias surface is the drop-in contract and is
17
- # never broken.
20
+ # never broken. Two names are deliberately NOT aliased (#204): `Sidekiq::Cron`
21
+ # and `Sidekiq::Status` — those namespaces belong to the third-party
22
+ # sidekiq-cron / sidekiq-status gems. `sidekiq-cron` owns `Sidekiq::Cron`, and
23
+ # squatting it made the gem's own Poller/Job classes collide at load;
24
+ # `sidekiq-status` opens `module Sidekiq::Status`, which resolves the constant
25
+ # instead of defining it, so the alias would hand the gem {Wurk::Status} to
26
+ # reopen and clobber. The guard comments at `CronParser` and `Status` below
27
+ # carry the full rationale.
18
28
  #
19
29
  # @example The same code targets Sidekiq or Wurk unchanged
20
30
  # class MyJob