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.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. 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