smith-agents 0.4.5 → 0.6.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.
Files changed (186) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -0
  3. data/README.md +191 -4
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +347 -25
  7. data/lib/smith/agent/lifecycle.rb +19 -10
  8. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  9. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  10. data/lib/smith/agent/registry.rb +8 -0
  11. data/lib/smith/agent.rb +37 -2
  12. data/lib/smith/budget/amount_contract.rb +67 -0
  13. data/lib/smith/budget/amount_representation.rb +43 -0
  14. data/lib/smith/budget/decimal_context.rb +18 -0
  15. data/lib/smith/budget/ledger.rb +110 -18
  16. data/lib/smith/budget/ledger_publication.rb +60 -0
  17. data/lib/smith/budget/ledger_state.rb +21 -0
  18. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  19. data/lib/smith/budget/public_state_validator.rb +25 -0
  20. data/lib/smith/budget/reservation.rb +20 -0
  21. data/lib/smith/budget/reservation_contract.rb +34 -0
  22. data/lib/smith/context/observation_masking.rb +3 -1
  23. data/lib/smith/context/state_injection.rb +1 -1
  24. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -4
  25. data/lib/smith/error.rb +5 -0
  26. data/lib/smith/errors.rb +10 -0
  27. data/lib/smith/exponential_backoff.rb +128 -0
  28. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  29. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  31. data/lib/smith/persistence_adapters/active_record_store.rb +7 -4
  32. data/lib/smith/persistence_adapters/cache_store.rb +1 -0
  33. data/lib/smith/persistence_adapters/memory.rb +16 -1
  34. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  35. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  36. data/lib/smith/persistence_adapters/redis_store.rb +28 -11
  37. data/lib/smith/persistence_adapters/retry.rb +22 -10
  38. data/lib/smith/persistence_adapters.rb +21 -18
  39. data/lib/smith/persistence_payload_conflict.rb +12 -0
  40. data/lib/smith/tool/budget_enforcement.rb +2 -2
  41. data/lib/smith/types.rb +1 -0
  42. data/lib/smith/version.rb +2 -1
  43. data/lib/smith/workflow/branch_env.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +21 -11
  45. data/lib/smith/workflow/composite/branch.rb +93 -0
  46. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  47. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  48. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  49. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  50. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  51. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  52. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  53. data/lib/smith/workflow/composite/contract.rb +100 -0
  54. data/lib/smith/workflow/composite/effects.rb +140 -0
  55. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  56. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  57. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  58. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  59. data/lib/smith/workflow/composite/enums.rb +27 -0
  60. data/lib/smith/workflow/composite/error.rb +54 -0
  61. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  62. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  63. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  64. data/lib/smith/workflow/composite/input.rb +53 -0
  65. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  66. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  67. data/lib/smith/workflow/composite/payload.rb +128 -0
  68. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  69. data/lib/smith/workflow/composite/plan.rb +130 -0
  70. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  71. data/lib/smith/workflow/composite/planner.rb +53 -0
  72. data/lib/smith/workflow/composite/preparation.rb +27 -0
  73. data/lib/smith/workflow/composite/reducer.rb +133 -0
  74. data/lib/smith/workflow/composite/reduction.rb +31 -0
  75. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  76. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  77. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  78. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  79. data/lib/smith/workflow/definition_identity.rb +25 -0
  80. data/lib/smith/workflow/deterministic_step.rb +2 -2
  81. data/lib/smith/workflow/dsl.rb +73 -5
  82. data/lib/smith/workflow/durability.rb +1 -1
  83. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  84. data/lib/smith/workflow/execution.rb +28 -30
  85. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  86. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  87. data/lib/smith/workflow/fanout_execution.rb +20 -19
  88. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  89. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  90. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  91. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  92. data/lib/smith/workflow/graph/metrics.rb +1 -3
  93. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  94. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  95. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  96. data/lib/smith/workflow/graph/reachability.rb +19 -31
  97. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  98. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  99. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  100. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  101. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  102. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  103. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  104. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  105. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  106. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  107. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  108. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  109. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  110. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  111. data/lib/smith/workflow/graph/validator.rb +1 -1
  112. data/lib/smith/workflow/graph.rb +62 -4
  113. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  114. data/lib/smith/workflow/identifier.rb +24 -0
  115. data/lib/smith/workflow/message_admission.rb +40 -0
  116. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  117. data/lib/smith/workflow/message_batch.rb +60 -0
  118. data/lib/smith/workflow/message_value_normalizer.rb +134 -0
  119. data/lib/smith/workflow/nested_execution.rb +10 -0
  120. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  121. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  122. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  123. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  124. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  125. data/lib/smith/workflow/parallel.rb +19 -22
  126. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  127. data/lib/smith/workflow/parallel_execution.rb +46 -14
  128. data/lib/smith/workflow/persistence.rb +37 -8
  129. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  130. data/lib/smith/workflow/prepared_step.rb +122 -0
  131. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  132. data/lib/smith/workflow/prepared_step_execution_authorization.rb +101 -0
  133. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  134. data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
  135. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  136. data/lib/smith/workflow/process_local.rb +33 -0
  137. data/lib/smith/workflow/retry_execution.rb +20 -11
  138. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  139. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  140. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  141. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  142. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  143. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  144. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  145. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  146. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  147. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  148. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  149. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  150. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  151. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  152. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  153. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  154. data/lib/smith/workflow/split_step_persistence/execution.rb +20 -64
  155. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
  156. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  157. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
  158. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
  159. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  160. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  161. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -0
  162. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  163. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  164. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  165. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  166. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  167. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  168. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  169. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  170. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  171. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  172. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  173. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  174. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +66 -0
  175. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  176. data/lib/smith/workflow/split_step_persistence.rb +50 -0
  177. data/lib/smith/workflow/step_completion.rb +31 -0
  178. data/lib/smith/workflow/step_context.rb +46 -0
  179. data/lib/smith/workflow/string_snapshot.rb +25 -0
  180. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  181. data/lib/smith/workflow/transition.rb +137 -133
  182. data/lib/smith/workflow/transition_actionability.rb +22 -0
  183. data/lib/smith/workflow.rb +64 -18
  184. data/lib/smith.rb +44 -2
  185. data/sig/smith.rbs +1 -0
  186. metadata +155 -19
