assistant 0.0.2 → 1.0.0.rc1

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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +13 -0
  3. data/.github/PULL_REQUEST_TEMPLATE.md +39 -0
  4. data/.github/dependabot.yml +4 -0
  5. data/.github/workflows/ci.yml +140 -0
  6. data/.github/workflows/docs.yml +64 -0
  7. data/.github/workflows/release.yml +46 -0
  8. data/.gitignore +5 -1
  9. data/.markdownlint.json +6 -0
  10. data/.opencode/.gitignore +4 -0
  11. data/.opencode/opencode.json +13 -0
  12. data/.opencode/skills/create-pr/SKILL.md +138 -0
  13. data/.opencode/skills/ruby-services/SKILL.md +81 -0
  14. data/.rubocop.yml +40 -148
  15. data/.ruby-version +1 -1
  16. data/.yardopts +17 -0
  17. data/CHANGELOG.md +434 -0
  18. data/CONTRIBUTING.md +131 -0
  19. data/Gemfile +10 -0
  20. data/Gemfile.lock +264 -94
  21. data/README.md +125 -16
  22. data/Rakefile +53 -3
  23. data/SECURITY.md +50 -0
  24. data/Steepfile +49 -0
  25. data/_config.yml +87 -0
  26. data/assistant.gemspec +33 -20
  27. data/docs/api-reference.md +264 -0
  28. data/docs/changelog.md +26 -0
  29. data/docs/deprecations.md +86 -0
  30. data/docs/examples/cli-handler.md +17 -0
  31. data/docs/examples/composing-services.md +17 -0
  32. data/docs/examples/execute-callbacks.md +17 -0
  33. data/docs/examples/index.md +29 -0
  34. data/docs/examples/instrumentation-notifier.md +17 -0
  35. data/docs/examples/rails-service.md +17 -0
  36. data/docs/examples/rbs-generator.md +17 -0
  37. data/docs/examples/sidekiq-worker.md +17 -0
  38. data/docs/getting-started.md +136 -0
  39. data/docs/guides/composing-services.md +222 -0
  40. data/docs/guides/index.md +25 -0
  41. data/docs/guides/inputs.md +333 -0
  42. data/docs/guides/logging-and-results.md +202 -0
  43. data/docs/guides/rbs-and-types.md +16 -0
  44. data/docs/guides/validation.md +180 -0
  45. data/docs/index.md +69 -0
  46. data/docs/roadmap.md +33 -0
  47. data/exe/assistant-rbs +7 -0
  48. data/lib/assistant/execute_callbacks.rb +103 -0
  49. data/lib/assistant/execute_callbacks.rbs +30 -0
  50. data/lib/assistant/input_builder/accessors.rb +36 -0
  51. data/lib/assistant/input_builder/accessors.rbs +10 -0
  52. data/lib/assistant/input_builder/default_option.rb +41 -0
  53. data/lib/assistant/input_builder/default_option.rbs +11 -0
  54. data/lib/assistant/input_builder/dsl.rb +37 -0
  55. data/lib/assistant/input_builder/dsl.rbs +12 -0
  56. data/lib/assistant/input_builder/optional_option.rb +45 -0
  57. data/lib/assistant/input_builder/optional_option.rbs +10 -0
  58. data/lib/assistant/input_builder/registry.rb +27 -0
  59. data/lib/assistant/input_builder/registry.rbs +13 -0
  60. data/lib/assistant/input_builder/require_validator.rb +104 -0
  61. data/lib/assistant/input_builder/require_validator.rbs +24 -0
  62. data/lib/assistant/input_builder/type_validator.rb +47 -0
  63. data/lib/assistant/input_builder/type_validator.rbs +18 -0
  64. data/lib/assistant/input_builder.rb +28 -0
  65. data/lib/assistant/input_builder.rbs +15 -0
  66. data/lib/assistant/log_item.rb +75 -17
  67. data/lib/assistant/log_item.rbs +40 -0
  68. data/lib/assistant/log_list.rb +44 -12
  69. data/lib/assistant/log_list.rbs +48 -0
  70. data/lib/assistant/rbs_generator/cli.rb +109 -0
  71. data/lib/assistant/rbs_generator/cli.rbs +24 -0
  72. data/lib/assistant/rbs_generator/renderer.rb +67 -0
  73. data/lib/assistant/rbs_generator/renderer.rbs +11 -0
  74. data/lib/assistant/rbs_generator/writer.rb +65 -0
  75. data/lib/assistant/rbs_generator/writer.rbs +24 -0
  76. data/lib/assistant/rbs_generator.rb +38 -0
  77. data/lib/assistant/rbs_generator.rbs +5 -0
  78. data/lib/assistant/refinements/string_blankness.rb +14 -0
  79. data/lib/assistant/refinements/string_blankness.rbs +6 -0
  80. data/lib/assistant/service.rb +328 -8
  81. data/lib/assistant/service.rbs +86 -0
  82. data/lib/assistant/version.rb +5 -1
  83. data/lib/assistant/version.rbs +5 -0
  84. data/lib/assistant.rb +53 -4
  85. data/lib/assistant.rbs +25 -0
  86. data/mise.toml +6 -0
  87. data/sig/examples/greeter.rbs +14 -0
  88. metadata +128 -112
  89. data/.circleci/config.yml +0 -45
  90. data/.fasterer.yml +0 -19
  91. data/.rspec +0 -3
  92. data/.rubocop_todo.yml +0 -0
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Converts a Service subclass to a `.rbs` source string. Pure
4
+ # function; does no I/O.
5
+ module Assistant::RbsGenerator::Renderer
6
+ class << self
7
+ # Render a `.rbs` source string for the given `Service` subclass.
8
+ # Module-prefix segments of the class name are wrapped in nested
9
+ # `module ... end` blocks; the trailing segment becomes the
10
+ # `class X < Assistant::Service` declaration. The body lists one
11
+ # `def name: () -> Type` and `def name?: () -> bool` per declared
12
+ # input.
13
+ #
14
+ # @param service_class [Class<Assistant::Service>]
15
+ # @return [String] the rendered `.rbs` source, ending with a newline
16
+ # @raise [RuntimeError] when `service_class` is anonymous or declares
17
+ # an input with a non-Class / anonymous `type:`
18
+ def render(service_class)
19
+ name = service_class.name or raise 'anonymous Service class cannot be rendered'
20
+ segments = name.split('::')
21
+ # `String#split` on a non-empty string always returns at least
22
+ # one element, but Steep can't prove that -- guard for narrowing.
23
+ class_name = segments.pop or raise "unexpected empty name for #{service_class.inspect}"
24
+ body_lines = render_class_body(class_name, service_class.input_definitions)
25
+ nested_lines = nest_in_modules(segments, body_lines)
26
+ "#{[Assistant::RbsGenerator::MARKER, '', *nested_lines].join("\n")}\n"
27
+ end
28
+
29
+ private
30
+
31
+ def render_class_body(class_name, definitions)
32
+ header = "class #{class_name} < Assistant::Service"
33
+ method_lines = definitions.flat_map { |name, options| input_method_lines(name, options) }
34
+ return [header, 'end'] if method_lines.empty?
35
+
36
+ [header, '', *method_lines.map { |line| " #{line}" }, '', 'end']
37
+ end
38
+
39
+ def nest_in_modules(segments, body_lines)
40
+ segments.reverse.reduce(body_lines) do |body, segment|
41
+ indented = body.map { |line| line.empty? ? '' : " #{line}" }
42
+ ["module #{segment}", *indented, 'end']
43
+ end
44
+ end
45
+
46
+ def input_method_lines(name, options)
47
+ [
48
+ "def #{name}: () -> #{render_type(name, options)}",
49
+ "def #{name}?: () -> bool"
50
+ ]
51
+ end
52
+
53
+ def render_type(name, options)
54
+ raise "input #{name.inspect} has no `type:` declared" unless options.key?(:type)
55
+
56
+ rendered = Array(options[:type]).map { |type| render_single_type(name, type) }
57
+ union = rendered.length == 1 ? rendered.first : "(#{rendered.join(' | ')})"
58
+ options[:allow_nil] == true ? "#{union}?" : union
59
+ end
60
+
61
+ def render_single_type(name, type)
62
+ raise "input #{name.inspect} has non-Class type #{type.inspect}" unless type.is_a?(Module)
63
+
64
+ type.name || raise("input #{name.inspect} has anonymous type")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,11 @@
1
+ module Assistant::RbsGenerator::Renderer
2
+ def self.render: (singleton(Assistant::Service) service_class) -> String
3
+
4
+ private
5
+
6
+ def self.render_class_body: (String class_name, Hash[Symbol, untyped] definitions) -> Array[String]
7
+ def self.nest_in_modules: (Array[String] segments, Array[String] body_lines) -> Array[String]
8
+ def self.input_method_lines: (Symbol name, Hash[Symbol, untyped] options) -> Array[String]
9
+ def self.render_type: (Symbol name, Hash[Symbol, untyped] options) -> String
10
+ def self.render_single_type: (Symbol name, untyped type) -> String
11
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Assistant::RbsGenerator
6
+ # File-writing layer with marker-aware idempotency.
7
+ class Writer
8
+ def initialize(output_dir:, quiet: false, stdout: $stdout, stderr: $stderr)
9
+ @output_dir = output_dir
10
+ @quiet = quiet
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ end
14
+
15
+ # Returns one of :written, :unchanged, :skipped.
16
+ def write(service_class, contents)
17
+ target = path_for(service_class)
18
+ return skip!(target) if exists_without_marker?(target)
19
+ return unchanged!(target) if File.exist?(target) && File.read(target) == contents
20
+
21
+ FileUtils.mkdir_p(File.dirname(target))
22
+ File.write(target, contents)
23
+ announce("[written] #{target}")
24
+ :written
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :output_dir, :quiet, :stdout, :stderr
30
+
31
+ def exists_without_marker?(target)
32
+ File.exist?(target) && !generated_file?(target)
33
+ end
34
+
35
+ def skip!(target)
36
+ stderr.puts "[skipped] #{target} (no generator marker; will not overwrite)"
37
+ :skipped
38
+ end
39
+
40
+ def unchanged!(target)
41
+ announce("[unchanged] #{target}")
42
+ :unchanged
43
+ end
44
+
45
+ def path_for(service_class)
46
+ name = service_class.name or raise "anonymous Service class #{service_class.inspect}"
47
+ relative = name.split('::').map { |seg| underscore(seg) }.join('/')
48
+ File.join(output_dir, "#{relative}.rbs")
49
+ end
50
+
51
+ # ASCII-only underscoring -- the gem ships no runtime deps, so this
52
+ # mirrors the common ActiveSupport rule without pulling it in.
53
+ def underscore(camel)
54
+ camel.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
55
+ end
56
+
57
+ def generated_file?(path)
58
+ File.foreach(path).first&.chomp == Assistant::RbsGenerator::MARKER
59
+ end
60
+
61
+ def announce(message)
62
+ stdout.puts(message) unless quiet
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,24 @@
1
+ class Assistant::RbsGenerator::Writer
2
+ @output_dir: String
3
+ @quiet: bool
4
+ @stdout: IO
5
+ @stderr: IO
6
+
7
+ def initialize: (output_dir: String, ?quiet: bool, ?stdout: IO, ?stderr: IO) -> void
8
+ def write: (singleton(Assistant::Service) service_class, String contents) -> Symbol
9
+
10
+ private
11
+
12
+ attr_reader output_dir: String
13
+ attr_reader quiet: bool
14
+ attr_reader stdout: IO
15
+ attr_reader stderr: IO
16
+
17
+ def path_for: (singleton(Assistant::Service) service_class) -> String
18
+ def exists_without_marker?: (String target) -> bool
19
+ def skip!: (String target) -> Symbol
20
+ def unchanged!: (String target) -> Symbol
21
+ def underscore: (String camel) -> String
22
+ def generated_file?: (String path) -> bool
23
+ def announce: (String message) -> void
24
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-class RBS generator for `Assistant::Service` subclasses. The
4
+ # generic RBS shipped in `lib/assistant/service.rbs` cannot describe the
5
+ # per-input getter / predicate methods produced by the
6
+ # `Assistant::InputBuilder` DSL because their names and return types are
7
+ # only known at class-definition time. Users with Steep run this
8
+ # generator over their service files to obtain accurate signatures for
9
+ # their own subclasses.
10
+ #
11
+ # The generator is intentionally **not** loaded by `require 'assistant'`
12
+ # -- it is a developer-time tool. The shipped `exe/assistant-rbs`
13
+ # binary requires it explicitly.
14
+ #
15
+ # Marked Experimental in `docs/v1/01-api-surface.md` so the output
16
+ # format may evolve within 1.x.
17
+ #
18
+ # See `docs/v1/02-features.md` (M11) for the contract.
19
+ module Assistant::RbsGenerator
20
+ # Header marker written as the first line of every generated `.rbs`
21
+ # file. {Writer} refuses to overwrite a file whose first line is
22
+ # not this exact string, so hand-edited signatures are preserved.
23
+ # @return [String]
24
+ MARKER = '# Generated by assistant-rbs; do not edit.'
25
+
26
+ # Default value for the `--output DIR` option of `exe/assistant-rbs`.
27
+ # @return [String]
28
+ DEFAULT_OUTPUT_DIR = 'sig'
29
+
30
+ # Default value for the positional `PATH` arguments of
31
+ # `exe/assistant-rbs` when none are supplied.
32
+ # @return [Array<String>]
33
+ DEFAULT_INPUT_PATHS = ['lib'].freeze
34
+ end
35
+
36
+ require 'assistant/rbs_generator/cli'
37
+ require 'assistant/rbs_generator/renderer'
38
+ require 'assistant/rbs_generator/writer'
@@ -0,0 +1,5 @@
1
+ module Assistant::RbsGenerator
2
+ MARKER: String
3
+ DEFAULT_OUTPUT_DIR: String
4
+ DEFAULT_INPUT_PATHS: Array[String]
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refines String with `#whitespace?`, true when a string is empty or
4
+ # contains only whitespace characters. Used by `InputBuilder` validators
5
+ # to treat whitespace-only strings as missing input without depending on
6
+ # ActiveSupport's `String#blank?`.
7
+ module Assistant::Refinements::StringBlankness
8
+ refine String do
9
+ # True when the string is empty or contains only whitespace.
10
+ def whitespace?
11
+ strip.empty?
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,6 @@
1
+ # Type signatures for `lib/assistant/refinements/string_blankness.rb`.
2
+ # RBS cannot fully model refinements; the bundled refinement module is
3
+ # declared as an empty module so downstream signatures can name it.
4
+
5
+ module Assistant::Refinements::StringBlankness
6
+ end
@@ -1,31 +1,351 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'execute_callbacks'
4
+ require_relative 'input_builder'
5
+ require_relative 'log_list'
6
+
3
7
  module Assistant
