phronomy 0.2.2 → 0.4.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -30
  3. data/README.md +106 -122
  4. data/lib/phronomy/agent/base.rb +135 -57
  5. data/lib/phronomy/agent/checkpoint.rb +53 -0
  6. data/lib/phronomy/agent/orchestrator.rb +119 -0
  7. data/lib/phronomy/agent/react_agent.rb +18 -28
  8. data/lib/phronomy/agent/shared_state.rb +303 -0
  9. data/lib/phronomy/agent/suspend_signal.rb +35 -0
  10. data/lib/phronomy/agent/team_coordinator.rb +285 -0
  11. data/lib/phronomy/agent.rb +2 -1
  12. data/lib/phronomy/configuration.rb +0 -24
  13. data/lib/phronomy/generator_verifier.rb +250 -0
  14. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
  15. data/lib/phronomy/railtie.rb +0 -6
  16. data/lib/phronomy/ruby_llm_patches.rb +20 -0
  17. data/lib/phronomy/tool/mcp_tool.rb +23 -26
  18. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
  19. data/lib/phronomy/vector_store/redis_search.rb +4 -4
  20. data/lib/phronomy/version.rb +1 -1
  21. data/lib/phronomy/workflow.rb +4 -7
  22. data/lib/phronomy/workflow_runner.rb +42 -30
  23. data/lib/phronomy.rb +18 -0
  24. data/scripts/check_readme_ruby.rb +38 -0
  25. metadata +12 -38
  26. data/docs/trustworthy_ai_enhancements.md +0 -332
  27. data/lib/phronomy/active_record/acts_as.rb +0 -48
  28. data/lib/phronomy/active_record/checkpoint.rb +0 -20
  29. data/lib/phronomy/active_record/extensions.rb +0 -14
  30. data/lib/phronomy/active_record/message.rb +0 -20
  31. data/lib/phronomy/actor.rb +0 -68
  32. data/lib/phronomy/memory/compression/base.rb +0 -37
  33. data/lib/phronomy/memory/compression/summary.rb +0 -107
  34. data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
  35. data/lib/phronomy/memory/compression.rb +0 -11
  36. data/lib/phronomy/memory/conversation_manager.rb +0 -213
  37. data/lib/phronomy/memory/retrieval/base.rb +0 -22
  38. data/lib/phronomy/memory/retrieval/composite.rb +0 -76
  39. data/lib/phronomy/memory/retrieval/recent.rb +0 -35
  40. data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
  41. data/lib/phronomy/memory/retrieval.rb +0 -12
  42. data/lib/phronomy/memory/storage/active_record.rb +0 -248
  43. data/lib/phronomy/memory/storage/base.rb +0 -155
  44. data/lib/phronomy/memory/storage/in_memory.rb +0 -152
  45. data/lib/phronomy/memory/storage.rb +0 -11
  46. data/lib/phronomy/memory.rb +0 -21
  47. data/lib/phronomy/rails/agent_job.rb +0 -75
  48. data/lib/phronomy/state_store/active_record.rb +0 -76
  49. data/lib/phronomy/state_store/base.rb +0 -112
  50. data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
  51. data/lib/phronomy/state_store/encryptor/base.rb +0 -34
  52. data/lib/phronomy/state_store/encryptor.rb +0 -16
  53. data/lib/phronomy/state_store/file.rb +0 -85
  54. data/lib/phronomy/state_store/in_memory.rb +0 -53
  55. data/lib/phronomy/state_store/redis.rb +0 -70
  56. data/lib/phronomy/state_store.rb +0 -9
  57. data/lib/phronomy/thread_actor_registry.rb +0 -85
  58. data/lib/phronomy/trust_pipeline.rb +0 -264
