stellwerk-ruby 0.1.0 → 0.2.1

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: 796b4095b7fea502bbfacb3e03aa62ab11fd85234c69e5ee2ebd867e82b1586b
4
- data.tar.gz: ef32afa830843674ef9e83175538181c087910be20fd7b9406a927ee192f1bd7
3
+ metadata.gz: 53518cb9a1c9f94bd19252dcecf8fce62e4cc808e926ee51bf05dd5aa93a3826
4
+ data.tar.gz: '0797c9b22de3cc4fdd6cdea65b117abd90437b9c80703577060e2765ccf0d2ca'
5
5
  SHA512:
6
- metadata.gz: 882b4adabe5be8af497114eefbd7e0454c145de3b98235de44e77db01e40e588fb56ffc21bc2741ba307cf4b2987a8b5ad6d4d6525ef6c394e1813f7cedb2c70
7
- data.tar.gz: 9ed76a0cfc751cb41fcc46c02d2a2fb97e48dcc0ac24ed8570bbe5bb5cbfddf8fdb762d125356db014e5ba963e9f35f442e0b6b7b24f43eac2aaf2f1f0fbb55d
6
+ metadata.gz: 169634b2e34156ab8f7742611fdf8739973b94be79c6458aa5b7bda06b7e8c42ec54d358e9e0fd44b683b6b6e3e34eff51bf0b23c1b1907dde72feebf1af9663
7
+ data.tar.gz: a196cf2f9ec2f44238af3d90d68838d5d0fdc36b4df47b5895b97100dc86f77d1e8b2ec9d029016ce09dcebfe746ce1be3a7ee198e09983bfb6f6f73a0d26968
data/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1] - 2026-02-02
9
+
10
+ ### Changed
11
+
12
+ - Updated default API URL to `https://stellwerk.io`
13
+ - Updated license validation endpoint to `POST /api/v1/sdk/validate`
14
+ - Updated API response parsing to use `limits.monthly_executions` and `limits.current_count`
15
+
16
+ ### Added
17
+
18
+ - `verify_ssl` configuration option to disable SSL verification for development
19
+
20
+ ## [0.2.0] - 2026-02-01
21
+
22
+ ### Added
23
+
24
+ - **License validation**: API key required for all flow executions
25
+ - Lazy validation on first execution with result caching
26
+ - Configurable cache TTL from server response
27
+ - **Execution metering**: Track and report flow executions
28
+ - Batch reporting at configurable intervals (default: 60 seconds)
29
+ - Automatic sync with Stellwerk API
30
+ - **Offline grace period**: Continue operating when server is unreachable
31
+ - Default: 24 hours or 100 executions
32
+ - Configurable via `offline_grace_period` and `offline_grace_executions`
33
+ - **New configuration options**:
34
+ - `api_key` - Required API key for license validation
35
+ - `api_url` - API endpoint (default: https://stellwerk.io)
36
+ - `sync_interval` - Batch reporting interval in seconds
37
+ - `offline_grace_period` - Offline operation allowance in seconds
38
+ - `offline_grace_executions` - Max offline executions
39
+ - **New error classes**:
40
+ - `LicenseError` - Raised when API key is missing or invalid
41
+ - `QuotaExceededError` - Raised when execution limit is reached
42
+ - `ApiError` - Raised on server communication failures
43
+ - **New helper methods**:
44
+ - `Stellwerk.licensed?` - Check if SDK is properly licensed
45
+ - `Stellwerk.license` - Access license details (plan, limits, usage)
46
+ - `Stellwerk.metering` - Access metering system
47
+ - Thread-safe license and metering implementations using Mutex
48
+
49
+ ### Changed
50
+
51
+ - `Stellwerk.configure` now yields a `Configuration` object instead of the module
52
+ - `Flow#execute` now enforces license validation and tracks executions
53
+ - Improved documentation with comprehensive troubleshooting guide
54
+
8
55
  ## [0.1.0] - 2026-01-31
9
56
 
10
57
  ### Added
data/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  A standalone Ruby gem for evaluating pre-compiled Stellwerk flows without any Rails or ActiveRecord dependencies.
4
4
 
5
+ ## What is Stellwerk?
6
+
7
+ [Stellwerk](https://stellwerk.io) is a visual platform for building decision flows and business logic without code. Design complex calculations, conditional branching, and data transformations using an intuitive drag-and-drop interface, then export your flows as compiled JSON to run anywhere.
8
+
9
+ This gem lets you execute those compiled flows in any Ruby application.
10
+
5
11
  ## Installation
6
12
 
7
13
  Add this line to your application's Gemfile:
@@ -22,55 +28,209 @@ Or install it yourself as:
22
28
  gem install stellwerk-ruby
23
29
  ```
24
30
 
25
- ## Usage
26
-
27
- ### Simple Usage
31
+ ## Quick Start
28
32
 
29
33
  ```ruby
30
34
  require 'stellwerk'
31
35
 
32
- # Load a compiled flow from a JSON file
33
- flow = Stellwerk::Flow.load('pricing.compiled.json')
36
+ # Configure your API key (required)
37
+ Stellwerk.configure do |config|
38
+ config.api_key = ENV['STELLWERK_API_KEY']
39
+ end
34
40
 
35
- # Execute with input parameters
41
+ # Load and execute a flow
42
+ flow = Stellwerk::Flow.load('pricing.compiled.json')
36
43
  result = flow.execute(quantity: 10, price: 25.00)
37
44
 
38
45
  if result.success?
39
- puts result.outputs[:total]
46
+ puts "Total: #{result.outputs[:total]}"
47
+ else
48
+ puts "Errors: #{result.errors}"
49
+ end
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ ### Required: API Key
55
+
56
+ All flow executions require a valid API key. Get your key from [stellwerk.io](https://stellwerk.io).
57
+
58
+ Stellwerk provides two types of API keys:
59
+
60
+ | Key Type | Prefix | Purpose | Quota |
61
+ |----------|--------|---------|-------|
62
+ | **Test** | `sk_test_...` | Development and testing | Not counted |
63
+ | **Live** | `sk_live_...` | Production use | Counted against plan |
64
+
65
+ Use test keys during development to avoid consuming your quota. Switch to live keys when deploying to production.
66
+
67
+ ```ruby
68
+ Stellwerk.configure do |config|
69
+ # Use test key in development, live key in production
70
+ config.api_key = ENV['STELLWERK_API_KEY']
71
+ end
72
+
73
+ # Or use the shorthand
74
+ Stellwerk.api_key = ENV['STELLWERK_API_KEY']
75
+ ```
76
+
77
+ **Tip**: Set different environment variables per environment:
78
+ ```bash
79
+ # .env.development
80
+ STELLWERK_API_KEY=sk_test_your_test_key
81
+
82
+ # .env.production
83
+ STELLWERK_API_KEY=sk_live_your_live_key
84
+ ```
85
+
86
+ ### Full Configuration Options
87
+
88
+ ```ruby
89
+ Stellwerk.configure do |config|
90
+ # Required: Your API key from stellwerk.io
91
+ config.api_key = ENV['STELLWERK_API_KEY']
92
+
93
+ # Optional: API endpoint (default: https://stellwerk.io)
94
+ config.api_url = 'https://stellwerk.io'
95
+
96
+ # Optional: Interval for batch reporting executions (default: 60 seconds)
97
+ config.sync_interval = 60
98
+
99
+ # Optional: How long to allow offline operation (default: 86400 = 24 hours)
100
+ config.offline_grace_period = 86_400
101
+
102
+ # Optional: Max executions during offline grace period (default: 100)
103
+ config.offline_grace_executions = 100
104
+
105
+ # Optional: Logger for debugging
106
+ config.logger = Logger.new(STDOUT)
107
+ end
108
+ ```
109
+
110
+ ## Usage Examples
111
+
112
+ ### Basic Execution
113
+
114
+ ```ruby
115
+ flow = Stellwerk::Flow.load('calculator.compiled.json')
116
+ result = flow.execute(x: 10, y: 20)
117
+
118
+ puts result.outputs[:sum] # => 30
119
+ ```
120
+
121
+ ### Error Handling
122
+
123
+ ```ruby
124
+ flow = Stellwerk::Flow.load('pricing.compiled.json')
125
+
126
+ begin
127
+ result = flow.execute(params)
128
+
129
+ if result.success?
130
+ process(result.outputs)
131
+ else
132
+ # Flow executed but returned errors (validation, business logic)
133
+ log_errors(result.errors)
134
+ end
135
+ rescue Stellwerk::LicenseError => e
136
+ # API key missing or invalid
137
+ puts "License error: #{e.message}"
138
+ rescue Stellwerk::QuotaExceededError => e
139
+ # Execution limit reached
140
+ puts "Quota exceeded: #{e.message}"
141
+ rescue Stellwerk::ApiError => e
142
+ # Server communication error
143
+ puts "API error: #{e.message} (status: #{e.status_code})"
144
+ end
145
+ ```
146
+
147
+ ### Batch Processing
148
+
149
+ ```ruby
150
+ flow = Stellwerk::Flow.load('invoice.compiled.json')
151
+
152
+ invoices.each do |invoice|
153
+ result = flow.execute(invoice.to_h)
154
+
155
+ if result.success?
156
+ invoice.update!(
157
+ subtotal: result.outputs[:subtotal],
158
+ tax: result.outputs[:tax],
159
+ total: result.outputs[:total]
160
+ )
161
+ end
162
+ end
163
+
164
+ # Executions are batched and reported automatically
165
+ ```
166
+
167
+ ### Checking License Status
168
+
169
+ ```ruby
170
+ # Check if properly licensed before processing
171
+ if Stellwerk.licensed?
172
+ process_flows
40
173
  else
41
- puts result.errors
174
+ puts "Please configure a valid API key"
42
175
  end
176
+
177
+ # Access license details
178
+ license = Stellwerk.license
179
+ puts "Plan: #{license.plan}"
180
+ puts "Limit: #{license.execution_limit}"
181
+ puts "Used: #{license.executions_used}"
43
182
  ```
44
183
 
45
184
  ### Direct Evaluator Usage
46
185
 
47
- For more control, you can use the evaluator directly:
186
+ For advanced use cases, you can use the evaluator directly:
48
187
 
49
188
  ```ruby
50
- require 'stellwerk'
51
189
  require 'json'
52
190
 
53
191
  json = JSON.parse(File.read('flow.json'))
54
192
  result = Stellwerk::Evaluator.call(
55
193
  compiled_json: json,
56
- params: { x: 10, y: 20 }
194
+ params: { x: 10, y: 20 },
195
+ metadata: { source: 'api', user_id: 123 }
57
196
  )
58
197
 
59
- puts result.outputs
60
- puts result.context
61
- puts result.applied_nodes
198
+ puts result.outputs # Output values
199
+ puts result.context # Full execution context
200
+ puts result.applied_nodes # Executed node IDs
62
201
  ```
63
202
 
64
- ### Configuration
203
+ ## Offline Mode
65
204
 
66
- ```ruby
67
- require 'logger'
205
+ If the Stellwerk server becomes unreachable after a successful license validation, the SDK enters an offline grace period:
68
206
 
69
- Stellwerk.configure do |config|
70
- config.logger = Logger.new(STDOUT)
207
+ - **Default duration**: 24 hours
208
+ - **Default execution limit**: 100 executions
209
+
210
+ During offline mode, executions are tracked locally and reported when connectivity is restored.
211
+
212
+ ```ruby
213
+ # Check if operating in offline mode
214
+ if Stellwerk.license.offline?
215
+ puts "Operating in offline mode"
71
216
  end
72
217
  ```
73
218
 
219
+ ## Result Object
220
+
221
+ Execution returns a `Stellwerk::Result` object:
222
+
223
+ ```ruby
224
+ result = flow.execute(params)
225
+
226
+ result.success? # => true/false
227
+ result.failure? # => true/false
228
+ result.outputs # => Hash of output values
229
+ result.errors # => Array of error messages/hashes
230
+ result.applied_nodes # => Array of executed node IDs
231
+ result.context # => Full execution context
232
+ ```
233
+
74
234
  ## Compiled JSON Format
75
235
 
76
236
  The gem expects compiled flow JSON in this structure:
@@ -135,21 +295,67 @@ The gem includes collection functions for use in formulas:
135
295
  - `SUMIF(array, "predicate", "projection")` - Conditional sum
136
296
  - `COUNTIF(array, "predicate")` - Conditional count
137
297
 
138
- ## Result Object
298
+ ## Troubleshooting
139
299
 
140
- Execution returns a `Stellwerk::Result` object:
300
+ ### "API key is required"
301
+
302
+ You must configure an API key before executing flows:
141
303
 
142
304
  ```ruby
143
- result = flow.execute(params)
305
+ Stellwerk.configure do |config|
306
+ config.api_key = ENV['STELLWERK_API_KEY']
307
+ end
308
+ ```
144
309
 
145
- result.success? # => true/false
146
- result.failure? # => true/false
147
- result.outputs # => Hash of output values
148
- result.errors # => Array of error messages/hashes
149
- result.applied_nodes # => Array of executed nodes
150
- result.context # => Full execution context
310
+ Get your API key from [stellwerk.io](https://stellwerk.io).
311
+
312
+ ### "Invalid API key"
313
+
314
+ Your API key is not recognized. Verify:
315
+ - The key is correctly copied (no extra spaces)
316
+ - The key is active in your Stellwerk dashboard
317
+ - You're using the correct key type (`sk_test_...` for development, `sk_live_...` for production)
318
+
319
+ ### "Execution limit reached"
320
+
321
+ You've exceeded your plan's monthly execution quota. Options:
322
+ - Wait for the next billing cycle
323
+ - Upgrade your plan at [stellwerk.io](https://stellwerk.io)
324
+
325
+ ### "Offline execution limit reached"
326
+
327
+ During offline mode, you've exceeded the allowed offline executions. Restore network connectivity to continue.
328
+
329
+ ### Debug Mode
330
+
331
+ Enable logging to see detailed execution information:
332
+
333
+ ```ruby
334
+ Stellwerk.configure do |config|
335
+ config.api_key = ENV['STELLWERK_API_KEY']
336
+ config.logger = Logger.new(STDOUT)
337
+ config.logger.level = Logger::DEBUG
338
+ end
339
+ ```
340
+
341
+ ### Flow Validation Errors
342
+
343
+ If `result.failure?` returns true, check `result.errors` for details:
344
+
345
+ ```ruby
346
+ result = flow.execute(params)
347
+ if result.failure?
348
+ result.errors.each do |error|
349
+ puts "Error: #{error}"
350
+ end
351
+ end
151
352
  ```
152
353
 
354
+ Common causes:
355
+ - Missing required input parameters
356
+ - Invalid data types
357
+ - Formula evaluation errors
358
+
153
359
  ## Development
154
360
 
155
361
  After checking out the repo, run:
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "openssl"
7
+
8
+ module Stellwerk
9
+ # HTTP client wrapper for Stellwerk API communication
10
+ #
11
+ # Uses net/http from Ruby stdlib for zero external dependencies.
12
+ # Handles authentication, retries, timeouts, and JSON parsing.
13
+ #
14
+ class ApiClient
15
+ # Default timeout for HTTP requests (seconds)
16
+ DEFAULT_TIMEOUT = 10
17
+
18
+ # Maximum total attempts for transient failures (initial + retries)
19
+ MAX_ATTEMPTS = 3
20
+
21
+ # Delay between retries (seconds)
22
+ RETRY_DELAY = 1
23
+
24
+ # @param api_url [String] Base URL for the API
25
+ # @param api_key [String] API key for authentication
26
+ # @param timeout [Integer] Request timeout in seconds
27
+ # @param verify_ssl [Boolean] Whether to verify SSL certificates
28
+ def initialize(api_url:, api_key:, timeout: DEFAULT_TIMEOUT, verify_ssl: true)
29
+ @api_url = api_url.chomp("/")
30
+ @api_key = api_key
31
+ @timeout = timeout
32
+ @verify_ssl = verify_ssl
33
+ end
34
+
35
+ # Make a GET request
36
+ #
37
+ # @param path [String] API endpoint path
38
+ # @return [Hash] Parsed JSON response
39
+ # @raise [ApiError] On request failure
40
+ def get(path)
41
+ request(:get, path)
42
+ end
43
+
44
+ # Make a POST request
45
+ #
46
+ # @param path [String] API endpoint path
47
+ # @param body [Hash] Request body (will be JSON encoded)
48
+ # @return [Hash] Parsed JSON response
49
+ # @raise [ApiError] On request failure
50
+ def post(path, body = {})
51
+ request(:post, path, body)
52
+ end
53
+
54
+ private
55
+
56
+ def request(method, path, body = nil)
57
+ uri = URI.parse("#{@api_url}#{path}")
58
+ retries = 0
59
+
60
+ begin
61
+ response = execute_request(method, uri, body)
62
+ handle_response(response)
63
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ENETUNREACH,
64
+ Net::OpenTimeout, Net::ReadTimeout, SocketError => e
65
+ retries += 1
66
+ if retries < MAX_ATTEMPTS
67
+ sleep(RETRY_DELAY * retries)
68
+ retry
69
+ end
70
+ raise ApiError.new(
71
+ "Failed to connect to Stellwerk API: #{e.message}",
72
+ status_code: nil,
73
+ response_body: nil
74
+ )
75
+ end
76
+ end
77
+
78
+ def execute_request(method, uri, body)
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = uri.scheme == "https"
81
+ http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
82
+ http.open_timeout = @timeout
83
+ http.read_timeout = @timeout
84
+
85
+ request = build_request(method, uri, body)
86
+ http.request(request)
87
+ end
88
+
89
+ def build_request(method, uri, body)
90
+ request_class = method == :get ? Net::HTTP::Get : Net::HTTP::Post
91
+ request = request_class.new(uri.request_uri)
92
+
93
+ request["Authorization"] = "Bearer #{@api_key}"
94
+ request["Content-Type"] = "application/json"
95
+ request["Accept"] = "application/json"
96
+ request["User-Agent"] = "stellwerk-ruby/#{Stellwerk::VERSION}"
97
+
98
+ request.body = JSON.generate(body) if body && method == :post
99
+
100
+ request
101
+ end
102
+
103
+ def handle_response(response)
104
+ case response.code.to_i
105
+ when 200..299
106
+ parse_json(response.body)
107
+ when 401
108
+ raise ApiError.new(
109
+ "Invalid API key",
110
+ status_code: 401,
111
+ response_body: response.body
112
+ )
113
+ when 403
114
+ raise ApiError.new(
115
+ "API key not authorized for this operation",
116
+ status_code: 403,
117
+ response_body: response.body
118
+ )
119
+ when 429
120
+ raise ApiError.new(
121
+ "Rate limit exceeded",
122
+ status_code: 429,
123
+ response_body: response.body
124
+ )
125
+ when 500..599
126
+ raise ApiError.new(
127
+ "Stellwerk API server error",
128
+ status_code: response.code.to_i,
129
+ response_body: response.body
130
+ )
131
+ else
132
+ raise ApiError.new(
133
+ "Unexpected API response: #{response.code}",
134
+ status_code: response.code.to_i,
135
+ response_body: response.body
136
+ )
137
+ end
138
+ end
139
+
140
+ def parse_json(body)
141
+ return {} if body.nil? || body.empty?
142
+
143
+ JSON.parse(body)
144
+ rescue JSON::ParserError => e
145
+ raise ApiError.new(
146
+ "Invalid JSON response from API: #{e.message}",
147
+ status_code: nil,
148
+ response_body: body
149
+ )
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stellwerk
4
+ # Configuration class for Stellwerk SDK settings
5
+ #
6
+ # @example
7
+ # Stellwerk.configure do |config|
8
+ # config.api_key = 'sk_stellwerk_...'
9
+ # config.api_url = 'https://stellwerk.io'
10
+ # end
11
+ #
12
+ class Configuration
13
+ # API key for license validation and metering
14
+ # @return [String, nil]
15
+ attr_accessor :api_key
16
+
17
+ # Base URL for the Stellwerk API
18
+ # @return [String]
19
+ attr_accessor :api_url
20
+
21
+ # Interval in seconds between batch metering reports
22
+ # @return [Integer]
23
+ attr_accessor :sync_interval
24
+
25
+ # Maximum seconds to allow offline operation when server is unreachable
26
+ # @return [Integer]
27
+ attr_accessor :offline_grace_period
28
+
29
+ # Maximum executions allowed during offline grace period
30
+ # @return [Integer]
31
+ attr_accessor :offline_grace_executions
32
+
33
+ # Logger instance
34
+ # @return [Logger]
35
+ attr_accessor :logger
36
+
37
+ # Whether to verify SSL certificates (disable for development only)
38
+ # @return [Boolean]
39
+ attr_accessor :verify_ssl
40
+
41
+ # Default API URL
42
+ DEFAULT_API_URL = "https://stellwerk.io"
43
+
44
+ # Default sync interval (60 seconds)
45
+ DEFAULT_SYNC_INTERVAL = 60
46
+
47
+ # Default offline grace period (24 hours)
48
+ DEFAULT_OFFLINE_GRACE_PERIOD = 86_400
49
+
50
+ # Default offline grace executions
51
+ DEFAULT_OFFLINE_GRACE_EXECUTIONS = 100
52
+
53
+ def initialize
54
+ @api_key = nil
55
+ @api_url = DEFAULT_API_URL
56
+ @sync_interval = DEFAULT_SYNC_INTERVAL
57
+ @offline_grace_period = DEFAULT_OFFLINE_GRACE_PERIOD
58
+ @offline_grace_executions = DEFAULT_OFFLINE_GRACE_EXECUTIONS
59
+ @logger = nil
60
+ @verify_ssl = true
61
+ end
62
+
63
+ # Check if an API key is configured
64
+ # @return [Boolean]
65
+ def api_key?
66
+ !@api_key.nil? && !@api_key.empty?
67
+ end
68
+
69
+ # Reset configuration to defaults
70
+ def reset!
71
+ @api_key = nil
72
+ @api_url = DEFAULT_API_URL
73
+ @sync_interval = DEFAULT_SYNC_INTERVAL
74
+ @offline_grace_period = DEFAULT_OFFLINE_GRACE_PERIOD
75
+ @offline_grace_executions = DEFAULT_OFFLINE_GRACE_EXECUTIONS
76
+ @logger = nil
77
+ @verify_ssl = true
78
+ end
79
+ end
80
+ end
@@ -15,4 +15,21 @@ module Stellwerk
15
15
 
16
16
  # Raised when a required input is missing
17
17
  class MissingInputError < Error; end
18
+
19
+ # Raised when API key is missing or invalid
20
+ class LicenseError < Error; end
21
+
22
+ # Raised when execution limit is reached
23
+ class QuotaExceededError < Error; end
24
+
25
+ # Raised when API communication fails
26
+ class ApiError < Error
27
+ attr_reader :status_code, :response_body
28
+
29
+ def initialize(message, status_code: nil, response_body: nil)
30
+ super(message)
31
+ @status_code = status_code
32
+ @response_body = response_body
33
+ end
34
+ end
18
35
  end
@@ -41,13 +41,32 @@ module Stellwerk
41
41
  # @param sub_flows [Hash] Additional sub-flows for map nodes
42
42
  # @param metadata [Hash] Additional metadata to merge into context
43
43
  # @return [Stellwerk::Result]
44
+ # @raise [LicenseError] If API key is not configured or invalid
45
+ # @raise [QuotaExceededError] If execution limit is exceeded
44
46
  def execute(params = {}, sub_flows: {}, metadata: {})
45
- Evaluator.call(
46
- compiled_json: @compiled_json,
47
- params: params,
48
- sub_flows: sub_flows,
49
- metadata: metadata
50
- )
47
+ enforce_license!
48
+ check_quota!
49
+
50
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
51
+ status = "success"
52
+
53
+ begin
54
+ result = Evaluator.call(
55
+ compiled_json: @compiled_json,
56
+ params: params,
57
+ sub_flows: sub_flows,
58
+ metadata: metadata
59
+ )
60
+
61
+ status = "error" unless result.success?
62
+ result
63
+ rescue StandardError
64
+ status = "error"
65
+ raise
66
+ ensure
67
+ duration_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - start_time
68
+ track_execution(duration_ms, status)
69
+ end
51
70
  end
52
71
 
53
72
  # Get the flow version from compiled JSON
@@ -73,6 +92,52 @@ module Stellwerk
73
92
 
74
93
  private
75
94
 
95
+ def enforce_license!
96
+ license = Stellwerk.license
97
+
98
+ # Try to validate if not yet validated or cache expired
99
+ unless license.valid?
100
+ license.validate!
101
+ end
102
+
103
+ # Check again after validation attempt
104
+ unless license.valid?
105
+ raise LicenseError, "Invalid or expired license"
106
+ end
107
+ end
108
+
109
+ def check_quota!
110
+ license = Stellwerk.license
111
+
112
+ unless license.within_limits?
113
+ if license.offline?
114
+ raise QuotaExceededError,
115
+ "Offline execution limit reached. Connect to the network to continue."
116
+ else
117
+ raise QuotaExceededError,
118
+ "Execution limit reached for your plan. Upgrade at https://stellwerk.io"
119
+ end
120
+ end
121
+ end
122
+
123
+ def track_execution(duration_ms, status)
124
+ license = Stellwerk.license
125
+
126
+ # Record offline execution if in grace period
127
+ license.record_offline_execution if license.offline?
128
+
129
+ # Track in metering system
130
+ Stellwerk.metering.track(
131
+ flow_id: flow_id,
132
+ duration_ms: duration_ms,
133
+ status: status
134
+ )
135
+ end
136
+
137
+ def flow_id
138
+ @compiled_json["id"] || @compiled_json[:id] || @path || "unknown"
139
+ end
140
+
76
141
  def validate!
77
142
  nodes = @compiled_json["nodes"] || @compiled_json[:nodes]
78
143
  unless nodes.is_a?(Hash)
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stellwerk
4
+ # License validation and caching
5
+ #
6
+ # Validates API keys against the Stellwerk server and caches the result.
7
+ # Supports offline grace periods when the server is unreachable.
8
+ #
9
+ # Thread-safe via Mutex.
10
+ #
11
+ class License
12
+ # Cached license data attributes
13
+ attr_reader :plan, :execution_limit, :executions_used, :expires_at
14
+
15
+ def initialize
16
+ @mutex = Mutex.new
17
+ @validated = false
18
+ @valid = false
19
+ @plan = nil
20
+ @execution_limit = 0
21
+ @executions_used = 0
22
+ @expires_at = nil
23
+ @offline_since = nil
24
+ @offline_executions = 0
25
+ end
26
+
27
+ # Validate the API key against the server
28
+ #
29
+ # @return [Boolean] Whether the license is valid
30
+ # @raise [LicenseError] If API key is missing
31
+ # @raise [ApiError] On server communication failure (after grace period)
32
+ def validate!
33
+ @mutex.synchronize do
34
+ validate_internal!
35
+ end
36
+ end
37
+
38
+ # Check if the current license is valid (cached)
39
+ #
40
+ # @return [Boolean]
41
+ def valid?
42
+ @mutex.synchronize do
43
+ return false unless @validated
44
+ return @valid if within_cache_ttl?
45
+ return in_offline_grace? if @offline_since
46
+
47
+ @valid
48
+ end
49
+ end
50
+
51
+ # Refresh the license if TTL has expired
52
+ #
53
+ # @return [Boolean] Whether the license is valid after refresh
54
+ def refresh_if_needed
55
+ @mutex.synchronize do
56
+ return @valid if within_cache_ttl?
57
+
58
+ validate_internal!
59
+ end
60
+ end
61
+
62
+ # Check if executions are within allowed limits
63
+ #
64
+ # @param pending_count [Integer] Number of pending executions to check
65
+ # @return [Boolean]
66
+ def within_limits?(pending_count = 1)
67
+ @mutex.synchronize do
68
+ return false unless @valid || in_offline_grace?
69
+
70
+ if @offline_since
71
+ @offline_executions + pending_count <= offline_grace_executions
72
+ else
73
+ @executions_used + pending_count <= @execution_limit
74
+ end
75
+ end
76
+ end
77
+
78
+ # Record an offline execution during grace period
79
+ def record_offline_execution
80
+ @mutex.synchronize do
81
+ @offline_executions += 1
82
+ end
83
+ end
84
+
85
+ # Update execution count from server response
86
+ #
87
+ # @param count [Integer] Current execution count from server
88
+ def update_executions_used(count)
89
+ @mutex.synchronize do
90
+ @executions_used = count
91
+ end
92
+ end
93
+
94
+ # Check if in offline mode
95
+ #
96
+ # @return [Boolean]
97
+ def offline?
98
+ @mutex.synchronize do
99
+ !@offline_since.nil?
100
+ end
101
+ end
102
+
103
+ # Reset the license state
104
+ def reset!
105
+ @mutex.synchronize do
106
+ @validated = false
107
+ @valid = false
108
+ @plan = nil
109
+ @execution_limit = 0
110
+ @executions_used = 0
111
+ @expires_at = nil
112
+ @offline_since = nil
113
+ @offline_executions = 0
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ def validate_internal!
120
+ config = Stellwerk.configuration
121
+
122
+ unless config.api_key?
123
+ raise LicenseError, "API key is required. Set Stellwerk.configuration.api_key"
124
+ end
125
+
126
+ begin
127
+ client = ApiClient.new(api_url: config.api_url, api_key: config.api_key, verify_ssl: config.verify_ssl)
128
+ response = client.post("/api/v1/sdk/validate")
129
+
130
+ apply_validation_response(response)
131
+ clear_offline_state
132
+ @validated = true
133
+ @valid
134
+ rescue ApiError => e
135
+ handle_validation_error(e)
136
+ end
137
+ end
138
+
139
+ def apply_validation_response(response)
140
+ @valid = response["valid"] == true
141
+ @plan = response["plan"]
142
+
143
+ limits = response["limits"] || {}
144
+ @execution_limit = limits["monthly_executions"].to_i
145
+ @executions_used = limits["current_count"].to_i
146
+
147
+ cache_ttl = response["cache_ttl"].to_i
148
+ cache_ttl = 3600 if cache_ttl <= 0
149
+ @expires_at = Time.now + cache_ttl
150
+ end
151
+
152
+ def handle_validation_error(error)
153
+ if error.status_code == 401
154
+ @valid = false
155
+ @validated = true
156
+ raise LicenseError, "Invalid API key"
157
+ end
158
+
159
+ # Server unreachable - enter or continue offline grace period
160
+ if @validated && @valid
161
+ enter_offline_grace unless @offline_since
162
+ Stellwerk.logger.warn("License server unreachable, operating in offline grace mode")
163
+ return in_offline_grace?
164
+ end
165
+
166
+ # Never validated successfully - cannot enter grace period
167
+ raise LicenseError, "Unable to validate license: #{error.message}"
168
+ end
169
+
170
+ def enter_offline_grace
171
+ @offline_since = Time.now
172
+ @offline_executions = 0
173
+ end
174
+
175
+ def clear_offline_state
176
+ @offline_since = nil
177
+ @offline_executions = 0
178
+ end
179
+
180
+ def within_cache_ttl?
181
+ @expires_at && Time.now < @expires_at
182
+ end
183
+
184
+ def in_offline_grace?
185
+ return false unless @offline_since
186
+
187
+ within_time = (Time.now - @offline_since) < offline_grace_period
188
+ within_executions = @offline_executions < offline_grace_executions
189
+
190
+ within_time && within_executions
191
+ end
192
+
193
+ def offline_grace_period
194
+ Stellwerk.configuration.offline_grace_period
195
+ end
196
+
197
+ def offline_grace_executions
198
+ Stellwerk.configuration.offline_grace_executions
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stellwerk
4
+ # Execution metering and batch reporting
5
+ #
6
+ # Tracks flow executions locally and periodically reports them
7
+ # to the Stellwerk server in batches for efficiency.
8
+ #
9
+ # Thread-safe via Mutex.
10
+ #
11
+ class Metering
12
+ # Execution record structure
13
+ Execution = Struct.new(:flow_id, :timestamp, :duration_ms, :status, keyword_init: true)
14
+
15
+ def initialize
16
+ @mutex = Mutex.new
17
+ @pending_executions = []
18
+ @last_flush_at = Time.now
19
+ @background_thread = nil
20
+ @running = false
21
+ end
22
+
23
+ # Track a flow execution
24
+ #
25
+ # @param flow_id [String] Identifier for the flow
26
+ # @param duration_ms [Integer] Execution duration in milliseconds
27
+ # @param status [String] Execution status ('success', 'error')
28
+ def track(flow_id:, duration_ms:, status:)
29
+ execution = Execution.new(
30
+ flow_id: flow_id,
31
+ timestamp: Time.now.utc.iso8601,
32
+ duration_ms: duration_ms,
33
+ status: status
34
+ )
35
+
36
+ @mutex.synchronize do
37
+ @pending_executions << execution
38
+ end
39
+
40
+ auto_flush_if_needed
41
+ end
42
+
43
+ # Flush pending executions to the server
44
+ #
45
+ # @return [Hash, nil] Server response or nil if nothing to flush
46
+ def flush
47
+ executions_to_send = nil
48
+
49
+ @mutex.synchronize do
50
+ return nil if @pending_executions.empty?
51
+
52
+ executions_to_send = @pending_executions.dup
53
+ @pending_executions.clear
54
+ @last_flush_at = Time.now
55
+ end
56
+
57
+ send_executions(executions_to_send)
58
+ rescue ApiError => e
59
+ # Re-queue executions on failure
60
+ @mutex.synchronize do
61
+ @pending_executions = executions_to_send + @pending_executions
62
+ end
63
+ Stellwerk.logger.error("Failed to report executions: #{e.message}")
64
+ nil
65
+ end
66
+
67
+ # Get count of pending executions
68
+ #
69
+ # @return [Integer]
70
+ def pending_count
71
+ @mutex.synchronize do
72
+ @pending_executions.length
73
+ end
74
+ end
75
+
76
+ # Start background auto-flush thread
77
+ #
78
+ # @return [Thread]
79
+ def start_background_flush
80
+ @mutex.synchronize do
81
+ return @background_thread if @running
82
+
83
+ @running = true
84
+ @background_thread = Thread.new { background_flush_loop }
85
+ end
86
+ end
87
+
88
+ # Stop background auto-flush thread
89
+ def stop_background_flush
90
+ @mutex.synchronize do
91
+ @running = false
92
+ end
93
+ @background_thread&.join(5)
94
+ @background_thread = nil
95
+ end
96
+
97
+ # Check if background flush is running
98
+ #
99
+ # @return [Boolean]
100
+ def background_flush_running?
101
+ @mutex.synchronize do
102
+ @running && @background_thread&.alive?
103
+ end
104
+ end
105
+
106
+ # Reset metering state
107
+ def reset!
108
+ stop_background_flush
109
+ @mutex.synchronize do
110
+ @pending_executions.clear
111
+ @last_flush_at = Time.now
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def auto_flush_if_needed
118
+ should_flush = @mutex.synchronize do
119
+ time_since_flush = Time.now - @last_flush_at
120
+ time_since_flush >= sync_interval
121
+ end
122
+
123
+ flush if should_flush
124
+ end
125
+
126
+ def background_flush_loop
127
+ while @mutex.synchronize { @running }
128
+ sleep(sync_interval)
129
+ flush if @mutex.synchronize { @running }
130
+ end
131
+ rescue StandardError => e
132
+ Stellwerk.logger.error("Background flush error: #{e.message}")
133
+ @mutex.synchronize { @running = false }
134
+ end
135
+
136
+ def send_executions(executions)
137
+ config = Stellwerk.configuration
138
+ return nil unless config.api_key?
139
+
140
+ client = ApiClient.new(api_url: config.api_url, api_key: config.api_key, verify_ssl: config.verify_ssl)
141
+
142
+ body = {
143
+ executions: executions.map do |exec|
144
+ {
145
+ flow_id: exec.flow_id,
146
+ timestamp: exec.timestamp,
147
+ duration_ms: exec.duration_ms,
148
+ status: exec.status
149
+ }
150
+ end
151
+ }
152
+
153
+ response = client.post("/api/v1/executions/report", body)
154
+
155
+ # Update license with current usage from response
156
+ limits = response["limits"] || {}
157
+ if limits["current_count"]
158
+ Stellwerk.license.update_executions_used(limits["current_count"])
159
+ end
160
+
161
+ response
162
+ end
163
+
164
+ def sync_interval
165
+ Stellwerk.configuration.sync_interval
166
+ end
167
+ end
168
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stellwerk
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/stellwerk.rb CHANGED
@@ -4,6 +4,10 @@ require "logger"
4
4
 
5
5
  require_relative "stellwerk/version"
6
6
  require_relative "stellwerk/errors"
7
+ require_relative "stellwerk/configuration"
8
+ require_relative "stellwerk/api_client"
9
+ require_relative "stellwerk/license"
10
+ require_relative "stellwerk/metering"
7
11
  require_relative "stellwerk/result"
8
12
  require_relative "stellwerk/functions"
9
13
  require_relative "stellwerk/evaluator"
@@ -37,24 +41,83 @@ require_relative "stellwerk/flow"
37
41
  #
38
42
  module Stellwerk
39
43
  class << self
40
- # Configuration options
41
- attr_writer :logger
44
+ # Returns the configuration instance
45
+ #
46
+ # @return [Configuration]
47
+ def configuration
48
+ @configuration ||= Configuration.new
49
+ end
50
+
51
+ # Configure Stellwerk
52
+ #
53
+ # @yield [Configuration] Yields the configuration instance
54
+ # @example
55
+ # Stellwerk.configure do |config|
56
+ # config.api_key = 'sk_stellwerk_...'
57
+ # config.logger = Logger.new(STDOUT)
58
+ # end
59
+ def configure
60
+ yield configuration
61
+ end
62
+
63
+ # Convenience setter for API key
64
+ #
65
+ # @param key [String] The API key
66
+ def api_key=(key)
67
+ configuration.api_key = key
68
+ end
42
69
 
43
70
  # Returns the configured logger (defaults to a null logger)
71
+ #
72
+ # @return [Logger]
44
73
  def logger
45
- @logger ||= Logger.new(File::NULL)
74
+ configuration.logger || @default_logger ||= Logger.new(File::NULL)
46
75
  end
47
76
 
48
- # Configure Stellwerk
77
+ # Set the logger
49
78
  #
50
- # @yield [self] Yields self for configuration
51
- def configure
52
- yield self
79
+ # @param logger [Logger]
80
+ def logger=(logger)
81
+ configuration.logger = logger
53
82
  end
54
83
 
55
- # Reset configuration to defaults
84
+ # Returns the license instance
85
+ #
86
+ # @return [License]
87
+ def license
88
+ @license ||= License.new
89
+ end
90
+
91
+ # Returns the metering instance
92
+ #
93
+ # @return [Metering]
94
+ def metering
95
+ @metering ||= Metering.new
96
+ end
97
+
98
+ # Check if the SDK is properly licensed
99
+ #
100
+ # Validates the API key on first call and caches the result.
101
+ #
102
+ # @return [Boolean]
103
+ def licensed?
104
+ return false unless configuration.api_key?
105
+
106
+ license.valid? || license.validate!
107
+ rescue LicenseError, ApiError
108
+ false
109
+ end
110
+
111
+ # Reset all state to defaults
112
+ #
113
+ # Resets configuration, license, and metering state.
56
114
  def reset!
57
- @logger = nil
115
+ metering.reset!
116
+ license.reset!
117
+ @configuration = nil
118
+ @license = nil
119
+ @metering = nil
120
+ @default_logger = nil
58
121
  end
59
122
  end
60
123
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stellwerk-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roadwerk
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-31 00:00:00.000000000 Z
11
+ date: 2026-02-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dentaku
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '3.12'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.18'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.18'
69
83
  description: |
70
84
  Stellwerk is a standalone Ruby gem for evaluating pre-compiled flow JSON
71
85
  without any Rails or ActiveRecord dependencies. It provides a fast, portable
@@ -80,10 +94,14 @@ files:
80
94
  - LICENSE.txt
81
95
  - README.md
82
96
  - lib/stellwerk.rb
97
+ - lib/stellwerk/api_client.rb
98
+ - lib/stellwerk/configuration.rb
83
99
  - lib/stellwerk/errors.rb
84
100
  - lib/stellwerk/evaluator.rb
85
101
  - lib/stellwerk/flow.rb
86
102
  - lib/stellwerk/functions.rb
103
+ - lib/stellwerk/license.rb
104
+ - lib/stellwerk/metering.rb
87
105
  - lib/stellwerk/result.rb
88
106
  - lib/stellwerk/version.rb
89
107
  homepage: https://github.com/roadwerk/stellwerk-ruby