ask-rails 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 805ea4f5713b8cfacc7f25c54b6229b788199ddba9ab1dc5a50c4719a834f256
4
- data.tar.gz: da2648a6027373d0c65f37786c347e6fda211d6f43a38378042acb8dae1a7ca8
3
+ metadata.gz: 2abe315f91db647a828734bbb14a557ba8feac60a35e99185f83545704e721cc
4
+ data.tar.gz: be8595aaa795abec9f74d8bee89e44cdfbb0da792b11e944eb7294529e7bede1
5
5
  SHA512:
6
- metadata.gz: 02e6b7d1dca1617b4070e9068cb6be162040756119ef1dbdab793ec27b1cb1aaa33a46eb88d7dcc9c9734e3b3c570172f816290f2629be0a09c8cdef041a88e9
7
- data.tar.gz: bedabfd712c7397baf254aecaf8c2c2316dfb47964303d4b91437e9ef53dcf187a31fccdc406e8b5434de5029d657179845d31044de8e2ee148abff0baef8063
6
+ metadata.gz: ccc55bc26de71c3382af0dc38ab825e1886ddfcfaf030715bf4dc3ff5c0016fe50dcaa3eb0c0f7dd630cbfeb3f35880cc0d4a28c9f1291ab4822872aa9501336
7
+ data.tar.gz: 0bab3d0dab2a293485984deb1acf767c14e63cc9cc8995fab0f2a0e70e276d6e847a2573d41cd535632033f1d151dfbe6cb3118a9c0763ac5c08eacf0593bef7
data/CHANGELOG.md CHANGED
@@ -1,26 +1,30 @@
1
- ## [0.7.0] — 2026-07-31
1
+ ## [0.8.0] — 2026-07-31
2
2
 
3
3
  ### Added
4
4
 
5
- - **`ask:workflow NAME` generator** scaffolds a workflow module under `app/workflows/<name>/` with `workflow.rb` and `steps/` directory. Requires ask-graph; aborts with a helpful message if it's not installed.
6
-
7
- - **`ask:agent NAME` generator** — scaffolds an individual agent under `app/agents/`.
8
-
9
- - **ask-graph support in install generator** — `ask:install` now creates `app/workflows/application_workflow.rb` and the workflows directory when ask-graph is installed. Pass `--skip-graph` to skip. The initializer's graph configuration block is only generated when ask-graph is present:
5
+ - **`Ask::Actions` — action convention** user-facing operations callable from any channel (web, Slack, voice) by name:
10
6
 
11
7
  ```ruby
12
- if defined?(Ask::Graph)
13
- Ask::Graph.storage = ASK_STATE
14
- end
8
+ Ask::Actions.dispatch(action: "chats.create", context: context, params: {})
15
9
  ```
16
10
 
17
- ### Changed
11
+ - `Ask::Actions::Result` — uniform response shape (`ok`/`error`, `message`, `data`, `code`)
12
+ - `Ask::Actions::Context` — per-request context bag; attributes become accessors
13
+ - `Ask::Actions::Backend` — dispatcher with explicit registration (`Ask::Actions.register`) and convention resolution (`"chats.create"` → `Chats::Create` via Zeitwerk). Unknown actions raise `Ask::Actions::Backend::UnknownAction` with guidance.
14
+
15
+ - **`ask:action NAME [NAMESPACE]` generator** — scaffolds actions:
16
+
17
+ ```bash
18
+ rails generate ask:action create_workspace # app/actions/create_workspace.rb
19
+ rails generate ask:action chats create # app/actions/chats/create.rb → "chats.create"
20
+ ```
18
21
 
19
- - Shared `ask_state` table is explicitly documented as serving both agent sessions and workflow checkpoints.
22
+ - **Actions in install generator** — `ask:install` now creates `app/actions/` and `app/actions/application_action.rb` (base class with `call(context:, params:)` `#call`). The initializer documents the actions block with a registration example.
20
23
 
21
24
  ### Tested
22
25
 
23
- - 11 generator tests, 51 assertions, 0 failures.
26
+ - 25 new tests (Result, Context, Backend, generators) — 48 runs, 141 assertions, 0 failures.
27
+ - End-to-end smoke test in a fresh Rails 8.1 app: `ask:install` + `ask:action` (namespaced and top-level) run; `Ask::Actions.dispatch` resolves both via convention; unknown actions raise with a helpful message.
24
28
 
25
29
  ## [0.7.0] — 2026-07-31
26
30
 
data/README.md CHANGED
@@ -24,6 +24,7 @@ This creates:
24
24
  |---|---|
25
25
  | `config/initializers/ask.rb` | Agent + workflow configuration (graph block only generated when ask-graph is installed) |
26
26
  | `app/agents/application_agent.rb` | Base class for your agents |
