smith-agents 0.11.0 → 0.12.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.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +712 -0
  3. data/README.md +622 -15
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +80 -14
  6. data/docs/PATTERNS.md +30 -13
  7. data/docs/PERSISTENCE.md +733 -5
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +67 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/invocation_preparation.rb +37 -14
  18. data/lib/smith/agent/lifecycle.rb +16 -5
  19. data/lib/smith/agent/observed_model.rb +29 -0
  20. data/lib/smith/agent/output_contract_check.rb +53 -0
  21. data/lib/smith/agent/output_integrity.rb +137 -0
  22. data/lib/smith/agent/output_policy.rb +92 -0
  23. data/lib/smith/agent/output_recovery.rb +129 -0
  24. data/lib/smith/agent/provider_attempt.rb +6 -0
  25. data/lib/smith/agent/provider_call_timing.rb +13 -4
  26. data/lib/smith/agent/provider_completion.rb +35 -32
  27. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  28. data/lib/smith/agent/provider_request.rb +36 -0
  29. data/lib/smith/agent/provider_streaming.rb +155 -0
  30. data/lib/smith/agent/provider_usage.rb +70 -3
  31. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  32. data/lib/smith/agent/usage_traces.rb +9 -2
  33. data/lib/smith/agent/usage_tracking.rb +64 -51
  34. data/lib/smith/agent.rb +5 -0
  35. data/lib/smith/artifacts/active_record_store.rb +79 -0
  36. data/lib/smith/artifacts/file.rb +45 -18
  37. data/lib/smith/artifacts/memory.rb +47 -36
  38. data/lib/smith/artifacts/s3.rb +126 -0
  39. data/lib/smith/artifacts/scoped_store.rb +55 -7
  40. data/lib/smith/artifacts/store_rules.rb +49 -0
  41. data/lib/smith/artifacts.rb +98 -1
  42. data/lib/smith/batch/message_shape.rb +36 -0
  43. data/lib/smith/batch.rb +166 -0
  44. data/lib/smith/budget/amount.rb +25 -0
  45. data/lib/smith/budget/continuation.rb +153 -0
  46. data/lib/smith/budget/host_ledger.rb +137 -0
  47. data/lib/smith/budget/ledger.rb +4 -2
  48. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  49. data/lib/smith/budget/request_hold.rb +211 -0
  50. data/lib/smith/budget/spent_reservation.rb +18 -0
  51. data/lib/smith/child_run.rb +41 -0
  52. data/lib/smith/child_runs_failed.rb +56 -0
  53. data/lib/smith/content.rb +96 -0
  54. data/lib/smith/context/session.rb +14 -2
  55. data/lib/smith/context/state_injection.rb +10 -5
  56. data/lib/smith/context.rb +28 -0
  57. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  58. data/lib/smith/doctor/checks/configuration.rb +0 -11
  59. data/lib/smith/doctor/checks/durability.rb +20 -18
  60. data/lib/smith/doctor/checks/state_model.rb +63 -0
  61. data/lib/smith/doctor/installer.rb +9 -2
  62. data/lib/smith/doctor.rb +4 -0
  63. data/lib/smith/errors.rb +249 -2
  64. data/lib/smith/idempotency_key/canonical.rb +74 -0
  65. data/lib/smith/idempotency_key.rb +145 -0
  66. data/lib/smith/launchers/inline.rb +46 -0
  67. data/lib/smith/message.rb +76 -0
  68. data/lib/smith/models/tool_routing.rb +47 -44
  69. data/lib/smith/output_failure.rb +130 -0
  70. data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
  71. data/lib/smith/pricing/catalog_validation.rb +91 -0
  72. data/lib/smith/pricing.rb +95 -64
  73. data/lib/smith/providers/anthropic/batches.rb +116 -0
  74. data/lib/smith/providers/batch_exchange.rb +77 -0
  75. data/lib/smith/providers/openai/responses.rb +45 -20
  76. data/lib/smith/providers/output_limit.rb +68 -0
  77. data/lib/smith/providers/request_holds.rb +139 -0
  78. data/lib/smith/providers/response_facts.rb +108 -0
  79. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  80. data/lib/smith/providers/schema_constraints.rb +87 -0
  81. data/lib/smith/providers/schema_rules.rb +66 -0
  82. data/lib/smith/providers/stop_reason.rb +39 -0
  83. data/lib/smith/providers/stream_facts.rb +192 -0
  84. data/lib/smith/response_memo.rb +152 -0
  85. data/lib/smith/setting_constructors.rb +47 -0
  86. data/lib/smith/streaming/json_field.rb +136 -0
  87. data/lib/smith/streaming/json_string.rb +91 -0
  88. data/lib/smith/streaming/session.rb +77 -0
  89. data/lib/smith/streaming/think_tags.rb +67 -0
  90. data/lib/smith/streaming.rb +64 -0
  91. data/lib/smith/testing/checks.rb +58 -0
  92. data/lib/smith/testing/hooks.rb +85 -0
  93. data/lib/smith/testing/minitest.rb +37 -0
  94. data/lib/smith/testing/rspec.rb +52 -0
  95. data/lib/smith/testing/stub.rb +110 -0
  96. data/lib/smith/testing.rb +158 -0
  97. data/lib/smith/tool/scoped_context.rb +9 -12
  98. data/lib/smith/tool.rb +14 -0
  99. data/lib/smith/trace/logger.rb +1 -1
  100. data/lib/smith/trace/memory.rb +1 -1
  101. data/lib/smith/trace/open_telemetry.rb +1 -1
  102. data/lib/smith/trace.rb +11 -4
  103. data/lib/smith/untrusted.rb +74 -0
  104. data/lib/smith/version.rb +2 -2
  105. data/lib/smith/workflow/active_clock.rb +61 -0
  106. data/lib/smith/workflow/agent_result.rb +37 -0
  107. data/lib/smith/workflow/artifact_integration.rb +57 -7
  108. data/lib/smith/workflow/await_step.rb +42 -0
  109. data/lib/smith/workflow/batch_charges.rb +74 -0
  110. data/lib/smith/workflow/batch_execution.rb +149 -0
  111. data/lib/smith/workflow/batch_holds.rb +73 -0
  112. data/lib/smith/workflow/batch_wait.rb +134 -0
  113. data/lib/smith/workflow/budget_integration.rb +11 -5
  114. data/lib/smith/workflow/budget_pause.rb +51 -0
  115. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  116. data/lib/smith/workflow/child_run_execution.rb +150 -0
  117. data/lib/smith/workflow/child_run_results.rb +55 -0
  118. data/lib/smith/workflow/composite/effects.rb +37 -6
  119. data/lib/smith/workflow/composite/error.rb +1 -1
  120. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  121. data/lib/smith/workflow/composite/input.rb +11 -4
  122. data/lib/smith/workflow/context_keys.rb +77 -0
  123. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  124. data/lib/smith/workflow/deterministic_execution.rb +61 -10
  125. data/lib/smith/workflow/deterministic_step.rb +55 -6
  126. data/lib/smith/workflow/dsl.rb +59 -0
  127. data/lib/smith/workflow/durability.rb +139 -30
  128. data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
  129. data/lib/smith/workflow/event_integration.rb +9 -0
  130. data/lib/smith/workflow/execution.rb +30 -23
  131. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  132. data/lib/smith/workflow/failure_reconstructor.rb +27 -4
  133. data/lib/smith/workflow/failure_record.rb +6 -1
  134. data/lib/smith/workflow/failure_record_validator.rb +11 -4
  135. data/lib/smith/workflow/fanout_execution.rb +5 -8
  136. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  137. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  138. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  139. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  140. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  141. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  142. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  143. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  144. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  145. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  146. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  147. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  148. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  149. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  150. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  151. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  152. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  153. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  154. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  155. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  156. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  157. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  158. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  159. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  160. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  161. data/lib/smith/workflow/graph.rb +12 -0
  162. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  163. data/lib/smith/workflow/message_batch.rb +6 -0
  164. data/lib/smith/workflow/message_roles.rb +25 -0
  165. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  166. data/lib/smith/workflow/nested_execution.rb +47 -4
  167. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  168. data/lib/smith/workflow/parallel_execution.rb +10 -0
  169. data/lib/smith/workflow/persistence.rb +136 -13
  170. data/lib/smith/workflow/remembered_calls.rb +92 -0
  171. data/lib/smith/workflow/retry_execution.rb +68 -9
  172. data/lib/smith/workflow/retry_wait.rb +134 -0
  173. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  174. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  175. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  176. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  177. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  178. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  179. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  180. data/lib/smith/workflow/step_completion.rb +36 -7
  181. data/lib/smith/workflow/step_context.rb +21 -0
  182. data/lib/smith/workflow/step_io.rb +132 -0
  183. data/lib/smith/workflow/step_stream.rb +52 -0
  184. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  185. data/lib/smith/workflow/transition.rb +267 -13
  186. data/lib/smith/workflow/transition_actionability.rb +2 -0
  187. data/lib/smith/workflow/usage_entry.rb +24 -3
  188. data/lib/smith/workflow/wait_completion.rb +131 -0
  189. data/lib/smith/workflow/wait_execution.rb +180 -0
  190. data/lib/smith/workflow/wait_record.rb +157 -0
  191. data/lib/smith/workflow.rb +113 -17
  192. data/lib/smith.rb +101 -45
  193. metadata +88 -1
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "store_rules"
6
+
7
+ module Smith
8
+ module Artifacts
9
+ # An artifact store in a database table, through the host's Active
10
+ # Record model (a class or its name):
11
+ #
12
+ # create_table :smith_artifacts do |t|
13
+ # t.string :ref, null: false, index: { unique: true }
14
+ # t.binary :data, null: false, limit: 64.megabytes # MySQL's blob holds 64 KiB
15
+ # t.string :content_type, null: false
16
+ # t.string :execution_namespace, index: true
17
+ # t.timestamps
18
+ # end
19
+ #
20
+ # Each artifact is one row, its ref a random UUID (after the store's
21
+ # tenant `namespace:` when one is set), with the run that stored it, so
22
+ # the store can say which run stored a ref.
23
+ class ActiveRecordStore
24
+ include StoreRules
25
+
26
+ INNER_REF = StoreRules::UUID
27
+
28
+ def initialize(model:, namespace: nil)
29
+ @model_source = model.is_a?(String) ? model.dup.freeze : model
30
+ @namespace = check_namespace!(namespace)
31
+ end
32
+
33
+ def store(data, content_type: "application/octet-stream", execution_namespace: nil)
34
+ enforce_tenant_isolation!
35
+ check_data!(data)
36
+ ref = generate_ref(SecureRandom.uuid)
37
+ model_class.create!(ref:, data:, content_type:, execution_namespace:)
38
+ ref
39
+ end
40
+
41
+ def fetch(ref)
42
+ enforce_tenant_isolation!
43
+ model_class.find_by(ref:)&.data if owns_ref?(ref)
44
+ end
45
+
46
+ # Whether a run (its execution namespace) stored this ref.
47
+ def stored_by?(ref, execution_namespace)
48
+ !execution_namespace.nil? && owns_ref?(ref) && model_class.where(ref:, execution_namespace:).exists?
49
+ end
50
+
51
+ def model = model_class
52
+
53
+ def expired(retention: nil, execution_namespace: nil)
54
+ retention = effective_retention(retention)
55
+ return [] unless retention
56
+
57
+ scope = model_class.where(model_class.arel_table[:created_at].lt(Time.now.utc - retention))
58
+ scope = scope.where(execution_namespace:) if execution_namespace
59
+ scope.pluck(:ref).select { |ref| owns_ref?(ref) }
60
+ end
61
+
62
+ # Removes the artifact (when a run is named, only if that run stored
63
+ # it).
64
+ def delete(ref, execution_namespace: nil)
65
+ enforce_tenant_isolation!
66
+ return unless owns_ref?(ref)
67
+
68
+ scope = model_class.where(ref:)
69
+ scope = scope.where(execution_namespace:) if execution_namespace
70
+ scope.delete_all
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ def model_class = @model_source.is_a?(String) ? Object.const_get(@model_source) : @model_source
77
+ end
78
+ end
79
+ end
@@ -1,38 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
3
+ require "fileutils"
4
4
  require "json"
