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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +712 -0
- data/README.md +622 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +67 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +70 -3
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -51
- data/lib/smith/agent.rb +5 -0
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +10 -5
- data/lib/smith/context.rb +28 -0
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +20 -18
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +249 -2
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +11 -5
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +36 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +113 -17
- data/lib/smith.rb +101 -45
- metadata +88 -1
|
@@ -52,19 +52,13 @@ module Smith
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def self.check_persist_and_restore(report, adapter)
|
|
55
|
-
|
|
56
|
-
payload = JSON.generate(workflow_class.new.to_state)
|
|
57
|
-
|
|
58
|
-
adapter.store(PROBE_KEY, payload)
|
|
59
|
-
restored_payload = adapter.fetch(PROBE_KEY)
|
|
60
|
-
restored = workflow_class.from_state(JSON.parse(restored_payload))
|
|
61
|
-
adapter.delete(PROBE_KEY)
|
|
62
|
-
|
|
55
|
+
restored = probe_round_trip(adapter)
|
|
63
56
|
valid = restored.state == :idle
|
|
64
57
|
report.add(
|
|
65
58
|
name: "durability.persist_restore",
|
|
66
59
|
status: valid ? :pass : :fail,
|
|
67
|
-
message: valid ? "Host persistence round-trip works" : "Host persistence round-trip failed"
|
|
60
|
+
message: valid ? "Host persistence round-trip works" : "Host persistence round-trip failed",
|
|
61
|
+
detail: "Written as runs write, with #{probe_write(adapter)}"
|
|
68
62
|
)
|
|
69
63
|
rescue StandardError => e
|
|
70
64
|
report.add(
|
|
@@ -74,15 +68,7 @@ module Smith
|
|
|
74
68
|
end
|
|
75
69
|
|
|
76
70
|
def self.check_resume_after_restore(report, adapter)
|
|
77
|
-
|
|
78
|
-
payload = JSON.generate(workflow_class.new.to_state)
|
|
79
|
-
|
|
80
|
-
adapter.store(PROBE_KEY, payload)
|
|
81
|
-
restored_payload = adapter.fetch(PROBE_KEY)
|
|
82
|
-
restored = workflow_class.from_state(JSON.parse(restored_payload))
|
|
83
|
-
adapter.delete(PROBE_KEY)
|
|
84
|
-
|
|
85
|
-
result = restored.run!
|
|
71
|
+
result = probe_round_trip(adapter).run!
|
|
86
72
|
valid = result.state == :done
|
|
87
73
|
|
|
88
74
|
report.add(
|
|
@@ -95,6 +81,22 @@ module Smith
|
|
|
95
81
|
message: "Resume after restore failed", detail: e.message)
|
|
96
82
|
end
|
|
97
83
|
|
|
84
|
+
# A probe run stored as runs are (Workflow#persist!: store_versioned
|
|
85
|
+
# where the adapter has it, and persistence_max_bytes) and restored.
|
|
86
|
+
def self.probe_round_trip(adapter)
|
|
87
|
+
workflow_class = build_probe_class
|
|
88
|
+
adapter.delete(PROBE_KEY)
|
|
89
|
+
workflow_class.new.persist!(PROBE_KEY, adapter:)
|
|
90
|
+
workflow_class.restore(PROBE_KEY, adapter:)
|
|
91
|
+
ensure
|
|
92
|
+
adapter.delete(PROBE_KEY)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.probe_write(adapter)
|
|
96
|
+
versioned = ::Smith::PersistenceAdapters.supports?(adapter, :store_versioned)
|
|
97
|
+
versioned ? "store_versioned" : "plain store (the adapter has no store_versioned)"
|
|
98
|
+
end
|
|
99
|
+
|
|
98
100
|
def self.build_probe_class
|
|
99
101
|
Class.new(::Smith::Workflow) do
|
|
100
102
|
initial_state :idle
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Doctor
|
|
5
|
+
module Checks
|
|
6
|
+
# Whether an ActiveRecord persistence adapter's model can hold runs:
|
|
7
|
+
# its locking column, a text payload, and a string key the database
|
|
8
|
+
# keeps unique. It reads schema metadata only, and skips when the
|
|
9
|
+
# database cannot be reached (the default doctor run is offline).
|
|
10
|
+
# Other adapters, and an adapter configuration that does not resolve
|
|
11
|
+
# (Durability reports it), are not checked.
|
|
12
|
+
module StateModel
|
|
13
|
+
NAME = "persistence.state_model"
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(report)
|
|
18
|
+
adapter = active_record_adapter
|
|
19
|
+
return unless adapter
|
|
20
|
+
|
|
21
|
+
adapter.check_schema!
|
|
22
|
+
report.add(name: NAME, status: :pass,
|
|
23
|
+
message: "The workflow state model has a lock_version, a text payload and a unique key")
|
|
24
|
+
report_encryption(report) if adapter.encrypted_payload?
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
report_failure(report, e)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def report_failure(report, error)
|
|
30
|
+
if unreachable?(error)
|
|
31
|
+
report.add(name: NAME, status: :skip, detail: error.class.name,
|
|
32
|
+
message: "The workflow state model was not checked: the database could not be reached")
|
|
33
|
+
else
|
|
34
|
+
report.add(name: NAME, status: :fail, message: "The workflow state model cannot hold runs",
|
|
35
|
+
detail: error.message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def report_encryption(report)
|
|
40
|
+
report.add(name: "#{NAME}.encryption", status: :warn,
|
|
41
|
+
message: "The workflow state payload is encrypted",
|
|
42
|
+
detail: "Runs store and read it, but split-step exact writes (replace_exact) compare " \
|
|
43
|
+
"payloads in the database and cannot use an encrypted one")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unreachable?(error)
|
|
47
|
+
classes = PersistenceAdapters::ActiveRecordConnectionErrors.classes
|
|
48
|
+
classes << ::ActiveRecord::NoDatabaseError if defined?(::ActiveRecord::NoDatabaseError)
|
|
49
|
+
classes.any? { error.is_a?(_1) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def active_record_adapter
|
|
53
|
+
return unless ::Smith.config.persistence_adapter
|
|
54
|
+
|
|
55
|
+
adapter = ::Smith.persistence_adapter
|
|
56
|
+
adapter if adapter.is_a?(::Smith::PersistenceAdapters::ActiveRecordStore)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
3
4
|
require "fileutils"
|
|
4
5
|
|
|
5
6
|
module Smith
|
|
@@ -41,8 +42,9 @@ module Smith
|
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def rails_template
|
|
45
|
+
# The generator's initializer, as `rails g smith:install --skip-state-model` writes it.
|
|
44
46
|
template_path = File.expand_path("../../generators/smith/install/templates/smith.rb.tt", __dir__)
|
|
45
|
-
File.read(template_path)
|
|
47
|
+
ERB.new(File.read(template_path), trim_mode: "-").result_with_hash(options: { skip_state_model: true })
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def plain_template
|
|
@@ -78,7 +80,12 @@ module Smith
|
|
|
78
80
|
# store(key, payload)
|
|
79
81
|
# fetch(key)
|
|
80
82
|
# delete(key)
|
|
81
|
-
|
|
83
|
+
#
|
|
84
|
+
# Artifacts (large outputs, attached documents) need a store that
|
|
85
|
+
# outlives the process: :s3 (S3, R2) or :active_record, with
|
|
86
|
+
# config.artifact_options; :memory keeps them in this process.
|
|
87
|
+
# config.artifact_store = :s3
|
|
88
|
+
# config.artifact_options = { bucket: "smith-artifacts", region: "us-east-1" }
|
|
82
89
|
config.trace_adapter = Smith::Trace::Memory.new
|
|
83
90
|
end
|
|
84
91
|
RUBY
|
data/lib/smith/doctor.rb
CHANGED
|
@@ -15,6 +15,8 @@ require_relative "doctor/checks/live"
|
|
|
15
15
|
require_relative "doctor/checks/models_registry"
|
|
16
16
|
require_relative "doctor/checks/openai_api_mode"
|
|
17
17
|
require_relative "doctor/checks/persistence_capabilities"
|
|
18
|
+
require_relative "doctor/checks/artifacts"
|
|
19
|
+
require_relative "doctor/checks/state_model"
|
|
18
20
|
|
|
19
21
|
module Smith
|
|
20
22
|
module Doctor
|
|
@@ -23,6 +25,8 @@ module Smith
|
|
|
23
25
|
|
|
24
26
|
Checks::Baseline.run(report)
|
|
25
27
|
Checks::Configuration.run(report)
|
|
28
|
+
Checks::Artifacts.run(report)
|
|
29
|
+
Checks::StateModel.run(report)
|
|
26
30
|
Checks::ModelsRegistry.run(report)
|
|
27
31
|
Checks::OpenaiApiMode.run(report)
|
|
28
32
|
Checks::Rails.run(report) if detect_rails?
|
data/lib/smith/errors.rb
CHANGED
|
@@ -69,6 +69,21 @@ module Smith
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
class BudgetExceeded < Error; end
|
|
72
|
+
|
|
73
|
+
# A host's budget ledger (`budget_ledger`) refused a provider request's
|
|
74
|
+
# hold; nothing was sent. A BudgetExceeded, so it is not retried unless a
|
|
75
|
+
# step's `retry_on` names it (a durable retry waits for budget). One that
|
|
76
|
+
# paused a tool loop carries its `continuation` (Budget::Continuation),
|
|
77
|
+
# which the step's retry continues from.
|
|
78
|
+
class BudgetUnavailable < BudgetExceeded
|
|
79
|
+
attr_reader :continuation
|
|
80
|
+
|
|
81
|
+
def initialize(message = "the budget ledger refused the request", continuation: nil)
|
|
82
|
+
@continuation = continuation
|
|
83
|
+
super(message)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
72
87
|
class DeadlineExceeded < Error; end
|
|
73
88
|
class MaxTransitionsExceeded < Error; end
|
|
74
89
|
class GuardrailFailed < Error; end
|
|
@@ -151,13 +166,32 @@ module Smith
|
|
|
151
166
|
|
|
152
167
|
class WorkflowError < Error; end
|
|
153
168
|
|
|
169
|
+
# A host's budget ledger (`budget_ledger`) could not be built or failed to
|
|
170
|
+
# answer `reserve!` (its service down, say); nothing was sent. Not a
|
|
171
|
+
# provider failure, so no fallback model is tried; not retried unless a
|
|
172
|
+
# step's `retry_on` names it.
|
|
173
|
+
class BudgetLedgerError < WorkflowError
|
|
174
|
+
def initialize(message = "the budget ledger failed") = super
|
|
175
|
+
end
|
|
176
|
+
|
|
154
177
|
class DeterministicStepFailure < WorkflowError
|
|
155
|
-
attr_reader :retryable, :kind, :details
|
|
178
|
+
attr_reader :retryable, :kind, :details, :new_idempotency_key, :idempotency_scope
|
|
179
|
+
|
|
180
|
+
# `new_idempotency_key: true` declares that the failed attempt's own
|
|
181
|
+
# requests took no effect, so a retry of the step that raised it sends
|
|
182
|
+
# them under new keys; `idempotency_scope` is that step's position. The
|
|
183
|
+
# flag is not persisted with the failure record.
|
|
184
|
+
def initialize(message, retryable: nil, kind: nil, details: nil, new_idempotency_key: false, # rubocop:disable Metrics/ParameterLists
|
|
185
|
+
idempotency_scope: nil)
|
|
186
|
+
unless [true, false, nil].include?(new_idempotency_key)
|
|
187
|
+
raise ArgumentError, "new_idempotency_key must be true or false"
|
|
188
|
+
end
|
|
156
189
|
|
|
157
|
-
def initialize(message, retryable: nil, kind: nil, details: nil)
|
|
158
190
|
@retryable = retryable
|
|
159
191
|
@kind = kind
|
|
160
192
|
@details = details
|
|
193
|
+
@new_idempotency_key = new_idempotency_key == true
|
|
194
|
+
@idempotency_scope = idempotency_scope
|
|
161
195
|
super(message)
|
|
162
196
|
end
|
|
163
197
|
end
|
|
@@ -173,6 +207,141 @@ module Smith
|
|
|
173
207
|
end
|
|
174
208
|
end
|
|
175
209
|
|
|
210
|
+
# An `await` step passed its deadline before `resume!` delivered its
|
|
211
|
+
# event, and the transition declares no `on_timeout` route. It takes the
|
|
212
|
+
# failure path (`on_failure`, or a raise) and is never retried.
|
|
213
|
+
class WaitTimedOut < WorkflowError
|
|
214
|
+
DETAIL_NAMES = %w[transition event deadline_at].freeze
|
|
215
|
+
private_constant :DETAIL_NAMES
|
|
216
|
+
|
|
217
|
+
attr_reader :transition, :event, :deadline_at
|
|
218
|
+
|
|
219
|
+
def initialize(transition:, event:, deadline_at:, message: nil)
|
|
220
|
+
@transition = DiagnosticText.capture(transition.to_s, max_bytes: 256)
|
|
221
|
+
@event = DiagnosticText.capture(event.to_s, max_bytes: 256)
|
|
222
|
+
@deadline_at = DiagnosticText.capture(deadline_at.to_s, max_bytes: 64)
|
|
223
|
+
super(message || "await #{@event} on transition #{@transition} passed its deadline #{@deadline_at}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Kept with the failure record, so a restored run's error is still a
|
|
227
|
+
# WaitTimedOut that names its wait.
|
|
228
|
+
def details = { transition:, event:, deadline_at: }.freeze
|
|
229
|
+
|
|
230
|
+
def self.from_details(details, message: nil)
|
|
231
|
+
raise ArgumentError, "wait timeout details must be a Hash" unless details.is_a?(Hash)
|
|
232
|
+
|
|
233
|
+
values = details.to_h { |key, value| [key.to_s, value] }
|
|
234
|
+
unless values.keys.sort == DETAIL_NAMES.sort && values.values.all?(String)
|
|
235
|
+
raise ArgumentError, "wait timeout details are invalid"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
new(transition: values["transition"], event: values["event"], deadline_at: values["deadline_at"], message:)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A `compute` or `run` step that declares `routes:` finished without
|
|
243
|
+
# calling `route_to`, declares no `on_success`, and did not opt into
|
|
244
|
+
# `fallthrough: true`. Without this error the run would silently take the
|
|
245
|
+
# first transition declared from the step's target state.
|
|
246
|
+
class UnroutedDeterministicStep < WorkflowError
|
|
247
|
+
attr_reader :transition, :routes
|
|
248
|
+
|
|
249
|
+
def initialize(transition:, routes:)
|
|
250
|
+
@transition = transition
|
|
251
|
+
@routes = routes
|
|
252
|
+
super(
|
|
253
|
+
"#{DiagnosticText.capture(transition.inspect, max_bytes: 256)} declares routes: " \
|
|
254
|
+
"#{DiagnosticText.capture(routes.inspect, max_bytes: 1024)} but did not call route_to; " \
|
|
255
|
+
"route to one of them, declare on_success, or opt into fallthrough: true"
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# A deterministic step wrote a context key that the workflow's
|
|
261
|
+
# Smith::Context does not list in `persist :a, :b`. The write would be
|
|
262
|
+
# dropped at the next checkpoint, so it fails the step instead.
|
|
263
|
+
class UndeclaredContextKey < WorkflowError
|
|
264
|
+
attr_reader :key, :persisted_keys
|
|
265
|
+
|
|
266
|
+
def initialize(key:, persisted_keys:)
|
|
267
|
+
@key = key
|
|
268
|
+
@persisted_keys = persisted_keys
|
|
269
|
+
super(
|
|
270
|
+
"context key #{DiagnosticText.capture(key.inspect, max_bytes: 256)} is not a persisted context key " \
|
|
271
|
+
"(persist #{DiagnosticText.capture(persisted_keys.map(&:inspect).join(", "), max_bytes: 1024)}); " \
|
|
272
|
+
"declare it in the context's persist list, or use persist :auto"
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# A step's content parts cannot be sent: a document or image is missing,
|
|
278
|
+
# unreadable, empty, not what its media type says, or past a bound, or no
|
|
279
|
+
# model in the chain takes it. Raised before the provider call, so it is
|
|
280
|
+
# never retried as a provider failure.
|
|
281
|
+
class ContentUnavailable < WorkflowError; end
|
|
282
|
+
|
|
283
|
+
# No artifact store is configured. Artifacts must outlive the process that
|
|
284
|
+
# stored them whenever a run waits, retries or resumes elsewhere, so Smith
|
|
285
|
+
# assumes none outside test_mode.
|
|
286
|
+
class ArtifactStoreMissing < WorkflowError
|
|
287
|
+
def initialize(message = "Smith.config.artifact_store is not configured; set it to :s3, :active_record or " \
|
|
288
|
+
":file (or a store) for runs that wait, retry or move between processes, or :memory " \
|
|
289
|
+
"for a single process")
|
|
290
|
+
super
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# A step streams to a sink Smith.config.stream_sinks does not name.
|
|
295
|
+
# Raised before the step's provider call.
|
|
296
|
+
class StreamSinkMissing < WorkflowError
|
|
297
|
+
def initialize(name)
|
|
298
|
+
super("Smith.config.stream_sinks has no sink #{name.inspect}; register the sink the step streams to")
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A step sends its request as a batch and Smith.config.batch_queue is not
|
|
303
|
+
# configured. Raised before anything is sent.
|
|
304
|
+
class BatchQueueMissing < WorkflowError
|
|
305
|
+
def initialize(message = "Smith.config.batch_queue is not configured; batch mode hands its requests to it")
|
|
306
|
+
super
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# A batch step's call cannot go as a batch: its model's provider has no
|
|
311
|
+
# batch interface in Smith (Anthropic has), or its agent has tools (a batch
|
|
312
|
+
# request is one turn). Raised before anything is sent.
|
|
313
|
+
class BatchModeUnavailable < WorkflowError; end
|
|
314
|
+
|
|
315
|
+
# A batch result that did not answer: a server error (`errored`), a
|
|
316
|
+
# `canceled` or `expired` request, or none by the wait's deadline
|
|
317
|
+
# (`missing`). An AgentError, so `retry_on` sends it again.
|
|
318
|
+
class BatchResultFailed < AgentError
|
|
319
|
+
attr_reader :kind, :error_type
|
|
320
|
+
|
|
321
|
+
def initialize(kind:, error_type: nil)
|
|
322
|
+
@kind = kind.to_s.dup.freeze
|
|
323
|
+
@error_type = DiagnosticText.capture(error_type.to_s, max_bytes: 128) if error_type
|
|
324
|
+
super("batch result #{@kind}#{" (#{@error_type})" if @error_type}")
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# A batch result answers a request other than the one its step renders
|
|
329
|
+
# now (a date in the prompt, data read afresh, a changed prompt), and the
|
|
330
|
+
# step declares `on_request_change: :fail`. An AgentError, so `retry_on`
|
|
331
|
+
# may send the request again (synchronously, if the mode callable says so
|
|
332
|
+
# at the next attempt); the result is charged all the same.
|
|
333
|
+
class BatchRequestChanged < AgentError
|
|
334
|
+
def initialize(message = "the step renders another request than its batch result answers") = super
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# The provider refused a batch request as invalid; sending it again would
|
|
338
|
+
# be refused again.
|
|
339
|
+
class BatchRequestInvalid < WorkflowError
|
|
340
|
+
def initialize(message = "the provider refused the batch request as invalid")
|
|
341
|
+
super
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
176
345
|
class SerializationError < Error; end
|
|
177
346
|
class AgentRegistryError < Error; end
|
|
178
347
|
|
|
@@ -190,6 +359,42 @@ module Smith
|
|
|
190
359
|
end
|
|
191
360
|
end
|
|
192
361
|
|
|
362
|
+
# A workflow state past Smith.config.persistence_max_bytes. A stored
|
|
363
|
+
# run's step whose result would not fit fails with it before the result
|
|
364
|
+
# is kept (its usage recorded, `on_failure` taken; a response memo keeps
|
|
365
|
+
# what its calls got). Any other state past the bound is refused when it
|
|
366
|
+
# is stored, before anything is written, and the stored state stays as it
|
|
367
|
+
# was.
|
|
368
|
+
class PersistedStateTooLarge < WorkflowError
|
|
369
|
+
DETAIL_NAMES = %w[key bytes limit].freeze
|
|
370
|
+
|
|
371
|
+
attr_reader :key, :bytes, :limit
|
|
372
|
+
|
|
373
|
+
def initialize(key:, bytes:, limit:, message: nil)
|
|
374
|
+
@key = key
|
|
375
|
+
@bytes = bytes
|
|
376
|
+
@limit = limit
|
|
377
|
+
super(message || "the workflow state for #{key.inspect} is #{bytes} bytes, past persistence_max_bytes (#{limit})")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def details = { key:, bytes:, limit: }.freeze
|
|
381
|
+
|
|
382
|
+
def self.from_details(details, message: nil)
|
|
383
|
+
raise ArgumentError, "state size details must be a Hash" unless details.is_a?(Hash)
|
|
384
|
+
|
|
385
|
+
values = details.to_h { |name, value| [name.to_s, value] }
|
|
386
|
+
raise ArgumentError, "state size details are invalid" unless details?(values)
|
|
387
|
+
|
|
388
|
+
new(key: values["key"], bytes: values["bytes"], limit: values["limit"], message:)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def self.details?(values)
|
|
392
|
+
values.keys.sort == DETAIL_NAMES.sort && values["key"].is_a?(String) &&
|
|
393
|
+
values.values_at("bytes", "limit").all? { _1.is_a?(Integer) && _1.positive? }
|
|
394
|
+
end
|
|
395
|
+
private_class_method :details?
|
|
396
|
+
end
|
|
397
|
+
|
|
193
398
|
# Raised when an adapter's optimistic-lock check detects a concurrent
|
|
194
399
|
# write: another process modified the key between this process's
|
|
195
400
|
# restore and persist. Hosts can rescue + restore + retry, or fail
|
|
@@ -252,6 +457,48 @@ module Smith
|
|
|
252
457
|
end
|
|
253
458
|
end
|
|
254
459
|
|
|
460
|
+
# Raised when a persisted payload's state is not one the restoring
|
|
461
|
+
# workflow class knows: neither declared with `state` nor named by any
|
|
462
|
+
# transition's `from:` or `to:`. Usually a deploy renamed or removed the
|
|
463
|
+
# state while a run was persisted. Restoring anyway would treat the
|
|
464
|
+
# unknown state as terminal and end the run silently. Hosts bridge a
|
|
465
|
+
# rename with `migrate_from`, or let the run finish on the workflow
|
|
466
|
+
# version that declared the state.
|
|
467
|
+
class UnknownStateOnRestore < Error
|
|
468
|
+
attr_reader :workflow, :persistence_key, :state
|
|
469
|
+
|
|
470
|
+
def initialize(workflow:, persistence_key:, state:)
|
|
471
|
+
@workflow = workflow
|
|
472
|
+
@persistence_key = persistence_key
|
|
473
|
+
@state = state
|
|
474
|
+
super(
|
|
475
|
+
"unknown state #{DiagnosticText.capture(state.inspect, max_bytes: 256)} restoring #{workflow} " \
|
|
476
|
+
"key=#{DiagnosticText.capture(persistence_key.inspect, max_bytes: 256)}: bump " \
|
|
477
|
+
"persistence_schema_version and declare `migrate_from` to map it, or finish the run on the " \
|
|
478
|
+
"workflow version that declared it."
|
|
479
|
+
)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Raised when a persisted payload's pending transition is not declared by
|
|
484
|
+
# the restoring workflow class, for the same reasons as
|
|
485
|
+
# UnknownStateOnRestore.
|
|
486
|
+
class UnknownTransitionOnRestore < Error
|
|
487
|
+
attr_reader :workflow, :persistence_key, :transition
|
|
488
|
+
|
|
489
|
+
def initialize(workflow:, persistence_key:, transition:)
|
|
490
|
+
@workflow = workflow
|
|
491
|
+
@persistence_key = persistence_key
|
|
492
|
+
@transition = transition
|
|
493
|
+
super(
|
|
494
|
+
"unknown pending transition #{DiagnosticText.capture(transition.inspect, max_bytes: 256)} restoring " \
|
|
495
|
+
"#{workflow} key=#{DiagnosticText.capture(persistence_key.inspect, max_bytes: 256)}: bump " \
|
|
496
|
+
"persistence_schema_version and declare `migrate_from` to map it, or finish the run on the " \
|
|
497
|
+
"workflow version that declared it."
|
|
498
|
+
)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
255
502
|
# Raised when restoring a persisted payload whose schema_version does
|
|
256
503
|
# not match the workflow's current persistence_schema_version AND no
|
|
257
504
|
# migration block is registered to bridge the gap. Hosts fix this by
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module IdempotencyKey
|
|
5
|
+
MAX_SCOPE_DEPTH = 32
|
|
6
|
+
MAX_SCOPE_NODES = 100_000
|
|
7
|
+
|
|
8
|
+
module Canonical
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# The JSON form a scope is hashed in: Symbols become Strings, Hash
|
|
12
|
+
# entries are sorted by key, and a Float with a whole value becomes
|
|
13
|
+
# the Integer, so equal scopes give equal keys whatever their key order
|
|
14
|
+
# or numeric spelling (`12` and `12.0` from a model's JSON). Other
|
|
15
|
+
# finite Floats keep their shortest exact text. NaN, Infinity and
|
|
16
|
+
# other objects are refused, because they have no stable identity; so
|
|
17
|
+
# are scopes past MAX_SCOPE_DEPTH levels or MAX_SCOPE_NODES values,
|
|
18
|
+
# which bounds the work at O(MAX_SCOPE_NODES).
|
|
19
|
+
def value(input, budget = [MAX_SCOPE_NODES], depth = 0)
|
|
20
|
+
charge!(budget, depth)
|
|
21
|
+
case input
|
|
22
|
+
when nil, true, false, Integer then input
|
|
23
|
+
when Float then float_value(input)
|
|
24
|
+
when String, Symbol then string(input.to_s)
|
|
25
|
+
when Array then input.map { |item| value(item, budget, depth + 1) }
|
|
26
|
+
when Hash then hash_entries(input, budget, depth)
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"idempotency key scope must be built from Strings, Symbols, numbers, booleans, nil, Arrays " \
|
|
30
|
+
"and Hashes, got #{DiagnosticText.error_class_name(input)}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def charge!(budget, depth)
|
|
35
|
+
raise ArgumentError, "idempotency key scope is nested deeper than #{MAX_SCOPE_DEPTH} levels" if too_deep?(depth)
|
|
36
|
+
return unless (budget[0] -= 1).negative?
|
|
37
|
+
|
|
38
|
+
raise ArgumentError, "idempotency key scope has more than #{MAX_SCOPE_NODES} values"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def too_deep?(depth) = depth > MAX_SCOPE_DEPTH
|
|
42
|
+
|
|
43
|
+
def float_value(value)
|
|
44
|
+
raise ArgumentError, "idempotency key scope numbers must be finite" unless value.finite?
|
|
45
|
+
|
|
46
|
+
value == value.truncate ? value.to_i : value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hash_entries(hash, budget, depth)
|
|
50
|
+
entries = hash.map { |key, item| [hash_key(key), value(item, budget, depth + 1)] }
|
|
51
|
+
keys = entries.map(&:first)
|
|
52
|
+
raise ArgumentError, "idempotency key scope has two keys with the same name" unless keys.uniq.size == keys.size
|
|
53
|
+
|
|
54
|
+
entries.sort_by(&:first).to_h
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def hash_key(key)
|
|
58
|
+
return string(key.to_s) if key.is_a?(String) || key.is_a?(Symbol)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "idempotency key scope Hash keys must be Strings or Symbols"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def string(string)
|
|
64
|
+
utf8 = string.encoding == Encoding::UTF_8 ? string : string.encode(Encoding::UTF_8)
|
|
65
|
+
return utf8 if utf8.valid_encoding?
|
|
66
|
+
|
|
67
|
+
raise EncodingError
|
|
68
|
+
rescue EncodingError
|
|
69
|
+
raise ArgumentError, "idempotency key scope Strings must be valid UTF-8 or convert to it"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_constant :Canonical
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "idempotency_key/canonical"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
# Keys for requests a step sends outside the system, so that a replay of
|
|
10
|
+
# the step (a retry, or a restore after a crash) repeats each request
|
|
11
|
+
# under the key it used before and the receiver can drop the duplicate.
|
|
12
|
+
#
|
|
13
|
+
# A key is the SHA-256 of the run's execution namespace (a UUID assigned
|
|
14
|
+
# when the workflow is built and persisted with it), the step's position
|
|
15
|
+
# (step number and transition, then any nested workflow step, parallel or
|
|
16
|
+
# fan-out branch, optimizer round and role, or orchestrator round and
|
|
17
|
+
# task inside it) and a caller-chosen scope. It is the same on every
|
|
18
|
+
# attempt of the step, so a retry after a timeout cannot repeat a request
|
|
19
|
+
# that took effect, and after a restore. An attempt of a step that fails
|
|
20
|
+
# in a way showing its own requests took no effect can ask for new keys
|
|
21
|
+
# on the attempts after it (`fail!` with `new_idempotency_key: true`, or
|
|
22
|
+
# `retry_on ... new_idempotency_key_on:`), for receivers that store a
|
|
23
|
+
# failed reply under its key. A replay keeps its keys only when the run
|
|
24
|
+
# was persisted before the step began, as `advance_persisted!` and
|
|
25
|
+
# `run_persisted!` do. A persisted payload is a run's identity: runs
|
|
26
|
+
# built with `new` never share keys, but runs restored from copies of one
|
|
27
|
+
# payload do.
|
|
28
|
+
#
|
|
29
|
+
# Keys exist only while a step does its own work (its body, agents,
|
|
30
|
+
# tools and guardrails); event handlers and `on_step` callbacks get none.
|
|
31
|
+
# The position is thread-local state Smith carries into its own branch
|
|
32
|
+
# threads and concurrent tool calls; code in a Fiber or Thread the host
|
|
33
|
+
# creates has none, while `step.idempotency_key` works anywhere.
|
|
34
|
+
#
|
|
35
|
+
# transition :notify, from: :reviewed, to: :notified do
|
|
36
|
+
# compute { |step| Mailer.deliver(review, idempotency_key: step.idempotency_key) }
|
|
37
|
+
# end
|
|
38
|
+
module IdempotencyKey
|
|
39
|
+
VERSION = "smith.idempotency.v1"
|
|
40
|
+
|
|
41
|
+
# Where execution is: the run's namespace and the path of positions
|
|
42
|
+
# inside it, deeply frozen. Internal: hosts reach keys through
|
|
43
|
+
# `current`, `step.idempotency_key` and `Tool#idempotency_key`.
|
|
44
|
+
Scope = Data.define(:run, :path) do
|
|
45
|
+
def within(*segment)
|
|
46
|
+
self.class.new(run:, path: [*path, IdempotencyKey.__send__(:own_segment, segment)].freeze)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def key(scope = nil)
|
|
50
|
+
Digest::SHA256.hexdigest(JSON.generate([VERSION, run, path, Canonical.value(scope)]))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private_constant :Scope
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# The key for `scope` at the current position. Raises WorkflowError
|
|
57
|
+
# outside a running workflow step.
|
|
58
|
+
def current(scope = nil)
|
|
59
|
+
current_scope!.key(scope)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def root_scope(run) = Scope.new(run: run.dup.freeze, path: [].freeze)
|
|
65
|
+
|
|
66
|
+
def current_scope
|
|
67
|
+
Tool.__send__(:current_idempotency_scope)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def current_scope!
|
|
71
|
+
current_scope || raise(WorkflowError, "an idempotency key is only available inside a running workflow step")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Narrows the current position for the block, when there is one.
|
|
75
|
+
def within(*segment, &)
|
|
76
|
+
outer = current_scope
|
|
77
|
+
return yield unless outer
|
|
78
|
+
|
|
79
|
+
swap(outer.within(*segment), &)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Runs the block with no position, so code that is not a step's own
|
|
83
|
+
# work (event handlers, callbacks) cannot take a step's key.
|
|
84
|
+
def without(&) = swap(nil, &)
|
|
85
|
+
|
|
86
|
+
# Interrupts are deferred while the position is installed and
|
|
87
|
+
# restored, as for every other per-step thread value.
|
|
88
|
+
def swap(scope, &block)
|
|
89
|
+
raise ArgumentError, "block required" unless block
|
|
90
|
+
|
|
91
|
+
outer = current_scope
|
|
92
|
+
Thread.handle_interrupt(Object => :never) do
|
|
93
|
+
install(scope)
|
|
94
|
+
begin
|
|
95
|
+
Thread.handle_interrupt(Object => :immediate, &block)
|
|
96
|
+
ensure
|
|
97
|
+
install(outer)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def install(scope)
|
|
103
|
+
raise ArgumentError, "an idempotency position must be a Scope or nil" unless scope.nil? || scope.is_a?(Scope)
|
|
104
|
+
|
|
105
|
+
Tool.__send__(:current_idempotency_scope=, scope)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A namespace for a payload written before namespaces were assigned at
|
|
109
|
+
# construction: derived from the payload's own durable facts, so every
|
|
110
|
+
# restore of it gets the same one, and shaped as the version 4 UUID
|
|
111
|
+
# `new` assigns.
|
|
112
|
+
def legacy_namespace(*facts)
|
|
113
|
+
hex = Digest::SHA256.hexdigest(JSON.generate([VERSION, "namespace", *facts.map(&:to_s)]))
|
|
114
|
+
variant = ((hex[16].to_i(16) & 0x3) | 0x8).to_s(16)
|
|
115
|
+
"#{hex[0, 8]}-#{hex[8, 4]}-4#{hex[13, 3]}-#{variant}#{hex[17, 3]}-#{hex[20, 12]}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A position segment: a label and Strings, Symbols or Integers, held
|
|
119
|
+
# as frozen copies so a caller cannot change a key after the fact.
|
|
120
|
+
def own_segment(segment)
|
|
121
|
+
segment.map do |part|
|
|
122
|
+
case part
|
|
123
|
+
when Integer then part
|
|
124
|
+
when Symbol then part.to_s.freeze
|
|
125
|
+
when String then Canonical.string(part).dup.freeze
|
|
126
|
+
else raise ArgumentError, "an idempotency position segment must be Strings, Symbols or Integers"
|
|
127
|
+
end
|
|
128
|
+
end.freeze
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Narrows the position to the `count`th copy of `value` seen under
|
|
132
|
+
# `label`, named by its content: a list a model regenerates on replay
|
|
133
|
+
# keeps each item's key whatever its order.
|
|
134
|
+
def within_content(label, value, copies, &)
|
|
135
|
+
digest = content_digest(value)
|
|
136
|
+
copies[digest] += 1
|
|
137
|
+
within(label, digest, copies[digest], &)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A digest of a value's canonical form, for a position named by its
|
|
141
|
+
# content (an orchestrator task) rather than by its place in a list.
|
|
142
|
+
def content_digest(value) = Digest::SHA256.hexdigest(JSON.generate(Canonical.value(value)))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|