plutonium 0.67.0 → 0.69.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 +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  4. data/.claude/skills/plutonium-resource/SKILL.md +3 -2
  5. data/.claude/skills/plutonium-ui/SKILL.md +2 -0
  6. data/CHANGELOG.md +33 -0
  7. data/SECURITY.md +1 -1
  8. data/app/assets/plutonium.css +3 -1
  9. data/config/locales/en/async.yml +11 -1
  10. data/config/locales/en/dashboard.yml +1 -0
  11. data/config/locales/en/ui.yml +3 -0
  12. data/docs/public/images/reference/async-progress-page.png +0 -0
  13. data/docs/public/images/reference/async-running-banner.png +0 -0
  14. data/docs/reference/behavior/async-interactions.md +62 -5
  15. data/docs/reference/dashboard/dsl.md +54 -3
  16. data/docs/reference/generators/lite.md +30 -6
  17. data/docs/reference/i18n.md +1 -1
  18. data/docs/reference/resource/definition.md +2 -0
  19. data/docs/reference/ui/forms.md +23 -0
  20. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  21. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  22. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  23. data/gemfiles/rails_7.gemfile.lock +5 -5
  24. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  25. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  26. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  27. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  28. data/lib/plutonium/dashboard/base.rb +80 -1
  29. data/lib/plutonium/dashboard/card.rb +12 -13
  30. data/lib/plutonium/dashboard/controller.rb +28 -10
  31. data/lib/plutonium/dashboard/dsl.rb +59 -2
  32. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  33. data/lib/plutonium/dashboard/filter.rb +114 -0
  34. data/lib/plutonium/dashboard/link.rb +35 -0
  35. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  36. data/lib/plutonium/definition/presentable.rb +4 -1
  37. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  38. data/lib/plutonium/interaction/async/context.rb +12 -2
  39. data/lib/plutonium/interaction/async/executor.rb +54 -0
  40. data/lib/plutonium/interaction/async/run.rb +128 -14
  41. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  42. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  43. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  44. data/lib/plutonium/resource/query_object.rb +6 -0
  45. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  46. data/lib/plutonium/ui/avatar.rb +2 -2
  47. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  48. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  49. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  50. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  51. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  52. data/lib/plutonium/ui/form/base.rb +20 -0
  53. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  54. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  55. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  56. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  57. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  58. data/lib/plutonium/ui/form/theme.rb +15 -1
  59. data/lib/plutonium/ui/grid/card.rb +12 -4
  60. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  61. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  62. data/lib/plutonium/ui/layout/base.rb +0 -9
  63. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  64. data/lib/plutonium/ui/page/index.rb +23 -4
  65. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  66. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  67. data/lib/plutonium/version.rb +1 -1
  68. data/package.json +1 -1
  69. data/plutonium.gemspec +1 -1
  70. data/src/css/components.css +9 -0
  71. data/src/css/font_awesome.css +2328 -0
  72. data/src/css/plutonium.css +1 -0
  73. metadata +16 -4