5
+ require "securerandom"
6
+
7
+ require_relative "store_rules"
5
8
 
6
9
  module Smith
7
10
  module Artifacts
11
+ # An artifact store in a directory (`dir:`), shared only when the
12
+ # directory is. Each artifact is a file named by a random UUID (after
13
+ # the store's tenant `namespace:` when one is set) beside a `.meta`
14
+ # file with its type and the run that stored it; no ref names any other
15
+ # file.
8
16
  class File
17
+ include StoreRules
18
+
19
+ INNER_REF = StoreRules::UUID
20
+
9
21
  def initialize(dir:, namespace: nil)
10
22
  @dir = dir
11
- @namespace = namespace
23
+ @namespace = check_namespace!(namespace)
12
24
  end
13
25
 
14
26
  def store(data, content_type: "application/octet-stream", execution_namespace: nil)
15
- ref = generate_ref
27
+ enforce_tenant_isolation!
28
+ check_data!(data)
29
+ ref = generate_ref(SecureRandom.uuid)
16
30
  meta = { content_type: content_type }
17
31
  meta[:execution_namespace] = execution_namespace if execution_namespace
18
- ::File.write(::File.join(@dir, ref), data)
32
+ ::File.binwrite(::File.join(@dir, ref), data)
19
33
  ::File.write(::File.join(@dir, "#{ref}.meta"), JSON.generate(meta))
