inquirex 0.5.0 → 0.6.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/CHANGELOG.md +18 -0
- data/README.md +119 -0
- data/docs/badges/coverage_badge.svg +2 -2
- data/examples/03_send_email_actions.rb +97 -0
- data/lib/inquirex/accumulator.rb +29 -0
- data/lib/inquirex/actions/action.rb +68 -0
- data/lib/inquirex/actions/base.rb +41 -0
- data/lib/inquirex/actions/custom.rb +31 -0
- data/lib/inquirex/actions/outbox.rb +57 -0
- data/lib/inquirex/actions/runner.rb +52 -0
- data/lib/inquirex/actions/send_email.rb +174 -0
- data/lib/inquirex/actions/template.rb +95 -0
- data/lib/inquirex/actions/webhook.rb +139 -0
- data/lib/inquirex/actions.rb +57 -0
- data/lib/inquirex/answers.rb +13 -0
- data/lib/inquirex/completion_metadata.rb +82 -0
- data/lib/inquirex/definition.rb +67 -5
- data/lib/inquirex/dsl/action_builder.rb +53 -0
- data/lib/inquirex/dsl/flow_builder.rb +49 -6
- data/lib/inquirex/engine/state_serializer.rb +6 -4
- data/lib/inquirex/engine.rb +97 -5
- data/lib/inquirex/errors.rb +5 -0
- data/lib/inquirex/graph/mermaid_exporter.rb +2 -0
- data/lib/inquirex/node.rb +2 -0
- data/lib/inquirex/rules/all.rb +12 -0
- data/lib/inquirex/rules/any.rb +11 -0
- data/lib/inquirex/rules/base.rb +6 -0
- data/lib/inquirex/rules/contains.rb +12 -0
- data/lib/inquirex/rules/equals.rb +12 -0
- data/lib/inquirex/rules/greater_than.rb +12 -0
- data/lib/inquirex/rules/less_than.rb +12 -0
- data/lib/inquirex/rules/not_empty.rb +12 -0
- data/lib/inquirex/validation/adapter.rb +1 -0
- data/lib/inquirex/validation/null_adapter.rb +5 -0
- data/lib/inquirex/version.rb +1 -1
- data/lib/inquirex/widget_registry.rb +1 -0
- data/lib/inquirex.rb +13 -0
- metadata +30 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f99f7adba97c905c55490b57467fe062251719bbb98d698fe49e6dd04360fcf7
|
|
4
|
+
data.tar.gz: 2c6850590153b96ee376ddaa0314c2de3cf729f6ea2d7c8f3d0ccb20d2359683
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18b95d28461f971551997bb177c6ead5c4165562f1d627460d58656440a0884bf0972eef2d97a31e3883f34720349be82e24e2fa567b7181eea0f14361182355
|
|
7
|
+
data.tar.gz: ed93da41fe8d2f66098b0165224e472d794d6f7ab9313dd8ec9a8a80b0d299ef5623d957361d83a27680b1ce3c025dd294fc6690c855c02a32df90348726718c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-07-19
|
|
4
|
+
|
|
5
|
+
- Post-completion `action` DSL verb with an extensible effect registry
|
|
6
|
+
- `send_email` effect: builds `Mail::Message` objects (multipart text/HTML)
|
|
7
|
+
from `{{field}}` templates into `Answers#outbox`; delivery is the host
|
|
8
|
+
application's responsibility. The `mail` gem is a soft dependency.
|
|
9
|
+
- `run { |answers, outbox| ... }` escape-hatch effect (stripped from JSON)
|
|
10
|
+
- `webhook` effect: POSTs the answers envelope to a static https URL whose
|
|
11
|
+
host must be covered by the new top-level `allowed_domains` declaration;
|
|
12
|
+
enforcement runs in `Definition#validate!`, so tampered JSON definitions
|
|
13
|
+
fail at rehydration
|
|
14
|
+
- `Inquirex::Actions::Runner` and `Inquirex::Actions.run(definition, answers)`
|
|
15
|
+
- Actions serialize to/from JSON with their rule gates
|
|
16
|
+
- `CompletionMetadata` (OpenStruct; `engine` and `engine_version` required)
|
|
17
|
+
stamped by the engine at flow completion, enrichable by front-ends via the
|
|
18
|
+
new `Engine#after_completion` hook, persisted in engine state, and merged
|
|
19
|
+
into answers by `Engine#answers_with_metadata`
|
|
20
|
+
|
|
3
21
|
## [0.1.0] - 2026-04-13
|
|
4
22
|
|
|
5
23
|
- Initial release
|
data/README.md
CHANGED
|
@@ -466,6 +466,120 @@ Pass a custom adapter to the engine:
|
|
|
466
466
|
engine = Inquirex::Engine.new(definition, validator: my_validator)
|
|
467
467
|
```
|
|
468
468
|
|
|
469
|
+
## Completion Metadata
|
|
470
|
+
|
|
471
|
+
When a flow finishes, the engine guarantees a `CompletionMetadata` — an
|
|
472
|
+
OpenStruct describing how the answers were collected. Only `engine` and
|
|
473
|
+
`engine_version` are required members; rendering front-ends attach richer
|
|
474
|
+
environment details from an `after_completion` hook:
|
|
475
|
+
|
|
476
|
+
```ruby
|
|
477
|
+
engine.after_completion do |eng|
|
|
478
|
+
eng.completion_metadata = Inquirex::CompletionMetadata.new(
|
|
479
|
+
engine: "inquirex-tty",
|
|
480
|
+
engine_version: Inquirex::TTY::VERSION,
|
|
481
|
+
uname: OpenStruct.new(Etc.uname),
|
|
482
|
+
user: Etc.getlogin,
|
|
483
|
+
terminal: ENV["LC_TERMINAL"] || ENV["TERM_PROGRAM"] || "Unknown"
|
|
484
|
+
)
|
|
485
|
+
end
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
If no hook supplies one, the engine stamps the minimal core version
|
|
489
|
+
(`engine: "inquirex"`, `engine_version: Inquirex::VERSION`). The metadata
|
|
490
|
+
persists through `Engine#to_state` / `Engine.from_state`, and
|
|
491
|
+
`engine.answers_with_metadata` merges it into the answers hash under
|
|
492
|
+
`:completion_metadata` — which also makes it available to post-completion
|
|
493
|
+
actions: webhook payloads carry it, and email templates can interpolate
|
|
494
|
+
`{{completion_metadata.engine}}`.
|
|
495
|
+
|
|
496
|
+
## Post-Completion Actions
|
|
497
|
+
|
|
498
|
+
After all questions are answered, `action` declarations run server-side with
|
|
499
|
+
the collected answers. The flagship effect is `send_email`, which **builds**
|
|
500
|
+
`Mail::Message` objects (the same object ActionMailer wraps) and attaches them
|
|
501
|
+
to `answers.outbox` — **nothing is delivered**; the host application decides
|
|
502
|
+
how and when to send.
|
|
503
|
+
|
|
504
|
+
```ruby
|
|
505
|
+
Inquirex.define id: "tax-intake-2025" do
|
|
506
|
+
allowed_domains "*.agentica.group" # egress allowlist for webhook effects
|
|
507
|
+
|
|
508
|
+
# ... ask steps ...
|
|
509
|
+
|
|
510
|
+
action :client_receipt, if: not_empty(:email) do
|
|
511
|
+
send_email to: "{{email}}",
|
|
512
|
+
from: "forms@agentica.group",
|
|
513
|
+
subject: "Thanks {{name}} — we received your intake",
|
|
514
|
+
text: <<~TEXT,
|
|
515
|
+
Hi {{name}},
|
|
516
|
+
We received your answers:
|
|
517
|
+
|
|
518
|
+
{{answers_summary}}
|
|
519
|
+
TEXT
|
|
520
|
+
html: <<~HTML
|
|
521
|
+
<p>Hi {{name}},</p>
|
|
522
|
+
<img src="https://cdn.agentica.group/logo.png" alt="Logo">
|
|
523
|
+
{{answers_summary}}
|
|
524
|
+
HTML
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
action :admin_alert do
|
|
528
|
+
send_email to: "owner@agentica.group", from: "forms@agentica.group",
|
|
529
|
+
subject: "New lead: {{name}} <{{email}}>",
|
|
530
|
+
html: "{{answers_summary}}"
|
|
531
|
+
run { |answers, outbox| Metrics.count(:lead, answers.to_flat_h) }
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
action :crm_push do
|
|
535
|
+
webhook url: "https://hooks.agentica.group/inquirex",
|
|
536
|
+
headers: { "X-Api-Key" => "..." }
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Execution and delivery:
|
|
542
|
+
|
|
543
|
+
```ruby
|
|
544
|
+
answers = Inquirex::Actions.run(definition, engine.answers)
|
|
545
|
+
answers.outbox.messages # => [Mail::Message, ...]
|
|
546
|
+
answers.outbox.results # => per-action :ok / :skipped / :failed trail
|
|
547
|
+
|
|
548
|
+
# In a Rails host:
|
|
549
|
+
answers.outbox.each do |message|
|
|
550
|
+
ActionMailer::Base.wrap_delivery_behavior(message) # adopt Rails delivery config
|
|
551
|
+
message.deliver
|
|
552
|
+
end
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Key semantics:
|
|
556
|
+
|
|
557
|
+
- **Templating is `{{field}}` interpolation only** — dot-notation keys resolved
|
|
558
|
+
against `Answers#to_flat_h`, deliberately inert (no code execution), so
|
|
559
|
+
definitions stored in a database render safely. Values interpolated into
|
|
560
|
+
`html:` bodies are HTML-escaped automatically; `text:` bodies stay verbatim.
|
|
561
|
+
The built-in `{{answers_summary}}` expands to all collected answers.
|
|
562
|
+
- **`if:` gates** reuse the serializable rule AST (`not_empty(:email)`, ...).
|
|
563
|
+
A false rule records `:skipped` — declare no actions (or gate them) when a
|
|
564
|
+
flow should only save answers.
|
|
565
|
+
- **`run { |answers, outbox| ... }`** is the full-Ruby escape hatch; like all
|
|
566
|
+
lambdas it is stripped from JSON.
|
|
567
|
+
- **Failures are isolated**: a raising effect records `:failed` in
|
|
568
|
+
`outbox.results` and never blocks other actions.
|
|
569
|
+
- **Images** in HTML bodies must be external URLs; attachments are unsupported.
|
|
570
|
+
- The `mail` gem is a soft dependency, needed only when a message is built
|
|
571
|
+
(Rails hosts already have it via ActionMailer).
|
|
572
|
+
- **`webhook url:`** POSTs `{"answers": {...}}` as JSON to a static URL. The
|
|
573
|
+
URL's host must be covered by `allowed_domains`, declared at the top of the
|
|
574
|
+
definition so the flow's egress surface is auditable at a glance. The check
|
|
575
|
+
runs inside `Definition.new` — a JSON definition whose webhook URL was
|
|
576
|
+
tampered with fails at *rehydration*, before anything executes. Also
|
|
577
|
+
enforced: https only (plain http just for localhost), no userinfo, no
|
|
578
|
+
`{{field}}` templates in URLs (the destination must be static), redirects
|
|
579
|
+
are not followed, and non-2xx responses record `:failed`.
|
|
580
|
+
- Effects are extensible: `Inquirex::Actions.register(:save_record, MyEffect)`
|
|
581
|
+
gives a new verb both DSL and JSON wire support.
|
|
582
|
+
|
|
469
583
|
## Serialization
|
|
470
584
|
|
|
471
585
|
Definitions support round-trip serialization:
|
|
@@ -483,6 +597,11 @@ Serialized structure includes:
|
|
|
483
597
|
- Steps and transitions
|
|
484
598
|
- Rule AST payloads
|
|
485
599
|
- Widget hints
|
|
600
|
+
- Post-completion actions (`actions`) with their rules and effects; `run`
|
|
601
|
+
blocks are stripped, and an action left with no serializable effects is
|
|
602
|
+
omitted entirely
|
|
603
|
+
- The `allowed_domains` egress allowlist, re-enforced against webhook URLs
|
|
604
|
+
every time a definition is rehydrated
|
|
486
605
|
|
|
487
606
|
Important serialization details:
|
|
488
607
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
16
16
|
<text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
|
|
17
17
|
<text x="31.5" y="14">coverage</text>
|
|
18
|
-
<text x="80" y="15" fill="#010101" fill-opacity=".3">
|
|
19
|
-
<text x="80" y="14">
|
|
18
|
+
<text x="80" y="15" fill="#010101" fill-opacity=".3">96%</text>
|
|
19
|
+
<text x="80" y="14">96%</text>
|
|
20
20
|
</g>
|
|
21
21
|
</svg>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 03: Post-completion actions building emails into the outbox.
|
|
5
|
+
#
|
|
6
|
+
# The `action` verb runs server-side after the flow finishes. send_email
|
|
7
|
+
# builds Mail::Message objects — nothing is delivered here; a host Rails app
|
|
8
|
+
# would iterate answers.outbox and deliver via its own ActionMailer config.
|
|
9
|
+
#
|
|
10
|
+
# Run:
|
|
11
|
+
# bundle exec ruby examples/03_send_email_actions.rb
|
|
12
|
+
|
|
13
|
+
require "bundler/setup"
|
|
14
|
+
require "inquirex"
|
|
15
|
+
|
|
16
|
+
DEFINITION = Inquirex.define id: "lead-intake", version: "1.0.0" do
|
|
17
|
+
# Egress allowlist: webhook effects may only POST to these hosts.
|
|
18
|
+
allowed_domains "*.agentica.group"
|
|
19
|
+
|
|
20
|
+
meta title: "Lead Intake", subtitle: "Tell us about your project"
|
|
21
|
+
start :name
|
|
22
|
+
|
|
23
|
+
ask :name do
|
|
24
|
+
type :string
|
|
25
|
+
question "What is your name?"
|
|
26
|
+
transition to: :email
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ask :email do
|
|
30
|
+
type :email
|
|
31
|
+
question "Where can we reach you? (leave blank to skip the receipt)"
|
|
32
|
+
transition to: :budget
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
ask :budget do
|
|
36
|
+
type :currency
|
|
37
|
+
question "What is your approximate budget?"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sent only when the visitor left an email address.
|
|
41
|
+
action :client_receipt, if: not_empty(:email) do
|
|
42
|
+
send_email to: "{{email}}",
|
|
43
|
+
from: "forms@agentica.group",
|
|
44
|
+
subject: "Thanks {{name}} — we got your inquiry",
|
|
45
|
+
text: <<~TEXT,
|
|
46
|
+
Hi {{name}},
|
|
47
|
+
|
|
48
|
+
We received your answers and will reply within one business day.
|
|
49
|
+
|
|
50
|
+
{{answers_summary}}
|
|
51
|
+
TEXT
|
|
52
|
+
html: <<~HTML
|
|
53
|
+
<p>Hi {{name}},</p>
|
|
54
|
+
<p>We received your answers and will reply within one business day.</p>
|
|
55
|
+
{{answers_summary}}
|
|
56
|
+
HTML
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Always notify the site owner, and demonstrate the Ruby escape hatch.
|
|
60
|
+
action :admin_alert do
|
|
61
|
+
send_email to: "owner@agentica.group",
|
|
62
|
+
from: "forms@agentica.group",
|
|
63
|
+
subject: "New lead: {{name}} (budget {{budget}})",
|
|
64
|
+
html: "{{answers_summary}}"
|
|
65
|
+
run { |answers, _outbox| puts ">> run{} saw budget: #{answers.budget}" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Webhook to an allowed_domains host. Gated by an always-false rule so this
|
|
69
|
+
# example runs offline — it records :skipped instead of POSTing. Drop the
|
|
70
|
+
# if: to exercise it for real.
|
|
71
|
+
action :crm_push, if: equals(:name, "__network_demo__") do
|
|
72
|
+
webhook url: "https://hooks.agentica.group/inquirex",
|
|
73
|
+
headers: { "X-Api-Key" => "demo" }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
answers = Inquirex::Actions.run(
|
|
78
|
+
DEFINITION,
|
|
79
|
+
name: "Ada Lovelace",
|
|
80
|
+
email: "ada@lovelace.io",
|
|
81
|
+
budget: 12_500.00
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
puts "Built #{answers.outbox.size} message(s):"
|
|
85
|
+
answers.outbox.each do |mail|
|
|
86
|
+
puts "-" * 60
|
|
87
|
+
puts "To: #{mail.to.join(", ")}"
|
|
88
|
+
puts "Subject: #{mail.subject}"
|
|
89
|
+
puts "Parts: #{mail.multipart? ? "text + html" : mail.content_type}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
puts "-" * 60
|
|
93
|
+
puts "Results: #{answers.outbox.results.map { |r| "#{r.action_id}=#{r.status}" }.join(", ")}"
|
|
94
|
+
|
|
95
|
+
# The definition (rules and all) round-trips through JSON — run{} is stripped:
|
|
96
|
+
restored = Inquirex::Definition.from_json(DEFINITION.to_json)
|
|
97
|
+
puts "Actions after JSON round-trip: #{restored.actions.map(&:id).inspect}"
|
data/lib/inquirex/accumulator.rb
CHANGED
|
@@ -8,9 +8,21 @@ module Inquirex
|
|
|
8
8
|
# @attr_reader name [Symbol] accumulator identifier (e.g. :price)
|
|
9
9
|
# @attr_reader type [Symbol] one of Node::TYPES (typically :currency, :integer, :decimal)
|
|
10
10
|
# @attr_reader default [Numeric] starting value (default: 0)
|
|
11
|
+
#
|
|
12
|
+
# @example Declare a running price total and contribute to it from a step
|
|
13
|
+
# Inquirex.define do
|
|
14
|
+
# accumulator :price, type: :currency, default: 0
|
|
15
|
+
# ask :dependents do
|
|
16
|
+
# type :integer
|
|
17
|
+
# accumulate :price, per_unit: 50
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
11
20
|
class Accumulator
|
|
12
21
|
attr_reader :name, :type, :default
|
|
13
22
|
|
|
23
|
+
# @param name [Symbol, String] accumulator identifier
|
|
24
|
+
# @param type [Symbol, String] value type, one of Node::TYPES
|
|
25
|
+
# @param default [Numeric] starting value before any contributions
|
|
14
26
|
def initialize(name:, type: :decimal, default: 0)
|
|
15
27
|
@name = name.to_sym
|
|
16
28
|
@type = type.to_sym
|
|
@@ -18,10 +30,19 @@ module Inquirex
|
|
|
18
30
|
freeze
|
|
19
31
|
end
|
|
20
32
|
|
|
33
|
+
# Serializes the accumulator to its wire format. The name is omitted —
|
|
34
|
+
# Definition#to_h keys the accumulators map by name.
|
|
35
|
+
#
|
|
36
|
+
# @return [Hash{String => Object}] e.g. { "type" => "currency", "default" => 0 }
|
|
21
37
|
def to_h
|
|
22
38
|
{ "type" => @type.to_s, "default" => @default }
|
|
23
39
|
end
|
|
24
40
|
|
|
41
|
+
# Deserializes an Accumulator from its wire format.
|
|
42
|
+
#
|
|
43
|
+
# @param name [Symbol, String] accumulator identifier (the map key)
|
|
44
|
+
# @param hash [Hash] type/default attributes (string or symbol keys)
|
|
45
|
+
# @return [Accumulator]
|
|
25
46
|
def self.from_h(name, hash)
|
|
26
47
|
new(
|
|
27
48
|
name: name,
|
|
@@ -44,10 +65,15 @@ module Inquirex
|
|
|
44
65
|
# @attr_reader shape [Symbol] one of :lookup, :per_selection, :per_unit, :flat
|
|
45
66
|
# @attr_reader payload [Object] shape-specific data (Hash or Numeric)
|
|
46
67
|
class Accumulation
|
|
68
|
+
# Valid accumulation shapes; exactly one must be declared per entry.
|
|
47
69
|
SHAPES = %i[lookup per_selection per_unit flat].freeze
|
|
48
70
|
|
|
49
71
|
attr_reader :target, :shape, :payload
|
|
50
72
|
|
|
73
|
+
# @param target [Symbol, String] accumulator name to contribute to
|
|
74
|
+
# @param shape [Symbol, String] one of SHAPES
|
|
75
|
+
# @param payload [Hash, Numeric] shape-specific data (Hash for :lookup/:per_selection)
|
|
76
|
+
# @raise [Errors::DefinitionError] when shape is not one of SHAPES
|
|
51
77
|
def initialize(target:, shape:, payload:)
|
|
52
78
|
@target = target.to_sym
|
|
53
79
|
@shape = shape.to_sym
|
|
@@ -73,6 +99,9 @@ module Inquirex
|
|
|
73
99
|
end
|
|
74
100
|
end
|
|
75
101
|
|
|
102
|
+
# Serializes to the single-key shape Hash that .from_h accepts.
|
|
103
|
+
#
|
|
104
|
+
# @return [Hash{String => Object}] e.g. { "per_unit" => 50 }
|
|
76
105
|
def to_h
|
|
77
106
|
{ @shape.to_s => serialize_payload }
|
|
78
107
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Actions
|
|
5
|
+
# A named post-completion unit: an optional rule gating execution and an
|
|
6
|
+
# ordered list of effects. Declared with the DSL word `action`:
|
|
7
|
+
#
|
|
8
|
+
# action :client_receipt, if: not_empty(:email) do
|
|
9
|
+
# send_email to: "{{email}}", subject: "Thanks {{name}}!", text: "..."
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Rules reuse the same serializable AST as transitions, so conditions
|
|
13
|
+
# survive the JSON round-trip. Non-serializable effects (run blocks) are
|
|
14
|
+
# stripped on serialization; an action left with no serializable effects
|
|
15
|
+
# is omitted from JSON entirely.
|
|
16
|
+
class Action
|
|
17
|
+
attr_reader :id, :rule, :effects
|
|
18
|
+
|
|
19
|
+
# @param id [Symbol] action identifier
|
|
20
|
+
# @param effects [Array<Actions::Base>] executed in declaration order
|
|
21
|
+
# @param rule [Rules::Base, nil] gate — action runs only when true
|
|
22
|
+
def initialize(id:, effects:, rule: nil)
|
|
23
|
+
@id = id.to_sym
|
|
24
|
+
@effects = effects.freeze
|
|
25
|
+
@rule = rule
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param answers_hash [Hash] step_id => value context for rule evaluation
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def applicable?(answers_hash)
|
|
32
|
+
@rule.nil? || @rule.evaluate(answers_hash)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] whether anything survives JSON serialization
|
|
36
|
+
def serializable?
|
|
37
|
+
@effects.any?(&:serializable?)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash] wire format; run blocks are stripped
|
|
41
|
+
def to_h
|
|
42
|
+
hash = { "id" => @id.to_s }
|
|
43
|
+
hash["if"] = @rule.to_h if @rule
|
|
44
|
+
hash["effects"] = @effects.select(&:serializable?).map(&:to_h)
|
|
45
|
+
hash
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param hash [Hash] string or symbol keys
|
|
49
|
+
# @return [Action]
|
|
50
|
+
def self.from_h(hash)
|
|
51
|
+
id = hash["id"] || hash[:id]
|
|
52
|
+
rule_data = hash["if"] || hash[:if]
|
|
53
|
+
effects_data = hash["effects"] || hash[:effects] || []
|
|
54
|
+
|
|
55
|
+
effects = effects_data.map do |effect_hash|
|
|
56
|
+
type = effect_hash["type"] || effect_hash[:type]
|
|
57
|
+
Actions.lookup(type).from_h(effect_hash)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
new(
|
|
61
|
+
id: id.to_sym,
|
|
62
|
+
effects: effects,
|
|
63
|
+
rule: rule_data ? Rules::Base.from_h(rule_data) : nil
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Actions
|
|
5
|
+
# Abstract base for action effects — the executable units inside an
|
|
6
|
+
# `action` block. Subclasses implement #call and, when serializable,
|
|
7
|
+
# #to_h / .from_h following the same round-trip pattern as Rules::Base.
|
|
8
|
+
#
|
|
9
|
+
# Effects that wrap Ruby procs (Actions::Custom) return false from
|
|
10
|
+
# #serializable? and are stripped from JSON, consistent with how
|
|
11
|
+
# lambdas are handled everywhere else in Inquirex.
|
|
12
|
+
class Base
|
|
13
|
+
# Executes the effect. Email-building effects append Mail::Message
|
|
14
|
+
# objects to the outbox; custom effects may do anything server-side.
|
|
15
|
+
#
|
|
16
|
+
# @param answers [Answers] completed answers
|
|
17
|
+
# @param outbox [Outbox] collector for built messages
|
|
18
|
+
# @return [void]
|
|
19
|
+
def call(answers, outbox)
|
|
20
|
+
raise NotImplementedError, "#{self.class}#call must be implemented"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Boolean] whether this effect survives JSON serialization
|
|
24
|
+
def serializable? = true
|
|
25
|
+
|
|
26
|
+
# Hook for effects that must be checked against the definition carrying
|
|
27
|
+
# them (e.g. Webhook vs allowed_domains). Runs inside
|
|
28
|
+
# Definition#validate!, which both DSL-built and JSON-rehydrated
|
|
29
|
+
# definitions pass through — so violations fail at load time.
|
|
30
|
+
#
|
|
31
|
+
# @param _definition [Definition]
|
|
32
|
+
# @raise [Errors::DefinitionError] on violation
|
|
33
|
+
def validate_against(_definition); end
|
|
34
|
+
|
|
35
|
+
# @return [Hash]
|
|
36
|
+
def to_h
|
|
37
|
+
raise NotImplementedError, "#{self.class}#to_h must be implemented"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Actions
|
|
5
|
+
# Escape-hatch effect wrapping an arbitrary Ruby block, declared in the
|
|
6
|
+
# DSL as `run { |answers, outbox| ... }`. Full language power — build a
|
|
7
|
+
# Mail::Message by hand with outbox.add_message, call a service, record
|
|
8
|
+
# metrics — at the cost of serialization: like every lambda in Inquirex,
|
|
9
|
+
# the block is stripped from JSON and exists only in Ruby-authored
|
|
10
|
+
# definitions.
|
|
11
|
+
class Custom < Base
|
|
12
|
+
# @param block [Proc] receives (answers, outbox)
|
|
13
|
+
def initialize(block)
|
|
14
|
+
super()
|
|
15
|
+
@block = block
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Invokes the wrapped block with the collected answers and the outbox.
|
|
20
|
+
#
|
|
21
|
+
# @param answers [Answers] completed answers
|
|
22
|
+
# @param outbox [Outbox] collector for messages the block may build
|
|
23
|
+
# @return [Object] whatever the block returns (recorded, not interpreted)
|
|
24
|
+
def call(answers, outbox)
|
|
25
|
+
@block.call(answers, outbox)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def serializable? = false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Actions
|
|
5
|
+
# Collected output of running a definition's actions: the Mail::Message
|
|
6
|
+
# objects built by send_email effects, plus a per-execution result trail.
|
|
7
|
+
#
|
|
8
|
+
# The outbox is attached to Answers#outbox and is intentionally excluded
|
|
9
|
+
# from Answers serialization — mail objects never leak into persisted
|
|
10
|
+
# answer data. Delivery is the host application's job:
|
|
11
|
+
#
|
|
12
|
+
# answers.outbox.each do |message|
|
|
13
|
+
# ActionMailer::Base.wrap_delivery_behavior(message)
|
|
14
|
+
# message.deliver
|
|
15
|
+
# end
|
|
16
|
+
class Outbox
|
|
17
|
+
include Enumerable
|
|
18
|
+
|
|
19
|
+
# One entry per effect execution (or per skipped action).
|
|
20
|
+
# status is :ok, :skipped (action rule was false), or :failed.
|
|
21
|
+
Result = Data.define(:action_id, :status, :error)
|
|
22
|
+
|
|
23
|
+
attr_reader :messages, :results
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@messages = []
|
|
27
|
+
@results = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param mail [Mail::Message]
|
|
31
|
+
# @return [Mail::Message]
|
|
32
|
+
def add_message(mail)
|
|
33
|
+
@messages << mail
|
|
34
|
+
mail
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param action_id [Symbol]
|
|
38
|
+
# @param status [Symbol] :ok, :skipped, or :failed
|
|
39
|
+
# @param error [StandardError, nil]
|
|
40
|
+
def record(action_id, status, error: nil)
|
|
41
|
+
@results << Result.new(action_id:, status:, error:)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Iterates over built Mail::Message objects.
|
|
45
|
+
def each(&) = @messages.each(&)
|
|
46
|
+
|
|
47
|
+
# @return [Integer] number of built messages
|
|
48
|
+
def size = @messages.size
|
|
49
|
+
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def empty? = @messages.empty?
|
|
52
|
+
|
|
53
|
+
# @return [Array<Result>] executions that raised
|
|
54
|
+
def failures = @results.select { |result| result.status == :failed }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Actions
|
|
5
|
+
# Executes a definition's actions against completed answers, in
|
|
6
|
+
# declaration order, populating Answers#outbox with built messages and a
|
|
7
|
+
# result trail.
|
|
8
|
+
#
|
|
9
|
+
# Deliberately separate from the Engine: in the cross-site architecture
|
|
10
|
+
# the frontend collects answers and a different server process
|
|
11
|
+
# post-processes them, so the runner is a pure function of
|
|
12
|
+
# (definition, answers) — which also makes rebuilding messages inside a
|
|
13
|
+
# background job trivial.
|
|
14
|
+
#
|
|
15
|
+
# A failing effect records a :failed result and never aborts the other
|
|
16
|
+
# actions; the host inspects outbox.failures and decides what to do.
|
|
17
|
+
class Runner
|
|
18
|
+
# @param definition [Definition]
|
|
19
|
+
def initialize(definition)
|
|
20
|
+
@definition = definition
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param answers [Answers, Hash] completed answers (a Hash is wrapped)
|
|
24
|
+
# @return [Answers] the (possibly wrapped) answers with #outbox populated
|
|
25
|
+
def call(answers)
|
|
26
|
+
answers = Answers.new(answers) unless answers.is_a?(Answers)
|
|
27
|
+
context = answers.to_h
|
|
28
|
+
|
|
29
|
+
@definition.actions.each do |action|
|
|
30
|
+
unless action.applicable?(context)
|
|
31
|
+
answers.outbox.record(action.id, :skipped)
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
execute(action, answers)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
answers
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def execute(action, answers)
|
|
43
|
+
action.effects.each do |effect|
|
|
44
|
+
effect.call(answers, answers.outbox)
|
|
45
|
+
answers.outbox.record(action.id, :ok)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
answers.outbox.record(action.id, :failed, error: e)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|