@@ -0,0 +1,49 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-10-15-async-run-pre-check.md",
3
+ "tasks": [
4
+ {
5
+ "id": 0,
6
+ "subject": "Task 1: Run model: reject, reject_run!, rejected outcome, max_rejections",
7
+ "status": "completed",
8
+ "description": "**Goal:** `Run` can collect rejections during a check, persist them in one write, and report `outcome == \"rejected\"`.\n\n**Files:**\n- Modify: `lib/plutonium/interaction/async/configuration.rb`\n- Modify: `lib/plutonium/interaction/async/run.rb`\n- Modify: `config/locales/en/async.yml`\n- Test: `test/plutonium/interaction/async/run_test.rb`\n\n**Acceptance Criteria:**\n- [ ] `config.async_interactions.max_rejections` defaults to 100.\n- [ ] `reject` raises outside `collect_rejections`; inside, it stores up to `max_rejections` messages and counts the rest.\n- [ ] `reject_run!(messages, overflow:)` writes entries tagged `\"stage\" => \"pre_check\"`, a truncation entry when `overflow > 0`, and `state: \"failed\"` in a single UPDATE.\n- [ ] `outcome` is `\"rejected\"` for a failed run with a tagged entry; untagged failed runs still read `\"failed\"`.\n\n**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb` \u2192 0 failures, 0 errors\n\nSee plan section 'Task 1: Run model: reject, reject_run!, rejected outcome, max_rejections' for steps.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/interaction/async/configuration.rb\", \"lib/plutonium/interaction/async/run.rb\", \"config/locales/en/async.yml\", \"test/plutonium/interaction/async/run_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb\", \"acceptanceCriteria\": [\"max_rejections defaults to 100\", \"reject raises outside collect_rejections and caps stored messages\", \"reject_run! writes tagged entries and failed in one UPDATE\", \"outcome reads rejected only for tagged failed runs\"], \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 1,
12
+ "subject": "Task 2: Executor runs the check before the work",
13
+ "status": "completed",
14
+ "description": "**Goal:** The executor calls `pre_check` with the right arguments for the run's shape and stops before any work when it rejects.\n\n**Files:**\n- Modify: `lib/plutonium/interaction/async/executor.rb` (`perform_opaque`, `perform_targets`, new private methods)\n- Modify: `config/locales/en/async.yml`\n- Test: `test/plutonium/interaction/async/executor_test.rb`\n\n**Acceptance Criteria:**\n- [ ] Opaque and targeted runs whose check rejects nothing perform as before.\n- [ ] A rejection stops `perform` / `perform_on`; `progress_done` stays 0; `outcome == \"rejected\"`.\n- [ ] Every rejection is recorded; past `max_rejections`, one \"and N more\" entry with the exact remainder.\n- [ ] An exception raised in `pre_check` reads `failed`, not `rejected`.\n- [ ] A wrong-arity `pre_check` fails the run with a message naming the class and expected signature.\n- [ ] A resumed targeted run's check receives only the unhandled records.\n- [ ] A `:transactional` run is rejected without opening a transaction.\n\n**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb` \u2192 0 failures, 0 errors\n\nSee plan section 'Task 2: Executor runs the check before the work' for steps.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/interaction/async/executor.rb\", \"config/locales/en/async.yml\", \"test/plutonium/interaction/async/executor_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb\", \"acceptanceCriteria\": [\"passing checks perform as before\", \"rejection stops work, progress_done 0, outcome rejected\", \"cap with exact remainder\", \"exception in check reads failed\", \"wrong arity names expected signature\", \"resumed run checks remaining records only\", \"transactional rejection opens no batch transaction\"], \"requiresUserVerification\": false}\n```",
15
+ "blockedBy": [
16
+ 0
17
+ ]
18
+ },
19
+ {
20
+ "id": 2,
21
+ "subject": "Task 3: Show rejected runs on the progress page and index",
22
+ "status": "completed",
23
+ "description": "**Goal:** A rejected run shows a warning \"Rejected\" badge, a \"Nothing was processed\" line, and its messages.\n\n**Files:**\n- Modify: `lib/plutonium/ui/interaction/async/run_progress.rb` (`BADGE_VARIANTS`, `render_panel`, new `render_rejected`)\n- Modify: `lib/plutonium/interaction/async/run_definition.rb` (both `colors:` hashes)\n- Modify: `config/locales/en/async.yml`\n- Test: `test/integration/admin_portal/async_run_progress_test.rb`\n\n**Acceptance Criteria:**\n- [ ] The show page renders a `pu-badge-warning` badge reading \"Rejected\", the line \"Nothing was processed\", and every rejection message.\n- [ ] A plain failed run does not show \"Nothing was processed\".\n- [ ] The index renders \"Rejected\" for the run.\n\n**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb` \u2192 0 failures, 0 errors\n\nSee plan section 'Task 3: Show rejected runs on the progress page and index' for steps.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/ui/interaction/async/run_progress.rb\", \"lib/plutonium/interaction/async/run_definition.rb\", \"config/locales/en/async.yml\", \"test/integration/admin_portal/async_run_progress_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb\", \"acceptanceCriteria\": [\"warning Rejected badge, Nothing was processed, all messages on show page\", \"plain failed run lacks the line\", \"index shows Rejected\"], \"requiresUserVerification\": false}\n```",
24
+ "blockedBy": [
25
+ 0
26
+ ]
27
+ },
28
+ {
29
+ "id": 3,
30
+ "subject": "Task 4: Dummy CSV import with a pre_check, end to end",
31
+ "status": "completed",
32
+ "description": "**Goal:** A real interaction in the dummy app exercises dispatch \u2192 staged file \u2192 pre_check \u2192 rejected or completed, and backs the docs example.\n\n**Files:**\n- Create: `test/dummy/app/interactions/import_tasks_interaction.rb`\n- Modify: `test/dummy/app/policies/task_policy.rb` (after `archive_all_async?`)\n- Modify: `test/dummy/app/definitions/task_definition.rb` (after `action :archive_all_async`)\n- Create: `test/integration/admin_portal/async_import_pre_check_test.rb`\n\n**Acceptance Criteria:**\n- [ ] Posting a CSV with bad rows creates a run that, once executed, reads `rejected`, lists one message per bad row, and creates no tasks.\n- [ ] Posting a valid CSV creates the tasks and the run reads `completed`.\n- [ ] The rejected run's progress page shows the badge and messages.\n\n**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb` \u2192 0 failures, 0 errors\n\nSee plan section 'Task 4: Dummy CSV import with a pre_check, end to end' for steps.\n\n```json:metadata\n{\"files\": [\"test/dummy/app/interactions/import_tasks_interaction.rb\", \"test/dummy/app/models/task.rb\", \"test/dummy/app/policies/task_policy.rb\", \"test/dummy/app/definitions/task_definition.rb\", \"test/integration/admin_portal/async_import_pre_check_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb\", \"acceptanceCriteria\": [\"bad CSV rejected whole with one message per bad row and no tasks\", \"valid CSV imported and completed\", \"progress page shows Rejected and messages\"], \"requiresUserVerification\": false}\n```",
33
+ "blockedBy": [
34
+ 1,
35
+ 2
36
+ ]
37
+ },
38
+ {
39
+ "id": 4,
40
+ "subject": "Task 5: Docs and skill",
41
+ "status": "completed",
42
+ "description": "**Goal:** The reference doc and the shipped skill explain `pre_check`, `reject`, the `rejected` outcome, `max_rejections`, and the heartbeat requirement, with samples that match the dummy app.\n\n**Files:**\n- Modify: `docs/reference/behavior/async-interactions.md`\n- Modify: `.claude/skills/plutonium-async-interactions/SKILL.md`\n\n**Acceptance Criteria:**\n- [ ] A \"Checking input before processing\" section after \"### Files\" in the doc, and after \"## Attributes and files\" in the skill.\n- [ ] `max_rejections` in the doc's Enabling config block.\n- [ ] \"Outcome vs state\" documents `rejected`.\n- [ ] Both heartbeat warnings mention `pre_check`.\n- [ ] No em dashes; samples match `ImportTasksInteraction`.\n- [ ] `yarn docs:build` passes.\n\n**Verify:** `yarn docs:build` \u2192 completes without dead-link errors\n\nSee plan section 'Task 5: Docs and skill' for steps.\n\n```json:metadata\n{\"files\": [\"docs/reference/behavior/async-interactions.md\", \".claude/skills/plutonium-async-interactions/SKILL.md\"], \"verifyCommand\": \"yarn docs:build\", \"acceptanceCriteria\": [\"new section in doc and skill\", \"max_rejections in config block\", \"rejected in Outcome vs state\", \"heartbeat notes mention pre_check\", \"no em dashes\", \"docs build passes\"], \"requiresUserVerification\": false}\n```",
43
+ "blockedBy": [
44
+ 3
45
+ ]
46
+ }
47
+ ],
48
+ "lastUpdated": "2026-10-15T04:38:35.300061+00:00"
49
+ }
@@ -0,0 +1,208 @@
1
+ # Async run pre_check
2
+
3
+ **Status:** approved design, not yet planned
4
+ **Date:** 2026-10-15
5
+ **Area:** `lib/plutonium/interaction/async/`
6
+
7
+ ## Problem
8
+
9
+ An async run has no stage for refusing its input. Cheap file checks already run
10
+ synchronously (the uploader's `Attacher.validate` rules run during interaction
11
+ validation, so a wrong type or size fails the form). Checks too slow or too deep
12
+ for a request have nowhere to go: parsing a 50k-row CSV, checking its headers,
13
+ validating every row, looking up referenced records. Today the only option is to
14
+ raise inside `perform`, which reads as a crash partway through and gives the user
15
+ one problem per attempt.
16
+
17
+ ## Decisions
18
+
19
+ | Question | Decision |
20
+ |---|---|
21
+ | After the check passes | Processing starts in the same job. No confirmation step. |
22
+ | Reporting problems | Collect every problem, then reject the run if any were recorded. |
23
+ | How a rejected run reads | `state` stays `failed`; `outcome` reads `rejected`. |
24
+ | Declaration style | A plain method in the run class, like `perform` / `perform_on`. |
25
+ | Cap on stored problems | Configurable, `config.async_interactions.max_rejections`, default 100. |
26
+
27
+ ## API
28
+
29
+ ```ruby
30
+ async do
31
+ def pre_check
32
+ attachment(:import_file).open do |f|
33
+ CSV.foreach(f, headers: true).with_index(2) do |row, line|
34
+ reject "line #{line}: missing email" if row["email"].blank?
35
+ heartbeat! if (line % 1_000).zero?
36
+ end
37
+ end
38
+ end
39
+
40
+ def perform
41
+ # only runs if pre_check rejected nothing
42
+ end
43
+ end
44
+ ```
45
+
46
+ A targeted run's check receives the resolved, authorized records:
47
+
48
+ ```ruby
49
+ async do
50
+ def pre_check(invoices)
51
+ currencies = invoices.map(&:currency).uniq
52
+ reject "mixed currencies: #{currencies.join(", ")}" if currencies.size > 1
53
+ end
54
+
55
+ def perform_on(invoice)
56
+ invoice.reissue!
57
+ end
58
+ end
59
+ ```
60
+
61
+ ### `Run#pre_check` / `Run#pre_check(records)`
62
+
63
+ - Optional. The base class defines a no-op, so existing runs are unaffected.
64
+ - Invoked through `send`, matching how `perform` / `perform_on` are invoked, so a
65
+ private definition works.
66
+ - Arguments follow the run's shape: none for opaque work, the resolved records for
67
+ targeted work. A definition with the wrong arity fails the run with a message
68
+ naming the class and the expected signature.
69
+
70
+ ### `Run#reject(message)`
71
+
72
+ - Records one problem and returns, so the check can report every bad row in one
73
+ pass.
74
+ - Valid only while `pre_check` runs. Called from `perform`, `perform_on`, or
75
+ anywhere else it raises, since a rejection nothing reads would be lost.
76
+
77
+ ## Execution
78
+
79
+ In `Async::Executor`, after `claim!`:
80
+
81
+ - **Opaque:** `pre_check` → `perform` → `finish!`
82
+ - **Targeted:** resolve targets → `record_unresolved` and the existing partial-batch
83
+ refusal for `:halt` / `:transactional` → `pre_check(resolved.records)` → the
84
+ per-target loop (or the transactional batch) → `finish!`
85
+
86
+ `pre_check` sees only the records the loop is about to process. Missing or
87
+ unauthorized ids are already recorded failures by then.
88
+
89
+ After `pre_check` returns:
90
+
91
+ - **Nothing rejected:** processing starts.
92
+ - **Anything rejected:** the executor calls `Run#reject_run!(messages, overflow:)`. `perform` /
93
+ `perform_on` never run, no target is touched, and `progress_done` stays 0.
94
+
95
+ An exception raised inside `pre_check` takes the existing failure path in
96
+ `Executor#call`. That run reads `failed`, not `rejected`: "your input is wrong" and
97
+ "the check itself broke" are different reports.
98
+
99
+ ## Recording
100
+
101
+ No migration.
102
+
103
+ Each rejection becomes an `errors_log` entry:
104
+
105
+ ```ruby
106
+ {"target_id" => nil, "message" => "line 12: missing email", "stage" => "pre_check"}
107
+ ```
108
+
109
+ - A nil `target_id` is already the run-level sentinel, so progress counters and
110
+ `handled_target_ids` are untouched.
111
+ - `stage` is a new key. Entries written before it have none, and current readers
112
+ ignore unknown keys.
113
+
114
+ `Run#reject_run!(messages, overflow:)` writes the entries and `state: "failed"`,
115
+ `finished_at`, `last_activity_at` in one `update!`, so a crash cannot leave a
116
+ failed run without its reasons or reasons on a run still marked running (the same
117
+ reasoning as `record_target_failures!`).
118
+
119
+ ### Cap
120
+
121
+ - Only the first `max_rejections` messages are stored.
122
+ - When more were rejected, one extra entry (also `stage: "pre_check"`) records the
123
+ remainder: "and 4900 more problems were found"
124
+ (`plutonium.async.executor.rejections_truncated`, `one` / `other`).
125
+ - `Async::Configuration#max_rejections`, default `100`. Not added to the install
126
+ generator's initializer template; documented with the other settings.
127
+ - The cap is applied in `reject`: it stores at most `max_rejections` messages and
128
+ counts the rest, so memory stays bounded and the remainder is exact.
129
+ `reject_run!` receives the stored messages and the overflow count.
130
+
131
+ ## Outcome and display
132
+
133
+ - `Run#outcome` returns `"rejected"` when `state == "failed"` and any `errors_log`
134
+ entry has `"stage" => "pre_check"`. A completed run can never carry such an
135
+ entry.
136
+ - `state` stays `failed`, so `ReapJob`, `in_progress`, the running banner and the
137
+ poll-stop logic are unchanged.
138
+ - **Badge:** `rejected: :warning` in the `display :outcome` and `column :outcome`
139
+ declarations in `RunDefinition`, and in `BADGE_VARIANTS` in
140
+ `lib/plutonium/ui/interaction/async/run_progress.rb`. Warning, not danger: the
141
+ input was refused and nothing broke.
142
+ - **Badge text:** the existing `Plutonium::Translation.value_label` lookup
143
+ (`plutonium.values.*`), falling back to "Rejected".
144
+ - **Progress panel:** a rejected run shows "Nothing was processed"
145
+ (`plutonium.async.progress.rejected`) above the failures list, so "0 of 50
146
+ targets" does not read as a run that died at its first target.
147
+ - **Failures list:** unchanged. Entries with a nil `target_id` already render
148
+ without the `#id:` prefix.
149
+
150
+ ## Edge cases
151
+
152
+ - **Resume after a reap:** A resumed opaque run re-runs `pre_check` then
153
+ `perform` from the start (as opaque work already does). A resumed targeted run
154
+ skips the check if an earlier pass applied a target (a handled id with no
155
+ failure entry; failed targets do not count), since targets are only performed
156
+ after the check passes. Otherwise it checks only the unhandled records.
157
+ Skipping keeps a half-applied run from being rejected as "nothing was
158
+ processed".
159
+ - **`:transactional`:** `pre_check` runs before the transaction opens. A rejected
160
+ batch never opens one.
161
+ - **Heartbeat:** the executor writes nothing while `pre_check` runs. A long check
162
+ must call `heartbeat!`, as opaque `perform` must today.
163
+ - **Reaped mid-check:** `heartbeat!` and `reject_run!` both raise
164
+ `ActiveRecord::StaleObjectError` against a superseded row. `Executor#superseded?`
165
+ already turns that into "no longer mine".
166
+
167
+ ## Testing
168
+
169
+ **Executor (`test/plutonium/interaction/async/executor_test.rb`)**
170
+ - Opaque and targeted runs pass the check, then perform.
171
+ - A rejection stops `perform` / `perform_on`; `progress_done` stays 0; outcome is
172
+ `rejected`.
173
+ - Several rejections are all recorded.
174
+ - The cap: `max_rejections` respected, plus the "and N more" entry with the exact
175
+ remainder.
176
+ - An exception raised in `pre_check` reads `failed`, not `rejected`.
177
+ - A wrong-arity `pre_check` fails with a message naming the expected signature.
178
+ - `reject` outside `pre_check` raises.
179
+ - A resumed targeted run's check receives only the remaining records.
180
+ - A `:transactional` run is rejected without opening a transaction.
181
+
182
+ **Run (`test/plutonium/interaction/async/run_test.rb`)**
183
+ - `outcome` reads `rejected` from tagged entries; entries without `stage` are
184
+ unaffected.
185
+ - `reject_run!` writes entries and state in one update.
186
+
187
+ **Integration (`test/integration/admin_portal/`)**
188
+ - The progress page shows the Rejected badge, the "Nothing was processed" line and
189
+ the messages; the index shows the badge.
190
+
191
+ **Dummy app**
192
+ - An opaque CSV import interaction in the admin portal, backing the integration
193
+ test and the docs example. Hand-authored: there is no interaction generator.
194
+
195
+ ## Docs
196
+
197
+ - `docs/reference/behavior/async-interactions.md`: a "Checking input before
198
+ processing" section; `max_rejections` with the other settings.
199
+ - `.claude/skills/plutonium-async-interactions/SKILL.md`: a matching section, and
200
+ `pre_check` added to the heartbeat warning in Critical.
201
+ - Every sample checked against the implementation before it is published.
202
+
203
+ ## Out of scope
204
+
205
+ - A confirmation step between check and processing.
206
+ - Excluding individual targets from the check and processing the rest (the
207
+ `:continue` failure policy covers per-target failures).
208
+ - Progress reporting for opaque work.
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- plutonium (0.66.0)
12
+ plutonium (0.68.0)
13
13
  action_policy (~> 0.7.0)