4
- # Base class for the Assistant gem
5
- class Service
6
- include ::Assistant::LogList
8
+ # Core Service base class. Subclasses declare inputs via the
9
+ # InputBuilder DSL, optionally register before/after/around execute
10
+ # hooks via ExecuteCallbacks, and implement `#execute`.
11
+ #
12
+ # @example Minimal service
13
+ # class Greet < Assistant::Service
14
+ # input :name, type: String, required: true
15
+ #
16
+ # def execute
17
+ # "Hello, #{name}!"
18
+ # end
19
+ # end
20
+ #
21
+ # Greet.run(name: 'Ada')
22
+ # # => { result: "Hello, Ada!", status: :ok, warnings: [] }
23
+ class Service # rubocop:disable Metrics/ClassLength
24
+ include Assistant::LogList
25
+
26
+ # Public reader for the full log timeline (info + warning + error), in
27
+ # insertion order. See docs/v1/02-features.md M4.
28
+ #
29
+ # @return [Array<Assistant::LogItem>]
30
+ attr_reader :logs
7
31
 
8
32
  class << self
9
- def run(*args)
10
- new(*args).run
33
+ include Assistant::InputBuilder
34
+ include Assistant::ExecuteCallbacks
35
+
36
+ # Convenience: build a service with the given keyword arguments
37
+ # and immediately invoke `#run`, returning the result hash.
38
+ # Equivalent to `new(**inputs).run`.
39
+ #
40
+ # @return [Hash] the result payload — see {Service#run}
41
+ def run(**)
42
+ new(**).run
43
+ end
44
+
45
+ # M-S4: per-subclass `Data` class whose members are the declared
46
+ # input names, in declaration order. Memoised on the subclass and
47
+ # transparently rebuilt if `input_definitions` changes (e.g. a
48
+ # late `input :foo` after the first snapshot call). Used by
49
+ # `Service#input_snapshot`; users normally never touch it.
50
+ #
51
+ # @return [Class] a `Data.define` subclass
52
+ def input_snapshot_class
53
+ keys = input_definitions.keys
54
+ return @input_snapshot_class if @input_snapshot_class && @input_snapshot_class_keys == keys
55
+
56
+ @input_snapshot_class_keys = keys
57
+ @input_snapshot_class = Data.define(*keys)
11
58
  end
