little_ghost 0.1.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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Supplies LittleGhost's ready-to-use model selection. The registry maps the
|
|
5
|
+
# +default+ role to GPT-5.6 Terra on OpenRouter or OpenAI and keeps the selected
|
|
6
|
+
# provider stable for its runtime.
|
|
7
|
+
#
|
|
8
|
+
# Prefixed credentials take precedence over conventional provider variables:
|
|
9
|
+
#
|
|
10
|
+
# 1. +LITTLEGHOST_OPENROUTER_API_KEY+
|
|
11
|
+
# 2. +LITTLEGHOST_OPENAI_API_KEY+
|
|
12
|
+
# 3. +OPENROUTER_API_KEY+
|
|
13
|
+
# 4. +OPENAI_API_KEY+
|
|
14
|
+
#
|
|
15
|
+
# Blank values are ignored. Resolving the +default+ role requires one supported
|
|
16
|
+
# key and raises ConfigurationError with the available choices otherwise. The
|
|
17
|
+
# environment is read when the registry is created, so build a new Runtime
|
|
18
|
+
# after changing credentials. Configure a ModelRegistry explicitly when the
|
|
19
|
+
# application needs another provider, model, or logical role.
|
|
20
|
+
#
|
|
21
|
+
# *Warning:* Setting any supported key authorizes LittleGhost to send model
|
|
22
|
+
# inputs, including prompts, conversation history, tool data, and attachments,
|
|
23
|
+
# to the selected external provider. When more than one key is present, the
|
|
24
|
+
# precedence above determines that destination. Applications with provider or
|
|
25
|
+
# data-residency requirements should configure a ModelRegistry explicitly.
|
|
26
|
+
class DefaultModelRegistry < ModelRegistry
|
|
27
|
+
CREDENTIALS = [
|
|
28
|
+
["LITTLEGHOST_OPENROUTER_API_KEY", :openrouter],
|
|
29
|
+
["LITTLEGHOST_OPENAI_API_KEY", :openai],
|
|
30
|
+
["OPENROUTER_API_KEY", :openrouter],
|
|
31
|
+
["OPENAI_API_KEY", :openai]
|
|
32
|
+
].freeze # :nodoc:
|
|
33
|
+
MODELS = {
|
|
34
|
+
openrouter: "openai/gpt-5.6-terra",
|
|
35
|
+
openai: "gpt-5.6-terra"
|
|
36
|
+
}.freeze # :nodoc:
|
|
37
|
+
PROVIDERS = {
|
|
38
|
+
openrouter: Providers::OpenRouter,
|
|
39
|
+
openai: Providers::OpenAI
|
|
40
|
+
}.freeze # :nodoc:
|
|
41
|
+
MISSING_CREDENTIAL_MESSAGE =
|
|
42
|
+
"No API key is configured for the default model. Set " \
|
|
43
|
+
"LITTLEGHOST_OPENROUTER_API_KEY, LITTLEGHOST_OPENAI_API_KEY, " \
|
|
44
|
+
"OPENROUTER_API_KEY, or OPENAI_API_KEY, or configure a custom " \
|
|
45
|
+
"model registry with LittleGhost.configure." # :nodoc:
|
|
46
|
+
|
|
47
|
+
# Selects one provider from the current environment and registers the
|
|
48
|
+
# +default+ profile. The selection remains fixed for this registry instance.
|
|
49
|
+
def initialize
|
|
50
|
+
super
|
|
51
|
+
variable, provider_name = CREDENTIALS.find { |name, _provider| !ENV[name].to_s.strip.empty? }
|
|
52
|
+
if variable
|
|
53
|
+
api_key = ENV.fetch(variable)
|
|
54
|
+
provider_class = PROVIDERS.fetch(provider_name)
|
|
55
|
+
provider(provider_name) do |model:, **|
|
|
56
|
+
provider_class.new(api_key:, model:)
|
|
57
|
+
end
|
|
58
|
+
profile("default", provider: provider_name, model: MODELS.fetch(provider_name))
|
|
59
|
+
else
|
|
60
|
+
configure_missing_credential
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def configure_missing_credential
|
|
67
|
+
provider(:unconfigured) { |**| raise ConfigurationError, MISSING_CREDENTIAL_MESSAGE }
|
|
68
|
+
profile("default", provider: :unconfigured, model: MODELS.fetch(:openai))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Base class for errors raised by LittleGhost.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
# Raised for invalid framework or application configuration.
|
|
7
|
+
class ConfigurationError < Error; end
|
|
8
|
+
# Raised when an invocation payload or operation is invalid.
|
|
9
|
+
class InvocationError < Error; end
|
|
10
|
+
# Raised when an invocation contains an unsupported input form.
|
|
11
|
+
class UnsupportedInputError < InvocationError; end
|
|
12
|
+
# Base class for provider response and protocol failures.
|
|
13
|
+
class ProviderError < Error; end
|
|
14
|
+
# Raised when a provider violates the expected request-response protocol.
|
|
15
|
+
class ProtocolError < ProviderError; end
|
|
16
|
+
# Raised when a provider reports that the request exceeds its context window.
|
|
17
|
+
class ContextWindowOverflowError < ProviderError; end
|
|
18
|
+
# Raised when configured generation limits stop the agent before completion.
|
|
19
|
+
class OutputLimitError < ProtocolError; end
|
|
20
|
+
# Raised when a model returns an invalid tool-call representation.
|
|
21
|
+
class MalformedToolCallError < ProtocolError; end
|
|
22
|
+
|
|
23
|
+
# Raised when structured output is absent, invalid, or exceeds safety limits.
|
|
24
|
+
class StructuredResultError < ProtocolError
|
|
25
|
+
# Declared schema name and validation messages returned by local checking.
|
|
26
|
+
attr_reader :schema_name, :validation_errors
|
|
27
|
+
|
|
28
|
+
# Records the schema and freezes normalized validation messages.
|
|
29
|
+
def initialize(message, schema_name:, validation_errors: [])
|
|
30
|
+
@schema_name = schema_name.to_s.freeze
|
|
31
|
+
@validation_errors = Array(validation_errors).map(&:to_s).freeze
|
|
32
|
+
super(message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Raised when repeated identical tool calls reach the configured termination limit.
|
|
37
|
+
class ToolLoopError < ProtocolError; end
|
|
38
|
+
# Base class for failures while executing a tool.
|
|
39
|
+
class ToolError < Error; end
|
|
40
|
+
# Raised when an active run cannot accept an interruption.
|
|
41
|
+
class AgentInterruptError < InvocationError; end
|
|
42
|
+
# Raised when cancellation stops an operation.
|
|
43
|
+
class CancelledError < Error; end
|
|
44
|
+
# Raised when an operation reaches its deadline.
|
|
45
|
+
class DeadlineExceededError < Error; end
|
|
46
|
+
# Raised when one or more managed resources fail to close.
|
|
47
|
+
class CleanupError < Error; end
|
|
48
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
# Events lets an application react to noteworthy agent activity without
|
|
7
|
+
# coupling LittleGhost to a logger or event backend. Listeners can feed local
|
|
8
|
+
# diagnostics, alerts, or an application's own event pipeline.
|
|
9
|
+
#
|
|
10
|
+
# class WarningCollector
|
|
11
|
+
# attr_reader :events
|
|
12
|
+
#
|
|
13
|
+
# def initialize
|
|
14
|
+
# @events = []
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# def emit(event)
|
|
18
|
+
# events << event
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# warnings = WarningCollector.new
|
|
23
|
+
# LittleGhost::Events.subscribe(warnings) do |event|
|
|
24
|
+
# %i[warn error].include?(event[:level])
|
|
25
|
+
# end
|
|
26
|
+
# LittleGhost::Events.warn("support.case.stalled", case_id: "case-42")
|
|
27
|
+
# warnings.events.last[:name] # => "support.case.stalled"
|
|
28
|
+
#
|
|
29
|
+
# Events describe point-in-time facts. Instrumentation measures work that has
|
|
30
|
+
# a start and finish. Payloads are copied, limited to JSON-safe values, and
|
|
31
|
+
# delivered with context local to the current execution. A broken listener
|
|
32
|
+
# never breaks the operation that emitted the event.
|
|
33
|
+
module Events
|
|
34
|
+
# Severity levels accepted by .emit and its convenience methods.
|
|
35
|
+
LEVELS = %i[debug info warn error].freeze
|
|
36
|
+
|
|
37
|
+
# JSON-lines listener suitable for diagnostics and local development.
|
|
38
|
+
# Values pass through a Support::Redactor before being written.
|
|
39
|
+
class ConsoleListener
|
|
40
|
+
# Writes redacted JSON lines to +io+.
|
|
41
|
+
def initialize(io: $stderr, redactor: Support::Redactor.new)
|
|
42
|
+
@io = io
|
|
43
|
+
@redactor = redactor
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Emits one complete JSON line atomically.
|
|
48
|
+
def emit(event)
|
|
49
|
+
@mutex.synchronize { @io.puts(JSON.generate(@redactor.call(event))) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Thread-safe event publisher with process-wide and fiber-scoped listeners.
|
|
54
|
+
# Reporters start without listeners so applications opt into their preferred
|
|
55
|
+
# event destination, including +ConsoleListener+ for JSON-line diagnostics.
|
|
56
|
+
class Reporter
|
|
57
|
+
# Starts with +listeners+ in subscription order.
|
|
58
|
+
def initialize(listeners: [])
|
|
59
|
+
@mutex = Mutex.new
|
|
60
|
+
@listeners = []
|
|
61
|
+
Array(listeners).each { |listener| subscribe(listener) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Subscribes +listener+. The optional block filters copied event hashes.
|
|
65
|
+
def subscribe(listener, &filter)
|
|
66
|
+
unless listener.respond_to?(:emit)
|
|
67
|
+
raise ArgumentError, "event listener must respond to emit"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@mutex.synchronize { @listeners << {listener:, filter:} }
|
|
71
|
+
listener
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Unsubscribes every entry matching +listener+.
|
|
75
|
+
def unsubscribe(listener)
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
@listeners.delete_if { |entry| listener === entry.fetch(:listener) }
|
|
78
|
+
end
|
|
79
|
+
listener
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Delivers an event and returns a detached copy of its complete hash.
|
|
83
|
+
def emit(level, name, payload = {})
|
|
84
|
+
level = level.to_sym
|
|
85
|
+
raise ArgumentError, "unknown event level: #{level}" unless LEVELS.include?(level)
|
|
86
|
+
raise ArgumentError, "event payload must be a hash" unless payload.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
event = {
|
|
89
|
+
name: normalize_string(name.to_s),
|
|
90
|
+
level:,
|
|
91
|
+
payload: deep_copy(payload),
|
|
92
|
+
context: context,
|
|
93
|
+
timestamp: Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
94
|
+
}
|
|
95
|
+
listeners.each do |entry|
|
|
96
|
+
listener = entry.fetch(:listener)
|
|
97
|
+
filter = entry[:filter]
|
|
98
|
+
next if filter && !filter.call(deep_copy(event))
|
|
99
|
+
|
|
100
|
+
listener.emit(deep_copy(event))
|
|
101
|
+
rescue
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
event
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Adds attributes to events emitted while the block runs.
|
|
108
|
+
def with_context(attributes)
|
|
109
|
+
values = context.merge(deep_copy(attributes.compact))
|
|
110
|
+
ExecutionState.with(context_key => values) { yield }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Copies the event context active in the current execution.
|
|
114
|
+
def context
|
|
115
|
+
deep_copy(ExecutionState[context_key] || {})
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def listeners
|
|
121
|
+
scoped = ExecutionState[:little_ghost_event_listeners] || []
|
|
122
|
+
@mutex.synchronize { @listeners.dup } + scoped
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def context_key = :little_ghost_event_context
|
|
126
|
+
|
|
127
|
+
def deep_copy(value, ancestors = {}, depth = 0)
|
|
128
|
+
raise ArgumentError, "event payload is nested too deeply" if depth > 100
|
|
129
|
+
|
|
130
|
+
case value
|
|
131
|
+
when Hash
|
|
132
|
+
copy_container(value, ancestors) do
|
|
133
|
+
value.to_h do |key, item|
|
|
134
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
135
|
+
raise ArgumentError, "event payload keys must be strings or symbols"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
[copy_key(key), deep_copy(item, ancestors, depth + 1)]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
when Array
|
|
142
|
+
copy_container(value, ancestors) do
|
|
143
|
+
value.map { |item| deep_copy(item, ancestors, depth + 1) }
|
|
144
|
+
end
|
|
145
|
+
when String
|
|
146
|
+
normalize_string(value)
|
|
147
|
+
when Float
|
|
148
|
+
raise ArgumentError, "event payload numbers must be finite" unless value.finite?
|
|
149
|
+
|
|
150
|
+
value
|
|
151
|
+
when Symbol
|
|
152
|
+
copy_symbol(value)
|
|
153
|
+
when Integer, true, false, nil
|
|
154
|
+
value
|
|
155
|
+
else
|
|
156
|
+
raise ArgumentError, "event payload values must be JSON-safe"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def copy_container(value, ancestors)
|
|
161
|
+
identity = value.object_id
|
|
162
|
+
raise ArgumentError, "event payload must not contain cycles" if ancestors.key?(identity)
|
|
163
|
+
|
|
164
|
+
ancestors[identity] = true
|
|
165
|
+
yield
|
|
166
|
+
ensure
|
|
167
|
+
ancestors.delete(identity) if identity
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def copy_key(key)
|
|
171
|
+
return copy_symbol(key) if key.is_a?(Symbol)
|
|
172
|
+
|
|
173
|
+
normalize_string(key.to_s)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def copy_symbol(value)
|
|
177
|
+
text = value.to_s
|
|
178
|
+
return value if text.ascii_only? || (text.encoding == Encoding::UTF_8 && text.valid_encoding?)
|
|
179
|
+
|
|
180
|
+
normalize_string(text)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def normalize_string(value)
|
|
184
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
class << self
|
|
189
|
+
# Accesses the process-wide reporter.
|
|
190
|
+
def reporter
|
|
191
|
+
reporter_mutex.synchronize { @reporter ||= Reporter.new }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Replaces the process-wide reporter. Existing references are unaffected.
|
|
195
|
+
def reporter=(value)
|
|
196
|
+
raise ArgumentError, "reporter must be an event reporter" unless value.is_a?(Reporter)
|
|
197
|
+
|
|
198
|
+
reporter_mutex.synchronize do
|
|
199
|
+
@reporter&.unsubscribe(@console_listener) if @console_listener
|
|
200
|
+
@reporter = value
|
|
201
|
+
@reporter.subscribe(@console_listener) if @console_listener
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The process-wide JSON-line console destination, or +nil+ when console
|
|
206
|
+
# delivery is disabled.
|
|
207
|
+
def console_output
|
|
208
|
+
reporter_mutex.synchronize { @console_output }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Selects +:stdout+, +:stderr+, or +nil+ as the process-wide JSON-line
|
|
212
|
+
# console destination. Replacing the destination leaves other listeners
|
|
213
|
+
# unchanged.
|
|
214
|
+
def console_output=(destination)
|
|
215
|
+
unless [nil, :stdout, :stderr].include?(destination)
|
|
216
|
+
raise ArgumentError, "event log destination must be :stdout, :stderr, or nil"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
reporter_mutex.synchronize do
|
|
220
|
+
@reporter ||= Reporter.new
|
|
221
|
+
@reporter.unsubscribe(@console_listener) if @console_listener
|
|
222
|
+
@console_output = destination
|
|
223
|
+
@console_listener = destination && ConsoleListener.new(io: (destination == :stdout) ? $stdout : $stderr)
|
|
224
|
+
@reporter.subscribe(@console_listener) if @console_listener
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Subscribes a process-wide listener.
|
|
229
|
+
def subscribe(...) = reporter.subscribe(...)
|
|
230
|
+
# Unsubscribes a process-wide listener.
|
|
231
|
+
def unsubscribe(...) = reporter.unsubscribe(...)
|
|
232
|
+
# Adds event context while a block runs.
|
|
233
|
+
def with_context(...) = reporter.with_context(...)
|
|
234
|
+
# Copies the current event context.
|
|
235
|
+
def context = reporter.context
|
|
236
|
+
|
|
237
|
+
LEVELS.each do |level|
|
|
238
|
+
define_method(level) do |name, payload = nil, **attributes|
|
|
239
|
+
if payload && !payload.is_a?(Hash)
|
|
240
|
+
raise ArgumentError, "event payload must be a hash"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
values = payload ? payload.merge(attributes) : attributes
|
|
244
|
+
reporter.emit(level, name, values)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Subscribes +listener+ only while the block runs.
|
|
249
|
+
def subscribed(listener, &block)
|
|
250
|
+
raise ArgumentError, "event listener must respond to emit" unless listener.respond_to?(:emit)
|
|
251
|
+
|
|
252
|
+
listeners = ExecutionState[:little_ghost_event_listeners] || []
|
|
253
|
+
entry = {listener:, filter: nil}
|
|
254
|
+
ExecutionState.with(little_ghost_event_listeners: listeners + [entry], &block)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def reporter_mutex
|
|
260
|
+
@reporter_mutex ||= Mutex.new
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# ExecutionState carries request-scoped values across fibers and the worker
|
|
5
|
+
# threads LittleGhost creates. It keeps event and instrumentation context from
|
|
6
|
+
# leaking between concurrent runs.
|
|
7
|
+
#
|
|
8
|
+
# Framework extensions may use #capture and #with to preserve event and
|
|
9
|
+
# instrumentation context. Captured hashes are immutable; values within them
|
|
10
|
+
# are not deep-copied.
|
|
11
|
+
module ExecutionState
|
|
12
|
+
STORAGE_KEY = :little_ghost_execution_state # :nodoc:
|
|
13
|
+
EMPTY_STATE = {}.freeze # :nodoc:
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Reads +key+ from the current execution state.
|
|
17
|
+
def [](key)
|
|
18
|
+
state[key]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Replaces +key+ in the current fiber-local state.
|
|
22
|
+
def []=(key, value)
|
|
23
|
+
replace(state.merge(key => value))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Deletes +key+ and returns its previous value.
|
|
27
|
+
def delete(key)
|
|
28
|
+
value = state[key]
|
|
29
|
+
replace(state.except(key))
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Captures the current immutable state hash for propagation.
|
|
34
|
+
def capture
|
|
35
|
+
state
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Merges +values+ while the block runs, then restores prior state.
|
|
39
|
+
def with(values)
|
|
40
|
+
previous = state
|
|
41
|
+
replace(previous.merge(values))
|
|
42
|
+
yield
|
|
43
|
+
ensure
|
|
44
|
+
replace(previous)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def state
|
|
50
|
+
Fiber[STORAGE_KEY] || EMPTY_STATE
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def replace(values)
|
|
54
|
+
Fiber[STORAGE_KEY] = values.empty? ? nil : values.freeze
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|