14
14
  csv
15
15
  listen (~> 3.8)
@@ -20,7 +20,7 @@ PATH
20
20
  phlex-tabler_icons
21
21
  phlexi-display (>= 0.2.0)
22
22
  phlexi-field (>= 0.2.0)
23
- phlexi-form (>= 0.14.3)
23
+ phlexi-form (>= 0.14.4)
24
24
  phlexi-menu (>= 0.4.1)
25
25
  phlexi-table (>= 0.2.0)
26
26
  rabl (~> 0.17.0)
@@ -271,7 +271,7 @@ GEM
271
271
  fiber-local
272
272
  phlex (~> 2.0)
273
273
  zeitwerk
274
- phlexi-form (0.14.3)
274
+ phlexi-form (0.14.4)
275
275
  activesupport
276
276
  phlex (~> 2.0)
277
277
  phlexi-field (~> 0.2.0)
@@ -608,10 +608,10 @@ CHECKSUMS
608
608
  phlex-tabler_icons (0.3.0) sha256=67d80cc09c619646cff171a620fa805cb49543625c3586a947cd752d44d2858e
609
609
  phlexi-display (0.2.0) sha256=73e22d01e7204a3b94dcb21432477982bfa5162926c11bacd7d48930114236de
610
610
  phlexi-field (0.2.0) sha256=ecb509cc2c0e0559f52651cac8d5dd2fca4bda3878cb7343ea7b298c760e7bca