12
59
  end
13
60
 
14
- def initialize(*args)
61
+ # @param args [Hash] keyword arguments matching the declared inputs.
62
+ # Unknown keys are accepted but excluded from {#input_snapshot}.
63
+ def initialize(**args)
15
64
  @inputs = args
65
+ apply_input_defaults
16
66
  @logs = []
17
67
  end
18
68
 
69
+ # Execute the validation + execute pipeline once and return the
70
+ # result payload. Idempotent: calling `#run` a second time returns
71
+ # an updated payload based on the memoised `#result`, but `#execute`
72
+ # itself runs only once (M-S1 hook chain is gated by {#result}'s
73
+ # `||=`).
74
+ #
75
+ # @return [Hash{Symbol => Object}] either
76
+ # - `{ result: Object, status: :ok | :with_warnings, warnings: Array<LogItem> }`
77
+ # on success, or
78
+ # - `{ errors: Array<LogItem>, result: nil, status: :with_errors }`
79
+ # when any error has been logged before or during validation.
19
80
  def run
81
+ @run_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ notify(:service_started)
83
+
84
+ validate_inputs
20
85
  validate
21
- { result: execute, status: status, warnings: warnings } if errors.empty?
86
+ notify(:service_validated)
87
+
88
+ return failed_payload if errors.any?
89
+
90
+ # Trigger `#execute` (through the M-S1 hook chain) eagerly so any
91
+ # error logged by a `before_/after_/around_execute` hook influences
92
+ # the terminal event and the payload's `:status` field.
93
+ result
94
+ errors.empty? ? executed_payload : failed_payload
95
+ end
96
+
97
+ # Memoised return value of {#execute}, threaded through the
98
+ # registered before/around/after execute hooks (M-S1).
99
+ #
100
+ # @return [Object] whatever the subclass's `#execute` returned
101
+ def result
102
+ @result ||= run_execute_with_callbacks
103
+ end
104
+
105
+ # @return [Boolean] true when no `:error` entries have been logged
106
+ def success?
107
+ errors.empty?
108
+ end
109
+
110
+ # @return [Boolean] true when at least one `:error` entry has been logged
111
+ def failure?
112
+ errors.any?
22
113
  end