@@ -1,152 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- module Storage
6
- # In-process storage for conversation messages backed by per-thread-id
7
- # {Phronomy::Actor} instances from {Phronomy::ThreadActorRegistry}.
8
- # Messages are lost when the process exits.
9
- #
10
- # @example
11
- # storage = Phronomy::Memory::Storage::InMemory.new
12
- # manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
13
- class InMemory < Base
14
- # Thread-local key for per-thread-id storage data (namespaced by store
15
- # instance object_id to support multiple independent InMemory stores).
16
- THREAD_DATA_KEY = :phronomy_storage_in_memory_data
17
-
18
- def initialize
19
- end
20
-
21
- # -----------------------------------------------------------------------
22
- # Legacy interface
23
- # -----------------------------------------------------------------------
24
-
25
- # @param thread_id [String]
26
- # @return [Array]
27
- def load(thread_id:)
28
- Phronomy::ThreadActorRegistry.for(thread_id).call { (thread_data.store || []).dup }
29
- end
30
-
31
- # @param thread_id [String]
32
- # @param messages [Array]
33
- def save(thread_id:, messages:)
34
- Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.store = messages.dup }
35
- end
36
-
37
- # @param thread_id [String]
38
- def clear(thread_id:)
39
- store_id = object_id
40
- Phronomy::ThreadActorRegistry.for(thread_id).call do
41
- (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
42
- end
43
- end
44
-
45
- # -----------------------------------------------------------------------
46
- # Raw message interface
47
- # -----------------------------------------------------------------------
48
-
49
- # @param thread_id [String]
50
- # @param messages [Array]
51
- # @param starting_seq [Integer]
52
- # @param recorded_at [Time, nil] timestamp for test overrides; defaults to +Time.now+
53
- def append_raw(thread_id:, messages:, starting_seq:, recorded_at: nil)
54
- now = recorded_at || Time.now
55
- Phronomy::ThreadActorRegistry.for(thread_id).call do
56
- data = thread_data
57
- messages.each_with_index do |msg, i|
58
- seq = starting_seq + i
59
- data.raw_messages << {seq: seq, message: msg, recorded_at: now}
60
- data.hwm = [data.hwm, seq].max
61
- end
62
- end
63
- end
64
-
65
- # @param thread_id [String]
66
- # @return [Integer]
67
- def next_seq(thread_id:)
68
- Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.hwm + 1 }
69
- end
70
-
71
- # Routes +block+ through the per-thread-id {Phronomy::Actor}, serialising
72
- # all operations for the same thread. Reentrant calls (the block itself
73
- # calling storage methods that also route through the Actor) are safe
74
- # because {Phronomy::Actor#call} detects the same-thread case and executes
75
- # inline.
76
- #
77
- # @param thread_id [String]
78
- def with_thread_lock(thread_id:, &block)
79
- Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
80
- end
81
-
82
- # @param thread_id [String]
83
- # @return [Array<Hash>]
84
- def load_raw(thread_id:)
85
- Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.dup }
86
- end
87
-
88
- # @param thread_id [String]
89
- def clear_raw(thread_id:)
90
- Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.clear }
91
- end
92
-
93
- # -----------------------------------------------------------------------
94
- # Compaction record interface
95
- # -----------------------------------------------------------------------
96
-
97
- # @param thread_id [String]
98
- # @param start_seq [Integer]
99
- # @param end_seq [Integer]
100
- # @param summary_text [String]
101
- def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
102
- Phronomy::ThreadActorRegistry.for(thread_id).call do
103
- thread_data.compactions << {start_seq: start_seq, end_seq: end_seq, summary_text: summary_text}
104
- end
105
- end
106
-
107
- # @param thread_id [String]
108
- # @return [Array<Hash>]
109
- def load_compactions(thread_id:)
110
- Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.dup }
111
- end
112
-
113
- # @param thread_id [String]
114
- def clear_compactions(thread_id:)
115
- Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.clear }
116
- end
117
-
118
- # Remove raw messages recorded before +older_than+ for this thread.
119
- #
120
- # @param thread_id [String]
121
- # @param older_than [Time]
122
- def purge_older_than(thread_id:, older_than:)
123
- Phronomy::ThreadActorRegistry.for(thread_id).call do
124
- thread_data.raw_messages.reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
125
- end
126
- end
127
-
128
- private
129
-
130
- # Returns (or lazily initialises) the {ThreadData} for the current Actor
131
- # thread and this storage instance. Must only be called from within a
132
- # {Phronomy::ThreadActorRegistry.for} block so that +Thread.current+ is
133
- # the correct Actor thread.
134
- def thread_data
135
- (Thread.current[THREAD_DATA_KEY] ||= {})[object_id] ||= ThreadData.new
136
- end
137
-
138
- # Value object holding all per-thread-id storage state.
139
- class ThreadData
140
- attr_accessor :store, :raw_messages, :compactions, :hwm
141
-
142
- def initialize
143
- @store = nil
144
- @raw_messages = []
145
- @compactions = []
146
- @hwm = -1
147
- end
148
- end
149
- end
150
- end
151
- end
152
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- # Storage is the persistence axis of conversation management.
6
- # Implementations are responsible only for saving and loading raw message arrays.
7
- # Token budgeting, retrieval selection, and compression are handled by other axes.
8
- module Storage
9
- end
10
- end
11
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- end
6
- end
7
-
8
- require_relative "memory/storage"
9
- require_relative "memory/storage/base"
10
- require_relative "memory/storage/in_memory"
11
- require_relative "memory/storage/active_record"
12
- require_relative "memory/retrieval"
13
- require_relative "memory/retrieval/base"
14
- require_relative "memory/retrieval/recent"
15
- require_relative "memory/retrieval/semantic"
16
- require_relative "memory/retrieval/composite"
17
- require_relative "memory/compression"
18
- require_relative "memory/compression/base"
19
- require_relative "memory/compression/summary"
20
- require_relative "memory/compression/tool_output_pruner"
21
- require_relative "memory/conversation_manager"
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Rails
5
- # ActiveJob-based job that runs a Phronomy agent in streaming mode and
6
- # broadcasts each event to an ActionCable stream.
7
- #
8
- # Enqueue with +perform_later+ to run the agent asynchronously in a background
9
- # worker. Every streaming event is forwarded to ActionCable subscribers in
10
- # real time.
11
- #
12
- # @example Enqueueing a streaming agent job
13
- # Phronomy::Rails::AgentJob.perform_later(
14
- # "MyAgent",
15
- # "What is the weather today?",
16
- # channel: "AgentChannel",
17
- # stream: "agent_#{current_user.id}"
18
- # )
19
- #
20
- # Events broadcast to the ActionCable stream:
21
- # { type: "token", content: "..." } — each content delta from the LLM
22
- # { type: "done", output: "..." } — final complete output
23
- # { type: "error", message: "..." } — when the agent or job raises
24
- #
25
- class AgentJob < ::ActiveJob::Base
26
- # @param agent_class_name [String]
27
- # The constantize-able class name of the agent to run (e.g. "MyAgent").
28
- # **Security**: only classes that are subclasses of +Phronomy::Agent::Base+
29
- # are accepted. Never pass a value derived from user-controlled input.
30
- # @param input [String, Hash]
31
- # User input forwarded unchanged to the agent's +#stream+ method.
32
- # @param channel [String]
33
- # ActionCable channel name. Retained for documentation / future routing.
34
- # @param stream [String]
35
- # ActionCable stream identifier passed to +ActionCable.server.broadcast+.
36
- # @param config [Hash]
37
- # Configuration forwarded to the agent's +#stream+ call. Both symbol and
38
- # string keys are accepted; all keys are converted to symbols before use.
39
- def perform(agent_class_name, input, channel:, stream:, config: {})
40
- klass = resolve_agent_class!(agent_class_name)
41
- agent = klass.new
42
- agent.stream(input, config: config.transform_keys(&:to_sym)) do |event|
43
- ActionCable.server.broadcast(stream, build_payload(event))
44
- end
45
- rescue => e
46
- ::Rails.logger.error("[Phronomy::Rails::AgentJob] agent error (#{e.class}): #{e.message}")
47
- ActionCable.server.broadcast(stream, {type: "error", message: "An error occurred while processing your request."})
48
- end
49
-
50
- private
51
-
52
- # Resolves and validates the agent class name.
53
- # Raises ArgumentError when the name does not resolve to a subclass of
54
- # Phronomy::Agent::Base, preventing arbitrary class instantiation.
55
- def resolve_agent_class!(class_name)
56
- klass = Object.const_get(class_name.to_s)
57
- unless klass.is_a?(Class) && klass < Phronomy::Agent::Base
58
- raise ArgumentError, "#{class_name.inspect} is not a Phronomy::Agent::Base subclass"
59
- end
60
- klass
61
- rescue NameError
62
- raise ArgumentError, "Unknown agent class: #{class_name.inspect}"
63
- end
64
-
65
- def build_payload(event)
66
- case event.type
67
- when :token then {type: "token", content: event.payload[:content]}
68
- when :done then {type: "done", output: event.payload[:output]}
69
- when :error then {type: "error", message: "An error occurred while processing your request."}
70
- else {type: event.type.to_s}
71
- end
72
- end
73
- end
74
- end
75
- end
@@ -1,76 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Phronomy
6
- module StateStore
7
- # ActiveRecord-backed state store.
8
- # Persists graph state to a relational database using an AR model.
9
- #
10
- # The model_class must respond to:
11
- # .find_by(thread_id:)
12
- # .find_or_initialize_by(thread_id:)
13
- # #state_json=, #save!
14
- # .where(thread_id:).delete_all
15
- #
16
- # Minimal migration:
17
- # create_table :phronomy_states do |t|
18
- # t.string :thread_id, null: false, index: { unique: true }
19
- # t.text :state_json, null: false
20
- # t.timestamps
21
- # end
22
- #
23
- # @example
24
- # Phronomy.configure do |c|
25
- # c.default_state_store = Phronomy::StateStore::ActiveRecord.new(
26
- # model_class: PhronomyState
27
- # )
28
- # end
29
- class ActiveRecord < Base
30
- # @param model_class [Class] ActiveRecord model with the schema above.
31
- # @param encryptor [Phronomy::StateStore::Encryptor::Base, nil]
32
- # Optional encryption adapter. When supplied, the JSON payload is
33
- # encrypted before writing and decrypted after reading.
34
- # When nil (default), data is stored as plain JSON.
35
- def initialize(model_class:, encryptor: nil)
36
- @model_class = model_class
37
- @encryptor = encryptor
38
- end
39
-
40
- # Serializes and upserts the state for the given thread_id.
41
- # @param state [Object] includes Phronomy::WorkflowContext
42
- # @return [self]
43
- def save(state)
44
- json = serialize_state(state)
45
- payload = @encryptor ? @encryptor.encrypt(json) : json
46
- # Use upsert to avoid a race condition where two concurrent saves for the
47
- # same thread_id would both see "no record" and collide on the unique index.
48
- @model_class.upsert(
49
- {thread_id: state.thread_id, state_json: payload},
50
- unique_by: :thread_id,
51
- update_only: [:state_json]
52
- )
53
- self
54
- end
55
-
56
- # Loads and deserializes the state for the given thread_id.
57
- # @param thread_id [String]
58
- # @return [Object, nil] state instance or nil
59
- def load(thread_id)
60
- record = @model_class.find_by(thread_id: thread_id)
61
- return nil unless record
62
-
63
- payload = record.state_json
64
- json = @encryptor ? @encryptor.decrypt(payload) : payload
65
- deserialize_state(json)
66
- end
67
-
68
- # Deletes the state for the given thread_id.
69
- # @return [self]
70
- def clear(thread_id)
71
- @model_class.where(thread_id: thread_id).delete_all
72
- self
73
- end
74
- end
75
- end
76
- end
@@ -1,112 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Phronomy
6
- module StateStore
7
- # Abstract base class for state persistence backends.
8
- # Subclasses must implement save, load, and clear.
9
- #
10
- # The state object passed to save must include Phronomy::WorkflowContext
11
- # and have a non-nil thread_id (set automatically by WorkflowRunner#invoke).
12
- class Base
13
- # Persists the state. The thread_id is read from state.thread_id.
14
- # @param state [Object] object including Phronomy::WorkflowContext
15
- # @return [self]
16
- def save(state)
17
- raise NotImplementedError, "#{self.class}#save is not implemented"
18
- end
19
-
20
- # Loads the state for the given thread_id.
21
- # @param thread_id [String]
22
- # @return [Object, nil] state object or nil if not found
23
- def load(thread_id)
24
- raise NotImplementedError, "#{self.class}#load is not implemented"
25
- end
26
-
27
- # Removes the saved state for the given thread_id.
28
- # @param thread_id [String]
29
- # @return [self]
30
- def clear(thread_id)
31
- raise NotImplementedError, "#{self.class}#clear is not implemented"
32
- end
33
-
34
- private
35
-
36
- # Serializes a state object to a JSON string.
37
- # Includes user-defined fields and internal graph metadata.
38
- def serialize_state(state)
39
- JSON.generate(
40
- state_class: state.class.name,
41
- state_data: json_safe(state.to_h),
42
- thread_id: state.thread_id,
43
- phase: state.phase&.to_s
44
- )
45
- end
46
-
47
- # Deserializes a JSON string back into a state object.
48
- # @return [Object] state instance with graph metadata restored
49
- def deserialize_state(json_str)
50
- data = JSON.parse(json_str, symbolize_names: true)
51
- state_class = safe_state_class(data[:state_class])
52
- state_data = symbolize_keys(data[:state_data])
53
- state = state_class.new(**state_data)
54
- state.set_graph_metadata(
55
- thread_id: data[:thread_id],
56
- phase: data[:phase]&.to_sym
57
- )
58
- state
59
- end
60
-
61
- # Resolves and validates a context class name.
62
- # When a registry has been configured via +Phronomy.register_workflow_context+,
63
- # only registered classes are accepted — this prevents unintended autoloading
64
- # of arbitrary files from an untrusted class name stored in Redis/DB.
65
- # When no registry is configured, falls back to Object.const_get with a check
66
- # that the resolved class includes Phronomy::WorkflowContext.
67
- def safe_state_class(class_name)
68
- registry = Phronomy.workflow_context_registry
69
- if registry
70
- klass = registry[class_name.to_s]
71
- unless klass
72
- raise ArgumentError,
73
- "Unregistered context class: #{class_name.inspect}. " \
74
- "Call Phronomy.register_workflow_context(#{class_name}) at startup."
75
- end
76
- return klass
77
- end
78
-
79
- klass = Object.const_get(class_name.to_s)
80
- unless klass.is_a?(Class) && klass.include?(Phronomy::WorkflowContext)
81
- raise ArgumentError, "Invalid context class: #{class_name.inspect}"
82
- end
83
- klass
84
- rescue NameError
85
- raise ArgumentError, "Unknown context class: #{class_name.inspect}"
86
- end
87
-
88
- # Recursively converts objects to JSON-safe primitives.
89
- def json_safe(obj)
90
- case obj
91
- when Hash
92
- obj.transform_keys(&:to_s).transform_values { |v| json_safe(v) }
93
- when Array
94
- obj.map { |v| json_safe(v) }
95
- when String, Numeric, TrueClass, FalseClass, NilClass
96
- obj
97
- else
98
- obj.respond_to?(:to_h) ? json_safe(obj.to_h) : obj.to_s
99
- end
100
- end
101
-
102
- # Recursively symbolizes hash keys.
103
- def symbolize_keys(obj)
104
- case obj
105
- when Hash then obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
106
- when Array then obj.map { |v| symbolize_keys(v) }
107
- else obj
108
- end
109
- end
110
- end
111
- end
112
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module StateStore
5
- module Encryptor
6
- # Encryptor backed by ActiveSupport::MessageEncryptor.
7
- #
8
- # Requires the +activesupport+ gem to be available in the host application.
9
- # Does NOT require rails — any Ruby project that depends on activesupport can
10
- # use this adapter.
11
- #
12
- # @example
13
- # encryptor = Phronomy::StateStore::Encryptor::ActiveSupport.new(
14
- # secret_key_base: ENV.fetch("SECRET_KEY_BASE")
15
- # )
16
- # store = Phronomy::StateStore::ActiveRecord.new(
17
- # model_class: PhronomyState,
18
- # encryptor: encryptor
19
- # )
20
- class ActiveSupport < Base
21
- # @param secret_key_base [String] secret used to derive the encryption key.
22
- # Must be at least 30 random bytes (use +SecureRandom.hex(64)+ to generate).
23
- # @param cipher [String] OpenSSL cipher name (default: "aes-256-gcm").
24
- # @raise [LoadError] when activesupport is not available.
25
- def initialize(secret_key_base:, cipher: "aes-256-gcm")
26
- require "active_support/message_encryptor"
27
- key = ::ActiveSupport::KeyGenerator.new(secret_key_base)
28
- .generate_key("phronomy state store", 32)
29
- @encryptor = ::ActiveSupport::MessageEncryptor.new(key, cipher: cipher)
30
- end
31
-
32
- # Encrypts the plaintext using AES-256-GCM.
33
- # @param plaintext [String]
34
- # @return [String] Base64-encoded authenticated ciphertext
35
- def encrypt(plaintext)
36
- @encryptor.encrypt_and_sign(plaintext)
37
- end
38
-
39
- # Decrypts and verifies the ciphertext.
40
- # @param ciphertext [String]
41
- # @return [String] the original plaintext
42
- # @raise [ActiveSupport::MessageEncryptor::InvalidMessage] on tampered data
43
- def decrypt(ciphertext)
44
- @encryptor.decrypt_and_verify(ciphertext)
45
- end
46
- end
47
- end
48
- end
49
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module StateStore
5
- module Encryptor
6
- # Abstract base class for state encryption adapters.
7
- #
8
- # Subclass and implement {#encrypt} and {#decrypt} to integrate any
9
- # symmetric encryption scheme. Pass an instance to
10
- # {Phronomy::StateStore::ActiveRecord} via the +encryptor:+ argument.
11
- #
12
- # @example
13
- # class MyEncryptor < Phronomy::StateStore::Encryptor::Base
14
- # def encrypt(plaintext) = Base64.strict_encode64(plaintext.reverse)
15
- # def decrypt(ciphertext) = Base64.strict_decode64(ciphertext).reverse
16
- # end
17
- class Base
18
- # Encrypts a plaintext string.
19
- # @param plaintext [String] the JSON string produced by the state store
20
- # @return [String] the encrypted ciphertext
21
- def encrypt(plaintext)
22
- raise NotImplementedError, "#{self.class}#encrypt is not implemented"
23
- end
24
-
25
- # Decrypts a ciphertext string.
26
- # @param ciphertext [String] previously produced by {#encrypt}
27
- # @return [String] the original plaintext
28
- def decrypt(ciphertext)
29
- raise NotImplementedError, "#{self.class}#decrypt is not implemented"
30
- end
31
- end
32
- end
33
- end
34
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "encryptor/base"
4
- require_relative "encryptor/active_support"
5
-
6
- module Phronomy
7
- module StateStore
8
- # Namespace for state encryption adapters.
9
- #
10
- # Available adapters:
11
- # - {Phronomy::StateStore::Encryptor::Base} — abstract interface
12
- # - {Phronomy::StateStore::Encryptor::ActiveSupport} — AES-256-GCM via ActiveSupport
13
- module Encryptor
14
- end
15
- end
16
- end
@@ -1,85 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "json"
5
-
6
- module Phronomy
7
- module StateStore
8
- # File-system-backed state store.
9
- # Persists graph state as a JSON file under a configurable directory.
10
- # No additional server or database migration is required — it works with
11
- # the local file system out of the box.
12
- #
13
- # Each thread_id is stored as a separate file named "<thread_id>.json".
14
- # The thread_id is sanitised before use as a filename to prevent path
15
- # traversal: only alphanumeric characters, hyphens, underscores, and dots
16
- # are allowed; all other characters are replaced with underscores.
17
- #
18
- # @note This store is suitable for single-process use (development, CLI
19
- # tools, tests). It is not safe for concurrent access across multiple
20
- # processes without external locking.
21
- #
22
- # @example
23
- # store = Phronomy::StateStore::File.new(dir: "tmp/workflow_states")
24
- # Phronomy::Workflow.define(MyContext, state_store: store) do
25
- # # ...
26
- # end
27
- class File < Base
28
- # @param dir [String] directory where state files are stored.
29
- # Created automatically if it does not exist.
30
- def initialize(dir: ::File.join(::Dir.tmpdir, "phronomy_states"))
31
- @dir = ::File.expand_path(dir)
32
- ::FileUtils.mkdir_p(@dir)
33
- end
34
-
35
- # @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
36
- # @return [self]
37
- def save(state)
38
- ::File.write(path(state.thread_id), serialize_state(state))
39
- self
40
- end
41
-
42
- # @param thread_id [String]
43
- # @return [Object, nil] state instance or nil if not found
44
- def load(thread_id)
45
- file = path(thread_id)
46
- return nil unless ::File.exist?(file)
47
-
48
- deserialize_state(::File.read(file))
49
- end
50
-
51
- # Removes the saved state file for the given thread_id.
52
- # @param thread_id [String]
53
- # @return [self]
54
- def clear(thread_id)
55
- file = path(thread_id)
56
- ::File.delete(file) if ::File.exist?(file)
57
- self
58
- end
59
-
60
- # Removes all state files managed by this store instance.
61
- # @return [self]
62
- def clear_all
63
- ::Dir.glob(::File.join(@dir, "*.json")).each { |f| ::File.delete(f) }
64
- self
65
- end
66
-
67
- # @return [String] the directory used by this store
68
- def directory
69
- @dir
70
- end
71
-
72
- private
73
-
74
- # Converts a thread_id into a safe filename component.
75
- # Characters outside [A-Za-z0-9._-] are replaced with underscores.
76
- def sanitize(thread_id)
77
- thread_id.to_s.gsub(/[^A-Za-z0-9._-]/, "_")
78
- end
79
-
80
- def path(thread_id)
81
- ::File.join(@dir, "#{sanitize(thread_id)}.json")
82
- end
83
- end
84
- end
85
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module StateStore
5
- # In-memory state store backed by per-thread-id {Phronomy::Actor} instances
6
- # from {Phronomy::ThreadActorRegistry}. Suitable for single-process use only.
7
- class InMemory < Base
8
- # Thread-local key for per-thread-id state data (namespaced by store
9
- # instance object_id to support multiple independent InMemory stores).
10
- THREAD_DATA_KEY = :phronomy_state_store_in_memory_data
11
-
12
- def initialize
13
- end
14
-
15
- # @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
16
- # @return [self]
17
- def save(state)
18
- store_id = object_id
19
- Phronomy::ThreadActorRegistry.for(state.thread_id).call do
20
- (Thread.current[THREAD_DATA_KEY] ||= {})[store_id] = state
21
- end
22
- self
23
- end
24
-
25
- # @param thread_id [String]
26
- # @return [Object, nil] state object or nil
27
- def load(thread_id)
28
- store_id = object_id
29
- Phronomy::ThreadActorRegistry.for(thread_id).call do
30
- (Thread.current[THREAD_DATA_KEY] ||= {})[store_id]
31
- end
32
- end
33
-
34
- # @param thread_id [String]
35
- # @return [self]
36
- def clear(thread_id)
37
- store_id = object_id
38
- Phronomy::ThreadActorRegistry.for(thread_id).call do
39
- (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
40
- end
41
- self
42
- end
43
-
44
- def clear_all
45
- store_id = object_id
46
- Phronomy::ThreadActorRegistry.each_actor do |actor|
47
- actor.call { (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id) }
48
- end
49
- self
50
- end
51
- end
52
- end
53
- end