20
34
  ref
21
35
  end
22
36
 
23
37
  def fetch(ref)
38
+ enforce_tenant_isolation!
39
+ return unless owns_ref?(ref)
40
+
24
41
  path = ::File.join(@dir, ref)
25
- ::File.exist?(path) ? ::File.read(path) : nil
42
+ ::File.binread(path) if ::File.file?(path)
43
+ end
44
+
45
+ # Removes the artifact (when a run is named, only if that run stored
46
+ # it).
47
+ def delete(ref, execution_namespace: nil)
48
+ enforce_tenant_isolation!
49
+ return unless owns_ref?(ref) && (execution_namespace.nil? || stored_by?(ref, execution_namespace))
50
+
51
+ [ref, "#{ref}.meta"].each { |name| ::FileUtils.rm_f(::File.join(@dir, name)) }
52
+ nil
53
+ end
54
+
55
+ # Whether a run (its execution namespace) stored this ref.
56
+ def stored_by?(ref, execution_namespace)
57
+ !execution_namespace.nil? && owns_ref?(ref) && stored_namespace(ref) == execution_namespace
26
58
  end
27
59
 
28
60
  def expired(retention: nil, execution_namespace: nil)
61
+ retention = effective_retention(retention)
29
62
  return [] unless retention
30
63
 