@@ -1,14 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "branch_env"
4
+ require_relative "parallel_agent_binding"
5
+ require_relative "thread_context_snapshot"
4
6
 
5
7
  module Smith
6
8
  class Workflow
7
9
  module ParallelExecution
10
+ NO_PARALLEL_BINDING = Object.new.freeze
11
+ private_constant :NO_PARALLEL_BINDING
12
+
8
13
  private
9
14
 
10
15
  def execute_parallel_step(transition, prepared_input: nil)
11
- count = Parallel.resolve_branch_count(transition, @context)
16
+ count = @resolved_parallel_branch_count || Parallel.resolve_branch_count(transition, @context)
12
17
  agent_class = resolve_agent_class(transition)
13
18
  estimates = compute_branch_estimates(@ledger, branch_count: count, agent_budget: agent_class&.budget)
14
19
  env = BranchEnv.new(
@@ -16,29 +21,32 @@ module Smith
16
21
  guardrail_sources: Tool.current_guardrails,
17
22
  scoped_store: propagate_scoped_artifacts,
18
23
  branch_estimates: estimates,
19
- deadline: wall_clock_deadline
24
+ deadline: wall_clock_deadline,
25
+ agent_class:
20
26
  )
21
27
  ledger = @ledger
22
28
  branches = Array.new(count) do |i|
23
- proc { |signal| run_branch(transition, i, env, ledger, signal) }
29
+ PreparedBranchExecution
30
+ .instance_method(:prepared_branch)
31
+ .bind_call(self, ParallelExecution.instance_method(:run_branch), transition, i, env, ledger)
24
32
  end
25
33
  Parallel.execute(branches: branches)
26
34
  end
27
35
 