611
- phlexi-form (0.14.3) sha256=47d69c4a49ba22279ba541b9b110e81d62acc4602513f21bb5c3d29fa9c8d5ca
611
+ phlexi-form (0.14.4)
612
612
  phlexi-menu (0.4.1) sha256=f6eeb1eb9597caadeee33a53f1520b3613a6ac616cf1e395e9d734a412895fb2
613
613
  phlexi-table (0.2.0) sha256=73bee135adbe73fcdb6fdd2672444373556dd9e6bba61e7b5b20ca8b7a1f822c
614
- plutonium (0.66.0)
614
+ plutonium (0.68.0)
615
615
  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
616
616
  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
617
617
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- plutonium (0.66.0)
12
+ plutonium (0.68.0)
13
13
  action_policy (~> 0.7.0)
14
14
  csv
15
15
  listen (~> 3.8)
@@ -20,7 +20,7 @@ PATH
20
20
  phlex-tabler_icons
21
21
  phlexi-display (>= 0.2.0)
22
22
  phlexi-field (>= 0.2.0)
23
- phlexi-form (>= 0.14.3)
23
+ phlexi-form (>= 0.14.4)
24
24
  phlexi-menu (>= 0.4.1)
25
25
  phlexi-table (>= 0.2.0)
26
26
  rabl (~> 0.17.0)