31
64
  cutoff = Time.now.utc - retention
32
- Dir.glob(::File.join(@dir, "*")).reject { |f| f.end_with?(".meta") }.filter_map do |path|
65
+ Dir.glob(::File.join(@dir, "*")).filter_map do |path|
33
66
  ref = ::File.basename(path)
34
- next unless ::File.mtime(path).utc < cutoff
35
- next if execution_namespace && !matches_execution_namespace?(ref, execution_namespace)
67
+ next unless owns_ref?(ref) && ::File.mtime(path).utc < cutoff
68
+ next if execution_namespace && stored_namespace(ref) != execution_namespace
36
69
 
37
70
  ref
38
71
  end
@@ -40,19 +73,13 @@ module Smith
40
73
 
41
74
  private
42
75
 
43
- def generate_ref
44
- raw = SecureRandom.uuid
45
- @namespace ? "#{@namespace}:#{raw}" : raw
46
- end
47
-
48
- def matches_execution_namespace?(ref, execution_namespace)
76
+ def stored_namespace(ref)
49
77
  meta_path = ::File.join(@dir, "#{ref}.meta")
50
- return false unless ::File.exist?(meta_path)
78
+ return unless ::File.exist?(meta_path)
51
79
 
52
- meta = JSON.parse(::File.read(meta_path), symbolize_names: true)
53
- meta[:execution_namespace] == execution_namespace
80
+ JSON.parse(::File.read(meta_path))["execution_namespace"]
54
81
  rescue JSON::ParserError
55
- false
82
+ nil
56
83
  end
57
84
  end
58
85
  end
@@ -2,73 +2,84 @@
2
2
 
3
3
  require "digest"
4
4
 
5
+ require_relative "store_rules"
6
+
5
7
  module Smith
6
8
  module Artifacts
9
+ # An artifact store in the process: for one process and for tests.
10
+ # Refs are content hashes, so runs that store the same bytes share one;
11
+ # each run's claim on it (and when it made the claim) is kept apart, so
12
+ # a run's delete drops only its own claim, and the bytes go with the
13
+ # last claim.
7
14
  class Memory
15
+ include StoreRules
16
+
17
+ INNER_REF = /\A\h{64}\z/
18
+ HOST = :host
19
+ private_constant :HOST
20
+
8
21
  attr_reader :namespace
9
22
 
10
23
  def initialize(namespace: nil)
11
- @namespace = namespace
24
+ @namespace = check_namespace!(namespace)
12
25
  @store = {}
13
26
  @metadata = {}
14
27
  end
15
28
 
16
29
  def store(data, content_type: "application/octet-stream", execution_namespace: nil)
17
30
  enforce_tenant_isolation!
18
- ref = generate_ref(data)
31
+ check_data!(data)
32
+ ref = generate_ref(Digest::SHA256.hexdigest(data))
19
33
  @store[ref] = data
20
- @metadata[ref] ||= { content_type: content_type, stored_at: Time.now.utc, execution_namespaces: [] }
21
- tag_execution_namespace(ref, execution_namespace)
34
+ meta = (@metadata[ref] ||= { content_type:, claims: {} })
35
+ meta[:claims][execution_namespace || HOST] = Time.now.utc
22
36
  ref
23
37
  end
24
38
 
25
39
  def fetch(ref)
26
40
  enforce_tenant_isolation!
