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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +127 -30
- data/README.md +106 -122
- data/lib/phronomy/agent/base.rb +135 -57
- data/lib/phronomy/agent/checkpoint.rb +53 -0
- data/lib/phronomy/agent/orchestrator.rb +119 -0
- data/lib/phronomy/agent/react_agent.rb +18 -28
- data/lib/phronomy/agent/shared_state.rb +303 -0
- data/lib/phronomy/agent/suspend_signal.rb +35 -0
- data/lib/phronomy/agent/team_coordinator.rb +285 -0
- data/lib/phronomy/agent.rb +2 -1
- data/lib/phronomy/configuration.rb +0 -24
- data/lib/phronomy/generator_verifier.rb +250 -0
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
- data/lib/phronomy/railtie.rb +0 -6
- data/lib/phronomy/ruby_llm_patches.rb +20 -0
- data/lib/phronomy/tool/mcp_tool.rb +23 -26
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
- data/lib/phronomy/vector_store/redis_search.rb +4 -4
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +4 -7
- data/lib/phronomy/workflow_runner.rb +42 -30
- data/lib/phronomy.rb +18 -0
- data/scripts/check_readme_ruby.rb +38 -0
- metadata +12 -38
- data/docs/trustworthy_ai_enhancements.md +0 -332
- data/lib/phronomy/active_record/acts_as.rb +0 -48
- data/lib/phronomy/active_record/checkpoint.rb +0 -20
- data/lib/phronomy/active_record/extensions.rb +0 -14
- data/lib/phronomy/active_record/message.rb +0 -20
- data/lib/phronomy/actor.rb +0 -68
- data/lib/phronomy/memory/compression/base.rb +0 -37
- data/lib/phronomy/memory/compression/summary.rb +0 -107
- data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
- data/lib/phronomy/memory/compression.rb +0 -11
- data/lib/phronomy/memory/conversation_manager.rb +0 -213
- data/lib/phronomy/memory/retrieval/base.rb +0 -22
- data/lib/phronomy/memory/retrieval/composite.rb +0 -76
- data/lib/phronomy/memory/retrieval/recent.rb +0 -35
- data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
- data/lib/phronomy/memory/retrieval.rb +0 -12
- data/lib/phronomy/memory/storage/active_record.rb +0 -248
- data/lib/phronomy/memory/storage/base.rb +0 -155
- data/lib/phronomy/memory/storage/in_memory.rb +0 -152
- data/lib/phronomy/memory/storage.rb +0 -11
- data/lib/phronomy/memory.rb +0 -21
- data/lib/phronomy/rails/agent_job.rb +0 -75
- data/lib/phronomy/state_store/active_record.rb +0 -76
- data/lib/phronomy/state_store/base.rb +0 -112
- data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
- data/lib/phronomy/state_store/encryptor/base.rb +0 -34
- data/lib/phronomy/state_store/encryptor.rb +0 -16
- data/lib/phronomy/state_store/file.rb +0 -85
- data/lib/phronomy/state_store/in_memory.rb +0 -53
- data/lib/phronomy/state_store/redis.rb +0 -70
- data/lib/phronomy/state_store.rb +0 -9
- data/lib/phronomy/thread_actor_registry.rb +0 -85
- 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
|
data/lib/phronomy/memory.rb
DELETED
|
@@ -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
|