plutonium 0.68.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/CHANGELOG.md +20 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +3 -1
- data/config/locales/en/async.yml +11 -1
- 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/generators/lite.md +30 -6
- data/docs/reference/i18n.md +1 -1
- 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/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/ui/breadcrumbs.rb +26 -8
- 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/index.rb +23 -4
- data/lib/plutonium/version.rb +1 -1
- data/package.json +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 +6 -2
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
# Async Run pre_check Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Give async runs an optional `pre_check` stage that can reject the run's input, reporting every problem at once, before any work is done.
|
|
6
|
+
|
|
7
|
+
**Architecture:** `Run` gains a no-op `pre_check`, a `reject` collector that is only live while the check runs, and `reject_run!`, which writes tagged `errors_log` entries and `failed` in one update. `Executor` calls the check after the claim (and, for targeted runs, after target resolution) and stops if anything was rejected. `outcome` reads `"rejected"` from the tagged entries; the progress page and badges render it.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, Rails (ActiveRecord, I18n), Phlex, Minitest via Appraisal.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md`
|
|
12
|
+
|
|
13
|
+
**User Verification:** NO. No user verification required.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Ground rules for every task
|
|
18
|
+
|
|
19
|
+
- Run tests through Appraisal from the repo root:
|
|
20
|
+
`bundle exec appraisal rails-8.1 ruby -Itest <file>` (add `-n "/pattern/"` to pick tests).
|
|
21
|
+
- **Never stage or commit** unless the user has asked for commits. Each task ends with a suggested commit message for when they do.
|
|
22
|
+
- Test files have no transactional rollback (`test_helper` does not load `rails/test_help`); every suite deletes the rows it creates in `setup` / `teardown`. Follow that.
|
|
23
|
+
- Appraisal rewrites `gemfiles/*.gemfile.lock`. Do not stage those.
|
|
24
|
+
- Ruby style: no defensive `respond_to?` checks; comments explain *why*, matching the density of the surrounding code; no em dashes in docs.
|
|
25
|
+
|
|
26
|
+
## File map
|
|
27
|
+
|
|
28
|
+
| File | Change |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `lib/plutonium/interaction/async/configuration.rb` | `max_rejections` setting |
|
|
31
|
+
| `lib/plutonium/interaction/async/run.rb` | `PRE_CHECK_STAGE`, `pre_check`, `reject`, `collect_rejections`, `reject_run!`, `rejected?`, `outcome` |
|
|
32
|
+
| `lib/plutonium/interaction/async/executor.rb` | call the check, arity guard |
|
|
33
|
+
| `lib/plutonium/interaction/async/run_definition.rb` | `rejected: :warning` badge colour |
|
|
34
|
+
| `lib/plutonium/ui/interaction/async/run_progress.rb` | badge variant, "Nothing was processed" line |
|
|
35
|
+
| `config/locales/en/async.yml` | `executor.rejections_truncated`, `executor.pre_check_arity`, `run.reject_outside_pre_check`, `progress.rejected` |
|
|
36
|
+
| `test/plutonium/interaction/async/run_test.rb` | model tests |
|
|
37
|
+
| `test/plutonium/interaction/async/executor_test.rb` | executor tests |
|
|
38
|
+
| `test/integration/admin_portal/async_run_progress_test.rb` | display tests |
|
|
39
|
+
| `test/dummy/app/interactions/import_tasks_interaction.rb` | new: CSV import with a pre_check |
|
|
40
|
+
| `test/dummy/app/policies/task_policy.rb` | `import_tasks?` |
|
|
41
|
+
| `test/dummy/app/definitions/task_definition.rb` | `action :import_tasks` |
|
|
42
|
+
| `test/integration/admin_portal/async_import_pre_check_test.rb` | new: end-to-end |
|
|
43
|
+
| `docs/reference/behavior/async-interactions.md` | new section, config line, outcome, heartbeat |
|
|
44
|
+
| `.claude/skills/plutonium-async-interactions/SKILL.md` | matching section, Critical note |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### Task 1: Run model: reject, reject_run!, rejected outcome, max_rejections
|
|
49
|
+
|
|
50
|
+
**Goal:** `Run` can collect rejections during a check, persist them in one write, and report `outcome == "rejected"`.
|
|
51
|
+
|
|
52
|
+
**Files:**
|
|
53
|
+
- Modify: `lib/plutonium/interaction/async/configuration.rb`
|
|
54
|
+
- Modify: `lib/plutonium/interaction/async/run.rb`
|
|
55
|
+
- Modify: `config/locales/en/async.yml`
|
|
56
|
+
- Test: `test/plutonium/interaction/async/run_test.rb`
|
|
57
|
+
|
|
58
|
+
**Acceptance Criteria:**
|
|
59
|
+
- [ ] `config.async_interactions.max_rejections` defaults to 100.
|
|
60
|
+
- [ ] `reject` raises outside `collect_rejections`; inside, it stores up to `max_rejections` messages and counts the rest.
|
|
61
|
+
- [ ] `reject_run!(messages, overflow:)` writes entries tagged `"stage" => "pre_check"`, a truncation entry when `overflow > 0`, and `state: "failed"` in a single UPDATE.
|
|
62
|
+
- [ ] `outcome` is `"rejected"` for a failed run with a tagged entry; untagged failed runs still read `"failed"`.
|
|
63
|
+
|
|
64
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb` → 0 failures, 0 errors
|
|
65
|
+
|
|
66
|
+
**Steps:**
|
|
67
|
+
|
|
68
|
+
- [ ] **Step 1: Write the failing tests**
|
|
69
|
+
|
|
70
|
+
Append inside `class Plutonium::Interaction::RunTest` in `test/plutonium/interaction/async/run_test.rb` (before its final `end`):
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# --- pre_check rejections -------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def with_max_rejections(limit)
|
|
76
|
+
config = Plutonium.configuration.async_interactions
|
|
77
|
+
previous = config.max_rejections
|
|
78
|
+
config.max_rejections = limit
|
|
79
|
+
yield
|
|
80
|
+
ensure
|
|
81
|
+
config.max_rejections = previous
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
test "max_rejections defaults to 100" do
|
|
85
|
+
assert_equal 100, Plutonium::Interaction::Async::Configuration.new.max_rejections
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A rejection outside the check would be collected by nothing and lost.
|
|
89
|
+
test "reject outside a check raises" do
|
|
90
|
+
error = assert_raises(RuntimeError) { build.reject("bad row") }
|
|
91
|
+
assert_match(/only available inside pre_check/, error.message)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
test "collect_rejections returns the messages and how many went past the cap" do
|
|
95
|
+
run = build
|
|
96
|
+
|
|
97
|
+
messages, overflow = with_max_rejections(2) do
|
|
98
|
+
run.collect_rejections { 5.times { |i| run.reject("row #{i}") } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
assert_equal ["row 0", "row 1"], messages
|
|
102
|
+
assert_equal 3, overflow
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
test "reject is closed again once the check is over" do
|
|
106
|
+
run = build
|
|
107
|
+
run.collect_rejections { run.reject("inside") }
|
|
108
|
+
|
|
109
|
+
assert_raises(RuntimeError) { run.reject("after") }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
test "reject_run! records tagged entries and fails the run in one write" do
|
|
113
|
+
run = build(TestOpaqueRun)
|
|
114
|
+
run.save!
|
|
115
|
+
|
|
116
|
+
statements = []
|
|
117
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
118
|
+
statements << payload[:sql] if payload[:sql].start_with?("UPDATE")
|
|
119
|
+
end
|
|
120
|
+
run.reject_run!(["line 2: missing email", "line 9: missing email"], overflow: 0)
|
|
121
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
122
|
+
|
|
123
|
+
assert_equal 1, statements.size, "entries and state must land together"
|
|
124
|
+
run.reload
|
|
125
|
+
assert_equal "failed", run.state
|
|
126
|
+
refute_nil run.finished_at
|
|
127
|
+
assert_equal ["line 2: missing email", "line 9: missing email"], run.errors_log.map { |e| e["message"] }
|
|
128
|
+
assert(run.errors_log.all? { |e| e["stage"] == "pre_check" && e["target_id"].nil? })
|
|
129
|
+
assert_equal 0, run.progress_done
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
test "reject_run! adds one entry for the problems past the cap" do
|
|
133
|
+
run = build(TestOpaqueRun)
|
|
134
|
+
run.save!
|
|
135
|
+
|
|
136
|
+
run.reject_run!(["line 2: missing email"], overflow: 4_900)
|
|
137
|
+
|
|
138
|
+
last = run.reload.errors_log.last
|
|
139
|
+
assert_equal "and 4900 more problems were found", last["message"]
|
|
140
|
+
assert_equal "pre_check", last["stage"]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
test "a run rejected by its check reads rejected, not failed" do
|
|
144
|
+
run = build(TestOpaqueRun, state: "failed",
|
|
145
|
+
errors_log: [{"target_id" => nil, "message" => "bad header", "stage" => "pre_check"}])
|
|
146
|
+
|
|
147
|
+
assert_equal "rejected", run.outcome
|
|
148
|
+
assert run.rejected?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
test "a failed run without check entries still reads failed" do
|
|
152
|
+
run = build(TestOpaqueRun, state: "failed",
|
|
153
|
+
errors_log: [{"target_id" => nil, "message" => "boom"}])
|
|
154
|
+
|
|
155
|
+
assert_equal "failed", run.outcome
|
|
156
|
+
refute run.rejected?
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
161
|
+
|
|
162
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb -n "/reject|max_rejections/"`
|
|
163
|
+
Expected: FAIL / ERROR (`undefined method 'max_rejections'`, `undefined method 'reject'`, ...)
|
|
164
|
+
|
|
165
|
+
- [ ] **Step 3: Add the setting**
|
|
166
|
+
|
|
167
|
+
In `lib/plutonium/interaction/async/configuration.rb`, after the `attachment_backend` accessor:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# @return [Integer] how many pre_check rejections a run stores. The rest
|
|
171
|
+
# are counted and reported as one "and N more" entry, so a file that is
|
|
172
|
+
# bad on every row cannot write tens of thousands of entries into the
|
|
173
|
+
# errors_log column (and onto the progress page).
|
|
174
|
+
attr_accessor :max_rejections
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
and in `initialize`, after `@stall_after = 1.hour`:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
@max_rejections = 100
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- [ ] **Step 4: Add the locale keys**
|
|
184
|
+
|
|
185
|
+
In `config/locales/en/async.yml`, under `plutonium.async.run` add:
|
|
186
|
+
|
|
187
|
+
```yaml
|
|
188
|
+
reject_outside_pre_check: "%{run_class}#reject is only available inside pre_check"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
and under `plutonium.async.executor` add:
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
rejections_truncated:
|
|
195
|
+
one: "and %{count} more problem was found"
|
|
196
|
+
other: "and %{count} more problems were found"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- [ ] **Step 5: Implement on Run**
|
|
200
|
+
|
|
201
|
+
In `lib/plutonium/interaction/async/run.rb`, after `FAILURE_POLICIES = ...`:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
# Tags the errors_log entries a pre_check rejection writes, which is what
|
|
205
|
+
# #outcome reads "rejected" from. A key in the existing JSON rather than a
|
|
206
|
+
# column: older entries simply lack it.
|
|
207
|
+
PRE_CHECK_STAGE = "pre_check"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Replace `#outcome` with:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
def outcome
|
|
214
|
+
return "rejected" if rejected?
|
|
215
|
+
|
|
216
|
+
(state == "completed" && errors_log.any?) ? "completed_with_errors" : state
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Failed because its pre_check refused the input, as opposed to failing
|
|
220
|
+
# while doing the work. Nothing was processed. state stays "failed", so
|
|
221
|
+
# every reader keyed on state (ReapJob, in_progress, the banner) is
|
|
222
|
+
# unaffected.
|
|
223
|
+
def rejected? = state == "failed" && errors_log.any? { |entry| entry["stage"] == PRE_CHECK_STAGE }
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
After `#record_target_failures!`, add:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
# Fails the run with its pre_check's rejections, in ONE write, for the
|
|
230
|
+
# reason record_target_failures! gives: a crash between two writes must
|
|
231
|
+
# not leave a failed run without its reasons, or reasons on a run still
|
|
232
|
+
# marked running. Entries carry the run-level nil target_id, so progress
|
|
233
|
+
# and handled_target_ids are untouched: nothing was processed.
|
|
234
|
+
#
|
|
235
|
+
# @param messages [Array<String>] the rejections that were stored
|
|
236
|
+
# @param overflow [Integer] how many more were rejected past max_rejections
|
|
237
|
+
def reject_run!(messages, overflow: 0)
|
|
238
|
+
entries = messages.map { |message| pre_check_entry(message) }
|
|
239
|
+
entries << pre_check_entry(I18n.t("plutonium.async.executor.rejections_truncated", count: overflow)) if overflow.positive?
|
|
240
|
+
|
|
241
|
+
now = Time.current
|
|
242
|
+
update!(errors_log: errors_log + entries, state: "failed", finished_at: now, last_activity_at: now)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Runs the block with #reject live, and closes it again afterwards.
|
|
246
|
+
# Called by Async::Executor around pre_check.
|
|
247
|
+
#
|
|
248
|
+
# @return [Array(Array<String>, Integer)] the stored messages, and how
|
|
249
|
+
# many more were rejected past max_rejections
|
|
250
|
+
def collect_rejections
|
|
251
|
+
@rejections = []
|
|
252
|
+
@rejection_count = 0
|
|
253
|
+
yield
|
|
254
|
+
[@rejections, @rejection_count - @rejections.size]
|
|
255
|
+
ensure
|
|
256
|
+
@rejections = nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Records one problem with the run's input and returns, so a check can
|
|
260
|
+
# report every bad row in one pass. Only live inside pre_check: a
|
|
261
|
+
# rejection from perform would be collected by nothing.
|
|
262
|
+
#
|
|
263
|
+
# Counts past max_rejections but stores no more than that, so memory
|
|
264
|
+
# stays bounded and the "and N more" count is exact.
|
|
265
|
+
#
|
|
266
|
+
# @param message [String]
|
|
267
|
+
# @return [void]
|
|
268
|
+
def reject(message)
|
|
269
|
+
raise I18n.t("plutonium.async.run.reject_outside_pre_check", run_class: self.class) if @rejections.nil?
|
|
270
|
+
|
|
271
|
+
@rejection_count += 1
|
|
272
|
+
@rejections << message.to_s if @rejections.size < Plutonium.configuration.async_interactions.max_rejections
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
After the base `#perform` (the one raising `NotImplementedError`), add:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
# Checks the run's input before any work, and refuses it with #reject.
|
|
281
|
+
# Optional: the default checks nothing. Override it as
|
|
282
|
+
# +pre_check(records)+ in a run with +perform_on+ (it receives the
|
|
283
|
+
# resolved, authorized records), or +pre_check+ for opaque work.
|
|
284
|
+
# See Async::Executor#pre_check_passed?.
|
|
285
|
+
def pre_check(*) = nil
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
In the `private` section, add:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
def pre_check_entry(message) = {"target_id" => nil, "message" => message, "stage" => PRE_CHECK_STAGE}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- [ ] **Step 6: Run the tests to verify they pass**
|
|
295
|
+
|
|
296
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb`
|
|
297
|
+
Expected: 0 failures, 0 errors
|
|
298
|
+
|
|
299
|
+
- [ ] **Step 7: Suggested commit (only when the user asks for commits)**
|
|
300
|
+
|
|
301
|
+
`feat(async): let a run reject its input with reject / reject_run!`
|
|
302
|
+
|
|
303
|
+
```json:metadata
|
|
304
|
+
{"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}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
### Task 2: Executor runs the check before the work
|
|
310
|
+
|
|
311
|
+
**Goal:** The executor calls `pre_check` with the right arguments for the run's shape and stops before any work when it rejects.
|
|
312
|
+
|
|
313
|
+
**Files:**
|
|
314
|
+
- Modify: `lib/plutonium/interaction/async/executor.rb` (`perform_opaque`, `perform_targets`, new private methods)
|
|
315
|
+
- Modify: `config/locales/en/async.yml`
|
|
316
|
+
- Test: `test/plutonium/interaction/async/executor_test.rb`
|
|
317
|
+
|
|
318
|
+
**Acceptance Criteria:**
|
|
319
|
+
- [ ] Opaque and targeted runs whose check rejects nothing perform as before.
|
|
320
|
+
- [ ] A rejection stops `perform` / `perform_on`; `progress_done` stays 0; `outcome == "rejected"`.
|
|
321
|
+
- [ ] Every rejection is recorded; past `max_rejections`, one "and N more" entry with the exact remainder.
|
|
322
|
+
- [ ] An exception raised in `pre_check` reads `failed`, not `rejected`.
|
|
323
|
+
- [ ] A wrong-arity `pre_check` fails the run with a message naming the class and expected signature.
|
|
324
|
+
- [ ] A resumed targeted run's check receives only the unhandled records.
|
|
325
|
+
- [ ] A `:transactional` run is rejected without opening a transaction.
|
|
326
|
+
|
|
327
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb` → 0 failures, 0 errors
|
|
328
|
+
|
|
329
|
+
**Steps:**
|
|
330
|
+
|
|
331
|
+
- [ ] **Step 1: Write the failing tests**
|
|
332
|
+
|
|
333
|
+
In `test/plutonium/interaction/async/executor_test.rb`, add these fixture classes after `class UndefinedWorkRun ... end` (top level, before the test class):
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# A check whose behaviour each test scripts. `checked` records what the check
|
|
337
|
+
# was handed, so a test can assert which records a targeted check saw.
|
|
338
|
+
class CheckedRun < ScriptedRun
|
|
339
|
+
on_failure :continue
|
|
340
|
+
cattr_accessor :check
|
|
341
|
+
cattr_accessor :checked
|
|
342
|
+
|
|
343
|
+
def pre_check(records)
|
|
344
|
+
self.class.checked = records.map(&:id)
|
|
345
|
+
self.class.check&.call(self, records)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Records whether the batch transaction opened. Executor#perform_all_or_nothing
|
|
350
|
+
# is the only caller of Run.transaction; update! goes through the connection.
|
|
351
|
+
class TransactionalCheckedRun < CheckedRun
|
|
352
|
+
on_failure :transactional
|
|
353
|
+
cattr_accessor :batch_opened
|
|
354
|
+
|
|
355
|
+
def self.transaction(...)
|
|
356
|
+
self.batch_opened = true
|
|
357
|
+
super
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
class CheckedOpaqueRun < Plutonium::Interaction::Async::Run
|
|
362
|
+
cattr_accessor :check
|
|
363
|
+
cattr_accessor :calls
|
|
364
|
+
|
|
365
|
+
def pre_check = self.class.check&.call(self)
|
|
366
|
+
|
|
367
|
+
def perform = self.class.calls += 1
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Opaque-shaped check declared on a targeted run: the arity mistake an author
|
|
371
|
+
# makes when copying an opaque example.
|
|
372
|
+
class MisdeclaredCheckRun < ScriptedRun
|
|
373
|
+
def pre_check = nil
|
|
374
|
+
end
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
In the test class `setup`, after `OpaqueScriptedRun.calls = 0`, add:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
CheckedRun.check = nil
|
|
381
|
+
CheckedRun.checked = nil
|
|
382
|
+
TransactionalCheckedRun.batch_opened = false
|
|
383
|
+
CheckedOpaqueRun.check = nil
|
|
384
|
+
CheckedOpaqueRun.calls = 0
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Append these tests inside the test class (before its final `end`):
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
# --- pre_check ------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
test "an opaque run whose check rejects nothing performs" do
|
|
393
|
+
run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
|
|
394
|
+
|
|
395
|
+
assert_equal 1, CheckedOpaqueRun.calls
|
|
396
|
+
assert_equal "completed", run.state
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
test "an opaque run whose check rejects is not performed" do
|
|
400
|
+
CheckedOpaqueRun.check = ->(run) { run.reject("missing header: email") }
|
|
401
|
+
|
|
402
|
+
run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
|
|
403
|
+
|
|
404
|
+
assert_equal 0, CheckedOpaqueRun.calls
|
|
405
|
+
assert_equal "rejected", run.outcome
|
|
406
|
+
assert_equal ["missing header: email"], messages(run)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
test "a targeted check receives the resolved records and gates the work" do
|
|
410
|
+
first = create_post!(user: @user, organization: @org, title: "first")
|
|
411
|
+
second = create_post!(user: @user, organization: @org, title: "second")
|
|
412
|
+
CheckedRun.check = ->(run, records) { run.reject("mixed") if records.size > 1 }
|
|
413
|
+
|
|
414
|
+
run = execute!(create_run!(CheckedRun, target_ids: [first.id, second.id]))
|
|
415
|
+
|
|
416
|
+
assert_equal [first.id, second.id].sort, CheckedRun.checked.sort
|
|
417
|
+
assert_empty ScriptedRun.performed, "a rejected run must not touch a target"
|
|
418
|
+
assert_equal "rejected", run.outcome
|
|
419
|
+
assert_equal 0, run.progress_done
|
|
420
|
+
assert_equal "first", first.reload.title
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
test "a targeted run whose check passes performs every target" do
|
|
424
|
+
post = create_post!(user: @user, organization: @org)
|
|
425
|
+
|
|
426
|
+
run = execute!(create_run!(CheckedRun, target_ids: [post.id]))
|
|
427
|
+
|
|
428
|
+
assert_equal [post.id], ScriptedRun.performed
|
|
429
|
+
assert_equal "completed", run.state
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
test "every rejection is recorded, in order" do
|
|
433
|
+
CheckedOpaqueRun.check = ->(run) { 3.times { |i| run.reject("line #{i + 2}: missing email") } }
|
|
434
|
+
|
|
435
|
+
run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
|
|
436
|
+
|
|
437
|
+
assert_equal ["line 2: missing email", "line 3: missing email", "line 4: missing email"], messages(run)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
test "rejections past max_rejections are summarized with the exact remainder" do
|
|
441
|
+
config = Plutonium.configuration.async_interactions
|
|
442
|
+
previous = config.max_rejections
|
|
443
|
+
config.max_rejections = 2
|
|
444
|
+
CheckedOpaqueRun.check = ->(run) { 7.times { |i| run.reject("line #{i}") } }
|
|
445
|
+
|
|
446
|
+
run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
|
|
447
|
+
|
|
448
|
+
assert_equal ["line 0", "line 1", "and 5 more problems were found"], messages(run)
|
|
449
|
+
ensure
|
|
450
|
+
config.max_rejections = previous
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# "Your input is wrong" and "the check itself broke" are different reports.
|
|
454
|
+
test "an exception raised in the check fails the run rather than rejecting it" do
|
|
455
|
+
CheckedOpaqueRun.check = ->(_run) { raise "unreadable file" }
|
|
456
|
+
|
|
457
|
+
run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
|
|
458
|
+
|
|
459
|
+
assert_equal "failed", run.outcome
|
|
460
|
+
assert_equal ["unreadable file"], messages(run)
|
|
461
|
+
assert_equal 0, CheckedOpaqueRun.calls
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
test "a check declared with the wrong arguments names the expected signature" do
|
|
465
|
+
post = create_post!(user: @user, organization: @org)
|
|
466
|
+
|
|
467
|
+
run = execute!(create_run!(MisdeclaredCheckRun, target_ids: [post.id]))
|
|
468
|
+
|
|
469
|
+
assert_equal "failed", run.state
|
|
470
|
+
assert_match(/MisdeclaredCheckRun#pre_check must take the records/, messages(run).first)
|
|
471
|
+
assert_empty ScriptedRun.performed
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
test "a resumed targeted run checks only the records it has left" do
|
|
475
|
+
done = create_post!(user: @user, organization: @org, title: "done")
|
|
476
|
+
remaining = create_post!(user: @user, organization: @org, title: "remaining")
|
|
477
|
+
run = create_run!(CheckedRun, target_ids: [done.id, remaining.id])
|
|
478
|
+
|
|
479
|
+
# Simulate an interruption after `done` was applied, then a reap.
|
|
480
|
+
Executor.new(run).send(:advance!, done.id)
|
|
481
|
+
run.update!(state: "pending")
|
|
482
|
+
|
|
483
|
+
execute!(run)
|
|
484
|
+
|
|
485
|
+
assert_equal [remaining.id], CheckedRun.checked
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
test "a transactional run is rejected without opening a transaction" do
|
|
489
|
+
post = create_post!(user: @user, organization: @org)
|
|
490
|
+
CheckedRun.check = ->(run, _records) { run.reject("no") }
|
|
491
|
+
|
|
492
|
+
run = execute!(create_run!(TransactionalCheckedRun, target_ids: [post.id]))
|
|
493
|
+
|
|
494
|
+
assert_equal "rejected", run.outcome
|
|
495
|
+
refute TransactionalCheckedRun.batch_opened, "the batch transaction must never open"
|
|
496
|
+
assert_empty ScriptedRun.performed
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The control for the test above: a passing check does reach the batch.
|
|
500
|
+
test "a transactional run whose check passes opens its batch" do
|
|
501
|
+
post = create_post!(user: @user, organization: @org)
|
|
502
|
+
|
|
503
|
+
run = execute!(create_run!(TransactionalCheckedRun, target_ids: [post.id]))
|
|
504
|
+
|
|
505
|
+
assert_equal "completed", run.state
|
|
506
|
+
assert TransactionalCheckedRun.batch_opened
|
|
507
|
+
end
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
511
|
+
|
|
512
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb -n "/check|rejection/"`
|
|
513
|
+
Expected: FAIL (rejected runs still perform; outcome reads `completed`)
|
|
514
|
+
|
|
515
|
+
- [ ] **Step 3: Add the arity message**
|
|
516
|
+
|
|
517
|
+
In `config/locales/en/async.yml`, under `plutonium.async.executor`:
|
|
518
|
+
|
|
519
|
+
```yaml
|
|
520
|
+
pre_check_arity:
|
|
521
|
+
targeted: "%{run_class}#pre_check must take the records: define pre_check(records) on a run with perform_on"
|
|
522
|
+
opaque: "%{run_class}#pre_check must take no arguments: define pre_check on a run with perform"
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
- [ ] **Step 4: Wire the check into the executor**
|
|
526
|
+
|
|
527
|
+
In `lib/plutonium/interaction/async/executor.rb`, replace `perform_opaque` with:
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
def perform_opaque
|
|
531
|
+
return unless pre_check_passed?
|
|
532
|
+
|
|
533
|
+
run.send(:perform)
|
|
534
|
+
run.finish!
|
|
535
|
+
end
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
In `perform_targets`, after the line `return refuse_partial_batch(resolved) if unresolved?(resolved) && !continue?`, add:
|
|
539
|
+
|
|
540
|
+
```ruby
|
|
541
|
+
return unless pre_check_passed?(resolved.records)
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Add these private methods after `perform_targets`:
|
|
545
|
+
|
|
546
|
+
```ruby
|
|
547
|
+
# Runs the run's pre_check with #reject live, and fails the run with
|
|
548
|
+
# whatever it rejected. Before any work: a rejected run touches no
|
|
549
|
+
# target. Outside the :transactional batch, which never opens for a
|
|
550
|
+
# rejected run.
|
|
551
|
+
#
|
|
552
|
+
# An exception from the check is NOT a rejection. It propagates to #call
|
|
553
|
+
# and fails the run the ordinary way, so "your input is wrong" and "the
|
|
554
|
+
# check itself broke" stay distinguishable (Run#outcome).
|
|
555
|
+
#
|
|
556
|
+
# send, for the reason #perform_opaque gives.
|
|
557
|
+
#
|
|
558
|
+
# @return [Boolean] whether the work may go ahead
|
|
559
|
+
def pre_check_passed?(*args)
|
|
560
|
+
verify_pre_check_arity!(args.size)
|
|
561
|
+
|
|
562
|
+
messages, overflow = run.collect_rejections { run.send(:pre_check, *args) }
|
|
563
|
+
return true if messages.empty? && overflow.zero?
|
|
564
|
+
|
|
565
|
+
run.reject_run!(messages, overflow: overflow)
|
|
566
|
+
false
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# A targeted check declared opaque-style (or the reverse) would raise a
|
|
570
|
+
# bare "wrong number of arguments" from inside the executor, naming
|
|
571
|
+
# neither the class nor the fix.
|
|
572
|
+
#
|
|
573
|
+
# Raised as NotImplementedError ("the run class is written wrong"), which
|
|
574
|
+
# #call records like any other failure.
|
|
575
|
+
def verify_pre_check_arity!(given)
|
|
576
|
+
arity = run.method(:pre_check).arity
|
|
577
|
+
return if arity.negative? ? given >= arity.abs - 1 : arity == given
|
|
578
|
+
|
|
579
|
+
raise NotImplementedError, I18n.t(
|
|
580
|
+
"plutonium.async.executor.pre_check_arity.#{run.targeted? ? "targeted" : "opaque"}",
|
|
581
|
+
run_class: run.class
|
|
582
|
+
)
|
|
583
|
+
end
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
- [ ] **Step 5: Run the tests to verify they pass**
|
|
587
|
+
|
|
588
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb`
|
|
589
|
+
Expected: 0 failures, 0 errors (all pre-existing executor tests still pass, since the base `pre_check(*)` accepts any arity and rejects nothing)
|
|
590
|
+
|
|
591
|
+
- [ ] **Step 6: Run the rest of the async suite**
|
|
592
|
+
|
|
593
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest -e 'Dir["test/plutonium/interaction/async/*_test.rb"].each { |f| require File.expand_path(f) }'`
|
|
594
|
+
Expected: 0 failures, 0 errors
|
|
595
|
+
|
|
596
|
+
- [ ] **Step 7: Suggested commit (only when the user asks for commits)**
|
|
597
|
+
|
|
598
|
+
`feat(async): run a pre_check before the work and stop on rejection`
|
|
599
|
+
|
|
600
|
+
```json:metadata
|
|
601
|
+
{"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}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
---
|
|
605
|
+
|
|
606
|
+
### Task 3: Show rejected runs on the progress page and index
|
|
607
|
+
|
|
608
|
+
**Goal:** A rejected run shows a warning "Rejected" badge, a "Nothing was processed" line, and its messages.
|
|
609
|
+
|
|
610
|
+
**Files:**
|
|
611
|
+
- Modify: `lib/plutonium/ui/interaction/async/run_progress.rb` (`BADGE_VARIANTS`, `render_panel`, new `render_rejected`)
|
|
612
|
+
- Modify: `lib/plutonium/interaction/async/run_definition.rb` (both `colors:` hashes)
|
|
613
|
+
- Modify: `config/locales/en/async.yml`
|
|
614
|
+
- Test: `test/integration/admin_portal/async_run_progress_test.rb`
|
|
615
|
+
|
|
616
|
+
**Acceptance Criteria:**
|
|
617
|
+
- [ ] The show page renders a `pu-badge-warning` badge reading "Rejected", the line "Nothing was processed", and every rejection message.
|
|
618
|
+
- [ ] A plain failed run does not show "Nothing was processed".
|
|
619
|
+
- [ ] The index renders "Rejected" for the run.
|
|
620
|
+
|
|
621
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb` → 0 failures, 0 errors
|
|
622
|
+
|
|
623
|
+
**Steps:**
|
|
624
|
+
|
|
625
|
+
- [ ] **Step 1: Write the failing tests**
|
|
626
|
+
|
|
627
|
+
Append inside `AdminPortal::AsyncRunProgressTest` (before its final `end`):
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
def rejection(message) = {"target_id" => nil, "message" => message, "stage" => "pre_check"}
|
|
631
|
+
|
|
632
|
+
test "a rejected run says so, says nothing was processed, and lists every problem" do
|
|
633
|
+
run = create_run!(state: "failed", progress_total: 3, progress_done: 0,
|
|
634
|
+
errors_log: [rejection("line 2: missing email"), rejection("line 7: missing email")])
|
|
635
|
+
|
|
636
|
+
get run_path(run)
|
|
637
|
+
assert_response :success
|
|
638
|
+
|
|
639
|
+
assert_match(/pu-badge-warning[^>]*>\s*Rejected\s*</, response.body)
|
|
640
|
+
assert_match(/Nothing was processed/, response.body)
|
|
641
|
+
assert_match(/line 2: missing email/, response.body)
|
|
642
|
+
assert_match(/line 7: missing email/, response.body)
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
test "a run that failed while working does not claim nothing was processed" do
|
|
646
|
+
run = create_run!(state: "failed", errors_log: [{"target_id" => nil, "message" => "boom"}])
|
|
647
|
+
|
|
648
|
+
get run_path(run)
|
|
649
|
+
|
|
650
|
+
refute_match(/Nothing was processed/, response.body)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
test "the index shows a rejected run as rejected" do
|
|
654
|
+
create_run!(state: "failed", errors_log: [rejection("bad header")])
|
|
655
|
+
|
|
656
|
+
get "/admin/async_runs"
|
|
657
|
+
|
|
658
|
+
assert_match(/Rejected/, response.body)
|
|
659
|
+
end
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
663
|
+
|
|
664
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb -n "/rejected|nothing was processed/"`
|
|
665
|
+
Expected: FAIL (no "Nothing was processed"; badge variant is `neutral`)
|
|
666
|
+
|
|
667
|
+
- [ ] **Step 3: Add the locale key**
|
|
668
|
+
|
|
669
|
+
In `config/locales/en/async.yml`, under `plutonium.async.progress`:
|
|
670
|
+
|
|
671
|
+
```yaml
|
|
672
|
+
rejected: "Nothing was processed"
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
- [ ] **Step 4: Render it**
|
|
676
|
+
|
|
677
|
+
In `lib/plutonium/ui/interaction/async/run_progress.rb`, add to `BADGE_VARIANTS` after `"completed_with_errors" => "warning",`:
|
|
678
|
+
|
|
679
|
+
```ruby
|
|
680
|
+
# The input was refused, nothing broke: see Run#rejected?.
|
|
681
|
+
"rejected" => "warning",
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
Replace `render_panel` with:
|
|
685
|
+
|
|
686
|
+
```ruby
|
|
687
|
+
def render_panel
|
|
688
|
+
div(class: "pu-card pu-run-progress", **poll_attributes) do
|
|
689
|
+
div(class: "pu-card-body space-y-4") do
|
|
690
|
+
render_status
|
|
691
|
+
render_progress
|
|
692
|
+
render_rejected if run.rejected?
|
|
693
|
+
render_failures if run.errors_log.any?
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
Add after `render_status`:
|
|
700
|
+
|
|
701
|
+
```ruby
|
|
702
|
+
# "0 of 50 targets" under a red list reads as a run that died at its
|
|
703
|
+
# first target. A rejected run never started, and says so.
|
|
704
|
+
def render_rejected
|
|
705
|
+
div(class: "text-sm text-[var(--pu-text-muted)]") do
|
|
706
|
+
plain t("plutonium.async.progress.rejected")
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
In `lib/plutonium/interaction/async/run_definition.rb`, change both `colors:` hashes (on `display :outcome` and `column :outcome`) to:
|
|
712
|
+
|
|
713
|
+
```ruby
|
|
714
|
+
colors: {running: :info, completed_with_errors: :warning, rejected: :warning}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
and add `rejected` to the comment above them: "`running`, `completed_with_errors` and `rejected` are coloured explicitly".
|
|
718
|
+
|
|
719
|
+
- [ ] **Step 5: Run the tests to verify they pass**
|
|
720
|
+
|
|
721
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb`
|
|
722
|
+
Expected: 0 failures, 0 errors
|
|
723
|
+
|
|
724
|
+
- [ ] **Step 6: Suggested commit (only when the user asks for commits)**
|
|
725
|
+
|
|
726
|
+
`feat(async): show rejected runs as rejected on the progress page and index`
|
|
727
|
+
|
|
728
|
+
```json:metadata
|
|
729
|
+
{"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}
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
### Task 4: Dummy CSV import with a pre_check, end to end
|
|
735
|
+
|
|
736
|
+
**Goal:** A real interaction in the dummy app exercises dispatch → staged file → pre_check → rejected or completed, and backs the docs example.
|
|
737
|
+
|
|
738
|
+
**Files:**
|
|
739
|
+
- Create: `test/dummy/app/interactions/import_tasks_interaction.rb`
|
|
740
|
+
- Modify: `test/dummy/app/policies/task_policy.rb` (after `archive_all_async?`)
|
|
741
|
+
- Modify: `test/dummy/app/definitions/task_definition.rb` (after `action :archive_all_async`)
|
|
742
|
+
- Create: `test/integration/admin_portal/async_import_pre_check_test.rb`
|
|
743
|
+
|
|
744
|
+
**Acceptance Criteria:**
|
|
745
|
+
- [ ] Posting a CSV with bad rows creates a run that, once executed, reads `rejected`, lists one message per bad row, and creates no tasks.
|
|
746
|
+
- [ ] Posting a valid CSV creates the tasks and the run reads `completed`.
|
|
747
|
+
- [ ] The rejected run's progress page shows the badge and messages.
|
|
748
|
+
|
|
749
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb` → 0 failures, 0 errors
|
|
750
|
+
|
|
751
|
+
**Steps:**
|
|
752
|
+
|
|
753
|
+
- [ ] **Step 1: Write the failing test**
|
|
754
|
+
|
|
755
|
+
Create `test/integration/admin_portal/async_import_pre_check_test.rb`:
|
|
756
|
+
|
|
757
|
+
```ruby
|
|
758
|
+
# frozen_string_literal: true
|
|
759
|
+
|
|
760
|
+
require "test_helper"
|
|
761
|
+
|
|
762
|
+
# A CSV import through the real dispatch path: the file is staged at dispatch,
|
|
763
|
+
# revived in the job, checked whole before a single task is created.
|
|
764
|
+
class AdminPortal::AsyncImportPreCheckTest < ActionDispatch::IntegrationTest
|
|
765
|
+
include IntegrationTestHelper
|
|
766
|
+
|
|
767
|
+
Executor = Plutonium::Interaction::Async::Executor
|
|
768
|
+
|
|
769
|
+
setup do
|
|
770
|
+
Plutonium::Interaction::Async::Run.delete_all
|
|
771
|
+
Task.delete_all
|
|
772
|
+
@admin = create_admin!
|
|
773
|
+
login_as_admin(@admin)
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
teardown do
|
|
777
|
+
Task.delete_all
|
|
778
|
+
Plutonium::Interaction::Async::Run.delete_all
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def import(csv)
|
|
782
|
+
file = Rack::Test::UploadedFile.new(StringIO.new(csv), "text/csv", original_filename: "tasks.csv")
|
|
783
|
+
post "/admin/tasks/resource_actions/import_tasks", params: {interaction: {import_file: file}}
|
|
784
|
+
|
|
785
|
+
# The test queue adapter never performs; drive the job's work directly.
|
|
786
|
+
run = ImportTasksInteraction::Run.sole
|
|
787
|
+
Executor.new(run).call
|
|
788
|
+
run.reload
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
test "a file with bad rows is rejected whole, every problem listed, nothing imported" do
|
|
792
|
+
run = import("title,status\nShip it,todo\n,doing\nWrite docs,someday\n")
|
|
793
|
+
|
|
794
|
+
assert_equal "rejected", run.outcome
|
|
795
|
+
assert_equal ["line 3: title is missing", "line 4: unknown status \"someday\""],
|
|
796
|
+
run.errors_log.map { |entry| entry["message"] }
|
|
797
|
+
assert_equal 0, Task.count, "a rejected import must not create anything"
|
|
798
|
+
|
|
799
|
+
get "/admin/async_runs/#{run.to_param}"
|
|
800
|
+
assert_match(/Rejected/, response.body)
|
|
801
|
+
assert_match(/Nothing was processed/, response.body)
|
|
802
|
+
assert_match(/line 3: title is missing/, response.body)
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
test "a valid file is imported" do
|
|
806
|
+
run = import("title,status\nShip it,todo\nWrite docs,\n")
|
|
807
|
+
|
|
808
|
+
assert_equal "completed", run.state
|
|
809
|
+
assert_equal [["Ship it", "todo"], ["Write docs", "todo"]], Task.order(:id).pluck(:title, :status)
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
- [ ] **Step 2: Run it to verify it fails**
|
|
815
|
+
|
|
816
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb`
|
|
817
|
+
Expected: ERROR, `uninitialized constant ImportTasksInteraction`
|
|
818
|
+
|
|
819
|
+
- [ ] **Step 3: Write the interaction**
|
|
820
|
+
|
|
821
|
+
Create `test/dummy/app/interactions/import_tasks_interaction.rb`:
|
|
822
|
+
|
|
823
|
+
```ruby
|
|
824
|
+
# frozen_string_literal: true
|
|
825
|
+
|
|
826
|
+
require "csv"
|
|
827
|
+
|
|
828
|
+
# A CSV import that checks the WHOLE file before creating anything: the
|
|
829
|
+
# browser-drivable example of an async run's pre_check. A file with a bad row
|
|
830
|
+
# is rejected with every problem listed, and no task is created.
|
|
831
|
+
class ImportTasksInteraction < ::ResourceInteraction
|
|
832
|
+
presents label: "Import tasks (async)",
|
|
833
|
+
icon: Phlex::TablerIcons::Upload
|
|
834
|
+
|
|
835
|
+
attribute :import_file
|
|
836
|
+
input :import_file, as: :file
|
|
837
|
+
|
|
838
|
+
validates :import_file, presence: true
|
|
839
|
+
|
|
840
|
+
async do
|
|
841
|
+
def pre_check
|
|
842
|
+
each_row do |row, line|
|
|
843
|
+
reject "line #{line}: title is missing" if row["title"].blank?
|
|
844
|
+
unless row["status"].blank? || Task::IMPORTABLE_STATUSES.include?(row["status"])
|
|
845
|
+
reject "line #{line}: unknown status #{row["status"].inspect}"
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def perform
|
|
851
|
+
each_row { |row, _line| Task.create!(title: row["title"], status: row["status"].presence || "todo") }
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
private
|
|
855
|
+
|
|
856
|
+
# Line numbers as a spreadsheet shows them: the header is line 1.
|
|
857
|
+
def each_row(&)
|
|
858
|
+
attachment(:import_file).open do |file|
|
|
859
|
+
CSV.new(file, headers: true).each.with_index(2, &)
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
end
|
|
863
|
+
end
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
Add the constant to `test/dummy/app/models/task.rb`, under `# add constants above.` (put it above that marker comment):
|
|
867
|
+
|
|
868
|
+
```ruby
|
|
869
|
+
IMPORTABLE_STATUSES = %w[todo doing done review].freeze
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
- [ ] **Step 4: Gate and declare the action**
|
|
873
|
+
|
|
874
|
+
In `test/dummy/app/policies/task_policy.rb`, after `archive_all_async?`:
|
|
875
|
+
|
|
876
|
+
```ruby
|
|
877
|
+
# Resource action: a CSV import dispatched to a run whose pre_check checks
|
|
878
|
+
# the whole file first.
|
|
879
|
+
def import_tasks?
|
|
880
|
+
create?
|
|
881
|
+
end
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
In `test/dummy/app/definitions/task_definition.rb`, after `action :archive_all_async, ...`:
|
|
885
|
+
|
|
886
|
+
```ruby
|
|
887
|
+
# Resource action, dispatched to a run that checks the whole file before
|
|
888
|
+
# importing any of it (see ImportTasksInteraction#pre_check).
|
|
889
|
+
action :import_tasks, interaction: ImportTasksInteraction
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
- [ ] **Step 5: Run it to verify it passes**
|
|
893
|
+
|
|
894
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb`
|
|
895
|
+
Expected: 0 failures, 0 errors
|
|
896
|
+
|
|
897
|
+
If `post` answers with a non-redirect (422), print `response.body` and check the form param key; the interactive action form posts `interaction[...]` (see `test/integration/admin_portal/async_dispatch_response_test.rb`).
|
|
898
|
+
|
|
899
|
+
- [ ] **Step 6: Run the full suite**
|
|
900
|
+
|
|
901
|
+
Run: `bundle exec appraisal rails-8.1 rake test`
|
|
902
|
+
Expected: 0 failures, 0 errors. (Task actions appear in other Task integration tests; a new resource-action button must not break their assertions.)
|
|
903
|
+
|
|
904
|
+
- [ ] **Step 7: Suggested commit (only when the user asks for commits)**
|
|
905
|
+
|
|
906
|
+
`test(async): CSV import in the dummy app exercising pre_check end to end`
|
|
907
|
+
|
|
908
|
+
```json:metadata
|
|
909
|
+
{"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}
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
---
|
|
913
|
+
|
|
914
|
+
### Task 5: Docs and skill
|
|
915
|
+
|
|
916
|
+
**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.
|
|
917
|
+
|
|
918
|
+
**Files:**
|
|
919
|
+
- Modify: `docs/reference/behavior/async-interactions.md`
|
|
920
|
+
- Modify: `.claude/skills/plutonium-async-interactions/SKILL.md`
|
|
921
|
+
|
|
922
|
+
**Acceptance Criteria:**
|
|
923
|
+
- [ ] A "Checking input before processing" section after "### Files" in the doc, and after "## Attributes and files" in the skill.
|
|
924
|
+
- [ ] `max_rejections` in the doc's Enabling config block.
|
|
925
|
+
- [ ] "Outcome vs state" documents `rejected`.
|
|
926
|
+
- [ ] Both heartbeat warnings mention `pre_check`.
|
|
927
|
+
- [ ] No em dashes; samples match `ImportTasksInteraction`.
|
|
928
|
+
- [ ] `yarn docs:build` passes.
|
|
929
|
+
|
|
930
|
+
**Verify:** `yarn docs:build` → completes without dead-link errors
|
|
931
|
+
|
|
932
|
+
**Steps:**
|
|
933
|
+
|
|
934
|
+
- [ ] **Step 1: Doc, config block**
|
|
935
|
+
|
|
936
|
+
In `docs/reference/behavior/async-interactions.md`, in the Enabling block, after the `stall_after` line:
|
|
937
|
+
|
|
938
|
+
```ruby
|
|
939
|
+
config.async_interactions.max_rejections = 100 # pre_check problems stored per run
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
- [ ] **Step 2: Doc, new section**
|
|
943
|
+
|
|
944
|
+
Insert before `### Sharing one run across interactions`:
|
|
945
|
+
|
|
946
|
+
````markdown
|
|
947
|
+
### Checking input before processing
|
|
948
|
+
|
|
949
|
+
Validations on the interaction run in the request, so they suit cheap checks (an uploader's size and type rules already fail the form there). A check too slow for a request, like reading every row of a large file, goes in the run's `pre_check`. It runs in the job, before any work, and refuses the input with `reject`:
|
|
950
|
+
|
|
951
|
+
```ruby
|
|
952
|
+
async do
|
|
953
|
+
def pre_check
|
|
954
|
+
each_row do |row, line|
|
|
955
|
+
reject "line #{line}: title is missing" if row["title"].blank?
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def perform
|
|
960
|
+
each_row { |row, _line| Task.create!(title: row["title"]) }
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
private
|
|
964
|
+
|
|
965
|
+
def each_row(&)
|
|
966
|
+
attachment(:import_file).open do |file|
|
|
967
|
+
CSV.new(file, headers: true).each.with_index(2, &)
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
- `reject` records a problem and returns, so one pass reports every bad row.
|
|
974
|
+
- If anything was rejected, the run ends `failed` with `outcome == "rejected"`, the problems listed on its progress page, and `perform` / `perform_on` never runs.
|
|
975
|
+
- A run with `perform_on` declares `pre_check(records)` and receives the resolved, authorized records the loop is about to process.
|
|
976
|
+
- An exception raised inside `pre_check` fails the run the ordinary way (`outcome == "failed"`): the check broke, the input was not judged.
|
|
977
|
+
- Only the first `config.async_interactions.max_rejections` problems (default 100) are stored, followed by one "and N more problems were found" entry.
|
|
978
|
+
- The check runs again when a stalled run is resumed, so it must only read. A resumed targeted run checks the records it has left, unless an earlier pass already applied a target (that pass's check passed, so it is not repeated).
|
|
979
|
+
- A targeted check receives the records as loaded when the run resolved its targets. `perform_on` gets each record re-read just before it runs, so a verdict based on record state can be stale by then; re-check anything that must hold at the moment of the work inside `perform_on`.
|
|
980
|
+
- A long check must call `heartbeat!` (see [Long work must say it is alive](#long-work-must-say-it-is-alive)).
|
|
981
|
+
````
|
|
982
|
+
|
|
983
|
+
- [ ] **Step 3: Doc, outcome section**
|
|
984
|
+
|
|
985
|
+
In "## Outcome vs state", after the code block, add:
|
|
986
|
+
|
|
987
|
+
```markdown
|
|
988
|
+
A run whose `pre_check` rejected its input reads `outcome == "rejected"` (its `state` is `"failed"`, and nothing was processed). A run that failed while working reads `"failed"`.
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
- [ ] **Step 4: Doc, heartbeat section**
|
|
992
|
+
|
|
993
|
+
In "### Long work must say it is alive", change the first bullet's opening to:
|
|
994
|
+
"**Opaque work, and any `pre_check`.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count."
|
|
995
|
+
|
|
996
|
+
- [ ] **Step 5: Skill**
|
|
997
|
+
|
|
998
|
+
In `.claude/skills/plutonium-async-interactions/SKILL.md`:
|
|
999
|
+
|
|
1000
|
+
In the Critical bullet "**Long work must call `heartbeat!`.**", after "so opaque `perform` writes nothing at all between claim and finish." add: "The same holds for `pre_check`."
|
|
1001
|
+
|
|
1002
|
+
Insert a section before `## Registering the Run resource`:
|
|
1003
|
+
|
|
1004
|
+
````markdown
|
|
1005
|
+
## Checking input before processing (`pre_check`)
|
|
1006
|
+
|
|
1007
|
+
For checks too slow for the request (every row of a large file, lookups per row), define `pre_check` in the run. It runs in the job before any work; `reject` records a problem and keeps going:
|
|
1008
|
+
|
|
1009
|
+
```ruby
|
|
1010
|
+
async do
|
|
1011
|
+
def pre_check # targeted runs: def pre_check(records)
|
|
1012
|
+
each_row do |row, line|
|
|
1013
|
+
reject "line #{line}: title is missing" if row["title"].blank?
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
def perform
|
|
1018
|
+
each_row { |row, _line| Task.create!(title: row["title"]) }
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
private
|
|
1022
|
+
|
|
1023
|
+
def each_row(&)
|
|
1024
|
+
attachment(:import_file).open { |f| CSV.new(f, headers: true).each.with_index(2, &) }
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
- Any rejection: run ends `failed`, `outcome == "rejected"`, problems on the progress page, no work done.
|
|
1030
|
+
- An exception in `pre_check` is a plain `failed`, not `rejected`.
|
|
1031
|
+
- `config.async_interactions.max_rejections` (default 100) caps stored problems; the rest become one "and N more" entry.
|
|
1032
|
+
- It re-runs on resume, so it must only read. Long checks must `heartbeat!`.
|
|
1033
|
+
- `reject` outside `pre_check` raises.
|
|
1034
|
+
````
|
|
1035
|
+
|
|
1036
|
+
- [ ] **Step 6: Build the docs**
|
|
1037
|
+
|
|
1038
|
+
Run: `yarn docs:build`
|
|
1039
|
+
Expected: build completes; no dead-link errors.
|
|
1040
|
+
|
|
1041
|
+
- [ ] **Step 7: Suggested commit (only when the user asks for commits)**
|
|
1042
|
+
|
|
1043
|
+
`docs(async): document pre_check, reject and the rejected outcome`
|
|
1044
|
+
|
|
1045
|
+
```json:metadata
|
|
1046
|
+
{"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}
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
---
|
|
1050
|
+
|
|
1051
|
+
## Final verification
|
|
1052
|
+
|
|
1053
|
+
- [ ] `bundle exec appraisal rails-8.1 rake test` → 0 failures, 0 errors
|
|
1054
|
+
- [ ] `bundle exec appraisal rails-7 ruby -Itest -e 'Dir["test/plutonium/interaction/async/*_test.rb", "test/integration/admin_portal/async_*_test.rb"].each { |f| require File.expand_path(f) }'` → 0 failures, 0 errors
|
|
1055
|
+
- [ ] `yarn docs:build` passes
|
|
1056
|
+
- [ ] `git status` shows no staged `gemfiles/*.gemfile.lock`
|