27
- return nil unless owns_ref?(ref)
28
-
29
- @store[ref]
30
- end
31
-
32
- def expired(retention: nil, execution_namespace: nil)
33
- effective_retention = retention || Smith.config.artifact_retention
34
- return [] unless effective_retention
35
-
36
- cutoff = Time.now.utc - effective_retention
37
- @metadata.select { |ref, meta| expired_match?(ref, meta, cutoff, execution_namespace) }.keys
41
+ @store[ref] if owns_ref?(ref)
38
42
  end
39
43
 
40
- private
44
+ # Drops a run's claim (or, with no run named, every claim); the bytes
45
+ # go when no claim is left.
46
+ def delete(ref, execution_namespace: nil)
47
+ enforce_tenant_isolation!
48
+ meta = @metadata[ref] if owns_ref?(ref)
49
+ return unless meta
41
50
 
42
- def tag_execution_namespace(ref, execution_namespace)
43
- return unless execution_namespace
51
+ execution_namespace ? meta[:claims].delete(execution_namespace) : meta[:claims].clear
52
+ return unless meta[:claims].empty?
44
53
 
45
- namespaces = @metadata[ref][:execution_namespaces]
46
- namespaces << execution_namespace unless namespaces.include?(execution_namespace)
54
+ @store.delete(ref)
55
+ @metadata.delete(ref)
56
+ nil
47
57
  end
48
58
 
49
- def expired_match?(ref, meta, cutoff, execution_namespace)
50
- owns_ref?(ref) &&
51
- meta[:stored_at] < cutoff &&
52
- (execution_namespace.nil? || meta[:execution_namespaces]&.include?(execution_namespace))
53
- end
59
+ # Whether a run (its execution namespace) stored this ref.
60
+ def stored_by?(ref, execution_namespace)
61
+ return false if execution_namespace.nil? || !owns_ref?(ref)
54
62
 
55
- def generate_ref(data)
56
- content_hash = Digest::SHA256.hexdigest(data.to_s)
57
- @namespace ? "#{@namespace}:#{content_hash}" : content_hash
63
+ @metadata.dig(ref, :claims)&.key?(execution_namespace) || false
58
64
  end
59
65
 
60
- def owns_ref?(ref)
61
- if @namespace
62
- ref.start_with?("#{@namespace}:")
63
- else
64
- !ref.include?(":")
66
+ # Refs whose claims (or the run's claim, when one is named) are all
67
+ # older than the retention.
68
+ def expired(retention: nil, execution_namespace: nil)
69
+ retention = effective_retention(retention)
70
+ return [] unless retention
71
+
72
+ cutoff = Time.now.utc - retention
73
+ @metadata.filter_map do |ref, meta|
74
+ ref if owns_ref?(ref) && claims_before?(meta[:claims], execution_namespace, cutoff)
65
75
  end
66
76
  end
67
77
 
68
- def enforce_tenant_isolation!
69
- return unless Smith.config.artifact_tenant_isolation
78
+ private
70
79
 
71
- raise Smith::Error, "artifact_tenant_isolation requires a namespace" unless @namespace
80
+ def claims_before?(claims, execution_namespace, cutoff)
81
+ claims = claims.slice(execution_namespace) if execution_namespace
82
+ !claims.empty? && claims.values.all? { |stored_at| stored_at < cutoff }
72
83
  end
73
84
  end