@@ -252,7 +252,7 @@ GEM
252
252
  fiber-local
253
253
  phlex (~> 2.0)
254
254
  zeitwerk
255
- phlexi-form (0.14.3)
255
+ phlexi-form (0.14.4)
256
256
  activesupport
257
257
  phlex (~> 2.0)
258
258
  phlexi-field (~> 0.2.0)
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- plutonium (0.66.0)
12
+ plutonium (0.68.0)
13
13
  action_policy (~> 0.7.0)
14
14
  csv
15
15
  listen (~> 3.8)
@@ -20,7 +20,7 @@ PATH
20
20
  phlex-tabler_icons
21
21
  phlexi-display (>= 0.2.0)
22
22
  phlexi-field (>= 0.2.0)
23
- phlexi-form (>= 0.14.3)
23
+ phlexi-form (>= 0.14.4)
24
24
  phlexi-menu (>= 0.4.1)
25
25
  phlexi-table (>= 0.2.0)
26
26
  rabl (~> 0.17.0)
@@ -254,7 +254,7 @@ GEM
254
254
  fiber-local
255
255
  phlex (~> 2.0)
256
256
  zeitwerk
257
- phlexi-form (0.14.3)
257
+ phlexi-form (0.14.4)
258
258
  activesupport
259
259
  phlex (~> 2.0)