28
36
  def run_branch(transition, index, env, ledger, signal)
29
- setup_branch_context(env, ledger)
30
- with_agent_context(resolve_agent_class(transition)) do
31
- branch_ledger = effective_call_ledger
32
- reserved = reserve_branch_call(branch_ledger, env, ledger)
33
- begin
34
- result = guarded_branch_call(transition, env, signal)
35
- finalize_branch(transition, index, result, branch_ledger, reserved).tap { reserved = nil }
36
- ensure
37
- settle_budget_on_failure(branch_ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
37
+ binding = ParallelAgentBinding.new(self, transition, env.agent_class)
38
+ with_branch_context(env, ledger, parallel_agent_binding: binding) do
39
+ with_agent_context(env.agent_class) do
40
+ branch_ledger = effective_call_ledger
41
+ reserved = reserve_branch_call(branch_ledger, env, ledger)
42
+ begin
43
+ result = guarded_branch_call(transition, env, signal)
44
+ finalize_branch(transition, index, result, branch_ledger, reserved).tap { reserved = nil }
45
+ ensure
46
+ settle_budget_on_failure(branch_ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
47
+ end
38
48
  end
39
49
  end
40
- ensure
41
- teardown_branch_context(env)
42
50
  end
43
51
 
44
52
  def reserve_branch_call(branch_ledger, env, workflow_ledger)
@@ -47,6 +55,29 @@ module Smith
47
55
  reserve_serial_budget(branch_ledger) if branch_ledger
48
56
  end
49
57
 
58
+ def with_branch_context(
59
+ env,
60
+ ledger,
61
+ parallel_agent_binding: NO_PARALLEL_BINDING,
62
+ agent_class: nil,
63
+ &block
64
+ )
65
+ snapshot = ThreadContextSnapshot.new
66
+ snapshot.around do
67
+ if agent_class
68
+ setup_fanout_branch_context(env, ledger, agent_class)
69
+ else
70
+ setup_branch_context(env, ledger)
71
+ end
72
+ unless parallel_agent_binding.equal?(NO_PARALLEL_BINDING)
73
+ Thread.current[:smith_parallel_agent_binding] = parallel_agent_binding
74
+ end
75
+ Thread.handle_interrupt(Object => :immediate, &block)
76
+ ensure
77
+ teardown_branch_context(env)
78
+ end
79
+ end
80
+
50
81
  def setup_branch_context(env, ledger)
51
82
  env.setup_thread
52
83
  Tool.current_ledger = ledger
@@ -60,6 +91,7 @@ module Smith
60
91
  clear_failed_billable_attempts
61
92
  Tool.current_ledger = nil
62
93
  Tool.current_tool_result_collector = nil
94
+ Thread.current[:smith_parallel_agent_binding] = nil
63
95
  env.teardown_thread
64
96
  end
65
97
 
@@ -10,17 +10,17 @@ module Smith
10
10
  class: self.class.name,
11
11
  state: @state,
12
12
  persistence_key: @persistence_key,
13
- context: persisted_context,
13
+ context: snapshot_value(persisted_context),
14
14
  budget_consumed: ledger_consumed,
15
15
  step_count: @step_count,
16
16
  execution_namespace: @execution_namespace,
17
17
  created_at: @created_at,
18
18
  updated_at: @updated_at,
19
19
  next_transition_name: @next_transition_name,
20
- session_messages: @session_messages || [],
20
+ session_messages: snapshot_session_messages,
21
21
  total_cost: @total_cost || 0.0,
22
22
  total_tokens: @total_tokens || 0,
23
- tool_results: @tool_results || [],
23
+ tool_results: snapshot_value(@tool_results || []),
24
24
  outcome: snapshot_outcome,
25
25
  # Durable usage fields. All wrapped in
26
26
  # snapshot_value so non-JSON-safe runtime values (e.g.
@@ -38,6 +38,11 @@ module Smith
38
38
  # stored value lags the workflow's current
39
39
  # persistence_schema_version.
40
40
  schema_version: self.class.persistence_schema_version,
41
+ # Host-supplied digest of the complete executable workflow
42
+ # definition. Smith does not derive this from Ruby Proc or VM
43
+ # internals; hosts that need restart-safe prepared-step recovery
44
+ # must bind it to their immutable code/package identity.
45
+ definition_digest: effective_definition_digest,
41
46
  # SHA256 digest of the seed_messages produced at this
42
47
  # workflow's construction. Stays stable across persist/restore
43
48
  # cycles so seed_validation can detect when the seed builder
@@ -59,7 +64,9 @@ module Smith
59
64
 
60
65
  private
61
66
 
62
- def restore_state(hash)
67
+ def restore_state(hash, allow_step_in_progress: false)
68
+ validate_raw_step_in_progress!(hash) if
69
+ self.class.idempotency_mode == :strict && !allow_step_in_progress
63
70
  migrated = migrate_if_needed(hash)
64
71
  normalized = normalize_persisted_state(migrated)
65
72
  persistence_version = validated_persistence_version(normalized)
@@ -89,6 +96,7 @@ module Smith
89
96
  # so the first persist! after restore expects version 0 (matches
90
97
  # the original store from the legacy adapter contract).
91
98
  @persistence_version = persistence_version
99
+ validate_definition_digest!(normalized)
92
100
  # Preserve the seed digest from the persisted payload so it
93
101
  # round-trips on subsequent persists. validate_seed_digest!
94
102
  # compares this against a fresh evaluation of the seed builder
@@ -100,7 +108,8 @@ module Smith
100
108
  # mode by raising if the marker is set on restore.
101
109
  @step_in_progress = normalized[:step_in_progress] || false
102
110
  @split_step_mutex = Mutex.new
103
- validate_step_in_progress!(normalized) if self.class.idempotency_mode == :strict
111
+ validate_step_in_progress!(normalized) if
112
+ self.class.idempotency_mode == :strict && !allow_step_in_progress
104
113
  end
105
114
 
106
115
  def validated_persistence_version(normalized)
@@ -111,6 +120,18 @@ module Smith
111
120
  "persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
112
121
  end
113
122
 
123
+ def validate_definition_digest!(normalized)
124
+ stored = normalized[:definition_digest]
125
+ return if stored.nil?
126
+
127
+ unless Smith::Types::Sha256Hex.valid?(stored)
128
+ raise Smith::SerializationError, "persisted workflow definition_digest must be a SHA-256 hex digest"
129
+ end
130
+ return if stored == self.class.definition_digest
131
+
132
+ raise Smith::WorkflowError, "persisted workflow definition does not match the current workflow class"
133
+ end
134
+
114
135
  def validate_step_in_progress!(normalized)
115
136
  return unless normalized[:step_in_progress] == true
116
137
 
@@ -120,6 +141,14 @@ module Smith
120
141
  )
121
142
  end
122
143
 
144
+ def validate_raw_step_in_progress!(state)
145
+ return unless state.is_a?(Hash)
146
+
147
+ marker = state[:step_in_progress] || state["step_in_progress"]
148
+ persistence_key = state[:persistence_key] || state["persistence_key"]
149
+ validate_step_in_progress!(step_in_progress: marker, persistence_key:)
150
+ end
151
+
123
152
  def validate_seed_digest!(normalized)
124
153
  stored_digest = normalized[:seed_digest]
125
154
  return if stored_digest.nil?
@@ -256,13 +285,13 @@ module Smith
256
285
  end
257
286
 
258
287
  def normalize_persisted_state(hash)
259
- normalized = hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
288
+ normalized = snapshot_value(hash).transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
289
+ normalized[:outcome] = symbolize_value(normalized[:outcome]) if normalized[:outcome].is_a?(Hash)
260
290
  normalize_symbol_fields!(normalized)
261
291
  normalize_nested_hashes!(normalized)
262
292
  normalize_session_messages!(normalized)
263
293
  normalize_tool_results!(normalized)
264
294
  normalize_usage_entries!(normalized)
265
- normalized[:outcome] = symbolize_value(normalized[:outcome]) if normalized[:outcome].is_a?(Hash)
266
295
  normalized
267
296
  end
268
297
 
@@ -318,7 +347,7 @@ module Smith
318
347
  return unless normalized[:session_messages].is_a?(Array)
319
348
 
320
349
  normalized[:session_messages] = normalized[:session_messages].map do |msg|
321
- msg.is_a?(Hash) ? symbolize_keys(msg) : msg
350
+ msg.is_a?(Hash) ? symbolize_keys(snapshot_value(msg)) : snapshot_value(msg)
322
351
  end
323
352
  end
324
353
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module PreparedBranchExecution
6
+ private
7
+
8
+ def prepared_branch(implementation, *arguments)
9
+ unless @split_step_active_execution_authorization
10
+ return proc { |signal| __send__(implementation.name, *arguments, signal) }
11
+ end
12
+
13
+ proc do |signal|
14
+ run = proc { implementation.bind_call(self, *arguments, signal) }
15
+ PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
16
+ end
17
+ end
18
+
19
+ def within_prepared_branch_execution(&)
20
+ authorization = @split_step_active_execution_authorization
21
+ return yield unless authorization
22
+
23
+ PreparedStepExecutionAuthorization
24
+ .instance_method(:within_branch_execution!)
25
+ .bind_call(authorization, &)
26
+ end
27
+ end
28
+
29
+ private_constant :PreparedBranchExecution
30
+ end
31
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "json"
5
+
6
+ require_relative "../types"
7
+
8
+ module Smith
9
+ class Workflow
10
+ class PreparedStep < Dry::Struct
11
+ ATTRIBUTES = %i[
12
+ token transition from persistence_key persistence_version step_number preparation_digest definition_digest
13
+ ].freeze
14
+ DIGEST_PATTERN = /\A[0-9a-f]{64}\z/
15
+ MAX_COUNTER_VALUE = (2**63) - 1
16
+ MAX_ATTRIBUTE_ENTRIES = 16
17
+ MAX_SERIALIZED_BYTES = 4 * 1024
18
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
19
+ OwnedString = Types::String.constructor do |value|
20
+ value.is_a?(String) ? value.dup.freeze : value
21
+ end
22
+ private_constant :OwnedString
23
+
24
+ attribute :token, OwnedString.constrained(format: UUID_PATTERN)
25
+ attribute :transition, OwnedString.constrained(min_size: 1)
26
+ attribute :from, OwnedString.constrained(min_size: 1)
27
+ attribute :persistence_key, OwnedString.constrained(min_size: 1)
28
+ attribute :persistence_version, Types::Integer.constrained(gteq: 1, lteq: MAX_COUNTER_VALUE)
29
+ attribute :step_number, Types::Integer.constrained(gteq: 1, lteq: MAX_COUNTER_VALUE)
30
+ attribute :preparation_digest, OwnedString.constrained(format: DIGEST_PATTERN)
31
+ attribute? :definition_digest, Types::Sha256Hex.optional
32
+
33
+ def self.deserialize(value)
34
+ attributes = parse_attributes(value)
35
+ normalized = normalize_attributes(attributes)
36
+ validate_attributes!(normalized)
37
+ validate_bounded_values!(normalized)
38
+ new(normalized)
39
+ rescue JSON::ParserError, TypeError => e
40
+ raise ArgumentError, "prepared step is invalid: #{e.message}"
41
+ end
42
+
43
+ def self.validate_attributes!(attributes)
44
+ unknown = attributes.keys - ATTRIBUTES
45
+ missing = ATTRIBUTES.first(7) - attributes.keys
46
+ raise ArgumentError, "prepared step contains unknown attributes: #{unknown.join(", ")}" if unknown.any?
47
+ raise ArgumentError, "prepared step is missing attributes: #{missing.join(", ")}" if missing.any?
48
+ end
49
+ private_class_method :validate_attributes!
50
+
51
+ def self.parse_attributes(value)
52
+ return validate_attribute_container!(value) if value.is_a?(Hash)
53
+ unless value.is_a?(String) && value.bytesize <= MAX_SERIALIZED_BYTES
54
+ raise ArgumentError, "prepared step must be a Hash or bounded JSON object"
55
+ end
56
+
57
+ parsed = JSON.parse(value)
58
+ return validate_attribute_container!(parsed) if parsed.is_a?(Hash)
59
+
60
+ raise ArgumentError, "prepared step JSON must contain an object"
61
+ end
62
+ private_class_method :parse_attributes
63
+
64
+ def self.validate_attribute_container!(attributes)
65
+ unless attributes.size <= MAX_ATTRIBUTE_ENTRIES
66
+ raise ArgumentError, "prepared step contains too many attributes"
67
+ end
68
+ unless attributes.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
69
+ raise ArgumentError, "prepared step attribute names must be strings or symbols"
70
+ end
71
+
72
+ attributes
73
+ end
74
+ private_class_method :validate_attribute_container!
75
+
76
+ def self.validate_bounded_values!(attributes)
77
+ bytes = attributes.sum { |key, value| bounded_attribute_bytes(key, value) }
78
+ return attributes if bytes <= MAX_SERIALIZED_BYTES
79
+
80
+ raise ArgumentError, "prepared step Hash exceeds maximum bytes"
81
+ end
82
+ private_class_method :validate_bounded_values!
83
+
84
+ def self.bounded_attribute_bytes(key, value)
85
+ validate_scalar_value!(value)
86
+ key.to_s.bytesize + (value.is_a?(String) ? value.bytesize : 8)
87
+ end
88
+ private_class_method :bounded_attribute_bytes
89
+
90
+ def self.validate_scalar_value!(value)
91
+ unless value.nil? || value.is_a?(String) || value.is_a?(Integer)
92
+ raise ArgumentError, "prepared step attribute values must be scalar"
93
+ end
94
+ return unless value.is_a?(Integer) && !value.between?(1, MAX_COUNTER_VALUE)
95
+
96
+ raise ArgumentError, "prepared step integer values must fit a positive signed 64-bit counter"
97
+ end
98
+ private_class_method :validate_scalar_value!
99
+
100
+ def self.normalize_attributes(attributes)
101
+ normalized = attributes.each_with_object({}) do |(key, nested), result|
102
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
103
+ raise ArgumentError, "prepared step contains duplicate attributes" if result.key?(normalized_key)
104
+
105
+ result[normalized_key] = nested
106
+ end
107
+ normalized[:definition_digest] = nil unless normalized.key?(:definition_digest)
108
+ normalized
109
+ end
110
+ private_class_method :normalize_attributes
111
+
112
+ def initialize(attributes)
113
+ owned = attributes.dup
114
+ digest = owned[:definition_digest] || owned["definition_digest"]
115
+ owned[:definition_digest] = digest.dup.freeze if digest.is_a?(String)
116
+ super(owned)
117
+ self.attributes.freeze
118
+ freeze
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "json"
5
+
6
+ require_relative "../types"
7
+ require_relative "prepared_step"
8
+
9
+ module Smith
10
+ class Workflow
11
+ class PreparedStepDispatch < Dry::Struct
12
+ ATTRIBUTES = %i[prepared_step token dispatch_digest].freeze
13
+ MAX_ATTRIBUTE_ENTRIES = 8
14
+ MAX_SERIALIZED_BYTES = 8 * 1024
15
+ OwnedString = Types::String.constructor do |value|
16
+ value.is_a?(String) ? value.dup.freeze : value
17
+ end
18
+ private_constant :OwnedString
19
+
20
+ attribute :prepared_step, Types.Instance(PreparedStep)
21
+ attribute :token, OwnedString.constrained(format: PreparedStep::UUID_PATTERN)
22
+ attribute :dispatch_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
23
+
24
+ def self.deserialize(value)
25
+ attributes = parse_attributes(value)
26
+ normalized = normalize_attributes(attributes)
27
+ validate_attributes!(normalized)
28
+ normalized[:prepared_step] = PreparedStep.deserialize(normalized[:prepared_step])
29
+ new(normalized)
30
+ rescue JSON::ParserError, TypeError => e
31
+ raise ArgumentError, "prepared-step dispatch is invalid: #{e.message}"
32
+ end
33
+
34
+ def self.parse_attributes(value)
35
+ return value if value.is_a?(Hash) && bounded_hash?(value)
36
+ unless value.is_a?(String) && value.bytesize <= MAX_SERIALIZED_BYTES
37
+ raise ArgumentError, "prepared-step dispatch must be a bounded Hash or JSON object"
38
+ end
39
+
40
+ parsed = JSON.parse(value)
41
+ return parsed if parsed.is_a?(Hash) && bounded_hash?(parsed)
42
+
43
+ raise ArgumentError, "prepared-step dispatch JSON must contain a bounded object"
44
+ end
45
+ private_class_method :parse_attributes
46
+
47
+ def self.bounded_hash?(attributes)
48
+ return false unless attributes.size <= MAX_ATTRIBUTE_ENTRIES
49
+ return false unless attributes.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
50
+
51
+ attributes.sum { |key, value| key.to_s.bytesize + bounded_value_bytes(value) } <= MAX_SERIALIZED_BYTES
52
+ end
53
+ private_class_method :bounded_hash?
54
+
55
+ def self.bounded_value_bytes(value)
56
+ return value.bytesize if value.is_a?(String)
57
+ return PreparedStep::MAX_SERIALIZED_BYTES if value.is_a?(Hash)
58
+
59
+ MAX_SERIALIZED_BYTES + 1
60
+ end
61
+ private_class_method :bounded_value_bytes
62
+
63
+ def self.normalize_attributes(attributes)
64
+ attributes.each_with_object({}) do |(key, value), result|
65
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
66
+ raise ArgumentError, "prepared-step dispatch contains duplicate attributes" if result.key?(normalized_key)
67
+
68
+ result[normalized_key] = value
69
+ end
70
+ end
71
+ private_class_method :normalize_attributes
72
+
73
+ def self.validate_attributes!(attributes)
74
+ unknown = attributes.keys - ATTRIBUTES
75
+ missing = ATTRIBUTES - attributes.keys
76
+ raise ArgumentError, "prepared-step dispatch contains unknown attributes: #{unknown.join(", ")}" if unknown.any?
77
+ raise ArgumentError, "prepared-step dispatch is missing attributes: #{missing.join(", ")}" if missing.any?
78
+ end
79
+ private_class_method :validate_attributes!
80
+
81
+ def initialize(attributes)
82
+ super
83
+ self.attributes.freeze
84
+ freeze
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "prepared_step"
4
+ require_relative "prepared_step_dispatch"
5
+ require_relative "prepared_step_execution_scope"
6
+ require_relative "process_local"
7
+ require_relative "split_step_persistence/execution_binding_snapshot"
8
+ require_relative "../errors"
9
+
10
+ module Smith
11
+ class Workflow
12
+ class PreparedStepExecutionAuthorization
13
+ include ProcessLocal
14
+
15
+ attr_reader :prepared_step, :dispatch_claim
16
+
17
+ def initialize(prepared_step:, dispatch_claim:, execution_bindings:)
18
+ unless prepared_step.is_a?(PreparedStep)
19
+ raise ArgumentError, "prepared_step must be a Smith::Workflow::PreparedStep"
20
+ end
21
+ unless dispatch_claim.nil? || dispatch_claim.is_a?(PreparedStepDispatch)
22
+ raise ArgumentError, "dispatch_claim must be a Smith::Workflow::PreparedStepDispatch or nil"
23
+ end
24
+ if dispatch_claim && dispatch_claim.prepared_step.to_h != prepared_step.to_h
25
+ raise ArgumentError, "dispatch claim must belong to the prepared step"
26
+ end
27
+ unless execution_bindings.is_a?(SplitStepPersistence::ExecutionBindingSnapshot)
28
+ raise ArgumentError, "execution_bindings must be a Smith execution binding snapshot"
29
+ end
30
+
31
+ @prepared_step = prepared_step
32
+ @dispatch_claim = dispatch_claim
33
+ @execution_bindings = execution_bindings
34
+ @execution_scope = PreparedStepExecutionScope.new
35
+ @process_id = Process.pid
36
+ freeze
37
+ end
38
+
39
+ def issued_in_current_process? = @process_id == Process.pid
40
+
41
+ def fetch_agent!(...)
42
+ ensure_binding_access!
43
+
44
+ @execution_bindings.fetch!(...)
45
+ end
46
+
47
+ def each_agent_binding(&block)
48
+ ensure_binding_access!
49
+ raise ArgumentError, "a block is required to inspect captured agent bindings" unless block
50
+
51
+ @execution_bindings.each_agent_binding(&block)
52
+ self
53
+ end
54
+
55
+ def verify_workflow!(workflow_class)
56
+ ensure_active_execution!
57
+ @execution_bindings.verify_workflow!(workflow_class)
58
+ end
59
+
60
+ def active_in_current_execution?
61
+ issued_in_current_process? && @execution_scope.active_for?(Thread.current, Fiber.current)
62
+ end
63
+
64
+ private
65
+
66
+ def within_branch_execution!(&)
67
+ ensure_current_process!
68
+ @execution_scope.within_branch(&)
69
+ end
70
+
71
+ def activate_execution!
72
+ ensure_current_process!
73
+ @execution_scope.activate!(Thread.current, Fiber.current)
74
+ end
75
+
76
+ def close_execution!
77
+ @execution_scope.close!(Thread.current, Fiber.current)
78
+ end
79
+
80
+ def ensure_active_execution!
81
+ return if active_in_current_execution?
82
+
83
+ raise WorkflowError, "prepared-step execution authorization is outside its active execution"
84
+ end
85
+
86
+ def ensure_binding_access!
87
+ accessible = issued_in_current_process? &&
88
+ @execution_scope.binding_accessible_for?(Thread.current, Fiber.current)
89
+ return if accessible
90
+
91
+ raise WorkflowError, "prepared-step execution authorization is outside its binding access scope"
92
+ end
93
+
94
+ def ensure_current_process!
95
+ return if issued_in_current_process?
96
+
97
+ raise WorkflowError, "prepared-step execution authorization belongs to another process"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ require_relative "../types"
6
+ require_relative "execution_result_snapshot"
7
+
8
+ module Smith
9
+ class Workflow
10
+ class PreparedStepExecutionResult < Dry::Struct
11
+ attribute :status, Types::Symbol.enum(:succeeded, :failed)
12
+ attribute :step, Types::Hash
13
+ attribute? :error, Types.Instance(StandardError).optional
14
+
15
+ def self.from_step(step)
16
+ error = step[:error]
17
+ new(status: error ? :failed : :succeeded, step:, error:)
18
+ end
19
+
20
+ def initialize(attributes)
21
+ owned = attributes.dup
22
+ owned[:step] = snapshot(owned.fetch(:step), freeze_copy: true, preserved_error: owned[:error])
23
+ super(owned)
24
+ validate_shape!
25
+ self.attributes.freeze
26
+ freeze
27
+ end
28
+
29
+ def succeeded? = status == :succeeded
30
+ def failed? = status == :failed
31
+ def step_snapshot = snapshot(step, freeze_copy: false, preserved_error: error)
32
+
33
+ private
34
+
35
+ def snapshot(value, freeze_copy:, preserved_error:)
36
+ ExecutionResultSnapshot.new(value, freeze_copy:, preserved_error:).call
37
+ end
38
+
39
+ def validate_shape!
40
+ valid = if failed?
41
+ error && step[:error].equal?(error)
42
+ else
43
+ error.nil? && !step.key?(:error)
44
+ end
45
+ raise ArgumentError, "prepared-step execution result fields do not match status" unless valid
46
+ end
47
+ end
48
+ end
49
+ end