23
114
 
115
+ # Terminal status for the success path. Failure runs use
116
+ # `:with_errors` directly in the result payload (see {#run}).
117
+ #
118
+ # @return [Symbol] `:ok` when there are no warnings, otherwise `:with_warnings`
24
119
  def status
25
120
  warnings.empty? ? :ok : :with_warnings
26
121
  end
27
122
 
28
- protected
123
+ # M-S2: instantiate `klass`, run it, merge its log timeline into the
124
+ # current service, and return the inner service instance.
125
+ #
126
+ # The full log timeline of the inner service (info + warning +
127
+ # error) is concatenated onto the outer service's `@logs` via
128
+ # `merge_logs`. Because the outer service's `errors`, `warnings`,
129
+ # and `status` are derived by filtering `@logs`, an inner error
130
+ # automatically downgrades the outer terminal status to
131
+ # `:with_errors`, and inner warnings surface as `:with_warnings`
132
+ # when no errors are present — without any special handling in
133
+ # the caller.
134
+ #
135
+ # The returned inner instance exposes `#result`, `#success?`,
136
+ # `#failure?`, etc. so the caller can branch on the inner outcome:
137
+ #
138
+ # @example
139
+ # def execute
140
+ # other = call_service(OtherService, foo: 1)
141
+ # return if failure?
142
+ # other.result + 1
143
+ # end
144
+ #
145
+ # `call_service` does **not** rescue exceptions raised by the inner
146
+ # service's `#execute` or by `Assistant.notifier`; those propagate
147
+ # to the caller, matching the base `Service#run` contract. To run
148
+ # an inner service that may raise, wrap the call in a `begin/rescue`
149
+ # and use `add_log(level: :error, …)` to record the failure.
150
+ #
151
+ # @param klass [Class<Assistant::Service>] the inner service class
152
+ # @param inputs [Hash] keyword arguments forwarded to `klass.new`
153
+ # @return [Assistant::Service] the inner service instance, already run
154
+ # @raise [ArgumentError] if `klass` is not a subclass of {Assistant::Service}
155
+ def call_service(klass, **inputs)
156
+ unless klass.is_a?(Class) && klass <= Assistant::Service
157
+ raise ArgumentError, "call_service expects an Assistant::Service subclass, got #{klass.inspect}"
158
+ end
159
+
160
+ inner = klass.new(**inputs)
161
+ inner.run
162
+ merge_logs(logs: inner.logs)
163
+ inner
164
+ end
165
+
166
+ # M-S4: a read-only `Data` view over the declared inputs of this
167
+ # service, post-`default:` / post-`allow_nil:`. Members are the
168
+ # input names declared via `Service.input` / `Service.inputs`, in
169
+ # declaration order; values are read from `@inputs` after
170
+ # `apply_input_defaults` has run, so callers see the same values
171
+ # the per-input getters expose.
172
+ #
173
+ # @example
174
+ # class Greet < Assistant::Service
175
+ # input :name, type: String, required: true
176
+ # input :loud, type: TrueClass, default: false
177
+ # end
178
+ #
179
+ # Greet.new(name: 'Ada').input_snapshot
180
+ # # => #<data name="Ada", loud=false>
181
+ #
182
+ # The returned object is a `Data` instance, so it is structurally
183
+ # immutable: no member can be reassigned. Member values that are
184
+ # themselves mutable (e.g. an `Array` passed as an input) keep
185
+ # their normal mutability — the snapshot does not deep-freeze.
186
+ #
187
+ # Only declared inputs appear in the snapshot. Extra keyword
188
+ # arguments accepted by `#initialize` (which live in `@inputs`
189
+ # but have no `input :foo` declaration) are intentionally excluded
190
+ # so the snapshot's shape matches the public DSL.
191
+ #
192
+ # A declared input with no default and no caller-supplied value
193
+ # appears with a `nil` member value, mirroring the behaviour of
194
+ # the per-input getter.
195
+ #
196
+ # @return [Data] read-only view over `input_definitions.keys`
197
+ def input_snapshot
198
+ keys = self.class.input_definitions.keys
199
+ self.class.input_snapshot_class.new(**keys.to_h { |name| [name, @inputs[name]] })
200
+ end
201
+
202
+ private
203
+
204
+ # Build the success-path result hash and fire the terminal
205
+ # `:service_executed` event. Triggers `#execute` lazily via `#result`.
206
+ def executed_payload
207
+ payload = { result:, status:, warnings: }
208
+ notify(:service_executed)
209
+ payload
210
+ end
211
+
212
+ # Build the failure-path result hash and fire the terminal
213
+ # `:service_failed` event. Does not invoke `#execute`.
214
+ def failed_payload
215
+ payload = { errors:, result: nil, status: :with_errors }
216
+ notify(:service_failed)
217
+ payload
218
+ end
219
+
220
+ # M1: apply input defaults declared via `input :name, default: ...`.
221
+ # A default fires when the key is absent, or when the value is an
222
+ # explicit `nil` and the input is not `allow_nil: true` (M2). Procs
223
+ # are invoked with no arguments (zero-arity enforced at
224
+ # class-definition time); literals are used as-is. Defaulted values
225
+ # are subject to the same type / required / if validation as
226
+ # caller-supplied values.
227
+ def apply_input_defaults
228
+ input_definitions_needing_default.each do |attr_name, options|
229
+ provider = options[:default]
230
+ @inputs[attr_name] = provider.is_a?(Proc) ? provider.call : provider
231
+ end
232
+ end
233
+
234
+ # Input definitions that declare a `:default` and whose key was not
235
+ # already supplied by the caller (with `allow_nil:` honoured).
236
+ def input_definitions_needing_default
237
+ self.class.input_definitions.select do |attr_name, options|
238
+ options.key?(:default) && !input_supplied?(attr_name, options)
239
+ end
240
+ end
241
+
242
+ # An explicit nil counts as "not supplied" so the default fires,
243
+ # unless the input opted into `allow_nil: true` — in which case the
244
+ # caller's nil is honoured and the default is skipped.
245
+ def input_supplied?(attr_name, options)
246
+ @inputs.key?(attr_name) && (options[:allow_nil] == true || !@inputs[attr_name].nil?)
247
+ end
248
+
249
+ def validate_inputs
250
+ # M9: regex matches only the canonical `valid_required_*?` /
251
+ # `valid_required_conditional_*?` / `valid_type_*?` predicates so
252
+ # the deprecated `valid_require_*?` aliases are not auto-invoked
253
+ # (which would emit the M9 deprecation warning from inside the
254
+ # framework itself).
255
+ methods.grep(/valid_(required|type)_\w+\?$/).each do |validation_method|
256
+ send(validation_method)
257
+ end
258
+ end
259
+
260
+ # M-S3: dispatch a frozen-set instrumentation event to the configured
261
+ # `Assistant.notifier`. Payload always includes `:service_class` and
262
+ # `:duration_s` (Float seconds since the start of `#run`). The notifier
263
+ # is treated as untrusted infra: any `StandardError` it raises is
264
+ # caught and surfaced via `Kernel.warn` so a misconfigured notifier
265
+ # cannot tear down every service in the process. Non-`StandardError`
266
+ # exceptions (e.g. `SystemExit`, `Interrupt`) are intentionally
267
+ # allowed to propagate.
268
+ def notify(event)
269
+ duration_s = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started_at
270
+ Assistant.notifier.call(event, { service_class: self.class, duration_s: duration_s })
271
+ rescue StandardError => e
272
+ Kernel.warn "assistant: notifier raised during #{event} for #{self.class}: #{e.class}: #{e.message}"
273
+ end
274
+
275
+ # M-S1: thread `#execute` through the registered before/around/after
276
+ # hook chains. Called once via `#result`'s memoization, so each
277
+ # service instance runs hooks at most once even if `#result` is
278
+ # invoked repeatedly.
279
+ def run_execute_with_callbacks
280
+ run_before_execute_hooks
281
+ result = run_around_execute_chain
282
+ run_after_execute_hooks(result)
283
+ result
284
+ end
285
+
286
+ # M-S1: invoke every registered `before_execute` hook in declaration
287
+ # order. Each hook is independent — a `StandardError` from one is
288
+ # logged via `add_log(level: :error, source: :hook, …)` and the
289
+ # remaining hooks still fire.
290
+ def run_before_execute_hooks
291
+ self.class.before_execute_hooks.each do |hook|
292
+ hook.bind_call(self)
293
+ rescue StandardError => e
294
+ log_hook_error(:before_execute, e)
295
+ end
296
+ end
297
+
298
+ # M-S1: invoke every registered `after_execute` hook in declaration
299
+ # order, passing the execute result as the single positional arg.
300
+ def run_after_execute_hooks(execute_result)
301
+ self.class.after_execute_hooks.each do |hook|
302
+ hook.bind_call(self, execute_result)
303
+ rescue StandardError => e
304
+ log_hook_error(:after_execute, e)
305
+ end
306
+ end
307
+
308
+ # M-S1: build the around-hook chain from the innermost layer (the
309
+ # raw `#execute` call) outwards. Declaration order wraps: the
310
+ # first-declared hook is the outermost layer. If an around hook
311
+ # raises before yielding to its continuation, that layer (and any
312
+ # inner layers, including `#execute` itself) does not run; the
313
+ # layer returns `nil` and outer hooks still wrap normally.
314
+ def run_around_execute_chain
315
+ chain = -> { execute }
316
+ self.class.around_execute_hooks.reverse_each do |hook|
317
+ chain = wrap_around_layer(hook, chain)
318
+ end
319
+ chain.call
320
+ end
321
+
322
+ # Build a single around layer: a lambda that runs `hook` with
323
+ # `inner` as its continuation. Hook exceptions raised before the
324
+ # continuation runs are caught, logged, and the layer returns nil.
325
+ def wrap_around_layer(hook, inner)
326
+ lambda do
327
+ hook.bind_call(self, &inner)
328
+ rescue StandardError => e
329
+ log_hook_error(:around_execute, e)
330
+ nil
331
+ end
332
+ end
333
+
334
+ # M-S1: shared hook-error logger. Stamps `source: :hook` and uses
335
+ # the hook type as `detail:` so callers can grep their log timeline
336
+ # by either field. The exception's backtrace is preserved on the
337
+ # `LogItem#trace` field for downstream diagnostics.
338
+ def log_hook_error(hook_type, exception)
339
+ add_log(
340
+ level: :error,
341
+ source: :hook,
342
+ detail: hook_type,
343
+ message: "#{exception.class}: #{exception.message}",
344
+ trace: exception.backtrace
345
+ )
346
+ end
347
+
348
+ attr_reader :inputs
29
349
 
