├── lib ├── hati_operation │ ├── version.rb │ ├── step_configs_container.rb │ └── base.rb ├── hati │ └── operation.rb └── hati_operation.rb ├── Gemfile ├── spec ├── spec_helper.rb ├── support │ └── dummy.rb ├── unit │ └── hati_operation │ │ ├── step_config_container_spec.rb │ │ ├── base_cmd_configs_spec.rb │ │ └── base_spec.rb └── hati_operaton │ └── integration │ └── base_spec.rb ├── LICENSE ├── .gitignore ├── hati-operation.gemspec └── README.md /lib/hati_operation/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiOperation 4 | VERSION = '0.1.2' 5 | end 6 | -------------------------------------------------------------------------------- /lib/hati/operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Compatibility shim for Bundler auto-requirexwxw 4 | require_relative '../hati_operation' 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'hati-command' 8 | gem 'rake' 9 | 10 | # Debug 11 | gem 'pry' 12 | 13 | # Spec 14 | gem 'rspec', '~> 3.0' 15 | 16 | # Linter & Static 17 | gem 'fasterer', '~> 0.11.0' 18 | gem 'rubocop', '~> 1.21' 19 | gem 'rubocop-rake' 20 | gem 'rubocop-rspec', require: false 21 | -------------------------------------------------------------------------------- /lib/hati_operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hati_operation/version' 4 | require 'hati_operation/step_configs_container' 5 | require 'hati_operation/base' 6 | # errors 7 | # require 'hati_operation/errors/base_error' 8 | # require 'hati_operation/errors/configuration_error' 9 | # require 'hati_operation/errors/fail_fast_error' 10 | # require 'hati_operation/errors/transaction_error' 11 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'hati_operation' 5 | 6 | RSpec.configure do |config| 7 | config.example_status_persistence_file_path = '.rspec_status' 8 | config.disable_monkey_patching! 9 | config.expect_with :rspec do |c| 10 | c.syntax = :expect 11 | end 12 | 13 | Dir[File.join('./spec/support/**/*.rb')].each { |f| require f } 14 | config.include Dummy 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/dummy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # NOTE: helper names follow convention 'support__' 4 | 5 | module Dummy 6 | def support_dummy_service_base 7 | stub_const('DummyServiceBase', Class.new do 8 | include HatiCommand::Cmd 9 | 10 | def call(params, halt: false) 11 | params.to_s 12 | halt ? Failure() : Success() 13 | end 14 | end) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hati_operation/step_configs_container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiOperation 4 | class StepConfigContainer 5 | def configurations 6 | @configurations ||= {} 7 | end 8 | 9 | def step(**kwargs) 10 | step_name, step_klass = kwargs.first 11 | 12 | configurations[step_name] = step_klass 13 | end 14 | 15 | # WIP: so far as API adapter 16 | def params(command = nil, err: nil) 17 | configurations[:params] = command 18 | configurations[:params_err] = err 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/unit/hati_operation/step_config_container_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiOperation::StepConfigContainer do 6 | subject(:configuration) { described_class } 7 | 8 | let(:container) { configuration.new } 9 | let(:configs) { container.configurations } 10 | 11 | describe '#operation_config' do 12 | it 'has configuration map' do 13 | expect(configs).to eq({}) 14 | end 15 | end 16 | 17 | describe '#step' do 18 | it 'use step for configs setup' do 19 | container.step a: 1 20 | 21 | expect(configs[:a]).to eq(1) 22 | end 23 | end 24 | 25 | describe '#params' do 26 | it 'use params for configs setup' do 27 | container.params 'a', err: 'b' 28 | 29 | aggregate_failures 'of params config' do 30 | expect(configs[:params]).to eq('a') 31 | expect(configs[:params_err]).to eq('b') 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hackico.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* 57 | 58 | # spec 59 | .rspec_status 60 | 61 | # libs 62 | Gemfile.lock 63 | 64 | # macOS 65 | .DS_Store 66 | -------------------------------------------------------------------------------- /spec/unit/hati_operation/base_cmd_configs_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiOperation::Base do 6 | subject(:base_klass) { described_class } 7 | let(:operation_name) { 'MyDummyOperation' } 8 | 9 | before do 10 | dummy_operation = stub_const(operation_name, base_klass) 11 | 12 | dummy_operation.operation do 13 | fail_fast 'Fail Fast Message' 14 | failure 'Failure Message' 15 | unexpected_err 'Unexpected Error' 16 | end 17 | 18 | dummy_operation.params 'a', err: 'b' 19 | dummy_operation.step a: 'a' 20 | dummy_operation.on_success 'Success Message' 21 | dummy_operation.on_failure 'Failure Message' 22 | end 23 | 24 | describe '.command_config' do 25 | let(:configs) { MyDummyOperation.command_config } 26 | 27 | it 'returns the configurations' do 28 | aggregate_failures 'of command options' do 29 | expect(configs[:fail_fast]).to eq('Fail Fast Message') 30 | expect(configs[:failure]).to eq('Failure Message') 31 | expect(configs[:unexpected_err]).to eq('Unexpected Error') 32 | end 33 | end 34 | end 35 | 36 | describe '.operation_config' do 37 | let(:configs) { MyDummyOperation.operation_config } 38 | 39 | it 'returns the configurations' do 40 | expect(configs[:params]).to eq('a') 41 | expect(configs[:params_err]).to eq('b') 42 | expect(configs[:a]).to eq('a') # step config 43 | expect(configs[:on_success]).to eq('Success Message') 44 | expect(configs[:on_failure]).to eq('Failure Message') 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /hati-operation.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require 'hati_operation/version' 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = 'hati-operation' 10 | spec.version = HatiOperation::VERSION 11 | spec.authors = ['Mariya Giy'] 12 | spec.email = %w[giy.mariya@gmail.com] 13 | spec.license = 'MIT' 14 | 15 | spec.summary = 'Ruby gem designed to seamlessly merge classic services with AI intelligence, enabling developers to build autonomous, agentic-ready operations that think, act, and integrate with ease.' 16 | spec.description = 'Modern service orchestration framework designed for the AI era. Enables rapid development of both traditional and AI-powered applications through composable, testable operations. Features agent-oriented architecture, AI-friendly patterns, and robust service composition.' 17 | spec.homepage = "https://github.com/hackico-ai/#{spec.name}" 18 | 19 | spec.required_ruby_version = '>= 3.0.0' 20 | 21 | spec.files = Dir['CHANGELOG.md', 'LICENSE', 'README.md', 'hati-operation.gemspec', 'lib/**/*'] 22 | spec.bindir = 'bin' 23 | spec.executables = [] 24 | spec.require_paths = ['lib'] 25 | 26 | spec.metadata['repo_homepage'] = spec.homepage 27 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 28 | 29 | spec.metadata['homepage_uri'] = spec.homepage 30 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" 31 | spec.metadata['source_code_uri'] = spec.homepage 32 | spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues" 33 | 34 | spec.metadata['rubygems_mfa_required'] = 'true' 35 | 36 | spec.add_dependency 'hati-command', '~> 0.1' 37 | end 38 | -------------------------------------------------------------------------------- /spec/unit/hati_operation/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiOperation::Base do 6 | subject(:base_klass) { described_class } 7 | # let(:operation_name) { 'MyDummyOperation' } 8 | 9 | # before do 10 | # stub_const(operation_name, base_klass) 11 | # end 12 | 13 | let(:dummy_operation) { Class.new(base_klass) } 14 | 15 | context 'when use configurations' do 16 | describe '.step' do 17 | it 'has configuration map' do 18 | expect(dummy_operation.operation_config).to eq({}) 19 | end 20 | end 21 | 22 | describe '.operation_config' do 23 | it 'has configuration map' do 24 | expect(dummy_operation.operation_config).to eq({}) 25 | end 26 | end 27 | end 28 | 29 | # NOTE: only for development and test 30 | context 'when private instance api' do 31 | let(:operation) { dummy_operation.send(:new) } 32 | let(:valid_result) { HatiCommand::Success.new('Valid Result') } 33 | 34 | describe '#step' do 35 | let(:invalid_result) { 'InvalidResult' } 36 | 37 | it 'unpacks value when given a valid result type' do 38 | expect(operation.step(valid_result)).to eq('Valid Result') 39 | end 40 | 41 | context 'when block given' do 42 | it 'evaluetes block ' do 43 | expect(operation.step { 1 }).to eq(1) 44 | end 45 | 46 | it 'wraps an error' do 47 | expect { operation.step { raise 'Booom' } }.to raise_error(HatiCommand::Errors::FailFastError) 48 | end 49 | end 50 | end 51 | 52 | describe '#step_configs' do 53 | it 'returns an empty hash when no configurations are set' do 54 | expect(operation.step_configs).to eq({}) 55 | end 56 | 57 | it 'stores configurations correctly' do 58 | operation.step_configs[:a] = 1 59 | 60 | expect(operation.step_configs[:a]).to eq(1) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/hati_operaton/integration/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | # TODO: redesign const & anonymous 6 | RSpec.describe HatiOperation::Base do 7 | subject(:base_klass) { described_class } 8 | let(:di_msg) { 'Great Success from DiService' } 9 | 10 | let(:base_service) { support_dummy_service_base } 11 | 12 | let(:base_operation) do 13 | Class.new(base_klass) do 14 | operation { unexpected_err true } 15 | end 16 | end 17 | 18 | let(:my_dummy_operation) do 19 | user_account = stub_const('AccountService', Class.new(base_service)) 20 | broadcast = stub_const('BroadcastService', Class.new(base_service)) 21 | withdrawal = stub_const('WithdrawalService', Class.new(base_service)) 22 | 23 | Class.new(base_operation) do 24 | step user_account: user_account 25 | step broadcast: broadcast 26 | step withdrawal: withdrawal 27 | 28 | def call(params, halt: false) 29 | account = step user_account.call(params[:account_id]) 30 | transfer = step withdaral(account, halt) 31 | broadcast.call(transfer) 32 | 33 | account 34 | end 35 | 36 | def withdaral(account, halt) 37 | withdrawal.call(account, halt: halt) 38 | end 39 | end 40 | end 41 | 42 | let(:di_service) do 43 | Class.new(base_service) do 44 | def call(account) 45 | account.to_s 46 | Success('Great Success from DiService') 47 | end 48 | end 49 | end 50 | 51 | context 'when aggregates services' do 52 | let(:params) { {} } 53 | 54 | it 'runs successfully' do 55 | result = my_dummy_operation.call(params) 56 | 57 | expect(result.success?).to be true 58 | end 59 | 60 | it 'runs faulty' do 61 | result = my_dummy_operation.call 62 | 63 | expect(result.failure?).to be true 64 | end 65 | 66 | it 'runs halty' do 67 | result = my_dummy_operation.call(halt: true) 68 | 69 | expect(result.failure?).to be true 70 | end 71 | 72 | it 'uses DI' do 73 | service = di_service 74 | 75 | result = my_dummy_operation.call(params) do 76 | step user_account: service 77 | end 78 | 79 | aggregate_failures do 80 | expect(result.success?).to be true 81 | expect(result.value).to eq(di_msg) 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/hati_operation/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hati_command' 4 | 5 | # Dev version Feautures 6 | # - implicit result return 7 | # - object lvl step for <#value> unpacking 8 | # - forced logical transactional behavior - always fail_fast! on 9 | # failure step unpacking 10 | # - class lvl macro for DI: 11 | # * step validate: Validator 12 | # * operation for customization (alias to command) 13 | # - always fail fast on step unpacking 14 | 15 | module HatiOperation 16 | class Base 17 | include HatiCommand::Cmd 18 | 19 | class << self 20 | alias operation command 21 | 22 | def operation_config 23 | @operation_config ||= {} 24 | end 25 | 26 | def params(command, err: nil) 27 | operation_config[:params] = command 28 | operation_config[:params_err] = err 29 | end 30 | 31 | def on_success(command) 32 | operation_config[:on_success] = command 33 | end 34 | 35 | def on_failure(command) 36 | operation_config[:on_failure] = command 37 | end 38 | 39 | # TODO: validate type 40 | def step(**kwargs) 41 | # TODO: add specific error 42 | raise 'Invalid Step type. Expected HatiCommand::Cmd' unless included_modules.include?(HatiCommand::Cmd) 43 | 44 | name, command = kwargs.first 45 | 46 | if kwargs[:err] 47 | error_name = "#{name}_error".to_sym 48 | operation_config[error_name] = kwargs[:err] 49 | end 50 | 51 | # WIP: restructure 52 | operation_config[name] = command 53 | 54 | define_method(name) do 55 | configs = self.class.operation_config 56 | 57 | step_exec_stack.append({ step: name, err: configs[error_name], done: false }) 58 | 59 | step_configs[name] || configs[name] 60 | end 61 | end 62 | 63 | def call(*args, **kwargs, &block) 64 | reciever = nil 65 | injected_params = nil 66 | 67 | if block_given? 68 | reciever = new 69 | container = StepConfigContainer.new 70 | 71 | container.instance_eval(&block) 72 | # WIP: work on defaults for DSL 73 | reciever.step_configs.merge!(container.configurations) 74 | injected_params = reciever.step_configs[:params] 75 | end 76 | 77 | params_modifier = injected_params || operation_config[:params] 78 | # TODO: naming 79 | if params_modifier 80 | unless kwargs[:params] 81 | raise 'If operation config :params is set, caller method must have :params keyword argument' 82 | end 83 | 84 | params_rez = params_modifier.call(kwargs[:params]) 85 | reciever_configs = reciever&.step_configs || {} 86 | params_err = reciever_configs[:params_err] || operation_config[:params_err] 87 | 88 | if params_rez.failure? 89 | # WIP: override or nest ??? 90 | params_rez.err = params_err if params_err 91 | 92 | return params_rez 93 | end 94 | 95 | kwargs[:params] = params_rez.value 96 | end 97 | 98 | result = super(*args, __command_reciever: reciever, **kwargs) 99 | # Wrap for implicit 100 | rez = result.respond_to?(:success?) ? result : HatiCommand::Success.new(result) 101 | 102 | # TODO: extract 103 | success_wrap = operation_config[:on_success] 104 | failure_wrap = operation_config[:on_failure] 105 | 106 | return success_wrap&.call(rez) if success_wrap && rez.success? 107 | return failure_wrap&.call(rez) if failure_wrap && rez.failure? 108 | 109 | rez 110 | end 111 | end 112 | 113 | def step_configs 114 | @step_configs ||= {} 115 | end 116 | 117 | # keep track of step macro calls 118 | def step_exec_stack 119 | @step_exec_stack ||= [] 120 | end 121 | 122 | # unpack result 123 | # wraps implicitly 124 | def step(result = nil, err: nil, &block) 125 | return __step_block_call!(err: err, &block) if block_given? 126 | 127 | last_step = step_exec_stack.last 128 | err ||= last_step[:err] if last_step 129 | 130 | if result.is_a?(HatiCommand::Result) 131 | Failure!(result, err: err || result.error) if result.failure? 132 | 133 | step_exec_stack.last[:done] = true if last_step 134 | 135 | return result.value 136 | end 137 | 138 | Failure!(result, err: err) if err && result.nil? 139 | 140 | step_exec_stack.last[:done] = true if last_step 141 | 142 | result 143 | end 144 | 145 | def __step_block_call!(err: nil) 146 | yield 147 | rescue StandardError => e 148 | err ? Failure!(e, err: err) : Failure!(e) 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HatiOperation 2 | 3 | [![Gem Version](https://badge.fury.io/rb/hati_operation.svg)](https://rubygems.org/gems/hati_operation) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](#license) 5 | 6 | HatiOperation is a next-generation Ruby toolkit that combines powerful service orchestration with modern AI-ready architecture. Built on top of [hati-command](https://github.com/hackico-ai/ruby-hati-command), it serves as both a traditional **service aggregator** and an **AI-enhanced orchestrator**, making it perfect for building modern applications that blend business logic with AI capabilities. 7 | 8 | ## Key Features 9 | 10 | ### Core Orchestration 11 | 12 | - **Step-based execution** – write each unit of work as a small service object and compose them with `step` 13 | - **Implicit result propagation** – methods return `Success(...)` or `Failure(...)` and are automatically unpacked 14 | - **Fail-fast transactions** – stop the chain as soon as a step fails 15 | - **Dependency injection (DI)** – override steps at call-time for ultimate flexibility 16 | - **Macro DSL** – declaratively configure validation, error mapping, transactions and more 17 | - **Service aggregation** – orchestrate multiple services into cohesive business operations 18 | 19 | ### AI-Ready Architecture 20 | 21 | - **Tool Integration** – seamlessly integrate AI services and LLM tools 22 | - **Safety Boundaries** – built-in guardrails for AI operations 23 | - **Action Composition** – chain multiple AI actions safely 24 | - **State Management** – track and manage AI agent state 25 | 26 | ### Development Acceleration 27 | 28 | - **Structured Patterns** – clear patterns for both human and AI comprehension 29 | - **Predictable Flow** – consistent operation structure for better maintainability 30 | - **Self-Documenting** – clear step definitions aid both human and AI understanding 31 | - **Context Awareness** – easy access to operation context for all services 32 | 33 | ## Table of Contents 34 | 35 | 1. [Key Features](#key-features) 36 | - [Core Orchestration](#core-orchestration) 37 | - [AI-Ready Architecture](#ai-ready-architecture) 38 | - [Development Acceleration](#development-acceleration) 39 | 2. [Architecture](#architecture) 40 | 3. [Installation](#installation) 41 | 4. [Quick Start](#quick-start) 42 | - [Traditional Business Operation](#traditional-business-operation) 43 | - [AI-Enhanced Operation](#ai-enhanced-operation) 44 | - [Base Operation Configuration](#base-operation-configuration) 45 | 5. [Step DSL](#step-dsl) 46 | 6. [Dependency Injection](#dependency-injection) 47 | 7. [Alternative DSL Styles](#alternative-dsl-styles) 48 | 8. [Testing](#testing) 49 | 9. [Authors](#authors) 50 | 10. [Development](#development) 51 | 11. [Contributing](#contributing) 52 | 12. [License](#license) 53 | 13. [Code of Conduct](#code-of-conduct) 54 | 55 | ## Architecture 56 | 57 | HatiOperation builds on top of [hati-command](https://github.com/hackico-ai/ruby-hati-command) and implements a versatile architecture that supports both traditional service aggregation and AI-enhanced operations: 58 | 59 | ``` 60 | ┌─────────────────────────────────────────────────────────────┐ 61 | │ HatiOperation │ 62 | │ (Universal Service Orchestrator) │ 63 | ├─────────────────────────────────────────────────────────────┤ 64 | │ │ 65 | │ Traditional Services AI/ML Services │ 66 | │ ┌─────────────┐ ┌─────────────┐ │ 67 | │ │ Business │ │ LLM │ │ 68 | │ │ Logic │ │ Tools │ │ 69 | │ └─────────────┘ └─────────────┘ │ 70 | │ │ 71 | │ ┌─────────────┐ ┌─────────────┐ │ 72 | │ │ Data │ │ Agent │ │ 73 | │ │ Services │ │ Actions │ │ 74 | │ └─────────────┘ └─────────────┘ │ 75 | │ │ 76 | │ ┌─────────────┐ ┌─────────────┐ │ 77 | │ │ External │ │ Safety │ │ 78 | │ │ APIs │ │ Guards │ │ 79 | │ └─────────────┘ └─────────────┘ │ 80 | │ │ 81 | ├─────────────────────────────────────────────────────────────┤ 82 | │ hati-command │ 83 | │ (Foundation Layer) │ 84 | └─────────────────────────────────────────────────────────────┘ 85 | ``` 86 | 87 | This dual-purpose architecture allows you to: 88 | 89 | - Compose traditional business services with robust error handling and transactions 90 | - Integrate AI capabilities with built-in safety mechanisms 91 | - Mix and match both paradigms in the same operation 92 | - Maintain clean separation of concerns while sharing common infrastructure 93 | 94 | ## Installation 95 | 96 | Add HatiOperation to your Gemfile and bundle: 97 | 98 | ```ruby 99 | # Gemfile 100 | gem 'hati_operation' 101 | ``` 102 | 103 | ```bash 104 | bundle install 105 | ``` 106 | 107 | Alternatively: 108 | 109 | ```bash 110 | gem install hati_operation 111 | ``` 112 | 113 | ## Quick Start 114 | 115 | HatiOperation can be used for both traditional business operations and AI-enhanced services. Here are examples of both: 116 | 117 | ### Traditional Business Operation 118 | 119 | ```ruby 120 | # app/controllers/api/v1/withdrawal_controller.rb 121 | class Api::V1::WithdrawalController < ApplicationController 122 | def create 123 | result = Withdrawal::Operation::Create.call(params: params.to_unsafe_h) 124 | run_and_render(result) 125 | end 126 | 127 | private 128 | 129 | def run_and_render(result) 130 | if result.success? 131 | render json: TransferSerializer.new.serialize(result.value), status: :created 132 | else 133 | error = ApiError.new(result.value) 134 | render json: error.to_json, status: error.status 135 | end 136 | end 137 | end 138 | 139 | # app/operations/withdrawal/operation/create.rb 140 | class Withdrawal::Operation::Create < HatiOperation::Base 141 | # Wrap everything in DB transaction 142 | ar_transaction :funds_transfer_transaction! 143 | 144 | def call(params:) 145 | params = step MyApiContract.call(params), err: ApiErr.call(422) 146 | transfer = step funds_transfer_transaction(params[:account_id]) 147 | EventBroadcast.new.stream(transfer.to_event) 148 | 149 | transfer.meta 150 | end 151 | 152 | def funds_transfer_transaction(acc_id) 153 | acc = Account.find_by(find_by: acc_id).presence : Failure!(err: ApiErr.call(404)) 154 | 155 | withdrawal = step WithdrawalService.call(acc), err: ApiErr.call(409) 156 | transfer = step ProcessTransferService.call(withdrawal), err: ApiErr.call(503) 157 | 158 | Success(transfer) 159 | end 160 | end 161 | ``` 162 | 163 | ### AI-Enhanced Operation 164 | 165 | ```ruby 166 | # app/operations/ai/content_generation.rb 167 | class AI::Operation::ContentGeneration < HatiOperation::Base 168 | # Register safety boundaries 169 | safety_guard :content_filter 170 | rate_limit max_tokens: 1000 171 | 172 | step validator: ContentValidator 173 | step generator: LLMService 174 | step filter: ContentFilter 175 | step formatter: OutputFormatter 176 | 177 | def call(params:) 178 | # Validate input and prepare prompt 179 | input = step validator.call(params[:prompt]) 180 | 181 | # Generate content with safety checks 182 | content = step generator.call(input), err: AIErr.call(503) 183 | filtered = step filter.call(content), err: AIErr.call(422) 184 | 185 | # Format and return 186 | step formatter.call(filtered) 187 | end 188 | end 189 | 190 | # Usage in controller 191 | class Api::V1::ContentController < ApplicationController 192 | def create 193 | result = AI::Operation::ContentGeneration.call(params: params.to_unsafe_h) do 194 | # Override services for different models/providers 195 | step generator: OpenAIService 196 | step filter: CustomContentFilter 197 | end 198 | 199 | render_result(result) 200 | end 201 | end 202 | ``` 203 | 204 | ### Base Operation Configuration 205 | 206 | ```ruby 207 | # Common configuration for API operations 208 | class ApiOperation < HatiOperation::Base 209 | operation do 210 | unexpected_err ApiErr.call(500) 211 | end 212 | end 213 | 214 | # Common configuration for AI operations 215 | class AIOperation < HatiOperation::Base 216 | operation do 217 | unexpected_err AIErr.call(500) 218 | safety_guard :content_filter 219 | rate_limit true 220 | end 221 | end 222 | ``` 223 | 224 | ## Step DSL 225 | 226 | The DSL gives you fine-grained control over every stage of the operation: 227 | 228 | ### Core DSL Methods 229 | 230 | - `step` – register a dependency service 231 | - `params` – validate/transform incoming parameters 232 | - `on_success` – handle successful operation results 233 | - `on_failure` – map and handle failure results 234 | 235 | ### Extended Configuration 236 | 237 | > **See:** [hati-command](https://github.com/hackico-ai/ruby-hati-command) for all configuration options 238 | 239 | - `ar_transaction` – execute inside database transaction 240 | - `fail_fast` – configure fail-fast behavior 241 | - `failure` – set default failure handling 242 | - `unexpected_err` – configure generic error behavior 243 | 244 | ## Dependency Injection 245 | 246 | At runtime you can swap out any step for testing, feature-flags, or different environments: 247 | 248 | ```ruby 249 | result = Withdrawal::Operation::Create.call(params) do 250 | step broadcast: DummyBroadcastService 251 | step transfer: StubbedPaymentProcessor 252 | end 253 | ``` 254 | 255 | ## Alternative DSL Styles 256 | 257 | ### Declarative Style 258 | 259 | Prefer more declarative code? Use the class-level DSL: 260 | 261 | ```ruby 262 | class Withdrawal::Operation::Create < ApiOperation 263 | params CreateContract, err: ApiErr.call(422) 264 | 265 | ar_transaction :funds_transfer_transaction! 266 | 267 | step withdrawal: WithdrawalService, err: ApiErr.call(409) 268 | step transfer: ProcessTransferService, err: ApiErr.call(503) 269 | step broadcast: Broadcast 270 | 271 | on_success SerializerService.call(Transfer, status: 201) 272 | on_failure ApiErrorSerializer 273 | 274 | # requires :params keyword to access overwritten params 275 | # same as params = step CreateContract.call(params), err: ApiErr.call(422) 276 | def call(params:) 277 | transfer = step funds_transfer_transaction!(params[:account_id]) 278 | broadcast.new.stream(transfer.to_event) 279 | transfer.meta 280 | end 281 | 282 | def funds_transfer_transaction!(acc_id) 283 | acc = step(err: ApiErr.call(404)) { User.find(id) } 284 | 285 | withdrawal = step withdrawal.call(acc) 286 | transfer = step transfer.call(withdrawal) 287 | Success(transfer) 288 | end 289 | end 290 | 291 | class Api::V2::WithdrawalController < ApiController 292 | def create 293 | run_and_render Withdrawal::Operation::Create 294 | end 295 | 296 | private 297 | 298 | def run_and_render(operation, &block) 299 | render JsonResult.prepare operation.call(params.to_unsafe_h).value 300 | end 301 | end 302 | ``` 303 | 304 | ### Full-Stack DI Example 305 | 306 | ```ruby 307 | class Api::V2::WithdrawalController < ApplicationController 308 | def create 309 | run_and_render Withdrawal::Operation::Create.call(params.to_unsafe_h) do 310 | step broadcast: API::V2::BroadcastService 311 | step transfer: API::V2::PaymentProcessorService 312 | step serializer: ExtendedTransferSerializer 313 | end 314 | end 315 | end 316 | ``` 317 | 318 | ## Testing 319 | 320 | Run the test-suite with: 321 | 322 | ```bash 323 | bundle exec rspec 324 | ``` 325 | 326 | HatiOperation is fully covered by RSpec. See `spec/` for reference examples including stubbed services and DI. 327 | 328 | ## Authors 329 | 330 | - [Marie Giy](https://github.com/mariegiy) 331 | 332 | ## Development 333 | 334 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 335 | 336 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 337 | 338 | ## Contributing 339 | 340 | Bug reports and pull requests are welcome on GitHub at https://github.com/hackico-ai/hati-command. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/hackico-ai/hati-command/blob/main/CODE_OF_CONDUCT.md). 341 | 342 | ## License 343 | 344 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 345 | 346 | ## Code of Conduct 347 | 348 | Everyone interacting in the HatCommand project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/hackico-ai/hati-command/blob/main/CODE_OF_CONDUCT.md). 349 | --------------------------------------------------------------------------------