legion-gaia 0.9.51 → 0.9.52
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 +8 -0
- data/lib/legion/gaia/advisory.rb +36 -7
- data/lib/legion/gaia/audit_observer.rb +1 -18
- data/lib/legion/gaia/bond_registry.rb +66 -10
- data/lib/legion/gaia/channel_adapter.rb +1 -1
- data/lib/legion/gaia/channel_registry.rb +32 -1
- data/lib/legion/gaia/channels/cli_adapter.rb +9 -6
- data/lib/legion/gaia/channels/teams/bot_framework_auth.rb +23 -10
- data/lib/legion/gaia/channels/teams/webhook_handler.rb +2 -1
- data/lib/legion/gaia/intent_classifier.rb +3 -3
- data/lib/legion/gaia/notification_gate/behavioral_evaluator.rb +2 -2
- data/lib/legion/gaia/notification_gate/delay_queue.rb +9 -0
- data/lib/legion/gaia/notification_gate/schedule_evaluator.rb +15 -9
- data/lib/legion/gaia/notification_gate.rb +11 -3
- data/lib/legion/gaia/offline_handler.rb +16 -8
- data/lib/legion/gaia/phase_wiring.rb +5 -2
- data/lib/legion/gaia/proactive_dispatcher.rb +29 -15
- data/lib/legion/gaia/router/router_bridge.rb +42 -5
- data/lib/legion/gaia/router/worker_routing.rb +1 -1
- data/lib/legion/gaia/routes.rb +9 -9
- data/lib/legion/gaia/session_store.rb +1 -1
- data/lib/legion/gaia/tick_history.rb +2 -2
- data/lib/legion/gaia/tracker_persistence.rb +26 -13
- data/lib/legion/gaia/version.rb +1 -1
- data/lib/legion/gaia/workflow/instance.rb +6 -4
- data/lib/legion/gaia.rb +10 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8390ab05b469136c10d656240dd01aed52856c2eb6ad853b566c1bf6712cc351
|
|
4
|
+
data.tar.gz: '08fa057814b2eb00550a6afb0db16074871a0974a026aa2c8b2caa23768921f4'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c2a1e84bf1839d3b50db07766243a15cd8bca7c56c6f14bbe68a7c4e9130a414fa745a06d0696901d319b784435943e5965b8397607c19ac9cbaa69bc534424
|
|
7
|
+
data.tar.gz: 9be977c4a11659433a0e6e7cbe11be7f5aee4692541b46b5be6f5c2856d38f9b1f1991b1653332847b8a57a7a5787b426e720d8b6940c136cb5c0d12b1f6e49d
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.52] - 2026-05-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Close the 2026-05-06 GAIA gap-analysis findings across advisory generation, Bot Framework JWT issuer validation, router outbound delivery, offline queuing, proactive partner channels, notification gating, Teams delivery, and shared-state synchronization.
|
|
9
|
+
- Normalize advisory routing hints, support string-keyed tool predictions, and return a stable empty advisory hash when advisory generation fails.
|
|
10
|
+
- Replace high-severity `log.unknown` message tracing with debug logging to avoid production PII log flooding.
|
|
11
|
+
- Harden PR review follow-up behavior for outbound queue acknowledgements, delayed notification TTL preservation, atomic bond channel metadata updates, and adapter delivery-signature caching.
|
|
12
|
+
|
|
5
13
|
## [0.9.51] - 2026-04-27
|
|
6
14
|
|
|
7
15
|
### Fixed
|
data/lib/legion/gaia/advisory.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Legion
|
|
|
13
13
|
# Returns Hash with optional keys: system_prompt, routing_hint,
|
|
14
14
|
# context_window, tool_hint, suppress, valence
|
|
15
15
|
def advise(caller:, conversation_id: nil, messages: nil)
|
|
16
|
-
log.
|
|
16
|
+
log.debug "advise(caller: #{caller}, conversation_id: #{conversation_id}, messages: #{messages}) "
|
|
17
17
|
return nil unless Gaia.started?
|
|
18
18
|
|
|
19
19
|
advisory = {}
|
|
@@ -27,17 +27,17 @@ module Legion
|
|
|
27
27
|
result
|
|
28
28
|
rescue StandardError => e
|
|
29
29
|
handle_exception(e, level: :warn, operation: 'gaia.advisory.advise', conversation_id: conversation_id)
|
|
30
|
-
|
|
30
|
+
{}
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def merge_tick_data!(advisory, tick_result)
|
|
34
|
-
log.
|
|
34
|
+
log.debug "merge_tick_data!(#{advisory.inspect}, #{tick_result})"
|
|
35
35
|
return unless tick_result && tick_result[:results]
|
|
36
36
|
|
|
37
37
|
results = tick_result[:results]
|
|
38
38
|
apply_tool_hints!(advisory, results)
|
|
39
39
|
apply_suppress!(advisory, results)
|
|
40
|
-
advisory[:routing_hint] = results.dig(:post_tick_reflection, :routing_preference)
|
|
40
|
+
advisory[:routing_hint] = normalize_routing_hint(results.dig(:post_tick_reflection, :routing_preference))
|
|
41
41
|
advisory[:context_window] = results.dig(:memory_retrieval, :cross_conversation)
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -45,7 +45,11 @@ module Legion
|
|
|
45
45
|
predictions = results.dig(:prediction_engine, :predictions)
|
|
46
46
|
return unless predictions
|
|
47
47
|
|
|
48
|
-
tools = predictions.
|
|
48
|
+
tools = predictions.filter_map do |prediction|
|
|
49
|
+
confidence = value_for(prediction, :confidence).to_f
|
|
50
|
+
tool = value_for(prediction, :tool)
|
|
51
|
+
tool if confidence >= 0.6 && tool
|
|
52
|
+
end
|
|
49
53
|
advisory[:tool_hint] = tools.empty? ? nil : tools
|
|
50
54
|
end
|
|
51
55
|
|
|
@@ -62,11 +66,36 @@ module Legion
|
|
|
62
66
|
return unless identity
|
|
63
67
|
|
|
64
68
|
learned = AuditObserver.instance.learned_data_for(identity)
|
|
65
|
-
advisory[:routing_hint] ||= learned[:routing_preference] if learned[:routing_preference]
|
|
69
|
+
advisory[:routing_hint] ||= normalize_routing_hint(learned[:routing_preference]) if learned[:routing_preference]
|
|
66
70
|
advisory[:tool_hint] ||= learned[:tool_predictions].keys.first(5) if learned[:tool_predictions]&.any?
|
|
67
71
|
end
|
|
68
72
|
|
|
69
|
-
|
|
73
|
+
def normalize_routing_hint(value)
|
|
74
|
+
return nil if value.nil?
|
|
75
|
+
|
|
76
|
+
if value.is_a?(Hash)
|
|
77
|
+
provider = value_for(value, :provider)
|
|
78
|
+
model = value_for(value, :model)
|
|
79
|
+
return nil if provider.to_s.empty? && model.to_s.empty?
|
|
80
|
+
|
|
81
|
+
return { provider: provider&.to_s, model: model&.to_s }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
{ provider: value.to_s, model: nil }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def value_for(hash, key)
|
|
88
|
+
return nil unless hash.respond_to?(:key?)
|
|
89
|
+
|
|
90
|
+
string_key = key.to_s
|
|
91
|
+
return hash[key] if hash.key?(key)
|
|
92
|
+
return hash[string_key] if hash.key?(string_key)
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private_class_method :merge_tick_data!, :apply_tool_hints!, :apply_suppress!, :merge_observer_data!,
|
|
98
|
+
:normalize_routing_hint, :value_for
|
|
70
99
|
end
|
|
71
100
|
end
|
|
72
101
|
end
|
|
@@ -12,7 +12,6 @@ module Legion
|
|
|
12
12
|
def initialize
|
|
13
13
|
@user_prefs = {}
|
|
14
14
|
@tool_patterns = {}
|
|
15
|
-
@quality_log = []
|
|
16
15
|
@mutex = Mutex.new
|
|
17
16
|
end
|
|
18
17
|
|
|
@@ -22,7 +21,6 @@ module Legion
|
|
|
22
21
|
@mutex.synchronize do
|
|
23
22
|
record_routing_preference(event)
|
|
24
23
|
record_tool_patterns(event)
|
|
25
|
-
record_quality(event)
|
|
26
24
|
end
|
|
27
25
|
identity = extract_caller_identity(event)
|
|
28
26
|
log.debug(
|
|
@@ -46,8 +44,7 @@ module Legion
|
|
|
46
44
|
prefs = @user_prefs[identity] || {}
|
|
47
45
|
{
|
|
48
46
|
routing_preference: prefs[:routing],
|
|
49
|
-
tool_predictions: top_tools_for_patterns
|
|
50
|
-
quality_signals: recent_quality
|
|
47
|
+
tool_predictions: top_tools_for_patterns
|
|
51
48
|
}
|
|
52
49
|
end
|
|
53
50
|
end
|
|
@@ -56,7 +53,6 @@ module Legion
|
|
|
56
53
|
@mutex.synchronize do
|
|
57
54
|
@user_prefs.clear
|
|
58
55
|
@tool_patterns.clear
|
|
59
|
-
@quality_log.clear
|
|
60
56
|
end
|
|
61
57
|
end
|
|
62
58
|
|
|
@@ -95,22 +91,9 @@ module Legion
|
|
|
95
91
|
end
|
|
96
92
|
end
|
|
97
93
|
|
|
98
|
-
def record_quality(event)
|
|
99
|
-
@quality_log << {
|
|
100
|
-
provider: event.dig(:routing, :provider),
|
|
101
|
-
tokens: event[:tokens],
|
|
102
|
-
timestamp: event[:timestamp]
|
|
103
|
-
}
|
|
104
|
-
@quality_log.shift if @quality_log.length > 100
|
|
105
|
-
end
|
|
106
|
-
|
|
107
94
|
def top_tools_for_patterns
|
|
108
95
|
@tool_patterns.sort_by { |_, v| -v[:count] }.first(10).to_h
|
|
109
96
|
end
|
|
110
|
-
|
|
111
|
-
def recent_quality
|
|
112
|
-
@quality_log.last(10)
|
|
113
|
-
end
|
|
114
97
|
end
|
|
115
98
|
end
|
|
116
99
|
end
|
|
@@ -9,20 +9,37 @@ module Legion
|
|
|
9
9
|
extend Legion::Logging::Helper
|
|
10
10
|
|
|
11
11
|
@bonds = Concurrent::Hash.new
|
|
12
|
+
@mutex = Mutex.new
|
|
12
13
|
|
|
13
14
|
module_function
|
|
14
15
|
|
|
15
|
-
def register(identity, bond: nil, role: nil, priority: :normal, channel_identity: nil
|
|
16
|
+
def register(identity, bond: nil, role: nil, priority: :normal, channel_identity: nil,
|
|
17
|
+
preferred_channel: nil, last_channel: nil)
|
|
16
18
|
effective_bond = (bond || role || :unknown).to_sym
|
|
17
|
-
@
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@bonds[identity.to_s] = build_entry(
|
|
21
|
+
identity,
|
|
22
|
+
bond: effective_bond,
|
|
23
|
+
priority: priority,
|
|
24
|
+
channel_identity: channel_identity,
|
|
25
|
+
preferred_channel: preferred_channel,
|
|
26
|
+
last_channel: last_channel
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
log.info("BondRegistry registered identity=#{identity} bond=#{effective_bond} priority=#{priority}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_entry(identity, bond:, priority:, channel_identity:, preferred_channel:, last_channel:)
|
|
33
|
+
{
|
|
18
34
|
identity: identity.to_s,
|
|
19
|
-
bond:
|
|
20
|
-
role:
|
|
35
|
+
bond: bond,
|
|
36
|
+
role: bond,
|
|
21
37
|
priority: priority.to_sym,
|
|
22
38
|
since: Time.now.utc,
|
|
23
|
-
channel_identity: channel_identity&.to_s
|
|
39
|
+
channel_identity: channel_identity&.to_s,
|
|
40
|
+
preferred_channel: preferred_channel&.to_sym,
|
|
41
|
+
last_channel: last_channel&.to_sym
|
|
24
42
|
}
|
|
25
|
-
log.info("BondRegistry registered identity=#{identity} bond=#{effective_bond} priority=#{priority}")
|
|
26
43
|
end
|
|
27
44
|
|
|
28
45
|
def bond(identity)
|
|
@@ -53,6 +70,21 @@ module Legion
|
|
|
53
70
|
@bonds.values
|
|
54
71
|
end
|
|
55
72
|
|
|
73
|
+
def record_channel(identity, channel_id:, channel_identity: nil)
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
entry = @bonds[identity.to_s]
|
|
76
|
+
return nil unless entry
|
|
77
|
+
|
|
78
|
+
channel = channel_id&.to_sym
|
|
79
|
+
updated = entry.merge(
|
|
80
|
+
last_channel: channel || entry[:last_channel],
|
|
81
|
+
preferred_channel: entry[:preferred_channel] || channel,
|
|
82
|
+
channel_identity: entry[:channel_identity] || channel_identity&.to_s
|
|
83
|
+
)
|
|
84
|
+
@bonds[identity.to_s] = updated
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
56
88
|
# Returns the single best partner bond entry using deterministic selection:
|
|
57
89
|
# 1. Prefer entries that have an explicit channel_identity stored (§9.6 guarantee)
|
|
58
90
|
# 2. Then prefer entries with priority: :primary
|
|
@@ -81,8 +113,14 @@ module Legion
|
|
|
81
113
|
|
|
82
114
|
identities = extract_identity_keys(content)
|
|
83
115
|
priority = content.match?(/primary/i) ? :primary : :normal
|
|
84
|
-
|
|
85
|
-
|
|
116
|
+
channel_identity = extract_channel_identity(content)
|
|
117
|
+
preferred_channel = extract_channel(content, 'preferred')
|
|
118
|
+
last_channel = extract_channel(content, 'last')
|
|
119
|
+
|
|
120
|
+
identities.each do |id|
|
|
121
|
+
register(id, bond: :partner, priority: priority, channel_identity: channel_identity,
|
|
122
|
+
preferred_channel: preferred_channel, last_channel: last_channel)
|
|
123
|
+
end
|
|
86
124
|
end
|
|
87
125
|
log.info("BondRegistry hydrated entries=#{result[:results].size}")
|
|
88
126
|
rescue StandardError => e
|
|
@@ -90,7 +128,7 @@ module Legion
|
|
|
90
128
|
end
|
|
91
129
|
|
|
92
130
|
def reset!
|
|
93
|
-
@bonds = Concurrent::Hash.new
|
|
131
|
+
@mutex.synchronize { @bonds = Concurrent::Hash.new }
|
|
94
132
|
log.debug('BondRegistry reset')
|
|
95
133
|
end
|
|
96
134
|
|
|
@@ -100,7 +138,25 @@ module Legion
|
|
|
100
138
|
|
|
101
139
|
match[1].split(/[,\s]+/).map(&:strip).reject(&:empty?)
|
|
102
140
|
end
|
|
103
|
-
|
|
141
|
+
|
|
142
|
+
def extract_channel_identity(content)
|
|
143
|
+
match = content.match(/channel[_\s-]*identity[:\s]+([^\n]+)/i)
|
|
144
|
+
first_match_value(match)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def extract_channel(content, kind)
|
|
148
|
+
match = content.match(/#{kind}[_\s-]*channel[:\s]+([^\n]+)/i)
|
|
149
|
+
first_match_value(match)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def first_match_value(match)
|
|
153
|
+
return nil unless match
|
|
154
|
+
|
|
155
|
+
match[1].split(/[,\s]+/).first&.strip
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private_class_method :build_entry, :extract_identity_keys, :extract_channel_identity, :extract_channel,
|
|
159
|
+
:first_match_value
|
|
104
160
|
end
|
|
105
161
|
end
|
|
106
162
|
end
|
|
@@ -63,7 +63,7 @@ module Legion
|
|
|
63
63
|
protected
|
|
64
64
|
|
|
65
65
|
def build_intent_metadata(content)
|
|
66
|
-
require_relative 'intent_classifier' unless defined?(IntentClassifier)
|
|
66
|
+
require_relative 'intent_classifier' unless defined?(Legion::Gaia::IntentClassifier)
|
|
67
67
|
classification = IntentClassifier.classify_with_engagement(content)
|
|
68
68
|
{
|
|
69
69
|
interaction_intent: classification[:intent],
|
|
@@ -5,12 +5,14 @@ module Legion
|
|
|
5
5
|
class ChannelRegistry
|
|
6
6
|
def initialize
|
|
7
7
|
@adapters = {}
|
|
8
|
+
@deliver_signature_cache = {}
|
|
8
9
|
@mutex = Mutex.new
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def register(adapter)
|
|
12
13
|
@mutex.synchronize do
|
|
13
14
|
@adapters[adapter.channel_id] = adapter
|
|
15
|
+
@deliver_signature_cache.delete(adapter.class)
|
|
14
16
|
end
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -46,7 +48,8 @@ module Legion
|
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
rendered = adapter.translate_outbound(output_frame)
|
|
49
|
-
normalize_delivery_result(adapter
|
|
51
|
+
normalize_delivery_result(deliver_to_adapter(adapter, rendered, output_frame),
|
|
52
|
+
channel_id: output_frame.channel_id)
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def start_all
|
|
@@ -59,6 +62,34 @@ module Legion
|
|
|
59
62
|
|
|
60
63
|
private
|
|
61
64
|
|
|
65
|
+
def deliver_to_adapter(adapter, rendered, output_frame)
|
|
66
|
+
conversation_id = output_frame.metadata[:conversation_id]
|
|
67
|
+
if conversation_id && deliver_accepts_conversation_id?(adapter)
|
|
68
|
+
adapter.deliver(rendered, conversation_id: conversation_id)
|
|
69
|
+
else
|
|
70
|
+
adapter.deliver(rendered)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def deliver_accepts_conversation_id?(adapter)
|
|
75
|
+
adapter_class = adapter.class
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
return @deliver_signature_cache[adapter_class] if @deliver_signature_cache.key?(adapter_class)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
accepts_conversation_id = deliver_parameters(adapter).any? do |type, name|
|
|
81
|
+
%i[key keyreq].include?(type) && name == :conversation_id
|
|
82
|
+
end
|
|
83
|
+
@mutex.synchronize { @deliver_signature_cache[adapter_class] = accepts_conversation_id }
|
|
84
|
+
accepts_conversation_id
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def deliver_parameters(adapter)
|
|
88
|
+
adapter.class.instance_method(:deliver).parameters
|
|
89
|
+
rescue StandardError
|
|
90
|
+
adapter.method(:deliver).parameters
|
|
91
|
+
end
|
|
92
|
+
|
|
62
93
|
def normalize_delivery_result(result, channel_id:)
|
|
63
94
|
return { delivered: false, reason: :adapter_returned_false, channel_id: channel_id } if result == false
|
|
64
95
|
return { delivered: true, channel_id: channel_id } unless result.is_a?(Hash)
|
|
@@ -15,6 +15,7 @@ module Legion
|
|
|
15
15
|
def initialize
|
|
16
16
|
super(channel_id: :cli, capabilities: CAPABILITIES)
|
|
17
17
|
@output_buffer = []
|
|
18
|
+
@mutex = Mutex.new
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def translate_inbound(raw_input)
|
|
@@ -35,22 +36,24 @@ module Legion
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def deliver(rendered_content)
|
|
38
|
-
@output_buffer << rendered_content
|
|
39
|
+
@mutex.synchronize { @output_buffer << rendered_content }
|
|
39
40
|
rendered_content
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def drain_output
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
output = @output_buffer.dup
|
|
46
|
+
@output_buffer.clear
|
|
47
|
+
output
|
|
48
|
+
end
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def last_output
|
|
49
|
-
@output_buffer.last
|
|
52
|
+
@mutex.synchronize { @output_buffer.last }
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def output_buffer_size
|
|
53
|
-
@output_buffer.size
|
|
56
|
+
@mutex.synchronize { @output_buffer.size }
|
|
54
57
|
end
|
|
55
58
|
end
|
|
56
59
|
end
|
|
@@ -102,15 +102,17 @@ module Legion
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def jwks_keys
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
105
|
+
jwks_mutex.synchronize do
|
|
106
|
+
now = Time.now.to_i
|
|
107
|
+
cache = @jwks_cache
|
|
108
|
+
return cache[:keys] if cache && cache[:expires_at] > now
|
|
109
|
+
|
|
110
|
+
metadata = fetch_json(OPENID_METADATA_URL)
|
|
111
|
+
jwks_uri = metadata['jwks_uri']
|
|
112
|
+
keys = fetch_json(jwks_uri).fetch('keys', [])
|
|
113
|
+
@jwks_cache = { keys: keys, expires_at: now + JWKS_CACHE_TTL }
|
|
114
|
+
keys
|
|
115
|
+
end
|
|
114
116
|
end
|
|
115
117
|
|
|
116
118
|
def fetch_json(url)
|
|
@@ -146,12 +148,23 @@ module Legion
|
|
|
146
148
|
issuer = payload['iss']
|
|
147
149
|
valid_issuers = [BOT_FRAMEWORK_ISSUER]
|
|
148
150
|
valid_issuers << EMULATOR_ISSUER if allow_emulator
|
|
149
|
-
valid_issuers.any? { |
|
|
151
|
+
valid_issuers.any? { |valid_issuer| issuer_matches?(issuer, valid_issuer) }
|
|
150
152
|
end
|
|
151
153
|
|
|
152
154
|
def check_issuer(payload, _allow_emulator)
|
|
153
155
|
{ valid: false, error: :invalid_issuer, issuer: payload['iss'] }
|
|
154
156
|
end
|
|
157
|
+
|
|
158
|
+
def issuer_matches?(issuer, valid_issuer)
|
|
159
|
+
return false if issuer.to_s.empty?
|
|
160
|
+
|
|
161
|
+
prefix = valid_issuer.end_with?('/') ? valid_issuer : "#{valid_issuer}/"
|
|
162
|
+
issuer == valid_issuer || issuer.start_with?(prefix)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def jwks_mutex
|
|
166
|
+
@jwks_mutex ||= Mutex.new
|
|
167
|
+
end
|
|
155
168
|
end
|
|
156
169
|
end
|
|
157
170
|
end
|
|
@@ -17,7 +17,7 @@ module Legion
|
|
|
17
17
|
module_function
|
|
18
18
|
|
|
19
19
|
def classify(content)
|
|
20
|
-
log.
|
|
20
|
+
log.debug "classify(#{content})"
|
|
21
21
|
text = content.to_s.strip
|
|
22
22
|
return :casual if text.empty?
|
|
23
23
|
|
|
@@ -31,12 +31,12 @@ module Legion
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def direct_engage?(content)
|
|
34
|
-
log.
|
|
34
|
+
log.debug "direct_engage?(#{content})"
|
|
35
35
|
content.to_s.match?(DIRECT_ADDRESS_PATTERN)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def classify_with_engagement(content)
|
|
39
|
-
log.
|
|
39
|
+
log.debug "classify_with_engagement(#{content})"
|
|
40
40
|
{ intent: classify(content), direct_engage: direct_engage?(content) }
|
|
41
41
|
end
|
|
42
42
|
end
|
|
@@ -25,14 +25,14 @@ module Legion
|
|
|
25
25
|
signals << arousal_signal if @arousal
|
|
26
26
|
signals << idle_signal if @idle_seconds
|
|
27
27
|
|
|
28
|
-
return
|
|
28
|
+
return 0.0 if signals.empty?
|
|
29
29
|
|
|
30
30
|
signals.sum / signals.size
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def should_deliver?(priority: :normal)
|
|
34
34
|
base = PRIORITY_BASE_THRESHOLD[priority] || PRIORITY_BASE_THRESHOLD[:normal]
|
|
35
|
-
effective_threshold = base + (
|
|
35
|
+
effective_threshold = base + (notification_score * THRESHOLD_MODIFIER)
|
|
36
36
|
priority_value(priority) >= effective_threshold
|
|
37
37
|
end
|
|
38
38
|
|
|
@@ -22,6 +22,15 @@ module Legion
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def requeue(entry)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
evicted = nil
|
|
28
|
+
evicted = @entries.shift if @entries.size >= @max_size
|
|
29
|
+
@entries << entry.merge(retry_count: entry[:retry_count].to_i + 1)
|
|
30
|
+
evicted
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
def size
|
|
26
35
|
@mutex.synchronize { @entries.size }
|
|
27
36
|
end
|
|
@@ -39,6 +39,10 @@ module Legion
|
|
|
39
39
|
Australia/Sydney Australia/Melbourne Pacific/Auckland
|
|
40
40
|
].freeze
|
|
41
41
|
|
|
42
|
+
SOUTHERN_DST_ZONES = %w[
|
|
43
|
+
Australia/Sydney Australia/Melbourne Pacific/Auckland
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
42
46
|
def initialize(schedule: [])
|
|
43
47
|
@schedule = normalize_schedule(schedule)
|
|
44
48
|
end
|
|
@@ -88,22 +92,22 @@ module Legion
|
|
|
88
92
|
def localize(time, timezone)
|
|
89
93
|
return time unless timezone
|
|
90
94
|
|
|
91
|
-
offset = tz_offset(timezone)
|
|
95
|
+
offset = tz_offset(timezone, time)
|
|
92
96
|
offset ? time.getlocal(offset) : time
|
|
93
97
|
end
|
|
94
98
|
|
|
95
|
-
def tz_offset(timezone)
|
|
99
|
+
def tz_offset(timezone, time = Time.now)
|
|
96
100
|
return nil unless timezone
|
|
97
|
-
return tzinfo_offset(timezone) if defined?(TZInfo)
|
|
101
|
+
return tzinfo_offset(timezone, time) if defined?(TZInfo)
|
|
98
102
|
|
|
99
103
|
base = STANDARD_OFFSETS[timezone]
|
|
100
104
|
return nil unless base
|
|
101
105
|
|
|
102
|
-
dst_active? && DST_ZONES.include?(timezone) ? dst_shift(base) : base
|
|
106
|
+
dst_active?(timezone, time) && DST_ZONES.include?(timezone) ? dst_shift(base) : base
|
|
103
107
|
end
|
|
104
108
|
|
|
105
|
-
def tzinfo_offset(timezone)
|
|
106
|
-
hours = TZInfo::Timezone.get(timezone).
|
|
109
|
+
def tzinfo_offset(timezone, time)
|
|
110
|
+
hours = TZInfo::Timezone.get(timezone).period_for_utc(time.utc).utc_total_offset / 3600.0
|
|
107
111
|
sign = hours.negative? ? '-' : '+'
|
|
108
112
|
abs_h = hours.abs.to_i
|
|
109
113
|
abs_m = ((hours.abs % 1) * 60).round
|
|
@@ -124,9 +128,11 @@ module Legion
|
|
|
124
128
|
format('%<sign>s%<h>02d:%<m>02d', sign: new_sign, h: abs_total / 60, m: abs_total % 60)
|
|
125
129
|
end
|
|
126
130
|
|
|
127
|
-
def dst_active?
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
def dst_active?(timezone, time)
|
|
132
|
+
month = time.utc.month
|
|
133
|
+
return month >= 10 || month <= 4 if SOUTHERN_DST_ZONES.include?(timezone)
|
|
134
|
+
|
|
135
|
+
month.between?(3, 11)
|
|
130
136
|
end
|
|
131
137
|
end
|
|
132
138
|
end
|
|
@@ -54,14 +54,22 @@ module Legion
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def process_delayed
|
|
57
|
-
|
|
58
|
-
deliverable = expired.map { |e| e[:frame] }
|
|
57
|
+
candidates = @delay_queue.drain_expired
|
|
59
58
|
|
|
60
59
|
unless @schedule_evaluator.quiet?
|
|
61
60
|
flushed = @delay_queue.flush
|
|
62
|
-
|
|
61
|
+
candidates.concat(flushed)
|
|
63
62
|
end
|
|
64
63
|
|
|
64
|
+
deliverable = []
|
|
65
|
+
candidates.each do |entry|
|
|
66
|
+
frame = entry[:frame]
|
|
67
|
+
if evaluate(frame) == :deliver
|
|
68
|
+
deliverable << frame
|
|
69
|
+
else
|
|
70
|
+
@delay_queue.requeue(entry)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
65
73
|
deliverable
|
|
66
74
|
end
|
|
67
75
|
|
|
@@ -21,37 +21,41 @@ module Legion
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def agent_online?(worker_id)
|
|
24
|
-
presence = presence_store[worker_id]
|
|
24
|
+
presence = mutex.synchronize { presence_store[worker_id] }
|
|
25
25
|
return false unless presence
|
|
26
26
|
|
|
27
27
|
(Time.now - presence[:last_seen]) < offline_threshold
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def record_presence(worker_id)
|
|
31
|
-
presence_store[worker_id] = { last_seen: Time.now }
|
|
31
|
+
mutex.synchronize { presence_store[worker_id] = { last_seen: Time.now } }
|
|
32
32
|
log.debug("OfflineHandler recorded presence worker_id=#{worker_id}")
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def pending_count(worker_id)
|
|
36
|
-
pending_store[worker_id]&.size || 0
|
|
36
|
+
mutex.synchronize { pending_store[worker_id]&.size || 0 }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def drain_pending(worker_id)
|
|
40
|
-
drained = pending_store.delete(worker_id) || []
|
|
40
|
+
drained = mutex.synchronize { pending_store.delete(worker_id) || [] }
|
|
41
41
|
log.info("OfflineHandler drained pending worker_id=#{worker_id} count=#{drained.size}") if drained.any?
|
|
42
42
|
drained
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def reset!
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
mutex.synchronize do
|
|
47
|
+
@presence_store = {}
|
|
48
|
+
@pending_store = {}
|
|
49
|
+
end
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
private
|
|
51
53
|
|
|
52
54
|
def queue_message(frame, worker_id)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
mutex.synchronize do
|
|
56
|
+
pending_store[worker_id] ||= []
|
|
57
|
+
pending_store[worker_id] << { frame: frame, queued_at: Time.now }
|
|
58
|
+
end
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
def notify_sender(frame)
|
|
@@ -92,6 +96,10 @@ module Legion
|
|
|
92
96
|
def pending_store
|
|
93
97
|
@pending_store ||= {}
|
|
94
98
|
end
|
|
99
|
+
|
|
100
|
+
def mutex
|
|
101
|
+
@mutex ||= Mutex.new
|
|
102
|
+
end
|
|
95
103
|
end
|
|
96
104
|
end
|
|
97
105
|
end
|
|
@@ -201,18 +201,21 @@ module Legion
|
|
|
201
201
|
}.freeze
|
|
202
202
|
|
|
203
203
|
@previous_reflection = {}
|
|
204
|
+
@previous_reflection_mutex = Mutex.new
|
|
204
205
|
|
|
205
206
|
module_function
|
|
206
207
|
|
|
207
208
|
def previous_reflection
|
|
208
|
-
@previous_reflection || {}
|
|
209
|
+
@previous_reflection_mutex.synchronize { @previous_reflection || {} }
|
|
209
210
|
end
|
|
210
211
|
|
|
211
212
|
def capture_tick_results(results)
|
|
212
213
|
return unless results.is_a?(Hash)
|
|
213
214
|
|
|
214
215
|
refl = results[:post_tick_reflection]
|
|
215
|
-
@
|
|
216
|
+
@previous_reflection_mutex.synchronize do
|
|
217
|
+
@previous_reflection = refl if refl.is_a?(Hash) && refl[:status] != :skipped
|
|
218
|
+
end
|
|
216
219
|
end
|
|
217
220
|
|
|
218
221
|
def build_cognitive_state(prior_results)
|
|
@@ -23,43 +23,57 @@ module Legion
|
|
|
23
23
|
@dispatch_log = []
|
|
24
24
|
@pending_buffer = []
|
|
25
25
|
@last_ignored_at = nil
|
|
26
|
+
@mutex = Mutex.new
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def can_dispatch?
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
prune_old_dispatches!
|
|
32
|
+
return false if @dispatch_log.size >= @max_per_day
|
|
33
|
+
return false if @dispatch_log.any? && (Time.now.utc - @dispatch_log.last[:at]) < @min_interval
|
|
34
|
+
return false if @last_ignored_at && (Time.now.utc - @last_ignored_at) < @ignore_cooldown
|
|
35
|
+
end
|
|
33
36
|
|
|
34
37
|
true
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
def record_dispatch!
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
count = @mutex.synchronize do
|
|
42
|
+
prune_old_dispatches!
|
|
43
|
+
@dispatch_log << { at: Time.now.utc }
|
|
44
|
+
@dispatch_log.size
|
|
45
|
+
end
|
|
46
|
+
log.info("ProactiveDispatcher recorded dispatch count=#{count}")
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def record_ignored!
|
|
44
|
-
@last_ignored_at = Time.now.utc
|
|
50
|
+
@mutex.synchronize { @last_ignored_at = Time.now.utc }
|
|
45
51
|
log.info('ProactiveDispatcher recorded ignored interaction')
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
def dispatches_today
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
prune_old_dispatches!
|
|
57
|
+
@dispatch_log.size
|
|
58
|
+
end
|
|
51
59
|
end
|
|
52
60
|
|
|
53
61
|
def queue_intent(intent)
|
|
54
|
-
@
|
|
55
|
-
|
|
62
|
+
pending = @mutex.synchronize do
|
|
63
|
+
@pending_buffer << intent
|
|
64
|
+
@pending_buffer.shift while @pending_buffer.size > MAX_PENDING
|
|
65
|
+
@pending_buffer.size
|
|
66
|
+
end
|
|
56
67
|
log.info("ProactiveDispatcher queued intent reason=#{intent.dig(:trigger,
|
|
57
|
-
:reason)} pending=#{
|
|
68
|
+
:reason)} pending=#{pending}")
|
|
58
69
|
end
|
|
59
70
|
|
|
60
71
|
def drain_pending
|
|
61
|
-
drained = @
|
|
62
|
-
|
|
72
|
+
drained = @mutex.synchronize do
|
|
73
|
+
copy = @pending_buffer.dup
|
|
74
|
+
@pending_buffer.clear
|
|
75
|
+
copy
|
|
76
|
+
end
|
|
63
77
|
log.info("ProactiveDispatcher drained pending count=#{drained.size}") if drained.any?
|
|
64
78
|
drained
|
|
65
79
|
end
|
|
@@ -11,8 +11,8 @@ module Legion
|
|
|
11
11
|
attr_reader :worker_routing, :channel_registry, :started
|
|
12
12
|
|
|
13
13
|
def initialize(channel_registry:, worker_routing: nil, allowed_worker_ids: [])
|
|
14
|
-
log.
|
|
15
|
-
|
|
14
|
+
log.debug "initialize(channel_registry: #{channel_registry}, " \
|
|
15
|
+
"worker_routing: #{worker_routing}, allowed_worker_ids: #{allowed_worker_ids}"
|
|
16
16
|
@channel_registry = channel_registry
|
|
17
17
|
@worker_routing = worker_routing || WorkerRouting.new(allowed_worker_ids: allowed_worker_ids)
|
|
18
18
|
@started = false
|
|
@@ -20,10 +20,12 @@ module Legion
|
|
|
20
20
|
|
|
21
21
|
def start
|
|
22
22
|
@started = true
|
|
23
|
+
subscribe_outbound if transport_available?
|
|
23
24
|
log.info("RouterBridge started workers=#{@worker_routing.size}")
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def stop
|
|
28
|
+
@outbound_consumer&.cancel if @outbound_consumer.respond_to?(:cancel)
|
|
27
29
|
@started = false
|
|
28
30
|
log.info('RouterBridge stopped')
|
|
29
31
|
end
|
|
@@ -33,7 +35,7 @@ module Legion
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def route_inbound(input_frame)
|
|
36
|
-
log.
|
|
38
|
+
log.debug "route_inbound(input_frame: #{input_frame})"
|
|
37
39
|
return { routed: false, reason: :not_started } unless started?
|
|
38
40
|
|
|
39
41
|
identity = extract_identity(input_frame)
|
|
@@ -47,7 +49,7 @@ module Legion
|
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def route_outbound(payload)
|
|
50
|
-
log.
|
|
52
|
+
log.debug "route_outbound(payload: #{payload})"
|
|
51
53
|
return { delivered: false, reason: :not_started } unless started?
|
|
52
54
|
|
|
53
55
|
frame = reconstruct_output_frame(payload)
|
|
@@ -82,6 +84,31 @@ module Legion
|
|
|
82
84
|
|
|
83
85
|
private
|
|
84
86
|
|
|
87
|
+
def subscribe_outbound
|
|
88
|
+
queue = Transport::Queues::Outbound.new
|
|
89
|
+
@outbound_consumer = queue.subscribe(manual_ack: true, block: false) do |delivery_info, _metadata, payload|
|
|
90
|
+
message = decode_payload(payload)
|
|
91
|
+
unless message
|
|
92
|
+
queue.reject(delivery_info.delivery_tag, requeue: false)
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
delivery_result = route_outbound(message)
|
|
97
|
+
if delivery_result.is_a?(Hash) && delivery_result[:delivered] == true && !delivery_result[:error]
|
|
98
|
+
queue.acknowledge(delivery_info.delivery_tag)
|
|
99
|
+
else
|
|
100
|
+
log.warn(
|
|
101
|
+
'RouterBridge outbound delivery not acknowledged ' \
|
|
102
|
+
"reason=#{delivery_result[:reason] || delivery_result[:error] || :unknown}"
|
|
103
|
+
)
|
|
104
|
+
queue.reject(delivery_info.delivery_tag, requeue: true)
|
|
105
|
+
end
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
handle_exception(e, level: :error, operation: 'gaia.router.router_bridge.subscribe_outbound')
|
|
108
|
+
queue.reject(delivery_info.delivery_tag, requeue: false)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
85
112
|
def extract_identity(input_frame)
|
|
86
113
|
input_frame.auth_context[:principal_id] ||
|
|
87
114
|
input_frame.auth_context[:aad_object_id] ||
|
|
@@ -108,15 +135,25 @@ module Legion
|
|
|
108
135
|
|
|
109
136
|
def offline_response(input_frame)
|
|
110
137
|
identity = extract_identity(input_frame)
|
|
138
|
+
offline_result = OfflineHandler.handle_offline_delivery(input_frame, worker_id: identity || :unassigned)
|
|
111
139
|
log.warn("RouterBridge could not route inbound frame_id=#{input_frame.id} identity=#{identity}")
|
|
112
140
|
{
|
|
113
141
|
routed: false,
|
|
114
142
|
reason: :worker_not_found,
|
|
115
143
|
identity: identity,
|
|
116
|
-
frame_id: input_frame.id
|
|
144
|
+
frame_id: input_frame.id,
|
|
145
|
+
queued: offline_result[:queued]
|
|
117
146
|
}
|
|
118
147
|
end
|
|
119
148
|
|
|
149
|
+
def decode_payload(raw)
|
|
150
|
+
parsed = Legion::JSON.load(raw)
|
|
151
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
handle_exception(e, level: :warn, operation: 'gaia.router.router_bridge.decode_payload')
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
120
157
|
def reconstruct_output_frame(payload)
|
|
121
158
|
OutputFrame.new(
|
|
122
159
|
id: payload[:id],
|
|
@@ -9,7 +9,7 @@ module Legion
|
|
|
9
9
|
attr_reader :allowed_worker_ids
|
|
10
10
|
|
|
11
11
|
def initialize(allowed_worker_ids: [])
|
|
12
|
-
log.
|
|
12
|
+
log.debug "initialize(allowed_worker_ids: #{allowed_worker_ids})"
|
|
13
13
|
@routes = {}
|
|
14
14
|
@allowed_worker_ids = allowed_worker_ids
|
|
15
15
|
@mutex = Mutex.new
|
data/lib/legion/gaia/routes.rb
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
module Legion
|
|
11
11
|
module Gaia
|
|
12
12
|
module Routes
|
|
13
|
-
def self.registered(app)
|
|
13
|
+
def self.registered(app) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
14
14
|
app.helpers do # rubocop:disable Metrics/BlockLength
|
|
15
15
|
unless method_defined?(:gaia_available?)
|
|
16
16
|
define_method(:gaia_available?) do
|
|
@@ -47,9 +47,9 @@ module Legion
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
unless method_defined?(:json_error)
|
|
50
|
-
define_method(:json_error) do |code, message, status_code:
|
|
50
|
+
define_method(:json_error) do |code, message, status_code: nil|
|
|
51
51
|
content_type :json
|
|
52
|
-
status status_code
|
|
52
|
+
status status_code if status_code
|
|
53
53
|
Legion::JSON.dump({ error: { code: code, message: message } })
|
|
54
54
|
end
|
|
55
55
|
end
|
|
@@ -76,7 +76,7 @@ module Legion
|
|
|
76
76
|
|
|
77
77
|
def self.register_ticks_route(app)
|
|
78
78
|
app.get '/api/gaia/ticks' do
|
|
79
|
-
halt 503, json_error('gaia_unavailable', 'gaia is not started'
|
|
79
|
+
halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
|
|
80
80
|
|
|
81
81
|
max_limit =
|
|
82
82
|
if defined?(Legion::Gaia::TickHistory) && Legion::Gaia::TickHistory.const_defined?(:MAX_ENTRIES)
|
|
@@ -93,7 +93,7 @@ module Legion
|
|
|
93
93
|
|
|
94
94
|
def self.register_channels_route(app)
|
|
95
95
|
app.get '/api/gaia/channels' do
|
|
96
|
-
halt 503, json_error('gaia_unavailable', 'gaia is not started'
|
|
96
|
+
halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
|
|
97
97
|
|
|
98
98
|
registry = Legion::Gaia.channel_registry
|
|
99
99
|
return json_response({ channels: [] }) unless registry
|
|
@@ -109,7 +109,7 @@ module Legion
|
|
|
109
109
|
|
|
110
110
|
def self.register_buffer_route(app)
|
|
111
111
|
app.get '/api/gaia/buffer' do
|
|
112
|
-
halt 503, json_error('gaia_unavailable', 'gaia is not started'
|
|
112
|
+
halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
|
|
113
113
|
|
|
114
114
|
buffer = Legion::Gaia.sensory_buffer
|
|
115
115
|
json_response({
|
|
@@ -122,7 +122,7 @@ module Legion
|
|
|
122
122
|
|
|
123
123
|
def self.register_sessions_route(app)
|
|
124
124
|
app.get '/api/gaia/sessions' do
|
|
125
|
-
halt 503, json_error('gaia_unavailable', 'gaia is not started'
|
|
125
|
+
halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
|
|
126
126
|
|
|
127
127
|
store = Legion::Gaia.session_store
|
|
128
128
|
json_response({
|
|
@@ -176,14 +176,14 @@ module Legion
|
|
|
176
176
|
|
|
177
177
|
def self.register_ingest_route(app)
|
|
178
178
|
app.post '/api/gaia/ingest' do
|
|
179
|
-
halt 503, json_error('gaia_unavailable', 'gaia is not started'
|
|
179
|
+
halt 503, json_error('gaia_unavailable', 'gaia is not started') unless gaia_available?
|
|
180
180
|
|
|
181
181
|
body = Legion::JSON.load(request.body.read)
|
|
182
182
|
content = body[:content] || body['content']
|
|
183
183
|
identity = body[:identity] || body['identity'] || 'unknown'
|
|
184
184
|
channel = (body[:channel_id] || body['channel_id'] || 'cli').to_sym
|
|
185
185
|
|
|
186
|
-
halt 400, json_error('missing_content', 'content is required'
|
|
186
|
+
halt 400, json_error('missing_content', 'content is required') if content.to_s.empty?
|
|
187
187
|
|
|
188
188
|
frame = Legion::Gaia::InputFrame.new(
|
|
189
189
|
content: content,
|
|
@@ -110,7 +110,7 @@ module Legion
|
|
|
110
110
|
# during the migration window.
|
|
111
111
|
@identity_index[normalized_identity] = old_session_id
|
|
112
112
|
@canonical_to_uuid[canonical_key] = normalized_identity
|
|
113
|
-
(@session_identity_index[old_session_id] ||= [])
|
|
113
|
+
(@session_identity_index[old_session_id] ||= []).push(canonical_key, normalized_identity).uniq!
|
|
114
114
|
old_session_id
|
|
115
115
|
end
|
|
116
116
|
|
|
@@ -17,12 +17,12 @@ module Legion
|
|
|
17
17
|
def record(tick_result)
|
|
18
18
|
return unless tick_result.is_a?(Hash) && tick_result[:results].is_a?(Hash)
|
|
19
19
|
|
|
20
|
-
timestamp = Time.now.utc.iso8601
|
|
20
|
+
timestamp = Time.now.utc.iso8601(3).freeze
|
|
21
21
|
new_events = tick_result[:results].filter_map do |phase_name, phase_data|
|
|
22
22
|
next unless phase_data.is_a?(Hash)
|
|
23
23
|
|
|
24
24
|
{
|
|
25
|
-
timestamp: timestamp
|
|
25
|
+
timestamp: timestamp,
|
|
26
26
|
phase: phase_name.to_s.freeze,
|
|
27
27
|
duration_ms: phase_data[:elapsed_ms] || phase_data[:duration_ms] || 0.0,
|
|
28
28
|
status: phase_data[:status] || :completed
|
|
@@ -12,13 +12,15 @@ module Legion
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
14
|
def register_tracker(name, tracker:, tags:)
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
mutex.synchronize do
|
|
16
|
+
@trackers ||= {}
|
|
17
|
+
@trackers[name] = { tracker: tracker, tags: tags }
|
|
18
|
+
end
|
|
17
19
|
log.info("TrackerPersistence registered tracker=#{name} tags=#{Array(tags).join(',')}")
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def registered_trackers
|
|
21
|
-
@trackers || {}
|
|
23
|
+
mutex.synchronize { (@trackers || {}).dup }
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def flush_dirty(store: nil)
|
|
@@ -26,7 +28,8 @@ module Legion
|
|
|
26
28
|
|
|
27
29
|
failed = false
|
|
28
30
|
flushed = 0
|
|
29
|
-
registered_trackers.
|
|
31
|
+
entries = registered_trackers.values
|
|
32
|
+
entries.each do |entry|
|
|
30
33
|
tracker = entry[:tracker]
|
|
31
34
|
next unless tracker.dirty?
|
|
32
35
|
|
|
@@ -36,7 +39,7 @@ module Legion
|
|
|
36
39
|
failed = true
|
|
37
40
|
end
|
|
38
41
|
end
|
|
39
|
-
@last_flush_at = Time.now.utc unless failed
|
|
42
|
+
mutex.synchronize { @last_flush_at = Time.now.utc } unless failed
|
|
40
43
|
if failed
|
|
41
44
|
log.warn("TrackerPersistence flush_dirty completed with errors flushed=#{flushed}")
|
|
42
45
|
else
|
|
@@ -50,11 +53,12 @@ module Legion
|
|
|
50
53
|
return unless store
|
|
51
54
|
|
|
52
55
|
failed = false
|
|
53
|
-
registered_trackers.
|
|
56
|
+
entries = registered_trackers.values
|
|
57
|
+
entries.each do |entry|
|
|
54
58
|
failed ||= !flush_tracker(entry[:tracker], store: store)
|
|
55
59
|
end
|
|
56
|
-
@last_flush_at = Time.now.utc unless failed
|
|
57
|
-
log.info("TrackerPersistence flushed all trackers count=#{
|
|
60
|
+
mutex.synchronize { @last_flush_at = Time.now.utc } unless failed
|
|
61
|
+
log.info("TrackerPersistence flushed all trackers count=#{entries.size}")
|
|
58
62
|
rescue StandardError => e
|
|
59
63
|
handle_exception(e, level: :warn, operation: 'gaia.tracker_persistence.flush_all')
|
|
60
64
|
end
|
|
@@ -71,18 +75,22 @@ module Legion
|
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
def last_flush_at
|
|
74
|
-
@last_flush_at
|
|
78
|
+
mutex.synchronize { @last_flush_at }
|
|
75
79
|
end
|
|
76
80
|
|
|
77
81
|
def should_flush?
|
|
78
|
-
|
|
82
|
+
mutex.synchronize do
|
|
83
|
+
return true if @last_flush_at.nil?
|
|
79
84
|
|
|
80
|
-
|
|
85
|
+
(Time.now.utc - @last_flush_at) >= FLUSH_INTERVAL
|
|
86
|
+
end
|
|
81
87
|
end
|
|
82
88
|
|
|
83
89
|
def reset!
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
mutex.synchronize do
|
|
91
|
+
@trackers = {}
|
|
92
|
+
@last_flush_at = nil
|
|
93
|
+
end
|
|
86
94
|
end
|
|
87
95
|
|
|
88
96
|
def flush_tracker(tracker, store:)
|
|
@@ -118,6 +126,11 @@ module Legion
|
|
|
118
126
|
true
|
|
119
127
|
end
|
|
120
128
|
private_class_method :upsert_succeeded?
|
|
129
|
+
|
|
130
|
+
def mutex
|
|
131
|
+
@mutex ||= Mutex.new
|
|
132
|
+
end
|
|
133
|
+
private_class_method :mutex
|
|
121
134
|
end
|
|
122
135
|
end
|
|
123
136
|
end
|
data/lib/legion/gaia/version.rb
CHANGED
|
@@ -51,16 +51,18 @@ module Legion
|
|
|
51
51
|
self
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
# Non-raising variant — returns true on success, false on
|
|
55
|
-
#
|
|
54
|
+
# Non-raising variant — returns true on success, false on transition failure.
|
|
55
|
+
# Pass strict: true to propagate unknown-state and invalid-transition errors.
|
|
56
56
|
#
|
|
57
57
|
# @param to_state [Symbol]
|
|
58
58
|
# @param ctx [Hash]
|
|
59
59
|
# @return [Boolean]
|
|
60
|
-
def transition(to_state, **ctx)
|
|
60
|
+
def transition(to_state, strict: false, **ctx)
|
|
61
61
|
transition!(to_state, **ctx)
|
|
62
62
|
true
|
|
63
|
-
rescue GuardRejected, CheckpointBlocked => e
|
|
63
|
+
rescue GuardRejected, CheckpointBlocked, UnknownState, InvalidTransition => e
|
|
64
|
+
raise if strict && (e.is_a?(UnknownState) || e.is_a?(InvalidTransition))
|
|
65
|
+
|
|
64
66
|
handle_exception(e, level: :debug, operation: 'gaia.workflow.instance.transition',
|
|
65
67
|
workflow: definition.name, to_state: to_state)
|
|
66
68
|
false
|
data/lib/legion/gaia.rb
CHANGED
|
@@ -206,6 +206,10 @@ module Legion
|
|
|
206
206
|
session = @session_store&.find_or_create(identity: identity || :anonymous)
|
|
207
207
|
@session_store&.touch(session.id, channel_id: input_frame.channel_id) if session
|
|
208
208
|
|
|
209
|
+
if identity
|
|
210
|
+
BondRegistry.record_channel(identity.to_s, channel_id: input_frame.channel_id,
|
|
211
|
+
channel_identity: channel_identity(input_frame))
|
|
212
|
+
end
|
|
209
213
|
observe_interlocutor(input_frame, identity) if identity && identity != :anonymous
|
|
210
214
|
|
|
211
215
|
log.info(
|
|
@@ -387,8 +391,6 @@ module Legion
|
|
|
387
391
|
end
|
|
388
392
|
|
|
389
393
|
def boot_agent_bridge
|
|
390
|
-
return unless settings&.dig(:router, :mode)
|
|
391
|
-
|
|
392
394
|
worker_id = settings&.dig(:router, :worker_id)
|
|
393
395
|
return unless worker_id
|
|
394
396
|
|
|
@@ -499,6 +501,11 @@ module Legion
|
|
|
499
501
|
ctx[:user_id]
|
|
500
502
|
end
|
|
501
503
|
|
|
504
|
+
def channel_identity(input_frame)
|
|
505
|
+
ctx = input_frame.auth_context || {}
|
|
506
|
+
ctx[:channel_identity] || ctx[:user_id] || ctx[:identity]
|
|
507
|
+
end
|
|
508
|
+
|
|
502
509
|
def observe_interlocutor(input_frame, identity)
|
|
503
510
|
role = BondRegistry.bond(identity.to_s)
|
|
504
511
|
|
|
@@ -544,7 +551,7 @@ module Legion
|
|
|
544
551
|
},
|
|
545
552
|
domain_tags: ['partner_interaction', observation[:channel].to_s],
|
|
546
553
|
origin: :direct_experience,
|
|
547
|
-
emotional_valence: @last_valences&.
|
|
554
|
+
emotional_valence: @last_valences&.first&.inspect,
|
|
548
555
|
emotional_intensity: 0.5,
|
|
549
556
|
confidence: 0.8
|
|
550
557
|
)
|