wallflower 0.1.0 → 0.2.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/README.md +64 -1
- data/app/controllers/wallflower/tasks_controller.rb +1 -0
- data/app/javascript/wallflower/catch_up_controller.js +20 -0
- data/app/jobs/wallflower/run_job.rb +10 -0
- data/app/jobs/wallflower/sweep_job.rb +26 -0
- data/app/models/wallflower/refusal.rb +9 -0
- data/app/models/wallflower/task.rb +26 -1
- data/app/views/wallflower/tasks/_live.html.erb +4 -1
- data/app/views/wallflower/tasks/index.html.erb +12 -1
- data/app/views/wallflower/tasks/show.html.erb +12 -0
- data/lib/generators/wallflower/install/install_generator.rb +9 -1
- data/lib/generators/wallflower/install/templates/add_error_message_to_wallflower_tasks.rb.erb +5 -0
- data/lib/generators/wallflower/install/templates/add_result_url_to_wallflower_tasks.rb.erb +5 -0
- data/lib/generators/wallflower/install/templates/create_wallflower_refusals.rb.erb +10 -0
- data/lib/generators/wallflower/update/update_generator.rb +33 -0
- data/lib/wallflower/configuration.rb +2 -1
- data/lib/wallflower/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 777814642256dec3caef9316ece5411d3ae340dd7073f3b56258b2a79804603c
|
|
4
|
+
data.tar.gz: 19545678d642b13d461f26408fc8f8844d3dd79fccaddf10c2b394e892561cef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1fbef5b2d7cf042c5c4c6063726696520396fc4fba134ae5dfda7ce4456b5d25c904e26fe1efd1a718f7a555c58e36a163f77cc41008acaa21ec78108ee63778
|
|
7
|
+
data.tar.gz: 345abf578eb99a1043797466ad1c5c81d29f91086bd4ec1497c24d20bdb95b2551cfff6925409ea7ebe7afb1b09360b23fd4e57bb4d1a0691b4ebfbe1888b60b
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Background task tracking for Rails apps: a person starts long-running work, foll
|
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
|
-
Add the gem and run its install generator, which copies
|
|
7
|
+
Add the gem and run its install generator, which copies the migrations for its tables and a Stimulus controller to `app/javascript/controllers/wallflower/catch_up_controller.js`:
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
10
|
gem "wallflower"
|
|
@@ -16,6 +16,13 @@ bin/rails generate wallflower:install
|
|
|
16
16
|
bin/rails db:migrate
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
After updating the gem, run its update generator to copy any migrations added since it was installed and the latest copy of its Stimulus controller, then migrate:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bin/rails generate wallflower:update
|
|
23
|
+
bin/rails db:migrate
|
|
24
|
+
```
|
|
25
|
+
|
|
19
26
|
Mount the engine at the path its pages should live under:
|
|
20
27
|
|
|
21
28
|
```ruby
|
|
@@ -63,6 +70,22 @@ A runner that produces a file attaches it as the task's result:
|
|
|
63
70
|
task.attach_result(io: StringIO.new(csv), filename: "transactions.csv")
|
|
64
71
|
```
|
|
65
72
|
|
|
73
|
+
A runner whose result is a page in the app, such as a report, links the task to it:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
task.link_result(console_report_path(report))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A finished task's page then offers an Open report button. The page itself stays the app's to draw.
|
|
80
|
+
|
|
81
|
+
A runner that works through rows can refuse one it cannot take, with a label for the row and a reason:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
task.refuse(label: "Row 4", reason: "Amount is missing")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Call `task.advance` for every row, refused or not. `advance` saves at most once a second, so calling it for every row of a large task does not slow it down, and the final count is saved when the task finishes. A finished task's page shows how many rows were changed (rows done less rows refused) and how many were refused, and lists each refused row with its reason.
|
|
88
|
+
|
|
66
89
|
A finished task's page then offers a Download button. The file is sent through Wallflower's own route, which gives it only to the person who started the task and answers anyone else with not found.
|
|
67
90
|
|
|
68
91
|
## Starting a task
|
|
@@ -73,8 +96,48 @@ task = Wallflower.start(kind: :export_transactions, person: current_user, accoun
|
|
|
73
96
|
|
|
74
97
|
The task is returned queued and its job is enqueued. When the job runs, the task is marked running, its runner is called, and when the runner returns the task is marked finished with the time it finished.
|
|
75
98
|
|
|
99
|
+
## When a task finishes
|
|
100
|
+
|
|
101
|
+
Set `on_finish` to run code once with the task when it finishes, such as sending the person a notification through the app's own notification system:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
Wallflower.configure do |config|
|
|
105
|
+
config.on_finish = ->(task) { TaskFinishedNotifier.with(task: task).deliver(task.person) }
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
With no `on_finish` set, tasks still run to the end.
|
|
110
|
+
|
|
111
|
+
## When a task fails
|
|
112
|
+
|
|
113
|
+
A runner that raises marks its task failed. The task keeps the error's message, its page shows it beside how far the task got, the error is reported through `Rails.error`, and `on_finish` runs once with the failed task.
|
|
114
|
+
|
|
115
|
+
A task still running with no update for longer than `stall_after`, one hour by default, is marked failed by `Wallflower::SweepJob`, with a reason saying it stalled. Schedule the sweep the way the app schedules any recurring job, for example in Solid Queue's `config/recurring.yml`:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
production:
|
|
119
|
+
wallflower_sweep:
|
|
120
|
+
class: Wallflower::SweepJob
|
|
121
|
+
schedule: every 10 minutes
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
Wallflower.configure do |config|
|
|
126
|
+
config.stall_after = 30.minutes
|
|
127
|
+
config.keep_for = 90.days # unset by default, which keeps every task forever
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
With `keep_for` set, the same sweep deletes each finished or failed task that ended longer ago than that, with its file and its refused rows. Queued and running tasks stay, however old. With it unset, nothing is ever deleted.
|
|
132
|
+
|
|
133
|
+
## The task list
|
|
134
|
+
|
|
135
|
+
The engine's root lists the tasks the signed-in person started in the current account, newest first. Each row shows the kind's title, the status as a coloured badge, the progress and when it was started, links to the task's page, and updates while the task runs.
|
|
136
|
+
|
|
76
137
|
## The task page
|
|
77
138
|
|
|
78
139
|
A task's page lives at `tasks/:id` under the engine's mount path. The person who started the task sees its kind's title, its status and, once the runner has set a total, a progress bar of done against total. The page listens for changes to its task over Turbo Streams, so its status and progress update without a reload, in a browser and in the Hotwire Native iOS app alike. Anyone else, and the same person viewing from another account, gets a not-found response.
|
|
79
140
|
|
|
141
|
+
The task page and the task list reload in place when they are shown again after being hidden, such as when the iOS app returns from the background, so they show changes broadcast while they could not receive them. A page that stays visible makes no extra request. The copied controller is registered by the host's own Stimulus setup, as `wallflower--catch-up`.
|
|
142
|
+
|
|
80
143
|
Wallflower's pages are drawn with keystone_ui, and live updates need Action Cable and turbo-rails, which the host's layout already loads for Turbo Streams.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
connect() {
|
|
5
|
+
this.page = this.element.ownerDocument
|
|
6
|
+
this.catchUp = this.catchUp.bind(this)
|
|
7
|
+
this.page.addEventListener("visibilitychange", this.catchUp)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
disconnect() {
|
|
11
|
+
this.page.removeEventListener("visibilitychange", this.catchUp)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
catchUp() {
|
|
15
|
+
if (this.page.visibilityState !== "visible") return
|
|
16
|
+
|
|
17
|
+
const view = this.page.defaultView
|
|
18
|
+
view.Turbo.visit(view.location.href, { action: "replace" })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -3,9 +3,19 @@
|
|
|
3
3
|
module Wallflower
|
|
4
4
|
class RunJob < ActiveJob::Base
|
|
5
5
|
def perform(task)
|
|
6
|
+
run(task)
|
|
7
|
+
Wallflower.configuration.on_finish&.call(task)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def run(task)
|
|
6
13
|
task.update!(status: "running")
|
|
7
14
|
Wallflower.kind(task.kind).runner.constantize.new.call(task)
|
|
8
15
|
task.update!(status: "finished", finished_at: Time.current)
|
|
16
|
+
rescue StandardError => error
|
|
17
|
+
task.update!(status: "failed", error_message: error.message, finished_at: Time.current)
|
|
18
|
+
Rails.error.report(error, handled: true, context: { wallflower_task_id: task.id })
|
|
9
19
|
end
|
|
10
20
|
end
|
|
11
21
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wallflower
|
|
4
|
+
class SweepJob < ActiveJob::Base
|
|
5
|
+
def perform
|
|
6
|
+
fail_stalled
|
|
7
|
+
delete_old
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def delete_old
|
|
13
|
+
keep_for = Wallflower.configuration.keep_for
|
|
14
|
+
return unless keep_for
|
|
15
|
+
|
|
16
|
+
Task.where(status: %w[finished failed]).where(finished_at: ...keep_for.ago).find_each(&:destroy!)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fail_stalled
|
|
20
|
+
Task.where(status: "running").where(updated_at: ...Wallflower.configuration.stall_after.ago).find_each do |task|
|
|
21
|
+
task.update!(status: "failed", error_message: "Stalled: no progress for #{Wallflower.configuration.stall_after.inspect}", finished_at: Time.current)
|
|
22
|
+
Wallflower.configuration.on_finish&.call(task)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -8,11 +8,18 @@ module Wallflower
|
|
|
8
8
|
belongs_to :account, polymorphic: true, optional: true
|
|
9
9
|
|
|
10
10
|
has_one_attached :result_file
|
|
11
|
+
has_many :refusals, dependent: :destroy
|
|
11
12
|
|
|
12
13
|
after_update_commit do
|
|
13
14
|
broadcast_replace_to self, target: ActionView::RecordIdentifier.dom_id(self, :live), partial: "wallflower/tasks/live", locals: { task: self }
|
|
14
15
|
end
|
|
15
16
|
|
|
17
|
+
STATUS_VARIANTS = { "finished" => :success, "failed" => :danger, "running" => :info }.freeze
|
|
18
|
+
|
|
19
|
+
def status_variant
|
|
20
|
+
STATUS_VARIANTS.fetch(status, :neutral)
|
|
21
|
+
end
|
|
22
|
+
|
|
16
23
|
def kind_title
|
|
17
24
|
Wallflower.kind(kind).title
|
|
18
25
|
end
|
|
@@ -21,12 +28,30 @@ module Wallflower
|
|
|
21
28
|
result_file.attach(io: io, filename: filename)
|
|
22
29
|
end
|
|
23
30
|
|
|
31
|
+
def changed_count
|
|
32
|
+
done - refusals.size
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def link_result(url)
|
|
36
|
+
update!(result_url: url)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def refuse(label:, reason:)
|
|
40
|
+
refusals.create!(label: label, reason: reason)
|
|
41
|
+
end
|
|
42
|
+
|
|
24
43
|
def set_total(total)
|
|
25
44
|
update!(total: total)
|
|
26
45
|
end
|
|
27
46
|
|
|
47
|
+
PROGRESS_SAVE_INTERVAL = 1.second
|
|
48
|
+
|
|
28
49
|
def advance(by = 1)
|
|
29
|
-
increment(:done, by)
|
|
50
|
+
increment(:done, by)
|
|
51
|
+
return if @progress_saved_at && Time.current - @progress_saved_at < PROGRESS_SAVE_INTERVAL
|
|
52
|
+
|
|
53
|
+
save!
|
|
54
|
+
@progress_saved_at = Time.current
|
|
30
55
|
end
|
|
31
56
|
end
|
|
32
57
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<div id="<%= dom_id(task, :live) %>">
|
|
2
|
-
<p id="<%= dom_id(task, :status) %>"><%= task.status.humanize %></p>
|
|
2
|
+
<p id="<%= dom_id(task, :status) %>"><%= render Keystone::Ui::BadgeComponent.new(label: task.status.humanize, variant: task.status_variant) %></p>
|
|
3
|
+
<% if task.status == "failed" %>
|
|
4
|
+
<p id="<%= dom_id(task, :error) %>"><%= task.error_message %></p>
|
|
5
|
+
<% end %>
|
|
3
6
|
<div id="<%= dom_id(task, :progress) %>">
|
|
4
7
|
<% if task.total %>
|
|
5
8
|
<%= render Keystone::Ui::ProgressComponent.new(value: task.done, max: task.total, label: "#{task.done} of #{task.total}") %>
|
|
@@ -1 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
<%= tag.div(data: { controller: "wallflower--catch-up" }) %>
|
|
2
|
+
<%= ui_page(max_width: :md) do %>
|
|
3
|
+
<h1>Tasks</h1>
|
|
4
|
+
<% @tasks.each do |task| %>
|
|
5
|
+
<div data-task-row>
|
|
6
|
+
<%= turbo_stream_from task %>
|
|
7
|
+
<%= link_to task.kind_title, task_path(task) %>
|
|
8
|
+
<%= render "wallflower/tasks/live", task: task %>
|
|
9
|
+
<p>Started <%= task.created_at.strftime("%b %-d, %Y") %></p>
|
|
10
|
+
</div>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
<%= turbo_stream_from @task %>
|
|
2
|
+
<%= tag.div(data: { controller: "wallflower--catch-up" }) %>
|
|
2
3
|
<%= ui_page(max_width: :md) do %>
|
|
3
4
|
<h1><%= @task.kind_title %></h1>
|
|
4
5
|
<%= render "wallflower/tasks/live", task: @task %>
|
|
6
|
+
<% if @task.status == "finished" %>
|
|
7
|
+
<p id="<%= dom_id(@task, :outcome) %>"><%= @task.changed_count %> changed, <%= @task.refusals.size %> refused</p>
|
|
8
|
+
<% if @task.refusals.any? %>
|
|
9
|
+
<div id="<%= dom_id(@task, :refusals) %>">
|
|
10
|
+
<%= ui_data_table(items: @task.refusals.order(:id), columns: [ { label: "Row" }, { reason: "Reason" } ]) %>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% if @task.status == "finished" && @task.result_url.present? %>
|
|
15
|
+
<%= ui_button(label: "Open report", href: @task.result_url, variant: :secondary) %>
|
|
16
|
+
<% end %>
|
|
5
17
|
<% if @task.status == "finished" && @task.result_file.attached? %>
|
|
6
18
|
<%= ui_button(label: "Download", href: download_task_path(@task), variant: :secondary) %>
|
|
7
19
|
<% end %>
|
|
@@ -10,10 +10,18 @@ module Wallflower
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "Installs Wallflower: copies its
|
|
13
|
+
desc "Installs Wallflower: copies its migrations and its Stimulus controller."
|
|
14
14
|
|
|
15
15
|
def copy_migration
|
|
16
16
|
migration_template "create_wallflower_tasks.rb.erb", "db/migrate/create_wallflower_tasks.rb"
|
|
17
|
+
migration_template "create_wallflower_refusals.rb.erb", "db/migrate/create_wallflower_refusals.rb"
|
|
18
|
+
migration_template "add_result_url_to_wallflower_tasks.rb.erb", "db/migrate/add_result_url_to_wallflower_tasks.rb"
|
|
19
|
+
migration_template "add_error_message_to_wallflower_tasks.rb.erb", "db/migrate/add_error_message_to_wallflower_tasks.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def copy_catch_up_controller
|
|
23
|
+
source = File.expand_path("../../../../app/javascript/wallflower/catch_up_controller.js", __dir__)
|
|
24
|
+
create_file "app/javascript/controllers/wallflower/catch_up_controller.js", File.read(source)
|
|
17
25
|
end
|
|
18
26
|
end
|
|
19
27
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class CreateWallflowerRefusals < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :wallflower_refusals do |t|
|
|
4
|
+
t.references :task, null: false, foreign_key: { to_table: :wallflower_tasks }
|
|
5
|
+
t.string :label, null: false
|
|
6
|
+
t.string :reason, null: false
|
|
7
|
+
t.timestamps
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Wallflower
|
|
7
|
+
module Generators
|
|
8
|
+
class UpdateGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("../install/templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Updates Wallflower: copies the migrations and the Stimulus controller added since it was installed."
|
|
14
|
+
|
|
15
|
+
def copy_refusals_migration
|
|
16
|
+
migration_template "create_wallflower_refusals.rb.erb", "db/migrate/create_wallflower_refusals.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def copy_result_url_migration
|
|
20
|
+
migration_template "add_result_url_to_wallflower_tasks.rb.erb", "db/migrate/add_result_url_to_wallflower_tasks.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def copy_error_message_migration
|
|
24
|
+
migration_template "add_error_message_to_wallflower_tasks.rb.erb", "db/migrate/add_error_message_to_wallflower_tasks.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def copy_catch_up_controller
|
|
28
|
+
source = File.expand_path("../../../../app/javascript/wallflower/catch_up_controller.js", __dir__)
|
|
29
|
+
create_file "app/javascript/controllers/wallflower/catch_up_controller.js", File.read(source)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Wallflower
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :authentication_method, :layout, :current_person_method, :current_account_method
|
|
5
|
+
attr_accessor :authentication_method, :layout, :current_person_method, :current_account_method, :on_finish, :stall_after, :keep_for
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
8
|
@authentication_method = :authenticate_user!
|
|
9
9
|
@layout = "application"
|
|
10
10
|
@current_person_method = :current_user
|
|
11
|
+
@stall_after = 1.hour
|
|
11
12
|
end
|
|
12
13
|
end
|
|
13
14
|
|
data/lib/wallflower/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wallflower
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tyler Schneider
|
|
@@ -92,15 +92,22 @@ files:
|
|
|
92
92
|
- Rakefile
|
|
93
93
|
- app/controllers/wallflower/application_controller.rb
|
|
94
94
|
- app/controllers/wallflower/tasks_controller.rb
|
|
95
|
+
- app/javascript/wallflower/catch_up_controller.js
|
|
95
96
|
- app/jobs/wallflower/run_job.rb
|
|
97
|
+
- app/jobs/wallflower/sweep_job.rb
|
|
96
98
|
- app/models/wallflower/application_record.rb
|
|
99
|
+
- app/models/wallflower/refusal.rb
|
|
97
100
|
- app/models/wallflower/task.rb
|
|
98
101
|
- app/views/wallflower/tasks/_live.html.erb
|
|
99
102
|
- app/views/wallflower/tasks/index.html.erb
|
|
100
103
|
- app/views/wallflower/tasks/show.html.erb
|
|
101
104
|
- config/routes.rb
|
|
102
105
|
- lib/generators/wallflower/install/install_generator.rb
|
|
106
|
+
- lib/generators/wallflower/install/templates/add_error_message_to_wallflower_tasks.rb.erb
|
|
107
|
+
- lib/generators/wallflower/install/templates/add_result_url_to_wallflower_tasks.rb.erb
|
|
108
|
+
- lib/generators/wallflower/install/templates/create_wallflower_refusals.rb.erb
|
|
103
109
|
- lib/generators/wallflower/install/templates/create_wallflower_tasks.rb.erb
|
|
110
|
+
- lib/generators/wallflower/update/update_generator.rb
|
|
104
111
|
- lib/wallflower.rb
|
|
105
112
|
- lib/wallflower/configuration.rb
|
|
106
113
|
- lib/wallflower/engine.rb
|