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
|
@@ -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
|
data/lib/smith/artifacts/file.rb
CHANGED
|
@@ -1,38 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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, "*")).
|
|
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 &&
|
|
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
|
|
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
|
|
78
|
+
return unless ::File.exist?(meta_path)
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
meta[:execution_namespace] == execution_namespace
|
|
80
|
+
JSON.parse(::File.read(meta_path))["execution_namespace"]
|
|
54
81
|
rescue JSON::ParserError
|
|
55
|
-
|
|
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
|
-
|
|
31
|
+
check_data!(data)
|
|
32
|
+
ref = generate_ref(Digest::SHA256.hexdigest(data))
|
|
19
33
|
@store[ref] = data
|
|
20
|
-
@metadata[ref] ||= { content_type
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
return unless
|
|
51
|
+
execution_namespace ? meta[:claims].delete(execution_namespace) : meta[:claims].clear
|
|
52
|
+
return unless meta[:claims].empty?
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
@store.delete(ref)
|
|
55
|
+
@metadata.delete(ref)
|
|
56
|
+
nil
|
|
47
57
|
end
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
return unless Smith.config.artifact_tenant_isolation
|
|
78
|
+
private
|
|
70
79
|
|
|
71
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
27
|
+
return unless ref.is_a?(String)
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
data/lib/smith/artifacts.rb
CHANGED
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Smith
|
|
4
|
-
|
|
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
|