30
350
  def execute; end
31
351
 
@@ -0,0 +1,86 @@
1
+ # Type signatures for `lib/assistant/service.rb`. See
2
+ # docs/v1/01-api-surface.md for the frozen 1.0 surface.
3
+ #
4
+ # NOTE: per-input methods generated by `Service.input :name, type: T`
5
+ # (`#name`, `#name?`, `#valid_type_name?`, `#valid_required_name?`,
6
+ # `#valid_required_conditional_name?`, and the deprecated
7
+ # `#valid_require_name?` / `#valid_require_conditional_name?` aliases)
8
+ # are *not* declared here because the names are only known at runtime.
9
+ # Users can run `bin/assistant-rbs` (M11, ships with 1.0) to generate
10
+ # per-class signatures for their own `Assistant::Service` subclasses.
11
+
12
+ class Assistant::Service
13
+ include Assistant::LogList
14
+
15
+ extend Assistant::InputBuilder
16
+ extend Assistant::ExecuteCallbacks
17
+
18
+ @inputs: Hash[Symbol, untyped]
19
+ @logs: Array[Assistant::LogItem]
20
+ @result: untyped
21
+ @run_started_at: Float
22
+
23
+ attr_reader logs: Array[Assistant::LogItem]
24
+
25
+ def self.run: (**untyped) -> Hash[Symbol, untyped]
26
+
27
+ # M-S4: per-subclass Data class whose members are the declared input
28
+ # names. Memoised; rebuilt when `input_definitions` changes.
29
+ def self.input_snapshot_class: () -> Class
30
+
31
+ def initialize: (**untyped) -> void
32
+
33
+ def run: () -> Hash[Symbol, untyped]
34
+
35
+ def result: () -> untyped
36
+
37
+ def success?: () -> bool
38
+
39
+ def failure?: () -> bool
40
+
41
+ def status: () -> Symbol
42
+
43
+ # M-S2: run a child `Assistant::Service` subclass, merge its log
44
+ # timeline into `self`, return the inner instance. Raises
45
+ # ArgumentError if `klass` is not an `Assistant::Service` subclass.
46
+ def call_service: (Class klass, **untyped inputs) -> Assistant::Service
47
+
48
+ # M-S4: read-only Data view over declared inputs, post-default and
49
+ # post-allow_nil. Only declared inputs are members; undeclared
50
+ # kwargs from `#initialize` are excluded.
51
+ def input_snapshot: () -> Data
52
+
53
+ private
54
+
55
+ attr_reader inputs: Hash[Symbol, untyped]
56
+
57
+ def executed_payload: () -> Hash[Symbol, untyped]
58
+
59
+ def failed_payload: () -> Hash[Symbol, untyped]
60
+
61
+ def apply_input_defaults: () -> void
62
+
63
+ def input_definitions_needing_default: () -> Hash[Symbol, Hash[Symbol, untyped]]
64
+
65
+ def input_supplied?: (Symbol attr_name, Hash[Symbol, untyped] options) -> bool
66
+
67
+ def validate_inputs: () -> Array[Symbol]
68
+
69
+ def notify: (Symbol event) -> void
70
+
71
+ def run_execute_with_callbacks: () -> untyped
72
+
73
+ def run_before_execute_hooks: () -> Array[UnboundMethod]
74
+
75
+ def run_after_execute_hooks: (untyped execute_result) -> Array[UnboundMethod]
76
+
77
+ def run_around_execute_chain: () -> untyped
78
+
79
+ def wrap_around_layer: (UnboundMethod hook, ^() -> untyped inner) -> ^() -> untyped
80
+
81
+ def log_hook_error: (Symbol hook_type, Exception exception) -> void
82
+
83
+ def execute: () -> untyped
84
+
85
+ def validate: () -> void
86
+ end