74
85
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "store_rules"
6
+
7
+ module Smith
8
+ module Artifacts
9
+ # An artifact store in an S3-compatible bucket: AWS S3, Cloudflare R2
10
+ # (`endpoint: "https://<account>.r2.cloudflarestorage.com"`,
11
+ # `region: "auto"`), MinIO and the like. Each artifact is one object
12
+ # under `prefix`, its ref a random UUID (after the store's tenant
13
+ # `namespace:` when one is set), and the run that stored it is kept in
14
+ # the object's metadata, so the store can say which run stored a ref.
15
+ #
16
+ # The client is the host's `Aws::S3::Client` (`client:`), or one built
17
+ # from the remaining options (`region:`, `endpoint:`,
18
+ # `access_key_id:`, `secret_access_key:`, ...), which needs the
19
+ # `aws-sdk-s3` gem in the host's bundle; Smith does not depend on it.
20
+ # Only keys of the store's ref shape under the prefix are artifacts, so
21
+ # other objects in the bucket are neither read, listed nor deleted.
22
+ class S3
23
+ include StoreRules
24
+
25
+ INNER_REF = StoreRules::UUID
26
+ NAMESPACE_KEY = "smith-execution-namespace"
27
+
28
+ def initialize(bucket:, prefix: "smith/artifacts", namespace: nil, client: nil, **client_options)
29
+ if client && client_options.any?
30
+ raise ArgumentError, "give Smith::Artifacts::S3 a client: or the options to build one, not both"
31
+ end
32
+
33
+ @bucket = bucket
34
+ @prefix = prefix.to_s.delete_suffix("/")
35
+ @namespace = check_namespace!(namespace)
36
+ @client = client || self.class.build_client(**client_options)
37
+ end
38
+
39
+ def self.build_client(**)
40
+ require "aws-sdk-s3"
41
+ ::Aws::S3::Client.new(**)
42
+ rescue LoadError
43
+ raise Smith::Error, "Smith::Artifacts::S3 needs the aws-sdk-s3 gem in the host's bundle, or a client:"
44
+ end
45
+
46
+ def store(data, content_type: "application/octet-stream", execution_namespace: nil)
47
+ enforce_tenant_isolation!
48
+ check_data!(data)
49
+ ref = generate_ref(SecureRandom.uuid)
50
+ metadata = execution_namespace ? { NAMESPACE_KEY => execution_namespace } : {}
51
+ @client.put_object(bucket: @bucket, key: object_key(ref), body: data, content_type:, metadata:)
52
+ ref
53
+ end
54
+
55
+ def fetch(ref)
56
+ enforce_tenant_isolation!
57
+ return unless owns_ref?(ref)
58
+
59
+ @client.get_object(bucket: @bucket, key: object_key(ref)).body.read
60
+ rescue StandardError => e
61
+ raise unless missing?(e)
62
+ end
63
+
64
+ # Whether a run (its execution namespace) stored this ref.
65
+ def stored_by?(ref, execution_namespace)
66
+ return false if execution_namespace.nil? || !owns_ref?(ref)
67
+
68
+ @client.head_object(bucket: @bucket, key: object_key(ref)).metadata[NAMESPACE_KEY] == execution_namespace
69
+ rescue StandardError => e
70
+ raise unless missing?(e)
71
+
72
+ false
73
+ end
74
+
75
+ # Listing the prefix, then (for one run's refs) a HEAD per expired
76
+ # object.
77
+ def expired(retention: nil, execution_namespace: nil)
78
+ retention = effective_retention(retention)
79
+ return [] unless retention
80
+
81
+ cutoff = Time.now.utc - retention
82
+ objects.filter_map do |object|
83
+ ref = object.key.delete_prefix(key_prefix)
84
+ next unless object.last_modified.utc < cutoff && owns_ref?(ref)
85
+ next if execution_namespace && !stored_by?(ref, execution_namespace)
86
+
87
+ ref
88
+ end
89
+ end
90
+
91
+ # Removes the artifact (when a run is named, only if that run stored
92
+ # it).
93
+ def delete(ref, execution_namespace: nil)
94
+ enforce_tenant_isolation!
95
+ return unless owns_ref?(ref) && (execution_namespace.nil? || stored_by?(ref, execution_namespace))
96
+
97
+ @client.delete_object(bucket: @bucket, key: object_key(ref))
98
+ nil
99
+ end
100
+
101
+ private
102
+
103
+ def key_prefix = @prefix.empty? ? "" : "#{@prefix}/"
104
+
105
+ def object_key(ref) = "#{key_prefix}#{ref}"
106
+
107
+ # Every object under the prefix, page by page.
108
+ def objects
109
+ Enumerator.new do |yielder|
110
+ token = nil
111
+ loop do
112
+ page = @client.list_objects_v2(bucket: @bucket, prefix: key_prefix, continuation_token: token)
113
+ page.contents.each { |object| yielder << object }
114
+ break unless page.is_truncated
115
+
116
+ token = page.next_continuation_token
117
+ end
118
+ end
119
+ end
120
+
121
+ # The SDK's missing-object errors (NoSuchKey on a read, NotFound on a
122
+ # head), by name, so Smith loads no SDK class.
123
+ def missing?(error) = error.class.name.to_s.end_with?("::NoSuchKey", "::NotFound")
124
+ end
125
+ end
126
+ end
@@ -2,27 +2,75 @@
2
2
 
3
3
  module Smith
4
4
  module Artifacts
5
+ # A run's view of the artifact store: it stores under its own
6
+ # namespace, and reads its own refs and those of the namespaces it may
7
+ # read (a child run's parent and that parent's ancestors). A store that
8
+ # records who stored a ref (`stored_by?`, as every built-in store does)
9
+ # is asked, so a ref names an artifact one of those runs stored and no
10
+ # other; with any other store the namespace prefix alone decides a read,
11
+ # and a delete is refused.
12
+ # Without a `backend:`, the configured store is resolved when a step
13
+ # first stores or reads, so a run that keeps no artifacts needs none.
5
14
  class ScopedStore
6
- def initialize(backend:, namespace:)
15
+ def initialize(namespace:, backend: nil, readable: [])
7
16
  @backend = backend
8
17
  @namespace = namespace