27
+ | `app/actions/application_action.rb` | Base class for your actions |
27
28
  | `app/workflows/application_workflow.rb` | Base class for your workflows (only with ask-graph) |
28
29
  | `db/migrate/*_create_ask_state.rb` | Shared key-value state table — workflow checkpoints and agent session indexes |
29
30
  | `db/migrate/*_create_ask_audit_logs.rb` | Agent session audit log |
@@ -68,6 +69,27 @@ module NotifyCustomer
68
69
  end
69
70
  ```
70
71
 
72
+ ### `ask:action NAME [NAMESPACE]`
73
+
74
+ Scaffolds a new action — a user-facing operation callable from any channel (web, Slack, voice) by name:
75
+
76
+ ```bash
77
+ rails generate ask:action create_workspace # app/actions/create_workspace.rb
78
+ rails generate ask:action chats create # app/actions/chats/create.rb
79
+ ```
80
+
81
+ Creates `app/actions/chats/create.rb`:
82
+
83
+ ```ruby
84
+ module Chats
85
+ class Create < ApplicationAction
86
+ def call
87
+ Ask::Actions::Result.ok(message: "Chat created", data: { id: record.id })
88
+ end
89
+ end
90
+ end
91
+ ```
92
+
71
93
  ### Skipping workflow scaffolding
72
94
 
73
95
  If you don't use ask-graph, install with:
@@ -132,6 +154,40 @@ result.payment
132
154
 
133
155
  Checkpoints are saved to the shared `ask_state` table, so workflows resume after crashes.
134
156
 
157
+ ### Actions
158
+
159
+ Actions are the operations your users trigger — booking an appointment, creating a chat, upgrading a plan. They live in `app/actions/` and are dispatched **by name**, so any channel (web controller, Slack handler, voice agent) calls the same operation:
160
+
161
+ ```ruby
162
+ # app/actions/chats/create.rb
163
+ module Chats
164
+ class Create < ApplicationAction
165
+ def call
166
+ chat = context.channel.start_new_chat!
167
+ Ask::Actions::Result.ok(message: "Chat created", data: { chat: chat })
168
+ end
169
+ end
170
+ end
171
+ ```
172
+
173
+ ```ruby
174
+ # From any channel:
175
+ context = Ask::Actions::Context.new(user: current_user, session: session)
176
+ result = Ask::Actions.dispatch(action: "chats.create", context: context, params: { name: "general" })
177
+ result.ok? # => true
178
+ result.message # => "Chat created"
179
+ result.data # => { chat: ... }
180
+ ```
181
+
182
+ By convention, `"chats.create"` resolves to `Chats::Create` — no registration needed (Zeitwerk autoloads `app/actions/`). Register explicitly to override the convention or to list actions in `Ask::Actions.available`:
183
+
184
+ ```ruby
185
+ # config/initializers/ask.rb
186
+ Ask::Actions.register "chats.create", Chats::Create
187
+ ```
188
+
189
+ Unknown action names raise `Ask::Actions::Backend::UnknownAction` with a helpful message.
190
+
135
191
  ## Configuration
136
192
 
137
193
  API keys are resolved automatically by `Ask::Auth`:
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Actions
5
+ # Dispatches named actions to their classes.
6
+ #
7
+ # Resolution order:
8
+ # 1. Explicitly registered actions (Ask::Actions.register)
9
+ # 2. Convention: "chats.create" resolves to Chats::Create
10
+ #
11
+ # Convention resolution works with Zeitwerk — app/actions/chats/create.rb
12
+ # is autoloaded as Chats::Create with no configuration.
13
+ class Backend
14
+ class UnknownAction < StandardError
15
+ def initialize(action)
16
+ super(<<~MSG.strip)
17
+ Unknown action #{action.inspect}. Define it at app/actions/ \
18
+ (e.g. app/actions/chats/create.rb for "chats.create") or register \
19
+ it with Ask::Actions.register.
20
+ MSG
21
+ end
22
+ end
23
+
24
+ CONVENTION = /\A[a-z0-9_.]+\z/
25
+
26
+ class << self
27
+ def register(action, klass)
28
+ registered[action.to_s] = klass
29
+ end
30
+
31
+ def registered
32
+ @registered ||= {}
33
+ end
34
+
35
+ def reset!
36
+ @registered = {}
37
+ end
38
+
39
+ def available
40
+ registered.keys
41
+ end
42
+
43
+ def dispatch(action:, context:, params: {})
44
+ resolve(action).call(context: context, params: params)
45
+ end
46
+
47
+ def resolve(action)
48
+ name = action.to_s
49
+ registered[name] || resolve_by_convention(name) || unknown_action!(name)
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_by_convention(name)
55
+ return nil unless name.match?(CONVENTION)
56
+
57
+ name.split(".").map { |part| part.split("_").map(&:capitalize).join }.join("::").safe_constantize
58
+ end
59
+
60
+ def unknown_action!(name)
61
+ raise UnknownAction, name
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Actions
5
+ # A per-request bag of context. Channel adapters construct it with
6
+ # whatever the app needs — user, session, workspace, channel, voice call —
7
+ # and actions read from it. Attributes become accessors on the instance.
8
+ #
9
+ # @example
10
+ # context = Ask::Actions::Context.new(user: user, session: session, workspace: workspace)
11
+ # context.user # => user
12
+ # context.workspace # => workspace
13
+ class Context
14
+ def initialize(**attributes)
15
+ attributes.each { |name, value| define_attribute(name, value) }
16
+ end
17
+
18
+ private
19
+
20
+ def define_attribute(name, value)
21
+ singleton_class.attr_accessor(name)
22
+ public_send("#{name}=", value)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Actions
5
+ # The uniform response shape every action returns. Channel adapters
6
+ # (web controllers, Slack handlers, voice agents) consume the same
7
+ # contract regardless of which surface a request came from.
8
+ #
9
+ # @example
10
+ # Ask::Actions::Result.ok(message: "Booking confirmed", data: { booking: booking })
11
+ # Ask::Actions::Result.error(message: "Slot unavailable", code: :slot_taken)
12
+ class Result
13
+ attr_reader :ok, :message, :data, :code
14
+
15
+ def initialize(ok:, message:, data: {}, code: nil)
16
+ @ok = ok
17
+ @message = message
18
+ @data = data
19
+ @code = code
20
+ end
21
+
22
+ def ok?
23
+ ok
24
+ end
25
+
26
+ def error?
27
+ !ok
28
+ end
29
+
30
+ def to_h
31
+ { ok: ok, message: message, data: data, code: code }
32
+ end
33
+
34
+ def self.ok(message: "", data: {}, code: nil)
35
+ new(ok: true, message: message, data: data, code: code)
36
+ end
37
+
38
+ def self.error(message: "", data: {}, code: nil)
39
+ new(ok: false, message: message, data: data, code: code)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actions/result"
4
+ require_relative "actions/context"
5
+ require_relative "actions/backend"
6
+
7
+ module Ask
8
+ # Actions — a convention for user-facing operations callable from any channel.
9
+ #
10
+ # Every action lives in app/actions/, responds to .call(context:, params:),
11
+ # and returns an Ask::Actions::Result. Dispatch them by name so any channel —
12
+ # web, Slack, voice — can invoke the same operation:
13
+ #
14
+ # Ask::Actions.dispatch(action: "chats.create", context: context, params: {})
15
+ #
16
+ # By convention, "chats.create" resolves to Chats::Create (app/actions/chats/create.rb).
17
+ # Register explicitly to override the convention or to list actions in
18
+ # Ask::Actions.available:
19
+ #
20
+ # Ask::Actions.register "chats.create", Chats::Create
21
+ module Actions
22
+ class << self
23
+ def register(action, klass)
24
+ Backend.register(action, klass)
25
+ end
26
+
27
+ def dispatch(action:, context:, params: {})
28
+ Backend.dispatch(action: action, context: context, params: params)
29
+ end
30
+
31
+ def resolve(action)
32
+ Backend.resolve(action)
33
+ end
34
+
35
+ def available
36
+ Backend.available
37
+ end
38
+
39
+ def reset!
40
+ Backend.reset!
41
+ end
42
+ end
43
+ end
44
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Rails
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
data/lib/ask/rails.rb CHANGED
@@ -9,4 +9,5 @@ end
9
9
 
10
10
  require_relative "rails/version"
11
11
  require_relative "rails/state"
12
+ require_relative "actions"
12
13
  require_relative "rails/railtie" if defined?(::Rails::Railtie)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= dispatch_name %> action.
4
+ #
5
+ # Dispatch it from any channel — web, Slack, voice:
6
+ # Ask::Actions.dispatch(action: "<%= dispatch_name %>", context: context, params: {})
7
+ #
8
+ # Return an Ask::Actions::Result:
9
+ # Ask::Actions::Result.ok(message: "Done", data: { id: record.id })
10
+ <% if namespaced? -%>
11
+ module <%= namespace_class_name %>
12
+ class <%= action_class_name %> < ApplicationAction
13
+ # def call
14
+ # Ask::Actions::Result.ok(message: "Done")
15
+ # end
16
+ end
17
+ end
18
+ <% else -%>
19
+ class <%= action_class_name %> < ApplicationAction
20
+ # def call
21
+ # Ask::Actions::Result.ok(message: "Done")
22
+ # end
23
+ end
24
+ <% end -%>
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Ask
6
+ module Generators
7
+ # Creates an action under app/actions. Actions are operations callable
8
+ # from any channel by name — dispatch them with Ask::Actions.dispatch.
9
+ #
10
+ # rails generate ask:action create_workspace
11
+ # # => app/actions/create_workspace.rb (dispatch name: "create_workspace")
12
+ #
13
+ # rails generate ask:action chats create
14
+ # # => app/actions/chats/create.rb (dispatch name: "chats.create")
15
+ class ActionGenerator < ::Rails::Generators::NamedBase
16
+ source_root File.expand_path("action/templates", __dir__)
17
+
18
+ desc "Creates an action under app/actions — a user-facing operation callable from any channel"
19
+
20
+ def create_action
21
+ template "action.rb", action_path
22
+ end
23
+
24
+ private
25
+
26
+ def action_path
27
+ if namespaced?
28
+ "app/actions/#{namespace_name}/#{action_name}.rb"
29
+ else
30
+ "app/actions/#{action_name}.rb"
31
+ end
32
+ end
33
+
34
+ def namespaced?
35
+ args.any?
36
+ end
37
+
38
+ def namespace_name
39
+ file_name
40
+ end
41
+
42
+ def action_name
43
+ namespaced? ? args.first.underscore : file_name
44
+ end
45
+
46
+ def namespace_class_name
47
+ namespace_name.camelize
48
+ end
49
+
50
+ def action_class_name
51
+ action_name.camelize
52
+ end
53
+
54
+ def dispatch_name
55
+ namespaced? ? "#{namespace_name}.#{action_name}" : action_name
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for your application's actions.
4
+ #
5
+ # Subclass this to define actions — operations callable from any channel
6
+ # (web, Slack, voice) by name:
7
+ #
8
+ # module Chats
9
+ # class Create < ApplicationAction
10
+ # def call
11
+ # chat = context.channel.start_new_chat!
12
+ # Ask::Actions::Result.ok(message: "Chat created", data: { chat: chat })
13
+ # end
14
+ # end
15
+ # end
16
+ #
17
+ # Then dispatch from anywhere:
18
+ # Ask::Actions.dispatch(action: "chats.create", context: context, params: {})
19
+ #
20
+ class ApplicationAction
21
+ def self.call(context:, params: {})
22
+ new(context: context, params: params).call
23
+ end
24
+
25
+ def initialize(context:, params: {})
26
+ @context = context
27
+ @params = params
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :context, :params
33
+ end
@@ -31,3 +31,13 @@ if defined?(Ask::Graph)
31
31
  # Ask::Graph.default_step_timeout 30
32
32
  # Ask::Graph.default_workflow_timeout 60
33
33
  end
34
+
35
+ # Actions
36
+ #
37
+ # Actions live in app/actions/ and are dispatched by name from any channel:
38
+ # Ask::Actions.dispatch(action: "chats.create", context: context, params: {})
39
+ #
40
+ # By convention, "chats.create" resolves to Chats::Create
41
+ # (app/actions/chats/create.rb). Register explicitly to override the
42
+ # convention or to list the action in Ask::Actions.available:
43
+ # Ask::Actions.register "chats.create", Chats::Create
@@ -27,6 +27,14 @@ module Ask
27
27
  template "application_agent.rb", "app/agents/application_agent.rb"
28
28
  end
29
29
 
30
+ def create_actions_directory
31
+ empty_directory "app/actions"
32
+ end
33
+
34
+ def create_application_action
35
+ template "application_action.rb", "app/actions/application_action.rb"
36
+ end
37
+
30
38
  def create_workflows_directory
31
39
  return if skip_graph?
32
40
  empty_directory "app/workflows"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -105,12 +105,19 @@ files:
105
105
  - LICENSE
106
106
  - README.md
107
107
  - lib/ask-rails.rb
108
+ - lib/ask/actions.rb
109
+ - lib/ask/actions/backend.rb
110
+ - lib/ask/actions/context.rb
111
+ - lib/ask/actions/result.rb
108
112
  - lib/ask/rails.rb
109
113
  - lib/ask/rails/railtie.rb
110
114
  - lib/ask/rails/state.rb
111
115
  - lib/ask/rails/version.rb
116
+ - lib/generators/ask/action/templates/action.rb
117
+ - lib/generators/ask/action_generator.rb
112
118
  - lib/generators/ask/agent/templates/agent.rb
113
119
  - lib/generators/ask/agent_generator.rb
120
+ - lib/generators/ask/install/templates/application_action.rb
114
121
  - lib/generators/ask/install/templates/application_agent.rb
115
122
  - lib/generators/ask/install/templates/application_workflow.rb
116
123
  - lib/generators/ask/install/templates/audit_log_migration.rb