autonoma-ai 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eadf302b401291f69a0046c7fa35232a31266b25babdcf681fa526eb55a031c7
4
+ data.tar.gz: b67501339a7e2b550e11c68edbd12a244565e6dde13f93f5661dfd37325b0ab4
5
+ SHA512:
6
+ metadata.gz: 824aa95f01aa67791daac6eb17ae4750527b58f80793244c0c16237946415f35cd34cd8a0c25a16b3be9225ae101463b45baaa7cec5383bd407e5914d7eb1581
7
+ data.tar.gz: 5b051dac04a96a1949d02fd1e6ea2a74883f3a6d4874df25f57cc0a381d9fc91ec711a07e0439facb288bf0c28deab376e593b7346e01fb800420900657633ad
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autonoma
4
+ class AutonomaError < StandardError
5
+ attr_reader :message, :code, :status
6
+
7
+ def initialize(message, code, status)
8
+ @message = message
9
+ @code = code
10
+ @status = status
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ module Errors
16
+ def self.invalid_signature
17
+ AutonomaError.new("Invalid signature", "INVALID_SIGNATURE", 401)
18
+ end
19
+
20
+ def self.invalid_body(detail)
21
+ AutonomaError.new("Invalid body: #{detail}", "INVALID_BODY", 400)
22
+ end
23
+
24
+ def self.unknown_action(action)
25
+ AutonomaError.new("Unknown action: #{action}", "UNKNOWN_ACTION", 400)
26
+ end
27
+
28
+ def self.production_blocked
29
+ AutonomaError.new("Blocked in production", "PRODUCTION_BLOCKED", 404)
30
+ end
31
+
32
+ def self.invalid_refs_token(detail)
33
+ AutonomaError.new("Invalid refs token: #{detail}", "INVALID_REFS_TOKEN", 403)
34
+ end
35
+
36
+ def self.same_secrets
37
+ AutonomaError.new(
38
+ "sharedSecret and signingSecret must be different. The shared secret is known by Autonoma; the signing secret must be private.",
39
+ "SAME_SECRETS",
40
+ 500
41
+ )
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "types"
4
+
5
+ module Autonoma
6
+ module Factory
7
+ # Define a factory for creating entities.
8
+ #
9
+ # input_fields is required: an array of hashes {name:, type:, required:}
10
+ # where type is one of 'string', 'integer', 'number', 'boolean',
11
+ # 'timestamp', 'date', 'uuid', 'json'.
12
+ #
13
+ # The factory's create callable receives a validated Hash and a
14
+ # FactoryContext, and must return a Hash with at least an "id" key.
15
+ #
16
+ # teardown is optional; without it the SDK has no way to remove rows
17
+ # the factory created.
18
+ def self.define_factory(create:, input_fields:, teardown: nil)
19
+ raise ArgumentError, 'Factory definition must include a callable "create"' unless create.respond_to?(:call)
20
+
21
+ if teardown && !teardown.respond_to?(:call)
22
+ raise ArgumentError, 'Factory "teardown" must be callable if provided'
23
+ end
24
+
25
+ if input_fields.nil? || !input_fields.is_a?(Array) || input_fields.empty?
26
+ raise ArgumentError,
27
+ "Factory must declare `input_fields: [...]`. The SDK derives the discover " \
28
+ "schema from it; there is no automatic fallback."
29
+ end
30
+
31
+ FactoryDefinition.new(create: create, teardown: teardown, input_fields: input_fields)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module Autonoma
7
+ module Fingerprint
8
+ # Compute a 16-char hex fingerprint of any JSON-serializable value.
9
+ def self.fingerprint(value)
10
+ normalized = sort_keys(value)
11
+ json_str = JSON.generate(normalized)
12
+ OpenSSL::Digest::SHA256.hexdigest(json_str)[0, 16]
13
+ end
14
+
15
+ def self.sort_keys(obj)
16
+ case obj
17
+ when Hash
18
+ obj.sort.to_h.transform_values { |v| sort_keys(v) }
19
+ when Array
20
+ obj.map { |v| sort_keys(v) }
21
+ else
22
+ obj
23
+ end
24
+ end
25
+
26
+ private_class_method :sort_keys
27
+ end
28
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Autonoma
6
+ module Graph
7
+ # Topological sort of nodes by FK dependency edges.
8
+ # Returns {"sorted" => [...], "cycles" => [[...]]}.
9
+ def self.topo_sort(nodes, edges)
10
+ node_set = nodes.to_set
11
+
12
+ # Filter to only edges between known nodes, skip self-referential
13
+ relevant_edges = edges.select do |e|
14
+ e["from"] != e["to"] && node_set.include?(e["from"]) && node_set.include?(e["to"])
15
+ end
16
+
17
+ # Build in-degree map and adjacency list
18
+ in_degree = nodes.each_with_object({}) { |n, h| h[n] = 0 }
19
+ adj = Hash.new { |h, k| h[k] = [] }
20
+
21
+ relevant_edges.each do |e|
22
+ # e["from"] depends on e["to"] (from has the FK pointing to to)
23
+ adj[e["to"]] << e["from"]
24
+ in_degree[e["from"]] = (in_degree[e["from"]] || 0) + 1
25
+ end
26
+
27
+ # First pass: standard Kahn's
28
+ queue = in_degree.select { |_, d| d == 0 }.keys.sort
29
+ sorted_nodes = []
30
+
31
+ until queue.empty?
32
+ node = queue.shift
33
+ sorted_nodes << node
34
+ (adj[node] || []).each do |neighbor|
35
+ in_degree[neighbor] -= 1
36
+ queue << neighbor if in_degree[neighbor] == 0
37
+ end
38
+ queue.sort!
39
+ end
40
+
41
+ # Find unsorted nodes
42
+ unsorted = nodes.reject { |n| sorted_nodes.include?(n) }
43
+
44
+ return { "sorted" => sorted_nodes, "cycles" => [] } if unsorted.empty?
45
+
46
+ cycles = find_sccs(unsorted, relevant_edges)
47
+
48
+ return { "sorted" => sorted_nodes, "cycles" => [] } if cycles.empty?
49
+
50
+ # Second pass: treat cycle nodes as resolved and re-sort remaining
51
+ cycle_nodes = cycles.flatten.to_set
52
+ still_unsorted = unsorted.reject { |n| cycle_nodes.include?(n) }
53
+
54
+ if still_unsorted.any?
55
+ still_set = still_unsorted.to_set
56
+ in_deg2 = still_unsorted.each_with_object({}) { |n, h| h[n] = 0 }
57
+ adj2 = Hash.new { |h, k| h[k] = [] }
58
+
59
+ relevant_edges.each do |e|
60
+ if still_set.include?(e["from"]) && still_set.include?(e["to"])
61
+ adj2[e["to"]] << e["from"]
62
+ in_deg2[e["from"]] = (in_deg2[e["from"]] || 0) + 1
63
+ end
64
+ end
65
+
66
+ queue2 = in_deg2.select { |_, d| d == 0 }.keys.sort
67
+ until queue2.empty?
68
+ node = queue2.shift
69
+ sorted_nodes << node
70
+ (adj2[node] || []).each do |neighbor|
71
+ in_deg2[neighbor] -= 1
72
+ queue2 << neighbor if in_deg2[neighbor] == 0
73
+ end
74
+ queue2.sort!
75
+ end
76
+ end
77
+
78
+ { "sorted" => sorted_nodes, "cycles" => cycles }
79
+ end
80
+
81
+ # Find a nullable FK edge in a cycle that can be deferred.
82
+ def self.find_deferrable_edge(cycle, edges)
83
+ cycle_set = cycle.to_set
84
+ edges.find do |e|
85
+ cycle_set.include?(e["from"]) &&
86
+ cycle_set.include?(e["to"]) &&
87
+ e["from"] != e["to"] &&
88
+ e["nullable"] == true
89
+ end
90
+ end
91
+
92
+ # Tarjan's SCC algorithm to identify exact cycles among remaining nodes.
93
+ def self.find_sccs(nodes, edges)
94
+ node_set = nodes.to_set
95
+ adj = Hash.new { |h, k| h[k] = [] }
96
+
97
+ edges.each do |e|
98
+ adj[e["to"]] << e["from"] if node_set.include?(e["from"]) && node_set.include?(e["to"])
99
+ end
100
+
101
+ index_counter = [0]
102
+ stack = []
103
+ on_stack = Set.new
104
+ indices = {}
105
+ lowlinks = {}
106
+ sccs = []
107
+
108
+ strong_connect = lambda do |v|
109
+ indices[v] = index_counter[0]
110
+ lowlinks[v] = index_counter[0]
111
+ index_counter[0] += 1
112
+ stack.push(v)
113
+ on_stack.add(v)
114
+
115
+ (adj[v] || []).each do |w|
116
+ if !indices.key?(w)
117
+ strong_connect.call(w)
118
+ lowlinks[v] = [lowlinks[v], lowlinks[w]].min
119
+ elsif on_stack.include?(w)
120
+ lowlinks[v] = [lowlinks[v], indices[w]].min
121
+ end
122
+ end
123
+
124
+ if lowlinks[v] == indices[v]
125
+ scc = []
126
+ loop do
127
+ w = stack.pop
128
+ on_stack.delete(w)
129
+ scc << w
130
+ break if w == v
131
+ end
132
+ sccs << scc if scc.length > 1
133
+ end
134
+ end
135
+
136
+ nodes.each do |node|
137
+ strong_connect.call(node) unless indices.key?(node)
138
+ end
139
+
140
+ sccs
141
+ end
142
+
143
+ private_class_method :find_sccs
144
+ end
145
+ end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "set"
6
+
7
+ require_relative "hmac"
8
+ require_relative "refs"
9
+ require_relative "errors"
10
+ require_relative "types"
11
+ require_relative "payload_topo"
12
+ require_relative "schema"
13
+
14
+ module Autonoma
15
+ module Handler
16
+ PROTOCOL_VERSION = begin
17
+ File.read(File.expand_path("../../../../protocol/version.txt", __dir__)).strip
18
+ rescue Errno::ENOENT, Errno::EACCES
19
+ "1.0"
20
+ end
21
+
22
+ TOKEN_RE = /\{\{\s*([^{}]+?)\s*\}\}/
23
+ CYCLE_RE = /\Acycle\((.*)\)\z/
24
+
25
+ def self.handle_request(config, req)
26
+ if config.shared_secret == config.signing_secret
27
+ raise Errors.same_secrets
28
+ end
29
+
30
+ unless config.allow_production
31
+ env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || ENV["ENV"]
32
+ raise Errors.production_blocked if env == "production"
33
+ end
34
+
35
+ signature = req.headers["x-signature"] || req.headers["X-Signature"] || ""
36
+
37
+ unless Hmac.verify_signature(req.body, signature, config.shared_secret)
38
+ raise Errors.invalid_signature
39
+ end
40
+
41
+ begin
42
+ body = JSON.parse(req.body)
43
+ rescue JSON::ParserError
44
+ raise Errors.invalid_body("invalid JSON")
45
+ end
46
+
47
+ action = body["action"]
48
+ raise Errors.invalid_body("missing action") unless action
49
+
50
+ case action
51
+ when "discover"
52
+ handle_discover(config)
53
+ when "up"
54
+ handle_up(config, body)
55
+ when "down"
56
+ handle_down(config, body)
57
+ else
58
+ raise Errors.unknown_action(action)
59
+ end
60
+ rescue AutonomaError => e
61
+ HandlerResponse.new(status: e.status, body: { "error" => e.message, "code" => e.code })
62
+ rescue StandardError => e
63
+ HandlerResponse.new(status: 500, body: { "error" => e.message, "code" => "INTERNAL_ERROR" })
64
+ end
65
+
66
+ # -----------------------------------------------------------------------
67
+ # discover
68
+ # -----------------------------------------------------------------------
69
+
70
+ def self.handle_discover(config)
71
+ schema = Schema.build_schema_from_factories(config.factories || {}, config.scope_field)
72
+ HandlerResponse.new(
73
+ status: 200,
74
+ body: build_sdk_meta(config).merge("schema" => Schema.schema_to_wire(schema))
75
+ )
76
+ end
77
+
78
+ # -----------------------------------------------------------------------
79
+ # up
80
+ # -----------------------------------------------------------------------
81
+
82
+ def self.handle_up(config, body)
83
+ create = body["create"]
84
+ raise Errors.invalid_body('missing "create" in request body') unless create
85
+
86
+ test_run_id = body["testRunId"] || SecureRandom.uuid
87
+
88
+ factories = config.factories || {}
89
+ if factories.empty?
90
+ raise Errors.invalid_body(
91
+ "no factories registered -- every model in `create` must have a factory."
92
+ )
93
+ end
94
+
95
+ tree = PayloadTopo.resolve_payload_tree(create)
96
+
97
+ refs = {}
98
+ id_map = {}
99
+
100
+ # Track per-model run index for {{index}} / {{cycle()}} substitution.
101
+ model_index = Hash.new(0)
102
+
103
+ tree.ops.each do |op|
104
+ model = op.model
105
+ factory = factories[model]
106
+ if factory.nil?
107
+ raise Errors.invalid_body(
108
+ "no factory registered for model \"#{model}\". " \
109
+ "Register one with define_factory(...) and add it to HandlerConfig factories."
110
+ )
111
+ end
112
+
113
+ idx = model_index[model]
114
+ model_index[model] = idx + 1
115
+
116
+ # Substitute built-in tokens then swap temp ids for real ids.
117
+ resolved = resolve_tokens(op.fields, test_run_id, idx)
118
+ resolved = swap_temp_ids(resolved, id_map)
119
+
120
+ # Validate through the factory's input_fields.
121
+ validated = Schema.validate_input(resolved, factory.input_fields)
122
+
123
+ ctx = FactoryContext.new(
124
+ refs: refs,
125
+ scenario_name: test_run_id,
126
+ test_run_id: test_run_id
127
+ )
128
+
129
+ record = factory.create.call(validated, ctx)
130
+
131
+ # Normalise to hash if needed.
132
+ if record.respond_to?(:to_h) && !record.is_a?(Hash)
133
+ record = record.to_h
134
+ end
135
+
136
+ if !record.is_a?(Hash) || record["id"].nil?
137
+ raise AutonomaError.new(
138
+ "Factory for \"#{model}\" must return a record hash with \"id\"",
139
+ "FACTORY_MISSING_PK",
140
+ 500
141
+ )
142
+ end
143
+
144
+ refs[model] = (refs[model] || []) + [record]
145
+ id_map[op.temp_id] = record["id"]
146
+ end
147
+
148
+ # Auth callback gets the first User (case-insensitive on model name).
149
+ auth_user = find_first_user(refs)
150
+ scope_value = detect_scope_value(refs, config.scope_field) || test_run_id
151
+ auth_context = AuthContext.new(scope_value: scope_value, refs: refs)
152
+ auth = config.auth.call(auth_user, auth_context)
153
+
154
+ if config.after_up
155
+ hook_ctx = HookContext.new(scenario_name: scope_value, refs: refs)
156
+ auth = config.after_up.call(hook_ctx, auth)
157
+ end
158
+
159
+ refs_token = Refs.sign_refs(
160
+ {
161
+ "refs" => refs,
162
+ "testRunId" => scope_value,
163
+ "environment" => "",
164
+ "aliasDependencies" => tree.alias_dependencies,
165
+ "aliasOwnerModel" => tree.alias_owner_model
166
+ },
167
+ config.signing_secret
168
+ )
169
+
170
+ HandlerResponse.new(
171
+ status: 200,
172
+ body: build_sdk_meta(config).merge(
173
+ "auth" => auth,
174
+ "refs" => refs,
175
+ "refsToken" => refs_token
176
+ )
177
+ )
178
+ end
179
+
180
+ # -----------------------------------------------------------------------
181
+ # down
182
+ # -----------------------------------------------------------------------
183
+
184
+ def self.handle_down(config, body)
185
+ refs_token = body["refsToken"]
186
+ raise Errors.invalid_body("missing refsToken") unless refs_token
187
+
188
+ begin
189
+ payload = Refs.verify_refs(refs_token, config.signing_secret)
190
+ rescue StandardError => e
191
+ raise Errors.invalid_refs_token(e.message)
192
+ end
193
+
194
+ refs = payload["refs"] || {}
195
+ test_run_id = payload["testRunId"] || ""
196
+ alias_deps = payload["aliasDependencies"] || {}
197
+ alias_owner_model = payload["aliasOwnerModel"] || {}
198
+
199
+ if config.before_down
200
+ hook_ctx = HookContext.new(scenario_name: test_run_id, refs: refs)
201
+ config.before_down.call(hook_ctx)
202
+ end
203
+
204
+ factories = config.factories || {}
205
+ teardown_order = PayloadTopo.compute_teardown_order(refs, alias_deps, alias_owner_model)
206
+
207
+ teardown_order.each do |model|
208
+ factory = factories[model]
209
+ next if factory.nil? || factory.teardown.nil?
210
+
211
+ records = refs[model] || []
212
+ ctx = FactoryContext.new(
213
+ refs: refs,
214
+ scenario_name: test_run_id,
215
+ test_run_id: test_run_id
216
+ )
217
+
218
+ records.reverse_each do |record|
219
+ factory.teardown.call(record, ctx)
220
+ end
221
+ end
222
+
223
+ HandlerResponse.new(status: 200, body: build_sdk_meta(config).merge("ok" => true))
224
+ end
225
+
226
+ # -----------------------------------------------------------------------
227
+ # helpers
228
+ # -----------------------------------------------------------------------
229
+
230
+ def self.build_sdk_meta(config)
231
+ sdk = config.sdk || {}
232
+ {
233
+ "version" => PROTOCOL_VERSION,
234
+ "sdk" => {
235
+ "language" => "ruby",
236
+ "orm" => sdk["orm"] || sdk[:orm] || "unknown",
237
+ "server" => sdk["server"] || sdk[:server] || "unknown"
238
+ }
239
+ }
240
+ end
241
+
242
+ # Substitute built-in tokens in field values: {{testRunId}}, {{index}},
243
+ # {{cycle(a,b,c)}}. Raises AutonomaError(UNRESOLVED_TOKEN) for any other
244
+ # {{token}}.
245
+ def self.resolve_tokens(value, test_run_id, index)
246
+ case value
247
+ when String
248
+ value.gsub(TOKEN_RE) do
249
+ token = Regexp.last_match(1).strip
250
+ if token == "testRunId"
251
+ test_run_id
252
+ elsif token == "index"
253
+ index.to_s
254
+ elsif (m = CYCLE_RE.match(token))
255
+ parts = m[1].split(",").map { |p| p.strip.gsub(/\A['"]|['"]\z/, "") }
256
+ parts.empty? ? "" : parts[index % parts.length]
257
+ else
258
+ raise AutonomaError.new(
259
+ "Unresolved token: {{#{token}}}",
260
+ "UNRESOLVED_TOKEN",
261
+ 400
262
+ )
263
+ end
264
+ end
265
+ when Array
266
+ value.map { |v| resolve_tokens(v, test_run_id, index) }
267
+ when Hash
268
+ value.each_with_object({}) { |(k, v), out| out[k] = resolve_tokens(v, test_run_id, index) }
269
+ else
270
+ value
271
+ end
272
+ end
273
+
274
+ # Replace any __temp_* placeholder string with its real id.
275
+ def self.swap_temp_ids(value, id_map)
276
+ case value
277
+ when String
278
+ value.start_with?("__temp_") ? (id_map[value] || value) : value
279
+ when Hash
280
+ value.each_with_object({}) { |(k, v), out| out[k] = swap_temp_ids(v, id_map) }
281
+ when Array
282
+ value.map { |v| swap_temp_ids(v, id_map) }
283
+ else
284
+ value
285
+ end
286
+ end
287
+
288
+ def self.find_first_user(refs)
289
+ refs.each do |model, records|
290
+ normalized = model.downcase
291
+ return records.first if (normalized == "user" || normalized == "users") && records.any?
292
+ end
293
+ nil
294
+ end
295
+
296
+ def self.detect_scope_value(refs, scope_field)
297
+ scope_normalized = scope_field.delete("_").downcase
298
+ refs.each_value do |records|
299
+ records.each do |record|
300
+ record.each do |key, value|
301
+ return value if key.delete("_").downcase == scope_normalized && value.is_a?(String)
302
+ end
303
+ end
304
+ end
305
+ nil
306
+ end
307
+
308
+ private_class_method :build_sdk_meta, :handle_discover, :handle_up,
309
+ :handle_down, :find_first_user, :detect_scope_value,
310
+ :resolve_tokens, :swap_temp_ids
311
+ end
312
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Autonoma
6
+ # Constant-time string comparison to prevent timing attacks.
7
+ # Shared by Hmac and Refs modules.
8
+ def self.secure_compare(a, b)
9
+ return false unless a.bytesize == b.bytesize
10
+
11
+ l = a.unpack("C*")
12
+ r = b.unpack("C*")
13
+ result = 0
14
+ l.each_with_index { |v, i| result |= v ^ r[i] }
15
+ result.zero?
16
+ end
17
+
18
+ module Hmac
19
+ # Sign a body string with a secret using HMAC-SHA256. Returns 64-char lowercase hex.
20
+ def self.sign_body(body, secret)
21
+ OpenSSL::HMAC.hexdigest("SHA256", secret, body)
22
+ end
23
+
24
+ # Verify a signature using constant-time comparison.
25
+ def self.verify_signature(body, signature, secret)
26
+ expected = sign_body(body, secret)
27
+ return false unless expected.length == signature.length
28
+
29
+ Autonoma.secure_compare(expected, signature)
30
+ rescue StandardError
31
+ false
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autonoma
4
+ module PayloadTopo
5
+ # Output of resolve_payload_tree.
6
+ class ResolvedTree
7
+ attr_accessor :ops, :aliases, :alias_owner_model, :alias_dependencies
8
+
9
+ def initialize
10
+ @ops = []
11
+ @aliases = {}
12
+ @alias_owner_model = {}
13
+ @alias_dependencies = {}
14
+ end
15
+ end
16
+
17
+ RESERVED_KEYS = Set.new(%w[_alias _ref]).freeze
18
+
19
+ # Walk a field value tree and append every _ref alias found.
20
+ def self.collect_refs(value, out)
21
+ case value
22
+ when Hash
23
+ ref = value["_ref"]
24
+ if ref.is_a?(String)
25
+ out << ref
26
+ return
27
+ end
28
+ value.each_value { |v| collect_refs(v, out) }
29
+ when Array
30
+ value.each { |v| collect_refs(v, out) }
31
+ end
32
+ end
33
+
34
+ # Replace each {"_ref": alias} with its temp id.
35
+ def self.resolve_refs(value, alias_to_temp_id)
36
+ case value
37
+ when Hash
38
+ ref = value["_ref"]
39
+ if ref.is_a?(String)
40
+ real = alias_to_temp_id[ref]
41
+ return real.nil? ? value : real
42
+ end
43
+ value.each_with_object({}) { |(k, v), out| out[k] = resolve_refs(v, alias_to_temp_id) }
44
+ when Array
45
+ value.map { |v| resolve_refs(v, alias_to_temp_id) }
46
+ else
47
+ value
48
+ end
49
+ end
50
+
51
+ # Topo-sort a create payload into an ordered list of CreateOp.
52
+ def self.resolve_payload_tree(create)
53
+ unless create.is_a?(Hash)
54
+ raise Errors.invalid_body("`create` must be an object keyed by model name")
55
+ end
56
+
57
+ # First pass: assign temp ids and collect alias declarations.
58
+ raw_entries = [] # [model, temp_id, entity, alias_name]
59
+ counter = 0
60
+ aliases = {}
61
+ alias_owner_model = {}
62
+
63
+ create.each do |model, entities|
64
+ unless entities.is_a?(Array)
65
+ raise Errors.invalid_body(
66
+ "`create.#{model}` must be a list of entity objects, got #{entities.class.name}"
67
+ )
68
+ end
69
+
70
+ entities.each do |entity|
71
+ unless entity.is_a?(Hash)
72
+ raise Errors.invalid_body(
73
+ "`create.#{model}` entries must be objects, got #{entity.class.name}"
74
+ )
75
+ end
76
+
77
+ temp_id = "__temp_#{model}_#{counter}"
78
+ counter += 1
79
+
80
+ alias_name = entity["_alias"]
81
+ if alias_name.is_a?(String)
82
+ if aliases.key?(alias_name)
83
+ raise Errors.invalid_body("duplicate _alias \"#{alias_name}\"")
84
+ end
85
+ aliases[alias_name] = temp_id
86
+ alias_owner_model[alias_name] = model
87
+ elsif !alias_name.nil?
88
+ raise Errors.invalid_body('"_alias" must be a string')
89
+ end
90
+
91
+ raw_entries << [model, temp_id, entity, alias_name.is_a?(String) ? alias_name : nil]
92
+ end
93
+ end
94
+
95
+ # Second pass: collect dependencies and strip reserved keys.
96
+ deps_by_temp_id = {}
97
+ fields_by_temp_id = {}
98
+ model_by_temp_id = {}
99
+ alias_by_temp_id = {}
100
+
101
+ raw_entries.each do |model, temp_id, entity, alias_name|
102
+ deps = []
103
+ cleaned = {}
104
+
105
+ entity.each do |key, value|
106
+ next if RESERVED_KEYS.include?(key)
107
+
108
+ collect_refs(value, deps)
109
+ cleaned[key] = resolve_refs(value, aliases)
110
+ end
111
+
112
+ unknown = deps.select { |a| !aliases.key?(a) }.uniq.sort
113
+ unless unknown.empty?
114
+ raise Errors.invalid_body(
115
+ "`create.#{model}` references unknown alias(es): #{unknown.join(", ")}"
116
+ )
117
+ end
118
+
119
+ deps_by_temp_id[temp_id] = deps
120
+ fields_by_temp_id[temp_id] = cleaned
121
+ model_by_temp_id[temp_id] = model
122
+ alias_by_temp_id[temp_id] = alias_name
123
+ end
124
+
125
+ # Build the temp_id graph and topo-sort.
126
+ in_degree = {}
127
+ raw_entries.each { |_, temp_id, _, _| in_degree[temp_id] = 0 }
128
+
129
+ edges = Hash.new { |h, k| h[k] = [] }
130
+
131
+ deps_by_temp_id.each do |temp_id, deps|
132
+ seen = Set.new
133
+ deps.each do |dep_alias|
134
+ dep_temp_id = aliases[dep_alias]
135
+ next if dep_temp_id == temp_id || seen.include?(dep_temp_id)
136
+
137
+ seen.add(dep_temp_id)
138
+ edges[dep_temp_id] << temp_id
139
+ in_degree[temp_id] += 1
140
+ end
141
+ end
142
+
143
+ # Kahn's, preserving payload order as the stable tie-breaker.
144
+ payload_order = {}
145
+ raw_entries.each_with_index { |(_, temp_id, _, _), idx| payload_order[temp_id] = idx }
146
+
147
+ ready = in_degree.select { |_, d| d == 0 }.keys.sort_by { |t| payload_order[t] }
148
+ sorted_temp_ids = []
149
+
150
+ until ready.empty?
151
+ tid = ready.shift
152
+ sorted_temp_ids << tid
153
+ (edges[tid] || []).each do |nxt|
154
+ in_degree[nxt] -= 1
155
+ ready << nxt if in_degree[nxt] == 0
156
+ end
157
+ ready.sort_by! { |t| payload_order[t] }
158
+ end
159
+
160
+ if sorted_temp_ids.length != payload_order.length
161
+ cycle = in_degree.select { |_, d| d > 0 }.keys.sort_by { |t| payload_order[t] }
162
+ cycle_models = cycle.map { |t| model_by_temp_id[t] }.join(", ")
163
+ raise Errors.invalid_body("cycle detected in _alias/_ref graph: #{cycle_models}")
164
+ end
165
+
166
+ # Build the result.
167
+ tree = ResolvedTree.new
168
+ tree.aliases = aliases
169
+ tree.alias_owner_model = alias_owner_model
170
+ tree.alias_dependencies = aliases.each_with_object({}) do |(alias_name, temp_id), h|
171
+ h[alias_name] = deps_by_temp_id[temp_id] || []
172
+ end
173
+
174
+ sorted_temp_ids.each do |tid|
175
+ tree.ops << CreateOp.new(
176
+ model: model_by_temp_id[tid],
177
+ fields: fields_by_temp_id[tid],
178
+ temp_id: tid
179
+ )
180
+ end
181
+
182
+ tree
183
+ end
184
+
185
+ # Order models for teardown.
186
+ def self.compute_teardown_order(refs, alias_dependencies, alias_owner_model)
187
+ models = refs.keys
188
+
189
+ if alias_dependencies.nil? || alias_dependencies.empty? ||
190
+ alias_owner_model.nil? || alias_owner_model.empty?
191
+ return models.reverse
192
+ end
193
+
194
+ # Build model-level dependency graph.
195
+ model_deps = models.each_with_object({}) { |m, h| h[m] = Set.new }
196
+
197
+ alias_dependencies.each do |alias_name, deps|
198
+ owner = alias_owner_model[alias_name]
199
+ next if owner.nil? || !model_deps.key?(owner)
200
+
201
+ deps.each do |dep_alias|
202
+ dep_model = alias_owner_model[dep_alias]
203
+ next if dep_model.nil? || dep_model == owner || !model_deps.key?(dep_model)
204
+
205
+ model_deps[owner].add(dep_model)
206
+ end
207
+ end
208
+
209
+ # Kahn's over models.
210
+ in_degree = models.each_with_object({}) { |m, h| h[m] = 0 }
211
+ adj = Hash.new { |h, k| h[k] = [] }
212
+
213
+ model_deps.each do |owner, deps|
214
+ deps.each do |dep_model|
215
+ adj[dep_model] << owner
216
+ in_degree[owner] += 1
217
+ end
218
+ end
219
+
220
+ payload_order = models.each_with_index.to_h
221
+ ready = in_degree.select { |_, d| d == 0 }.keys.sort_by { |m| payload_order[m] }
222
+ up_order = []
223
+
224
+ until ready.empty?
225
+ m = ready.shift
226
+ up_order << m
227
+ (adj[m] || []).each do |nxt|
228
+ in_degree[nxt] -= 1
229
+ ready << nxt if in_degree[nxt] == 0
230
+ end
231
+ ready.sort_by! { |m2| payload_order[m2] }
232
+ end
233
+
234
+ # Fall back to reversed insertion order if cycle detected (shouldn't happen).
235
+ return models.reverse if up_order.length != models.length
236
+
237
+ up_order.reverse
238
+ end
239
+
240
+ private_class_method :collect_refs, :resolve_refs
241
+ end
242
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+ require "bigdecimal"
7
+ require "date"
8
+ require "time"
9
+
10
+ module Autonoma
11
+ module Refs
12
+ # Sign a refs payload into a 3-part token string.
13
+ def self.sign_refs(payload, secret)
14
+ header = base64url_encode(JSON.generate({ alg: "HS256", typ: "REFS" }))
15
+ body = base64url_encode(JSON.generate(make_json_safe(payload)))
16
+ signature = hmac_sign("#{header}.#{body}", secret)
17
+ "#{header}.#{body}.#{signature}"
18
+ end
19
+
20
+ # Verify and decode a refs token. Returns the payload hash or raises.
21
+ def self.verify_refs(token, secret)
22
+ parts = token.split(".")
23
+ raise "malformed token" unless parts.length == 3
24
+
25
+ header, body, signature = parts
26
+ expected = hmac_sign("#{header}.#{body}", secret)
27
+
28
+ raise "signature mismatch" unless Autonoma.secure_compare(expected, signature)
29
+
30
+ JSON.parse(base64url_decode(body))
31
+ end
32
+
33
+ def self.base64url_encode(data)
34
+ data = data.encode("UTF-8") if data.is_a?(String)
35
+ Base64.urlsafe_encode64(data, padding: false)
36
+ end
37
+
38
+ def self.base64url_decode(data)
39
+ # Add padding back
40
+ padding = 4 - (data.length % 4)
41
+ data += "=" * padding if padding != 4
42
+ Base64.urlsafe_decode64(data)
43
+ end
44
+
45
+ def self.hmac_sign(data, secret)
46
+ sig = OpenSSL::HMAC.digest("SHA256", secret, data)
47
+ Base64.urlsafe_encode64(sig, padding: false)
48
+ end
49
+
50
+ # Recursively convert non-JSON-safe types (Time, DateTime, BigDecimal, etc.)
51
+ # to strings so that JSON.generate does not raise.
52
+ def self.make_json_safe(obj)
53
+ case obj
54
+ when Hash
55
+ obj.transform_values { |v| make_json_safe(v) }
56
+ when Array
57
+ obj.map { |v| make_json_safe(v) }
58
+ when Time, DateTime
59
+ obj.iso8601(3)
60
+ when Date
61
+ obj.iso8601
62
+ when BigDecimal
63
+ obj.to_s("F")
64
+ when Symbol
65
+ obj.to_s
66
+ else
67
+ obj
68
+ end
69
+ end
70
+
71
+ private_class_method :base64url_encode, :base64url_decode, :hmac_sign
72
+ end
73
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autonoma
4
+ module Schema
5
+ VALID_TYPES = Set.new(%w[string integer number boolean timestamp date uuid json]).freeze
6
+
7
+ # Map a Ruby type string to the SDK's coarse type string.
8
+ def self.field_type_from_class(type_str)
9
+ return "string" if type_str.nil?
10
+
11
+ normalized = type_str.to_s.downcase
12
+ VALID_TYPES.include?(normalized) ? normalized : "string"
13
+ end
14
+
15
+ # Convert CamelCase to snake_case for cosmetic tableName.
16
+ def self.camel_to_snake(name)
17
+ out = []
18
+ name.each_char.with_index do |ch, i|
19
+ if ch =~ /[A-Z]/ && i > 0 && name[i - 1] !~ /[A-Z]/
20
+ out << "_"
21
+ end
22
+ out << ch.downcase
23
+ end
24
+ out.join
25
+ end
26
+
27
+ # Build field list from input_fields array.
28
+ # Each entry is a hash with :name, :type, :required keys.
29
+ def self.fields_from_input_fields(input_fields)
30
+ fields = [
31
+ FieldInfo.new(
32
+ name: "id",
33
+ type: "string",
34
+ is_required: false,
35
+ is_id: true,
36
+ has_default: true
37
+ )
38
+ ]
39
+
40
+ (input_fields || []).each do |f|
41
+ name = f[:name] || f["name"]
42
+ type = f[:type] || f["type"]
43
+ required = f[:required].nil? ? f["required"] : f[:required]
44
+
45
+ fields << FieldInfo.new(
46
+ name: name.to_s,
47
+ type: field_type_from_class(type),
48
+ is_required: !!required,
49
+ is_id: false,
50
+ has_default: !required
51
+ )
52
+ end
53
+
54
+ fields
55
+ end
56
+
57
+ # Build the SDK's discover-time schema from registered factories.
58
+ def self.build_schema_from_factories(factories, scope_field)
59
+ models = []
60
+
61
+ (factories || {}).each do |entity, factory|
62
+ if factory.input_fields.nil? || factory.input_fields.empty?
63
+ raise ArgumentError,
64
+ "Factory \"#{entity}\" has no input_fields. " \
65
+ "Every factory must declare input_fields in define_factory(...)."
66
+ end
67
+
68
+ models << ModelInfo.new(
69
+ name: entity,
70
+ table_name: camel_to_snake(entity),
71
+ fields: fields_from_input_fields(factory.input_fields)
72
+ )
73
+ end
74
+
75
+ SchemaInfo.new(
76
+ models: models,
77
+ edges: [],
78
+ relations: [],
79
+ scope_field: scope_field
80
+ )
81
+ end
82
+
83
+ # Serialise a SchemaInfo to the JSON shape the dashboard expects.
84
+ def self.schema_to_wire(schema)
85
+ {
86
+ "models" => schema.models.map do |m|
87
+ {
88
+ "name" => m.name,
89
+ "tableName" => m.table_name,
90
+ "fields" => m.fields.map do |f|
91
+ {
92
+ "name" => f.name,
93
+ "type" => f.type,
94
+ "isRequired" => f.is_required,
95
+ "isId" => f.is_id,
96
+ "hasDefault" => f.has_default
97
+ }
98
+ end
99
+ }
100
+ end,
101
+ "edges" => schema.edges.map do |e|
102
+ {
103
+ "from" => e.from_model,
104
+ "to" => e.to_model,
105
+ "localField" => e.local_field,
106
+ "foreignField" => e.foreign_field,
107
+ "nullable" => e.nullable
108
+ }
109
+ end,
110
+ "relations" => schema.relations.map do |r|
111
+ {
112
+ "parentModel" => r.parent_model,
113
+ "childModel" => r.child_model,
114
+ "parentField" => r.parent_field,
115
+ "childField" => r.child_field
116
+ }
117
+ end,
118
+ "scopeField" => schema.scope_field
119
+ }
120
+ end
121
+
122
+ # Validate input fields hash: strip unknown keys, check required fields.
123
+ def self.validate_input(fields, input_fields)
124
+ known_names = Set.new
125
+ required_names = []
126
+
127
+ (input_fields || []).each do |f|
128
+ name = (f[:name] || f["name"]).to_s
129
+ known_names.add(name)
130
+ req = f[:required].nil? ? f["required"] : f[:required]
131
+ required_names << name if req
132
+ end
133
+
134
+ # Strip unknown keys
135
+ validated = fields.select { |k, _| known_names.include?(k.to_s) }
136
+
137
+ # Check required fields
138
+ missing = required_names.select { |name| !validated.key?(name) && !validated.key?(name.to_sym) }
139
+ unless missing.empty?
140
+ raise Errors.invalid_body("missing required fields: #{missing.join(", ")}")
141
+ end
142
+
143
+ validated
144
+ end
145
+
146
+ private_class_method :camel_to_snake, :fields_from_input_fields
147
+ end
148
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Autonoma
4
+ FieldInfo = Struct.new(:name, :type, :is_required, :is_id, :has_default, keyword_init: true)
5
+ ModelInfo = Struct.new(:name, :table_name, :fields, keyword_init: true)
6
+
7
+ FKEdge = Struct.new(:from_model, :to_model, :local_field, :foreign_field, :nullable, keyword_init: true)
8
+
9
+ SchemaRelation = Struct.new(:parent_model, :child_model, :parent_field, :child_field, keyword_init: true)
10
+
11
+ SchemaInfo = Struct.new(:models, :edges, :relations, :scope_field, keyword_init: true)
12
+
13
+ CreateOp = Struct.new(:model, :fields, :temp_id, keyword_init: true)
14
+
15
+ HandlerRequest = Struct.new(:body, :headers, keyword_init: true) do
16
+ def initialize(body:, headers: {})
17
+ super
18
+ end
19
+ end
20
+
21
+ HandlerResponse = Struct.new(:status, :body, keyword_init: true)
22
+
23
+ HookContext = Struct.new(:scenario_name, :refs, keyword_init: true)
24
+ AuthContext = Struct.new(:scope_value, :refs, keyword_init: true)
25
+
26
+ FactoryContext = Struct.new(:refs, :scenario_name, :test_run_id, keyword_init: true)
27
+
28
+ FactoryDefinition = Struct.new(:create, :teardown, :input_fields, keyword_init: true)
29
+
30
+ HandlerConfig = Struct.new(
31
+ :scope_field,
32
+ :shared_secret,
33
+ :signing_secret,
34
+ :allow_production,
35
+ :auth,
36
+ :sdk,
37
+ :before_down,
38
+ :after_up,
39
+ :factories,
40
+ keyword_init: true
41
+ ) do
42
+ def initialize(scope_field:, shared_secret:, signing_secret:, auth:,
43
+ allow_production: false, sdk: nil,
44
+ before_down: nil, after_up: nil, factories: nil)
45
+ super
46
+ end
47
+ end
48
+ end
data/lib/autonoma.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "autonoma/errors"
4
+ require_relative "autonoma/types"
5
+ require_relative "autonoma/hmac"
6
+ require_relative "autonoma/refs"
7
+ require_relative "autonoma/graph"
8
+ require_relative "autonoma/fingerprint"
9
+ require_relative "autonoma/payload_topo"
10
+ require_relative "autonoma/schema"
11
+ require_relative "autonoma/factory"
12
+ require_relative "autonoma/handler"
13
+
14
+ module Autonoma
15
+ VERSION = "0.2.0"
16
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "autonoma"
4
+
5
+ module AutonomaRails
6
+ # Rails controller mixin for handling Autonoma protocol requests.
7
+ #
8
+ # Usage in a Rails controller:
9
+ #
10
+ # class AutonomaController < ApplicationController
11
+ # include AutonomaRails::Handler
12
+ #
13
+ # skip_before_action :verify_authenticity_token
14
+ #
15
+ # def handle
16
+ # autonoma_handle(autonoma_config)
17
+ # end
18
+ #
19
+ # private
20
+ #
21
+ # def autonoma_config
22
+ # @autonoma_config ||= Autonoma::HandlerConfig.new(
23
+ # scope_field: "organizationId",
24
+ # shared_secret: ENV["AUTONOMA_SHARED_SECRET"],
25
+ # signing_secret: ENV["AUTONOMA_SIGNING_SECRET"],
26
+ # auth: ->(user, ctx) { { "token" => "..." } },
27
+ # factories: { "User" => Autonoma::Factory.define_factory(...) }
28
+ # )
29
+ # end
30
+ # end
31
+ #
32
+ module Handler
33
+ def autonoma_handle(config)
34
+ enriched = config.dup
35
+ sdk = (enriched.sdk || {}).merge("server" => "rails")
36
+ enriched.sdk = sdk
37
+
38
+ body_str = request.raw_post
39
+ headers = request.headers.to_h
40
+ .select { |k, _| k.is_a?(String) }
41
+ .transform_keys(&:downcase)
42
+
43
+ req = Autonoma::HandlerRequest.new(body: body_str, headers: headers)
44
+ result = Autonoma::Handler.handle_request(enriched, req)
45
+
46
+ render json: Autonoma::Refs.make_json_safe(result.body), status: result.status
47
+ end
48
+ end
49
+
50
+ # Rack middleware alternative for mounting Autonoma as a Rack app.
51
+ class Middleware
52
+ def initialize(app, config, path: "/api/autonoma")
53
+ @app = app
54
+ @config = enrich_config(config)
55
+ @path = path
56
+ end
57
+
58
+ def call(env)
59
+ unless env["PATH_INFO"] == @path && env["REQUEST_METHOD"] == "POST"
60
+ return @app.call(env)
61
+ end
62
+
63
+ body_str = env["rack.input"].read
64
+ env["rack.input"].rewind
65
+
66
+ headers = {}
67
+ env.each do |key, value|
68
+ if key.start_with?("HTTP_")
69
+ header_name = key[5..].downcase.tr("_", "-")
70
+ headers[header_name] = value
71
+ end
72
+ end
73
+ headers["content-type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
74
+
75
+ req = Autonoma::HandlerRequest.new(body: body_str, headers: headers)
76
+ result = Autonoma::Handler.handle_request(@config, req)
77
+
78
+ [
79
+ result.status,
80
+ { "Content-Type" => "application/json" },
81
+ [JSON.generate(Autonoma::Refs.make_json_safe(result.body))]
82
+ ]
83
+ end
84
+
85
+ private
86
+
87
+ def enrich_config(config)
88
+ enriched = config.dup
89
+ sdk = (enriched.sdk || {}).merge("server" => "rails")
90
+ enriched.sdk = sdk
91
+ enriched
92
+ end
93
+ end
94
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: autonoma-ai
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Autonoma AI
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Ruby SDK for the Autonoma Environment Factory. Handles HMAC verification,
42
+ JWT refs, template resolution, factory-driven entity creation, and scoped teardown.
43
+ email:
44
+ - eng@autonoma.ai
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - lib/autonoma.rb
50
+ - lib/autonoma/errors.rb
51
+ - lib/autonoma/factory.rb
52
+ - lib/autonoma/fingerprint.rb
53
+ - lib/autonoma/graph.rb
54
+ - lib/autonoma/handler.rb
55
+ - lib/autonoma/hmac.rb
56
+ - lib/autonoma/payload_topo.rb
57
+ - lib/autonoma/refs.rb
58
+ - lib/autonoma/schema.rb
59
+ - lib/autonoma/types.rb
60
+ - lib/autonoma_rails/server.rb
61
+ homepage: https://autonoma.ai
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://autonoma.ai
66
+ source_code_uri: https://github.com/Autonoma-AI/sdk
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.1'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.0.3.1
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Autonoma SDK — automate the Autonoma Environment Factory endpoint
86
+ test_files: []