18
+ @readable = [namespace, *readable].freeze
9
19
  end
10
20
 
11
21
  def store(data, content_type: "application/octet-stream")
12
- inner_ref = @backend.store(data, content_type: content_type, execution_namespace: @namespace)
22
+ inner_ref = backend.store(data, content_type: content_type, execution_namespace: @namespace)
13
23
  "#{@namespace}:#{inner_ref}"
14
24
  end
15
25
 
16
26
  def fetch(ref)
17
- return nil unless ref.start_with?("#{@namespace}:")
27
+ return unless ref.is_a?(String)
18
28
 
19
- inner_ref = ref.delete_prefix("#{@namespace}:")
20
- @backend.fetch(inner_ref)
29
+ namespace = @readable.find { |candidate| ref.start_with?("#{candidate}:") }
30
+ return nil unless namespace
31
+
32
+ inner = ref.delete_prefix("#{namespace}:")
33
+ return nil if vouches? && !backend.stored_by?(inner, namespace)
34
+
35
+ backend.fetch(inner)
36
+ end
37
+
38
+ # Removes one of this run's own refs (never a readable ancestor's or
39
+ # another run's): only through a store that can tell which run stored
40
+ # a ref. A store whose delete takes the run (every built-in one) is
41
+ # asked to remove only what this run stored, and Memory drops only
42
+ # this run's claim on bytes other runs share; any other store deletes
43
+ # a ref this run stored.
44
+ def delete(ref)
45
+ unless vouches? && backend.respond_to?(:delete)
46
+ raise Smith::Error, "deleting a run's artifact needs a store with stored_by? and delete"
47
+ end
48
+ return unless ref.is_a?(String)
49
+
50
+ inner = ref.delete_prefix("#{@namespace}:")
51
+ if per_run_delete? then backend.delete(inner, execution_namespace: @namespace)
52
+ elsif backend.stored_by?(inner, @namespace) then backend.delete(inner)
53
+ end
54
+ nil
21
55
  end
22
56
 
57
+ # Whether the store can tell which run stored a ref.
58
+ def vouches? = backend.respond_to?(:stored_by?)
59
+
23
60
  def expired(retention: nil)
24
- @backend.expired(retention: retention, execution_namespace: @namespace)
25
- .map { |ref| "#{@namespace}:#{ref}" }
61
+ raise Smith::Error, "the artifact store does not list expired refs" unless backend.respond_to?(:expired)
62
+
63
+ backend.expired(retention: retention, execution_namespace: @namespace).map { |ref| "#{@namespace}:#{ref}" }
64
+ end
65
+
66
+ private
67
+
68
+ def backend = @backend ||= Smith.artifact_backend
69
+
70
+ def per_run_delete?
71
+ backend.method(:delete).parameters.any? do |kind, name|
72
+ %i[key keyreq].include?(kind) && name == :execution_namespace
73
+ end
26
74
  end
27
75
  end