260
260
  phlexi-field (~> 0.2.0)
@@ -10,10 +10,12 @@ module Pu
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Install a nightly SqliteMaintenanceJob (PRAGMA optimize + VACUUM)"
13
+ desc "Install a nightly SqliteMaintenanceJob (PRAGMA optimize + VACUUM) and schedule SolidQueue finished-job cleanup"
14
14
 
15
15
  class_option :schedule, type: :string, default: "every day at 3:30am",
16
16
  desc: "Cron-style schedule for the maintenance job"
17
+ class_option :clear_finished_schedule, type: :string, default: "every hour at minute 12",
18
+ desc: "Schedule for clearing finished SolidQueue jobs"
17
19
 
18
20
  def start
19
21
  template "app/jobs/sqlite_maintenance_job.rb"
@@ -22,6 +24,11 @@ module Pu
22
24
  unless add_recurring_tasks(maintenance_task_yaml, marker: "sqlite_maintenance")
23
25
  log :skip, "could not schedule (config/recurring.yml missing or already scheduled)"
24
26
  end
27
+ # Rails 8 apps ship this task already; the marker matches its command
28
+ # so we never schedule it twice under a different key.
29
+ unless add_recurring_tasks(clear_finished_jobs_task_yaml, marker: "clear_finished_in_batches")
30
+ log :skip, "finished-job cleanup not scheduled (config/recurring.yml missing or already scheduled)"
31
+ end
25
32
  else
26
33
  log :info, "solid_queue not found — job installed but not scheduled. Add a 'sqlite_maintenance' entry to your scheduler."
27
34
  end
@@ -40,6 +47,18 @@ module Pu
40
47
  description: "VACUUM + PRAGMA optimize across SQLite databases"
41
48
  YAML
42
49
  end
50
+
51
+ # SolidQueue keeps finished jobs (preserve_finished_jobs defaults to true)
52
+ # and only deletes them when asked. Without this the queue database grows
53
+ # without bound.
54
+ def clear_finished_jobs_task_yaml
55
+ <<~YAML
56
+ clear_solid_queue_finished_jobs:
57
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
58
+ schedule: "#{options[:clear_finished_schedule]}"
59
+ description: "Delete SolidQueue jobs finished longer ago than clear_finished_jobs_after"
60
+ YAML
61
+ end
43
62
  end
44
63
  end
45
64
  end
@@ -26,6 +26,12 @@ class SqliteMaintenanceJob < ApplicationJob
26
26
  # churning DB sits at a steady-state size without nightly reclamation.
27
27
  VACUUM_DBS = %w[primary errors rails_pulse].freeze
28
28
 
