spurline-deploy 0.3.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/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- metadata +161 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Channels
|
|
5
|
+
# Abstract interface for channel adapters. Each channel parses events from
|
|
6
|
+
# a specific external source and resolves session affinity.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses must implement:
|
|
9
|
+
# #channel_name - Symbol identifying this channel
|
|
10
|
+
# #route(payload) - Parse payload, resolve session, return Event or nil
|
|
11
|
+
# #supported_events - Array of event type symbols this channel handles
|
|
12
|
+
class Base
|
|
13
|
+
# Symbol identifying this channel (e.g., :github, :slack).
|
|
14
|
+
def channel_name
|
|
15
|
+
raise NotImplementedError, "#{self.class.name} must implement #channel_name"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Parses a raw payload hash and returns a routed Event, or nil if the
|
|
19
|
+
# payload is not recognized or not relevant.
|
|
20
|
+
#
|
|
21
|
+
# @param payload [Hash] The raw event payload (e.g., parsed webhook JSON)
|
|
22
|
+
# @param headers [Hash] Optional HTTP headers for signature verification
|
|
23
|
+
# @return [Spurline::Channels::Event, nil]
|
|
24
|
+
# ASYNC-READY:
|
|
25
|
+
def route(payload, headers: {})
|
|
26
|
+
raise NotImplementedError, "#{self.class.name} must implement #route"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the event types this channel can handle.
|
|
30
|
+
# @return [Array<Symbol>]
|
|
31
|
+
def supported_events
|
|
32
|
+
raise NotImplementedError, "#{self.class.name} must implement #supported_events"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Whether this channel handles the given event type.
|
|
36
|
+
def handles?(event_type)
|
|
37
|
+
supported_events.include?(event_type.to_sym)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Channels
|
|
7
|
+
# Immutable value object representing an external event routed through a channel.
|
|
8
|
+
# Events are the universal internal representation regardless of which channel
|
|
9
|
+
# produced them. Frozen on creation.
|
|
10
|
+
#
|
|
11
|
+
# Attributes:
|
|
12
|
+
# channel - Symbol identifying the source channel (e.g., :github)
|
|
13
|
+
# event_type - Symbol for the event kind (e.g., :issue_comment, :pr_review)
|
|
14
|
+
# payload - Hash of parsed event data (channel-specific)
|
|
15
|
+
# trust - Symbol trust level (default :external)
|
|
16
|
+
# session_id - String session ID if routing resolved, nil otherwise
|
|
17
|
+
# received_at - Time the event was received
|
|
18
|
+
class Event
|
|
19
|
+
attr_reader :channel, :event_type, :payload, :trust, :session_id, :received_at
|
|
20
|
+
|
|
21
|
+
def initialize(channel:, event_type:, payload:, trust: :external, session_id: nil, received_at: nil)
|
|
22
|
+
validate_channel!(channel)
|
|
23
|
+
validate_event_type!(event_type)
|
|
24
|
+
validate_payload!(payload)
|
|
25
|
+
validate_trust!(trust)
|
|
26
|
+
|
|
27
|
+
@channel = channel.to_sym
|
|
28
|
+
@event_type = event_type.to_sym
|
|
29
|
+
@payload = deep_freeze(payload)
|
|
30
|
+
@trust = trust.to_sym
|
|
31
|
+
@session_id = session_id&.to_s&.freeze
|
|
32
|
+
@received_at = (received_at || Time.now).freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether this event was matched to a specific session.
|
|
37
|
+
def routed?
|
|
38
|
+
!@session_id.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Serializes the event to a plain hash suitable for JSON serialization.
|
|
42
|
+
def to_h
|
|
43
|
+
{
|
|
44
|
+
channel: @channel,
|
|
45
|
+
event_type: @event_type,
|
|
46
|
+
payload: unfreeze_hash(@payload),
|
|
47
|
+
trust: @trust,
|
|
48
|
+
session_id: @session_id,
|
|
49
|
+
received_at: @received_at.iso8601(6),
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Reconstructs an Event from a hash (e.g., from JSON deserialization).
|
|
54
|
+
def self.from_h(hash)
|
|
55
|
+
h = symbolize_keys(hash)
|
|
56
|
+
new(
|
|
57
|
+
channel: h[:channel],
|
|
58
|
+
event_type: h[:event_type],
|
|
59
|
+
payload: symbolize_keys(h[:payload] || {}),
|
|
60
|
+
trust: h[:trust] || :external,
|
|
61
|
+
session_id: h[:session_id],
|
|
62
|
+
received_at: h[:received_at] ? Time.parse(h[:received_at].to_s) : nil
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ==(other)
|
|
67
|
+
other.is_a?(Event) &&
|
|
68
|
+
channel == other.channel &&
|
|
69
|
+
event_type == other.event_type &&
|
|
70
|
+
payload == other.payload &&
|
|
71
|
+
trust == other.trust &&
|
|
72
|
+
session_id == other.session_id
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def inspect
|
|
76
|
+
"#<Spurline::Channels::Event channel=#{channel} type=#{event_type} " \
|
|
77
|
+
"session=#{session_id || 'unrouted'} trust=#{trust}>"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def validate_channel!(channel)
|
|
83
|
+
raise ArgumentError, "channel must be a Symbol or String" unless channel.respond_to?(:to_sym)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_event_type!(event_type)
|
|
87
|
+
raise ArgumentError, "event_type must be a Symbol or String" unless event_type.respond_to?(:to_sym)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_payload!(payload)
|
|
91
|
+
raise ArgumentError, "payload must be a Hash, got #{payload.class}" unless payload.is_a?(Hash)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_trust!(trust)
|
|
95
|
+
level = trust.to_sym
|
|
96
|
+
unless Spurline::Security::Content::TRUST_LEVELS.include?(level)
|
|
97
|
+
raise Spurline::ConfigurationError,
|
|
98
|
+
"Invalid trust level for channel event: #{trust.inspect}. " \
|
|
99
|
+
"Must be one of: #{Spurline::Security::Content::TRUST_LEVELS.inspect}."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def deep_freeze(obj)
|
|
104
|
+
case obj
|
|
105
|
+
when Hash
|
|
106
|
+
obj.each_with_object({}) { |(k, v), h| h[k.freeze] = deep_freeze(v) }.freeze
|
|
107
|
+
when Array
|
|
108
|
+
obj.map { |v| deep_freeze(v) }.freeze
|
|
109
|
+
when String
|
|
110
|
+
obj.dup.freeze
|
|
111
|
+
else
|
|
112
|
+
obj.freeze
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def unfreeze_hash(obj)
|
|
117
|
+
case obj
|
|
118
|
+
when Hash
|
|
119
|
+
obj.each_with_object({}) { |(k, v), h| h[k] = unfreeze_hash(v) }
|
|
120
|
+
when Array
|
|
121
|
+
obj.map { |v| unfreeze_hash(v) }
|
|
122
|
+
else
|
|
123
|
+
obj
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.symbolize_keys(hash)
|
|
128
|
+
return {} unless hash.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
hash.each_with_object({}) do |(k, v), h|
|
|
131
|
+
h[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Channels
|
|
5
|
+
# GitHub webhook channel. Parses issue_comment, pull_request_review_comment,
|
|
6
|
+
# and pull_request_review events. Routes to sessions whose metadata contains
|
|
7
|
+
# a matching channel_context.
|
|
8
|
+
#
|
|
9
|
+
# Session affinity is resolved by matching:
|
|
10
|
+
# session.metadata[:channel_context] == { channel: :github, identifier: "owner/repo#123" }
|
|
11
|
+
#
|
|
12
|
+
# All GitHub webhook data enters at trust level :external.
|
|
13
|
+
class GitHub < Base
|
|
14
|
+
SUPPORTED_EVENTS = %i[
|
|
15
|
+
issue_comment
|
|
16
|
+
pull_request_review_comment
|
|
17
|
+
pull_request_review
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Maps GitHub webhook X-GitHub-Event header values to internal event types.
|
|
21
|
+
EVENT_MAP = {
|
|
22
|
+
"issue_comment" => :issue_comment,
|
|
23
|
+
"pull_request_review_comment" => :pull_request_review_comment,
|
|
24
|
+
"pull_request_review" => :pull_request_review,
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :store
|
|
28
|
+
|
|
29
|
+
def initialize(store:)
|
|
30
|
+
@store = store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def channel_name
|
|
34
|
+
:github
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def supported_events
|
|
38
|
+
SUPPORTED_EVENTS
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Parses a GitHub webhook payload and routes to a matching session.
|
|
42
|
+
#
|
|
43
|
+
# @param payload [Hash] Parsed JSON body of the webhook
|
|
44
|
+
# @param headers [Hash] HTTP headers, expects "X-GitHub-Event" key
|
|
45
|
+
# @return [Spurline::Channels::Event, nil]
|
|
46
|
+
# ASYNC-READY:
|
|
47
|
+
def route(payload, headers: {})
|
|
48
|
+
event_header = headers["X-GitHub-Event"] || headers["x-github-event"]
|
|
49
|
+
return nil unless event_header
|
|
50
|
+
|
|
51
|
+
event_type = EVENT_MAP[event_header]
|
|
52
|
+
return nil unless event_type
|
|
53
|
+
|
|
54
|
+
action = payload_value(payload, :action)
|
|
55
|
+
return nil unless actionable?(event_type, action)
|
|
56
|
+
|
|
57
|
+
parsed = parse_event(event_type, payload)
|
|
58
|
+
return nil unless parsed
|
|
59
|
+
|
|
60
|
+
identifier = build_identifier(parsed)
|
|
61
|
+
session_id = find_session_by_context(identifier)
|
|
62
|
+
|
|
63
|
+
Event.new(
|
|
64
|
+
channel: :github,
|
|
65
|
+
event_type: event_type,
|
|
66
|
+
payload: parsed,
|
|
67
|
+
trust: :external,
|
|
68
|
+
session_id: session_id,
|
|
69
|
+
received_at: Time.now
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Determines if the action is one we care about.
|
|
76
|
+
def actionable?(event_type, action)
|
|
77
|
+
case event_type
|
|
78
|
+
when :issue_comment
|
|
79
|
+
%w[created edited].include?(action)
|
|
80
|
+
when :pull_request_review_comment
|
|
81
|
+
%w[created edited].include?(action)
|
|
82
|
+
when :pull_request_review
|
|
83
|
+
%w[submitted edited].include?(action)
|
|
84
|
+
else
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Extracts relevant fields from the webhook payload.
|
|
90
|
+
def parse_event(event_type, payload)
|
|
91
|
+
case event_type
|
|
92
|
+
when :issue_comment
|
|
93
|
+
parse_issue_comment(payload)
|
|
94
|
+
when :pull_request_review_comment
|
|
95
|
+
parse_pr_review_comment(payload)
|
|
96
|
+
when :pull_request_review
|
|
97
|
+
parse_pr_review(payload)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_issue_comment(payload)
|
|
102
|
+
comment = payload_value(payload, :comment) || {}
|
|
103
|
+
issue = payload_value(payload, :issue) || {}
|
|
104
|
+
repo = payload_value(payload, :repository) || {}
|
|
105
|
+
pr = payload_value(issue, :pull_request)
|
|
106
|
+
|
|
107
|
+
# Only handle comments on pull requests, not issues
|
|
108
|
+
return nil unless pr
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
action: payload_value(payload, :action),
|
|
112
|
+
body: payload_value(comment, :body),
|
|
113
|
+
author: dig_value(comment, :user, :login),
|
|
114
|
+
pr_number: payload_value(issue, :number),
|
|
115
|
+
repo_full_name: payload_value(repo, :full_name),
|
|
116
|
+
comment_id: payload_value(comment, :id),
|
|
117
|
+
html_url: payload_value(comment, :html_url),
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_pr_review_comment(payload)
|
|
122
|
+
comment = payload_value(payload, :comment) || {}
|
|
123
|
+
pr = payload_value(payload, :pull_request) || {}
|
|
124
|
+
repo = payload_value(payload, :repository) || {}
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
action: payload_value(payload, :action),
|
|
128
|
+
body: payload_value(comment, :body),
|
|
129
|
+
author: dig_value(comment, :user, :login),
|
|
130
|
+
pr_number: payload_value(pr, :number),
|
|
131
|
+
repo_full_name: payload_value(repo, :full_name),
|
|
132
|
+
comment_id: payload_value(comment, :id),
|
|
133
|
+
path: payload_value(comment, :path),
|
|
134
|
+
diff_hunk: payload_value(comment, :diff_hunk),
|
|
135
|
+
html_url: payload_value(comment, :html_url),
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_pr_review(payload)
|
|
140
|
+
review = payload_value(payload, :review) || {}
|
|
141
|
+
pr = payload_value(payload, :pull_request) || {}
|
|
142
|
+
repo = payload_value(payload, :repository) || {}
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
action: payload_value(payload, :action),
|
|
146
|
+
body: payload_value(review, :body),
|
|
147
|
+
author: dig_value(review, :user, :login),
|
|
148
|
+
state: payload_value(review, :state),
|
|
149
|
+
pr_number: payload_value(pr, :number),
|
|
150
|
+
repo_full_name: payload_value(repo, :full_name),
|
|
151
|
+
review_id: payload_value(review, :id),
|
|
152
|
+
html_url: payload_value(review, :html_url),
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Builds the channel_context identifier string: "owner/repo#pr_number"
|
|
157
|
+
def build_identifier(parsed)
|
|
158
|
+
repo = parsed[:repo_full_name]
|
|
159
|
+
pr = parsed[:pr_number]
|
|
160
|
+
return nil unless repo && pr
|
|
161
|
+
|
|
162
|
+
"#{repo}##{pr}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Searches the session store for a suspended session with matching channel_context.
|
|
166
|
+
def find_session_by_context(identifier)
|
|
167
|
+
return nil unless identifier
|
|
168
|
+
return nil unless @store.respond_to?(:ids)
|
|
169
|
+
|
|
170
|
+
@store.ids.each do |id|
|
|
171
|
+
session = @store.load(id)
|
|
172
|
+
next unless session
|
|
173
|
+
next unless session.state == :suspended
|
|
174
|
+
|
|
175
|
+
context = session.metadata[:channel_context]
|
|
176
|
+
next unless context.is_a?(Hash)
|
|
177
|
+
next unless context[:channel]&.to_sym == :github
|
|
178
|
+
next unless context[:identifier] == identifier
|
|
179
|
+
|
|
180
|
+
return session.id
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Safe hash value access supporting both symbol and string keys.
|
|
187
|
+
def payload_value(hash, key)
|
|
188
|
+
return nil unless hash.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
hash[key] || hash[key.to_s]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Safe nested hash access.
|
|
194
|
+
def dig_value(hash, *keys)
|
|
195
|
+
result = hash
|
|
196
|
+
keys.each do |key|
|
|
197
|
+
return nil unless result.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
result = result[key] || result[key.to_s]
|
|
200
|
+
end
|
|
201
|
+
result
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Channels
|
|
7
|
+
# Central dispatcher for channel events. Accepts raw payloads, identifies
|
|
8
|
+
# the correct channel, calls route, and optionally resumes suspended sessions.
|
|
9
|
+
#
|
|
10
|
+
# The router is transport-agnostic -- it processes parsed payloads, not HTTP
|
|
11
|
+
# requests. Webhook endpoints (Rack middleware, Rails controllers) are the
|
|
12
|
+
# caller's responsibility.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# store = Spurline::Session::Store::Memory.new
|
|
16
|
+
# github = Spurline::Channels::GitHub.new(store: store)
|
|
17
|
+
# router = Spurline::Channels::Router.new(store: store, channels: [github])
|
|
18
|
+
#
|
|
19
|
+
# event = router.dispatch(channel_name: :github, payload: webhook_body, headers: headers)
|
|
20
|
+
# if event&.routed?
|
|
21
|
+
# agent = MyAgent.new(session_id: event.session_id)
|
|
22
|
+
# agent.resume { |chunk| ... }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
class Router
|
|
26
|
+
attr_reader :store
|
|
27
|
+
|
|
28
|
+
def initialize(store:, channels: [])
|
|
29
|
+
@store = store
|
|
30
|
+
@channels = {}
|
|
31
|
+
channels.each { |ch| register(ch) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Registers a channel adapter.
|
|
35
|
+
def register(channel)
|
|
36
|
+
unless channel.respond_to?(:channel_name) && channel.respond_to?(:route)
|
|
37
|
+
raise ArgumentError,
|
|
38
|
+
"Channel must implement #channel_name and #route. Got #{channel.class.name}."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@channels[channel.channel_name.to_sym] = channel
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns all registered channel names.
|
|
45
|
+
def channel_names
|
|
46
|
+
@channels.keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns a registered channel by name, or nil.
|
|
50
|
+
def channel_for(name)
|
|
51
|
+
@channels[name.to_sym]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Dispatches a payload to the named channel and returns the resulting Event.
|
|
55
|
+
#
|
|
56
|
+
# If the event maps to a suspended session, the router calls
|
|
57
|
+
# Suspension.resume! to transition the session back to :running.
|
|
58
|
+
# The caller is then responsible for instantiating the agent and
|
|
59
|
+
# calling agent.resume.
|
|
60
|
+
#
|
|
61
|
+
# @param channel_name [Symbol] The channel to dispatch to
|
|
62
|
+
# @param payload [Hash] The parsed event payload
|
|
63
|
+
# @param headers [Hash] Optional HTTP headers
|
|
64
|
+
# @return [Spurline::Channels::Event, nil]
|
|
65
|
+
# ASYNC-READY:
|
|
66
|
+
def dispatch(channel_name:, payload:, headers: {})
|
|
67
|
+
channel = @channels[channel_name.to_sym]
|
|
68
|
+
return nil unless channel
|
|
69
|
+
|
|
70
|
+
event = channel.route(payload, headers: headers)
|
|
71
|
+
return nil unless event
|
|
72
|
+
|
|
73
|
+
resume_if_suspended!(event) if event.routed?
|
|
74
|
+
|
|
75
|
+
event
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Wraps an event's payload as a Content object via Gates::ToolResult.
|
|
79
|
+
# Use this when feeding the event payload into the context pipeline.
|
|
80
|
+
def wrap_payload(event)
|
|
81
|
+
text = event.payload.is_a?(Hash) ? JSON.generate(event.payload) : event.payload.to_s
|
|
82
|
+
Security::Gates::ToolResult.wrap(
|
|
83
|
+
text,
|
|
84
|
+
tool_name: "channel:#{event.channel}"
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def resume_if_suspended!(event)
|
|
91
|
+
session = @store.load(event.session_id)
|
|
92
|
+
return unless session
|
|
93
|
+
return unless session.state == :suspended
|
|
94
|
+
|
|
95
|
+
Session::Suspension.resume!(session)
|
|
96
|
+
rescue Spurline::InvalidResumeError
|
|
97
|
+
# Session is not actually suspended -- the channel's routing may be stale.
|
|
98
|
+
# Swallow the error; the caller can inspect the event and decide.
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
class Check
|
|
6
|
+
CHECKERS = [
|
|
7
|
+
Checks::ProjectStructure,
|
|
8
|
+
Checks::Permissions,
|
|
9
|
+
Checks::AgentLoadability,
|
|
10
|
+
Checks::AdapterResolution,
|
|
11
|
+
Checks::Credentials,
|
|
12
|
+
Checks::SessionStore,
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(project_root:, verbose: false)
|
|
16
|
+
@project_root = File.expand_path(project_root)
|
|
17
|
+
@verbose = verbose
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run!
|
|
21
|
+
results = run_checks
|
|
22
|
+
print_report(results)
|
|
23
|
+
results
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :project_root, :verbose
|
|
29
|
+
|
|
30
|
+
def run_checks
|
|
31
|
+
CHECKERS.flat_map do |checker_class|
|
|
32
|
+
checker_class.new(project_root: project_root).run
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
[Checks::CheckResult.new(
|
|
35
|
+
status: :fail,
|
|
36
|
+
name: checker_name(checker_class),
|
|
37
|
+
message: "#{e.class}: #{e.message}"
|
|
38
|
+
)]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def print_report(results)
|
|
43
|
+
puts "spur check"
|
|
44
|
+
puts
|
|
45
|
+
|
|
46
|
+
results.each do |result|
|
|
47
|
+
label = status_label(result.status)
|
|
48
|
+
line = " #{label.ljust(5)} #{result.name}"
|
|
49
|
+
if show_message?(result) && result.message && !result.message.empty?
|
|
50
|
+
line << " - #{result.message}"
|
|
51
|
+
end
|
|
52
|
+
puts line
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
puts
|
|
56
|
+
passes = results.count { |result| result.status == :pass }
|
|
57
|
+
failures = results.count { |result| result.status == :fail }
|
|
58
|
+
warnings = results.count { |result| result.status == :warn }
|
|
59
|
+
|
|
60
|
+
puts "#{passes} passed, #{failures} failed, #{warnings} #{warnings == 1 ? "warning" : "warnings"}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def show_message?(result)
|
|
64
|
+
verbose || result.status != :pass
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def status_label(status)
|
|
68
|
+
case status
|
|
69
|
+
when :pass
|
|
70
|
+
"ok"
|
|
71
|
+
when :warn
|
|
72
|
+
"WARN"
|
|
73
|
+
when :fail
|
|
74
|
+
"FAIL"
|
|
75
|
+
else
|
|
76
|
+
status.to_s.upcase
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def checker_name(checker_class)
|
|
81
|
+
checker_class.name.split("::").last
|
|
82
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
83
|
+
.downcase
|
|
84
|
+
.to_sym
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
module Checks
|
|
6
|
+
class AdapterResolution < Base
|
|
7
|
+
def run
|
|
8
|
+
load_framework!
|
|
9
|
+
files = agent_files
|
|
10
|
+
|
|
11
|
+
if files.empty?
|
|
12
|
+
return [fail(:adapter_resolution, message: "No agent files found under app/agents")]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
files.each { |file| require file }
|
|
16
|
+
agents = resolve_agent_classes
|
|
17
|
+
|
|
18
|
+
if agents.empty?
|
|
19
|
+
return [fail(:adapter_resolution, message: "No Spurline::Agent subclasses found in app/agents")]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unresolved = []
|
|
23
|
+
|
|
24
|
+
agents.each do |agent_class|
|
|
25
|
+
model_name = agent_class.model_config && agent_class.model_config[:name]
|
|
26
|
+
if model_name.nil?
|
|
27
|
+
unresolved << "#{agent_class.name} has no model configuration"
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
agent_class.adapter_registry.resolve(model_name)
|
|
33
|
+
rescue Spurline::AdapterNotFoundError => e
|
|
34
|
+
unresolved << "#{agent_class.name}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if unresolved.empty?
|
|
39
|
+
[pass(:adapter_resolution)]
|
|
40
|
+
else
|
|
41
|
+
[fail(:adapter_resolution, message: unresolved.join("; "))]
|
|
42
|
+
end
|
|
43
|
+
rescue LoadError, NameError, SyntaxError => e
|
|
44
|
+
[fail(:adapter_resolution, message: "#{e.class}: #{e.message}")]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def load_framework!
|
|
50
|
+
initializer = File.join(project_root, "config", "spurline.rb")
|
|
51
|
+
if File.file?(initializer)
|
|
52
|
+
require initializer
|
|
53
|
+
else
|
|
54
|
+
require "spurline"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def agent_files
|
|
59
|
+
files = Dir[File.join(project_root, "app", "agents", "**", "*.rb")]
|
|
60
|
+
files.sort_by do |path|
|
|
61
|
+
[File.basename(path) == "application_agent.rb" ? 0 : 1, path]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_agent_classes
|
|
66
|
+
agents_root = File.join(project_root, "app", "agents")
|
|
67
|
+
|
|
68
|
+
ObjectSpace.each_object(Class).select do |klass|
|
|
69
|
+
next false unless klass < Spurline::Agent
|
|
70
|
+
next false unless klass.name
|
|
71
|
+
|
|
72
|
+
source_path = Object.const_source_location(klass.name)&.first
|
|
73
|
+
next false unless source_path
|
|
74
|
+
|
|
75
|
+
File.expand_path(source_path).start_with?(File.expand_path(agents_root))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
module Checks
|
|
6
|
+
class AgentLoadability < Base
|
|
7
|
+
def run
|
|
8
|
+
load_framework!
|
|
9
|
+
files = agent_files
|
|
10
|
+
|
|
11
|
+
if files.empty?
|
|
12
|
+
return [fail(:agent_loadability, message: "No agent files found under app/agents")]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
files.each { |file| require file }
|
|
16
|
+
[pass(:agent_loadability)]
|
|
17
|
+
rescue LoadError, NameError, SyntaxError => e
|
|
18
|
+
[fail(:agent_loadability, message: "#{e.class}: #{e.message}")]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def load_framework!
|
|
24
|
+
initializer = File.join(project_root, "config", "spurline.rb")
|
|
25
|
+
if File.file?(initializer)
|
|
26
|
+
require initializer
|
|
27
|
+
else
|
|
28
|
+
require "spurline"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def agent_files
|
|
33
|
+
files = Dir[File.join(project_root, "app", "agents", "**", "*.rb")]
|
|
34
|
+
files.sort_by do |path|
|
|
35
|
+
[File.basename(path) == "application_agent.rb" ? 0 : 1, path]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|