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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
- data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
- data/.claude/skills/plutonium-resource/SKILL.md +3 -2
- data/.claude/skills/plutonium-ui/SKILL.md +2 -0
- data/CHANGELOG.md +33 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +3 -1
- data/config/locales/en/async.yml +11 -1
- data/config/locales/en/dashboard.yml +1 -0
- data/config/locales/en/ui.yml +3 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/reference/behavior/async-interactions.md +62 -5
- data/docs/reference/dashboard/dsl.md +54 -3
- data/docs/reference/generators/lite.md +30 -6
- data/docs/reference/i18n.md +1 -1
- data/docs/reference/resource/definition.md +2 -0
- data/docs/reference/ui/forms.md +23 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
- data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
- data/gemfiles/rails_7.gemfile.lock +5 -5
- data/gemfiles/rails_8.0.gemfile.lock +3 -3
- data/gemfiles/rails_8.1.gemfile.lock +3 -3
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
- data/lib/plutonium/dashboard/base.rb +80 -1
- data/lib/plutonium/dashboard/card.rb +12 -13
- data/lib/plutonium/dashboard/controller.rb +28 -10
- data/lib/plutonium/dashboard/dsl.rb +59 -2
- data/lib/plutonium/dashboard/evaluation.rb +30 -0
- data/lib/plutonium/dashboard/filter.rb +114 -0
- data/lib/plutonium/dashboard/link.rb +35 -0
- data/lib/plutonium/dashboard/route_resolution.rb +6 -2
- data/lib/plutonium/definition/presentable.rb +4 -1
- data/lib/plutonium/interaction/async/configuration.rb +15 -0
- data/lib/plutonium/interaction/async/context.rb +12 -2
- data/lib/plutonium/interaction/async/executor.rb +54 -0
- data/lib/plutonium/interaction/async/run.rb +128 -14
- data/lib/plutonium/interaction/async/run_definition.rb +5 -4
- data/lib/plutonium/interaction/async/run_policy.rb +5 -5
- data/lib/plutonium/interaction/response/redirect.rb +19 -7
- data/lib/plutonium/resource/query_object.rb +6 -0
- data/lib/plutonium/routing/dashboard_registration.rb +5 -0
- data/lib/plutonium/ui/avatar.rb +2 -2
- data/lib/plutonium/ui/breadcrumbs.rb +26 -8
- data/lib/plutonium/ui/dashboard/board.rb +4 -1
- data/lib/plutonium/ui/dashboard/frame.rb +1 -1
- data/lib/plutonium/ui/dashboard/metric.rb +18 -5
- data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
- data/lib/plutonium/ui/form/base.rb +20 -0
- data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
- data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
- data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
- data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
- data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
- data/lib/plutonium/ui/form/theme.rb +15 -1
- data/lib/plutonium/ui/grid/card.rb +12 -4
- data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
- data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
- data/lib/plutonium/ui/layout/base.rb +0 -9
- data/lib/plutonium/ui/page/dashboard.rb +1 -0
- data/lib/plutonium/ui/page/index.rb +23 -4
- data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
- data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -1
- data/src/css/components.css +9 -0
- data/src/css/font_awesome.css +2328 -0
- data/src/css/plutonium.css +1 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|