29
+ # VACUUM holds that exclusive lock for as long as the rewrite takes, which on
30
+ # a large primary is long enough for inbound requests to time out waiting.
31
+ # WAL reuses freelist pages anyway, so the rewrite only pays off once a real
32
+ # share of the file is free, usually after a bulk delete.
33
+ VACUUM_MIN_FREE_RATIO = 0.1
34
+
29
35
  def perform
30
36
  OPTIMIZE_DBS.each { |db_name| run_maintenance(db_name) }
31
37
  end
@@ -46,6 +52,7 @@ class SqliteMaintenanceJob < ApplicationJob
46
52
  conn.execute("PRAGMA optimize")
47
53
 
48
54
  next unless VACUUM_DBS.include?(db_name)
55
+ next unless vacuum_worthwhile?(conn, db_name)
49
56
 
50
57
  Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name}" }
51
58
  started = Time.current
@@ -53,8 +60,18 @@ class SqliteMaintenanceJob < ApplicationJob
53
60
  Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name} done in #{(Time.current - started).round(2)}s" }
54
61
  end
55
62
  rescue => e
56
- Rails.error.report(e, context: {db: db_name, action: "sqlite_maintenance"})
63
+ Rails.error.report(e, context: {db: db_name, action: "sqlite_maintenance"}, source: "sqlite_maintenance")
57
64
  ensure
58
65
  MaintenanceConnection.remove_connection
59
66
  end
67
+
68
+ def vacuum_worthwhile?(conn, db_name)
69
+ free = conn.select_value("PRAGMA freelist_count").to_i
70
+ total = conn.select_value("PRAGMA page_count").to_i
71
+ ratio = total.zero? ? 0 : free.fdiv(total)
72
+ return true if ratio >= VACUUM_MIN_FREE_RATIO
73
+
74
+ Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name} skipped, #{(ratio * 100).round(1)}% free" }
75
+ false
76
+ end
60
77
  end
@@ -32,7 +32,7 @@ module Plutonium
32
32
  delegate :current_user, :current_scoped_entity, :scoped_to_entity?,
33
33
  :params, :request, :controller, :current_engine,
34
34
  :resource_url_for, :authorized_resource_scope, :allowed_to?, :policy_for,
35
- :registered_resources, :root_path,
35
+ :registered_resources, :root_path, :current_package,
36
36
  to: :view_context
37
37
 
38
38
  class << self
@@ -92,6 +92,75 @@ module Plutonium
92
92
  card
93
93
  end
94
94
 
95
+ # A filter's chosen value for this request, or its default.
96
+ # @raise [ArgumentError] when the dashboard declares no such filter
97
+ def filter_value(key)
98
+ filter_values.fetch(key.to_sym) { raise ArgumentError, "#{self.class.name} declares no filter #{key.inspect}" }
99
+ end
100
+
101
+ # Every filter's chosen value, keyed by filter: what the page carries
102
+ # onto each card's frame URL. Read once per request.
103
+ def filter_values
104
+ @filter_values ||= self.class.filters.to_h { |filter| [filter.key, filter.value_from(self)] }.freeze
105
+ end
106
+
107
+ # A filter's value => label pairs for this request, built once.
108
+ def filter_choices(filter)
109
+ (@filter_choices ||= {})[filter.key] ||= filter.choices_for(self)
110
+ end
111
+
112
+ # The resource's authorized scope with the scope chosen by its
113
+ # `filter ..., scopes:` applied by the index's own query object, so a
114
+ # card's number matches the index list {#filtered_url} opens. Without
115
+ # such a filter, or with "All" chosen, the authorized scope as is.
116
+ #
117
+ # The scope block runs against this dashboard's controller, so it has
118
+ # `current_user` and the portal's helpers, but not a resource
119
+ # controller's (`current_parent`, `current_definition`, ...).
120
+ def filtered(resource_class)
121
+ relation = authorized_resource_scope(resource_class)
122
+ name = chosen_scope(resource_class)
123
+ return relation unless name
124
+
125
+ scope_query_for(resource_class).scope_definitions.fetch(name).apply(relation, context: controller)
126
+ end
127
+
128
+ # The resource's index, on the scope chosen by its `filter ..., scopes:`.
129
+ # "All" is sent explicitly when the definition has a `default_scope`, or
130
+ # the index would apply that instead.
131
+ def filtered_url(resource_class)
132
+ name = chosen_scope(resource_class)
133
+ scope = name || ("" if resource_definition_for(resource_class).default_scope)
134
+ options = scope.nil? ? {} : {q: {scope:}}
135
+ resource_url_for(resource_class, parent: nil, **options)
136
+ end
137
+
138
+ # The resource's definition in this dashboard's portal, falling back to
139
+ # the base one, as a resource controller resolves it.
140
+ def resource_definition_for(resource_class)
141
+ (@resource_definitions ||= {})[resource_class] ||= begin
142
+ portal_definition = current_package && "#{current_package}::#{resource_class}Definition".safe_constantize
143
+ (portal_definition || "#{resource_class}Definition".constantize).new
144
+ end
145
+ end
146
+
147
+ # The index's query object for the resource, holding its definition's
148
+ # scopes: how each applies, whether it shows, and its label. It reads
149
+ # no params and builds no URLs here.
150
+ def scope_query_for(resource_class)
151
+ (@scope_queries ||= {})[resource_class] ||=
152
+ Plutonium::Resource::QueryObject.new(resource_class, {}, nil) do |query|
153
+ resource_definition_for(resource_class).defined_scopes.each do |name, scope|
154
+ query.define_scope name, scope[:block], **scope[:options]
155
+ end
156
+ end
157
+ end
158
+
159
+ # The toolbar links whose `condition:` passes for this request.
160
+ def visible_links
161
+ self.class.links.select { |link| link.visible?(self) }
162
+ end
163
+
95
164
  # The refresh interval for a card: its own, else the dashboard default.