28
76
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Artifacts
5
+ # What every built-in store holds to: a tenant `namespace:` of letters,
6
+ # digits, `-` and `_` (so its refs stay readable), the
7
+ # `artifact_tenant_isolation` setting (refuse to work without a
8
+ # namespace), refs that are the store's own (its tenant's prefix, then
9
+ # the store's ref shape, nothing else), the `artifact_retention`
10
+ # setting when no retention is given, and no run behind a ref whose run
11
+ # is not named.
12
+ module StoreRules
13
+ NAMESPACE = /\A[A-Za-z0-9_-]+\z/
14
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
15
+
16
+ private
17
+
18
+ def check_namespace!(namespace)
19
+ return namespace if namespace.nil? || (namespace.is_a?(String) && NAMESPACE.match?(namespace))
20
+
21
+ raise ArgumentError, "an artifact store's namespace: must be letters, digits, - and _"
22
+ end
23
+
24
+ def enforce_tenant_isolation!
25
+ return unless Smith.config.artifact_tenant_isolation
26
+
27
+ raise Smith::Error, "artifact_tenant_isolation requires a namespace" unless @namespace
28
+ end
29
+
30
+ # The store's tenant prefix, then a ref of the store's shape.
31
+ def owns_ref?(ref)
32
+ return false unless ref.is_a?(String)
33
+
34
+ inner = @namespace ? ref.delete_prefix("#{@namespace}:") : ref
35
+ (!@namespace || inner != ref) && self.class::INNER_REF.match?(inner)
36
+ end
37
+
38
+ def effective_retention(retention) = retention || Smith.config.artifact_retention
39
+
40
+ # An artifact is bytes: other values would be stored as whatever a
41
+ # store makes of them (Ruby's inspect, or a refusal).
42
+ def check_data!(data)
43
+ raise TypeError, "an artifact is bytes (a String); serialize other values first" unless data.is_a?(String)
44
+ end
45
+
46
+ def generate_ref(raw) = @namespace ? "#{@namespace}:#{raw}" : raw
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- module Artifacts; end
4
+ # Where steps keep payloads too large to keep inline, and the documents
5
+ # and images content parts name. Like workflow state, artifacts must
6
+ # outlive the process that stored them whenever a run waits, retries or
7
+ # resumes elsewhere, so the store is configured, never assumed:
8
+ #
9
+ # Smith.configure do |config|
10
+ # config.artifact_store = :s3 # or :active_record, :file, :memory, or a store
11
+ # config.artifact_options = { bucket: "smith-artifacts",
12
+ # endpoint: "https://<account>.r2.cloudflarestorage.com" }
13
+ # end
14
+ #
15
+ # With nothing configured, `Smith.artifacts` raises
16
+ # Smith::ArtifactStoreMissing, except in `test_mode`, where it is an
17
+ # in-process Memory store.
18
+ module Artifacts
19
+ REQUIRED_METHODS = %i[store fetch].freeze
20
+ NAMED = { memory: :Memory, file: :File, s3: :S3, active_record: :ActiveRecordStore }.freeze
21
+
22
+ # The store a configuration names: a store itself (an object, or a
23
+ # class or module whose own methods store and fetch), a store class
24
+ # (built with the options), or one of :memory, :file, :s3 and
25
+ # :active_record. A store needs `store` and `fetch`; `stored_by?` lets
26
+ # runs read only their own refs, and `expired` and `delete` serve
27
+ # cleanup.
28
+ def self.resolve(store, **)
29
+ return if store.nil?
30
+ return store if REQUIRED_METHODS.all? { |method_name| store.respond_to?(method_name) }
31
+ return validate!(store.new(**)) if store.is_a?(Class)
32
+ return validate!(store) unless store.is_a?(Symbol) || store.is_a?(String)
33
+
34
+ named(store, **)
35
+ end
36
+
37
+ def self.named(store, **)
38
+ name = NAMED.fetch(store.to_sym) do
39
+ raise ArgumentError, "unknown artifact store #{store.inspect}; use :s3, :active_record, :file or :memory"
40
+ end
41
+ const_get(name).new(**)
42
+ end
43
+
44
+ def self.validate!(store)
45
+ missing = REQUIRED_METHODS.reject { |method_name| store.respond_to?(method_name) }
46
+ return store if missing.empty?
47
+
48
+ raise ArgumentError, "an artifact store must implement #{missing.join(", ")}"
49
+ end
50
+ end
51
+
52
+ # Inside a step, the run's scoped view of the artifact store; elsewhere
53
+ # the store itself.
54
+ def self.artifacts
55
+ scoped_artifacts || artifact_backend
56
+ end
57
+
58
+ # The configured store itself, never a run's scoped view of it.
59
+ # Built once per configuration, under a lock, so threads that resolve it
60
+ # together share one store.
61
+ def self.artifact_backend
62
+ signature = artifact_signature
63
+ current = @_artifact_current
64
+ return current.last if current&.first == signature
65
+
66
+ (@_artifact_lock ||= Mutex.new).synchronize do
67
+ current = @_artifact_current
68
+ return current.last if current&.first == signature
69
+
70
+ @_artifact_current = [signature, build_artifact_backend].freeze
71
+ @_artifact_current.last
72
+ end
73
+ end
74
+
75
+ def self.build_artifact_backend
76
+ resolved = Artifacts.resolve(config.artifact_store, **(config.artifact_options || {}))
77
+ resolved ||= Artifacts::Memory.new if config.test_mode
78
+ resolved || raise(ArtifactStoreMissing)
79
+ end
80
+ private_class_method :build_artifact_backend
81
+
82
+ # A configured store object is compared by identity; a name or class by
83
+ # value, with its options; test_mode matters only when nothing is set.
84
+ def self.artifact_signature
85
+ store = config.artifact_store
86
+ name = store.is_a?(Symbol) || store.is_a?(String) || store.is_a?(Module) ? snapshot_value(store) : store.object_id
87
+ [name, snapshot_value(config.artifact_options || {}), store.nil? && config.test_mode]
88
+ end
89
+ private_class_method :artifact_signature
90
+
91
+ def self.artifacts=(store)
92
+ config.artifact_store = store
93
+ end
94
+
95
+ def self.scoped_artifacts
96
+ Thread.current[:smith_scoped_artifacts]
97
+ end
98
+
99
+ def self.scoped_artifacts=(store)
100
+ Thread.current[:smith_scoped_artifacts] = store
101
+ end
5
102
  end