96
165
  # A card declared `refresh: false` never refreshes.
97
166
  def refresh_for(card)
@@ -106,6 +175,16 @@ module Plutonium
106
175
 
107
176
  private
108
177
 
178
+ # The scope name a `filter ..., scopes: resource_class` has chosen, or
179
+ # nil for "All" or when the dashboard has no such filter.
180
+ def chosen_scope(resource_class)
181
+ filter = self.class.filters.find { |candidate| candidate.resource_class == resource_class }
182
+ return unless filter
183
+
184
+ value = filter_value(filter.key)
185
+ value unless value == Filter::ALL
186
+ end
187
+
109
188
  def t(key, **options)
110
189
  Plutonium::Translation.t(key, **options)
111
190
  end
@@ -9,6 +9,8 @@ module Plutonium
9
9
  # Built by {DSL} at class-load and frozen; everything request-dependent
10
10
  # (label translation, `condition:`, `href:`) is resolved per render.
11
11
  class Card
12
+ include Evaluation
13
+
12
14
  KINDS = %i[metric chart custom].freeze
13
15
 
14
16
  # The dashboard grid is 12 columns wide, so halves, thirds and quarters
@@ -21,11 +23,14 @@ module Plutonium
21
23
 
22
24
  METRIC_FORMATS = %i[number currency percentage human].freeze
23
25
  METRIC_POSITIVE = %i[up down].freeze
26
+ # How a metric's change reads: a percent of the previous value, or the
27
+ # difference in points (for a value that is itself a percentage).
28
+ METRIC_CHANGE_UNITS = %i[percent points].freeze
24
29
  CHART_TYPES = %i[line area column bar pie donut scatter].freeze
25
30
 
26
31
  # Options every kind accepts.
27
32
  COMMON_OPTIONS = %i[label description icon span lazy refresh condition href].freeze
28
- METRIC_OPTIONS = %i[format precision unit prefix suffix positive change_label].freeze
33
+ METRIC_OPTIONS = %i[format precision unit prefix suffix positive change_label change_unit].freeze
29
34
  CHART_OPTIONS = %i[type height].freeze
30
35
 
31
36
  attr_reader :key, :kind, :icon, :span, :refresh, :condition, :href, :block, :options, :dashboard_class
@@ -87,14 +92,7 @@ module Plutonium
87
92
  # the page AND the card endpoint, so a hidden card cannot be fetched
88
93
  # directly.
89
94
  def visible?(dashboard)
90
- return true if condition.nil?
91
-
92
- result = case condition
93
- when Symbol then dashboard.send(condition)
94
- when Proc then evaluate_on(dashboard, condition)
95
- else condition
96
- end
97
- result ? true : false
95
+ condition_passes?(dashboard, condition)
98
96
  end
99
97
 
100
98
  # The card's link target for this request, if any.
@@ -135,10 +133,6 @@ module Plutonium
135
133
 
136
134
  private
137
135
 
138
- def evaluate_on(dashboard, callable)
139
- callable.arity.zero? ? dashboard.instance_exec(&callable) : dashboard.instance_exec(dashboard, &callable)
140
- end
141
-
142
136
  def validate_span!(span)
143
137
  return GRID_COLUMNS if span == :full
144
138
  return span if span.is_a?(Integer) && span.between?(1, GRID_COLUMNS)
@@ -178,6 +172,11 @@ module Plutonium
178
172
  raise ArgumentError, "metric #{key.inspect}: positive must be :up or :down, got #{positive.inspect}"
179
173
  end
180
174
 
175
+ change_unit = specific.fetch(:change_unit, :percent)
176
+ unless METRIC_CHANGE_UNITS.include?(change_unit)
177
+ raise ArgumentError, "metric #{key.inspect}: change_unit must be one of #{METRIC_CHANGE_UNITS.inspect}, got #{change_unit.inspect}"
178
+ end
179
+
181
180
  specific.merge(positive:)
182
181
  end
183
182