simple_acp 0.0.1

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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/CHANGELOG.md +5 -0
  4. data/COMMITS.md +196 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +385 -0
  7. data/Rakefile +13 -0
  8. data/docs/api/client-base.md +383 -0
  9. data/docs/api/index.md +159 -0
  10. data/docs/api/models.md +286 -0
  11. data/docs/api/server-base.md +379 -0
  12. data/docs/api/storage.md +347 -0
  13. data/docs/assets/images/simple_acp.jpg +0 -0
  14. data/docs/client/index.md +279 -0
  15. data/docs/client/sessions.md +324 -0
  16. data/docs/client/streaming.md +345 -0
  17. data/docs/client/sync-async.md +308 -0
  18. data/docs/core-concepts/agents.md +253 -0
  19. data/docs/core-concepts/events.md +337 -0
  20. data/docs/core-concepts/index.md +147 -0
  21. data/docs/core-concepts/messages.md +211 -0
  22. data/docs/core-concepts/runs.md +278 -0
  23. data/docs/core-concepts/sessions.md +281 -0
  24. data/docs/examples.md +659 -0
  25. data/docs/getting-started/configuration.md +166 -0
  26. data/docs/getting-started/index.md +62 -0
  27. data/docs/getting-started/installation.md +95 -0
  28. data/docs/getting-started/quick-start.md +189 -0
  29. data/docs/index.md +119 -0
  30. data/docs/server/creating-agents.md +360 -0
  31. data/docs/server/http-endpoints.md +411 -0
  32. data/docs/server/index.md +218 -0
  33. data/docs/server/multi-turn.md +329 -0
  34. data/docs/server/streaming.md +315 -0
  35. data/docs/storage/custom.md +414 -0
  36. data/docs/storage/index.md +176 -0
  37. data/docs/storage/memory.md +198 -0
  38. data/docs/storage/postgresql.md +350 -0
  39. data/docs/storage/redis.md +287 -0
  40. data/examples/01_basic/client.rb +88 -0
  41. data/examples/01_basic/server.rb +100 -0
  42. data/examples/02_async_execution/client.rb +107 -0
  43. data/examples/02_async_execution/server.rb +56 -0
  44. data/examples/03_run_management/client.rb +115 -0
  45. data/examples/03_run_management/server.rb +84 -0
  46. data/examples/04_rich_messages/client.rb +160 -0
  47. data/examples/04_rich_messages/server.rb +180 -0
  48. data/examples/05_await_resume/client.rb +164 -0
  49. data/examples/05_await_resume/server.rb +114 -0
  50. data/examples/06_agent_metadata/client.rb +188 -0
  51. data/examples/06_agent_metadata/server.rb +192 -0
  52. data/examples/README.md +252 -0
  53. data/examples/run_demo.sh +137 -0
  54. data/lib/simple_acp/client/base.rb +448 -0
  55. data/lib/simple_acp/client/sse.rb +141 -0
  56. data/lib/simple_acp/models/agent_manifest.rb +129 -0
  57. data/lib/simple_acp/models/await.rb +123 -0
  58. data/lib/simple_acp/models/base.rb +147 -0
  59. data/lib/simple_acp/models/errors.rb +102 -0
  60. data/lib/simple_acp/models/events.rb +256 -0
  61. data/lib/simple_acp/models/message.rb +235 -0
  62. data/lib/simple_acp/models/message_part.rb +225 -0
  63. data/lib/simple_acp/models/metadata.rb +161 -0
  64. data/lib/simple_acp/models/run.rb +298 -0
  65. data/lib/simple_acp/models/session.rb +137 -0
  66. data/lib/simple_acp/models/types.rb +210 -0
  67. data/lib/simple_acp/server/agent.rb +116 -0
  68. data/lib/simple_acp/server/app.rb +264 -0
  69. data/lib/simple_acp/server/base.rb +510 -0
  70. data/lib/simple_acp/server/context.rb +210 -0
  71. data/lib/simple_acp/server/falcon_runner.rb +61 -0
  72. data/lib/simple_acp/storage/base.rb +129 -0
  73. data/lib/simple_acp/storage/memory.rb +108 -0
  74. data/lib/simple_acp/storage/postgresql.rb +233 -0
  75. data/lib/simple_acp/storage/redis.rb +178 -0
  76. data/lib/simple_acp/version.rb +5 -0
  77. data/lib/simple_acp.rb +91 -0
  78. data/mkdocs.yml +152 -0
  79. data/sig/simple_acp.rbs +4 -0
  80. metadata +418 -0
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleAcp
4
+ module Models
5
+ # Represents a single agent execution.
6
+ #
7
+ # Tracks the lifecycle of an agent run from creation through completion,
8
+ # including status, output messages, errors, and timing.
9
+ #
10
+ # == Status Lifecycle
11
+ #
12
+ # - created -> in_progress -> completed | failed | cancelled | awaiting
13
+ # - awaiting -> in_progress (on resume)
14
+ # - cancelling -> cancelled
15
+ class Run < Base
16
+ # @!attribute [r] run_id
17
+ # @return [String] unique UUID for this run
18
+ attribute :run_id, required: true
19
+
20
+ # @!attribute [r] agent_name
21
+ # @return [String] name of the agent being executed
22
+ attribute :agent_name, required: true
23
+
24
+ # @!attribute [r] session_id
25
+ # @return [String, nil] optional session ID
26
+ attribute :session_id
27
+
28
+ # @!attribute [r] status
29
+ # @return [String] current status (created, in-progress, completed, failed, cancelled, awaiting)
30
+ attribute :status, default: Types::RunStatus::CREATED
31
+
32
+ # @!attribute [r] await_request
33
+ # @return [AwaitRequest, nil] request for client input (when awaiting)
34
+ attribute :await_request
35
+
36
+ # @!attribute [r] output
37
+ # @return [Array<Message>] output messages from the agent
38
+ attribute :output, default: -> { [] }
39
+
40
+ # @!attribute [r] error
41
+ # @return [Error, nil] error details if failed
42
+ attribute :error
43
+
44
+ # @!attribute [r] created_at
45
+ # @return [Time, nil] when the run was created
46
+ attribute :created_at
47
+
48
+ # @!attribute [r] finished_at
49
+ # @return [Time, nil] when the run finished (completed, failed, or cancelled)
50
+ attribute :finished_at
51
+
52
+ def initialize(**kwargs)
53
+ super
54
+ @run_id ||= Types.generate_uuid
55
+ @output ||= []
56
+ @created_at ||= Time.now
57
+ end
58
+
59
+ # Create from a hash (JSON deserialization).
60
+ #
61
+ # @param hash [Hash, nil] run data
62
+ # @return [Run, nil] the run or nil
63
+ def self.from_hash(hash)
64
+ return nil if hash.nil?
65
+
66
+ instance = allocate
67
+ instance.send(:initialize_from_hash, hash)
68
+ instance
69
+ end
70
+
71
+ # Check if the run is in a terminal state.
72
+ #
73
+ # @return [Boolean] true if completed, failed, or cancelled
74
+ def terminal?
75
+ Types::RunStatus.terminal?(@status)
76
+ end
77
+
78
+ # Check if the run is currently executing.
79
+ #
80
+ # @return [Boolean] true if in_progress
81
+ def in_progress?
82
+ @status == Types::RunStatus::IN_PROGRESS
83
+ end
84
+
85
+ # Check if the run is waiting for client input.
86
+ #
87
+ # @return [Boolean] true if awaiting
88
+ def awaiting?
89
+ @status == Types::RunStatus::AWAITING
90
+ end
91
+
92
+ # Check if the run completed successfully.
93
+ #
94
+ # @return [Boolean] true if completed
95
+ def completed?
96
+ @status == Types::RunStatus::COMPLETED
97
+ end
98
+
99
+ # Check if the run failed.
100
+ #
101
+ # @return [Boolean] true if failed
102
+ def failed?
103
+ @status == Types::RunStatus::FAILED
104
+ end
105
+
106
+ # Check if the run was cancelled.
107
+ #
108
+ # @return [Boolean] true if cancelled
109
+ def cancelled?
110
+ @status == Types::RunStatus::CANCELLED
111
+ end
112
+
113
+ # Check if the run is being cancelled.
114
+ #
115
+ # @return [Boolean] true if cancelling
116
+ def cancelling?
117
+ @status == Types::RunStatus::CANCELLING
118
+ end
119
+
120
+ # Transition to in_progress status.
121
+ #
122
+ # @return [self] for chaining
123
+ def start!
124
+ @status = Types::RunStatus::IN_PROGRESS
125
+ self
126
+ end
127
+
128
+ # Transition to awaiting status.
129
+ #
130
+ # @param request [AwaitRequest] the request for client input
131
+ # @return [self] for chaining
132
+ def await!(request)
133
+ @status = Types::RunStatus::AWAITING
134
+ @await_request = request
135
+ self
136
+ end
137
+
138
+ # Transition to completed status.
139
+ #
140
+ # @param output [Array<Message>, nil] optional output messages
141
+ # @return [self] for chaining
142
+ def complete!(output = nil)
143
+ @status = Types::RunStatus::COMPLETED
144
+ @output = output if output
145
+ @finished_at = Time.now
146
+ self
147
+ end
148
+
149
+ # Transition to failed status.
150
+ #
151
+ # @param error [Error, String] the error or error message
152
+ # @return [self] for chaining
153
+ def fail!(error)
154
+ @status = Types::RunStatus::FAILED
155
+ @error = error.is_a?(Error) ? error : Error.server_error(error.to_s)
156
+ @finished_at = Time.now
157
+ self
158
+ end
159
+
160
+ # Transition to cancelling status.
161
+ #
162
+ # @return [self] for chaining
163
+ def cancel!
164
+ @status = Types::RunStatus::CANCELLING
165
+ self
166
+ end
167
+
168
+ # Transition to cancelled status.
169
+ #
170
+ # @return [self] for chaining
171
+ def cancelled!
172
+ @status = Types::RunStatus::CANCELLED
173
+ @finished_at = Time.now
174
+ self
175
+ end
176
+
177
+ # Add a message to the output.
178
+ #
179
+ # @param message [Message, Hash] the message to add
180
+ # @return [self] for chaining
181
+ def add_output(message)
182
+ @output << (message.is_a?(Message) ? message : Message.from_hash(message))
183
+ self
184
+ end
185
+
186
+ # Raise an exception if the run failed.
187
+ #
188
+ # @return [self] if not failed
189
+ # @raise [RunError] if failed
190
+ def raise_for_status!
191
+ return self unless failed?
192
+
193
+ raise SimpleAcp::RunError, @error&.message || "Run failed"
194
+ end
195
+
196
+ # Validate the run.
197
+ #
198
+ # @return [Boolean] true if run_id, agent_name, and status are valid
199
+ def valid?
200
+ return false unless Types.valid_uuid?(@run_id)
201
+ return false unless Types.valid_agent_name?(@agent_name)
202
+ return false unless Types::RunStatus.valid?(@status)
203
+
204
+ true
205
+ end
206
+
207
+ private
208
+
209
+ def initialize_from_hash(hash)
210
+ @run_id = hash["run_id"] || hash[:run_id]
211
+ @agent_name = hash["agent_name"] || hash[:agent_name]
212
+ @session_id = hash["session_id"] || hash[:session_id]
213
+ @status = hash["status"] || hash[:status] || Types::RunStatus::CREATED
214
+ @created_at = parse_time(hash["created_at"] || hash[:created_at])
215
+ @finished_at = parse_time(hash["finished_at"] || hash[:finished_at])
216
+
217
+ output_data = hash["output"] || hash[:output] || []
218
+ @output = output_data.map { |m| Message.from_hash(m) }
219
+
220
+ if hash["error"] || hash[:error]
221
+ @error = Error.from_hash(hash["error"] || hash[:error])
222
+ end
223
+
224
+ if hash["await_request"] || hash[:await_request]
225
+ @await_request = AwaitRequest.from_hash(hash["await_request"] || hash[:await_request])
226
+ end
227
+ end
228
+
229
+ def parse_time(value)
230
+ return nil if value.nil?
231
+ return value if value.is_a?(Time)
232
+
233
+ Time.parse(value.to_s)
234
+ rescue ArgumentError
235
+ nil
236
+ end
237
+ end
238
+
239
+ # Request body for creating a run
240
+ class RunCreateRequest < Base
241
+ attribute :agent_name, required: true
242
+ attribute :input, required: true
243
+ attribute :mode
244
+ attribute :session_id
245
+ attribute :session
246
+
247
+ def self.from_hash(hash)
248
+ return nil if hash.nil?
249
+
250
+ instance = super
251
+
252
+ input_data = hash["input"] || hash[:input] || []
253
+ instance.input = input_data.map { |m| Message.from_hash(m) }
254
+
255
+ if hash["session"] || hash[:session]
256
+ instance.session = Session.from_hash(hash["session"] || hash[:session])
257
+ end
258
+
259
+ instance
260
+ end
261
+
262
+ def valid?
263
+ return false unless Types.valid_agent_name?(@agent_name)
264
+ return false if @input.nil? || @input.empty?
265
+ return false if @mode && !Types::RunMode.valid?(@mode)
266
+
267
+ true
268
+ end
269
+ end
270
+
271
+ # Request body for resuming a run
272
+ class RunResumeRequest < Base
273
+ attribute :run_id, required: true
274
+ attribute :await_resume, required: true
275
+ attribute :mode, required: true
276
+
277
+ def self.from_hash(hash)
278
+ return nil if hash.nil?
279
+
280
+ instance = super
281
+
282
+ if hash["await_resume"] || hash[:await_resume]
283
+ instance.await_resume = AwaitResume.from_hash(hash["await_resume"] || hash[:await_resume])
284
+ end
285
+
286
+ instance
287
+ end
288
+
289
+ def valid?
290
+ return false unless Types.valid_uuid?(@run_id)
291
+ return false if @await_resume.nil?
292
+ return false unless Types::RunMode.valid?(@mode)
293
+
294
+ true
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleAcp
4
+ module Models
5
+ # Maintains state and conversation history across interactions.
6
+ #
7
+ # Sessions allow agents to maintain context between requests,
8
+ # storing both conversation history and arbitrary state data.
9
+ #
10
+ # @example Using sessions with the client
11
+ # client.use_session("my-session")
12
+ # client.run_sync(agent: "counter", input: "increment") # Count: 1
13
+ # client.run_sync(agent: "counter", input: "increment") # Count: 2
14
+ class Session < Base
15
+ # @!attribute [r] id
16
+ # @return [String] unique session ID
17
+ attribute :id, required: true
18
+
19
+ # @!attribute [r] history
20
+ # @return [Array<Message>] conversation history
21
+ attribute :history, default: -> { [] }
22
+
23
+ # @!attribute [r] state
24
+ # @return [Object, nil] arbitrary state data
25
+ attribute :state
26
+
27
+ def initialize(**kwargs)
28
+ super
29
+ @id ||= Types.generate_uuid
30
+ @history ||= []
31
+ end
32
+
33
+ # Create from a hash (JSON deserialization).
34
+ #
35
+ # @param hash [Hash, nil] session data
36
+ # @return [Session, nil] the session or nil
37
+ def self.from_hash(hash)
38
+ return nil if hash.nil?
39
+
40
+ instance = allocate
41
+ instance.send(:initialize_from_hash, hash)
42
+ instance
43
+ end
44
+
45
+ # Create a new session with a generated ID.
46
+ #
47
+ # @return [Session] new session
48
+ def self.create
49
+ new(id: Types.generate_uuid)
50
+ end
51
+
52
+ # Add a message to the conversation history.
53
+ #
54
+ # @param message [Message, Hash] the message to add
55
+ # @return [self] for chaining
56
+ def add_to_history(message)
57
+ @history << (message.is_a?(Message) ? message : Message.from_hash(message))
58
+ self
59
+ end
60
+
61
+ # Clear all conversation history.
62
+ #
63
+ # @return [self] for chaining
64
+ def clear_history!
65
+ @history = []
66
+ self
67
+ end
68
+
69
+ # Set the session state.
70
+ #
71
+ # @param state_data [Object] arbitrary state data
72
+ # @return [self] for chaining
73
+ def set_state(state_data)
74
+ @state = state_data
75
+ self
76
+ end
77
+
78
+ # Clear the session state.
79
+ #
80
+ # @return [self] for chaining
81
+ def clear_state!
82
+ @state = nil
83
+ self
84
+ end
85
+
86
+ # Get the number of messages in history.
87
+ #
88
+ # @return [Integer] message count
89
+ def message_count
90
+ @history.length
91
+ end
92
+
93
+ # Check if the session has no history or state.
94
+ #
95
+ # @return [Boolean] true if no history and no state
96
+ def empty?
97
+ @history.empty? && @state.nil?
98
+ end
99
+
100
+ # Validate the session.
101
+ #
102
+ # @return [Boolean] true if ID is a valid UUID
103
+ def valid?
104
+ Types.valid_uuid?(@id)
105
+ end
106
+
107
+ private
108
+
109
+ def initialize_from_hash(hash)
110
+ @id = hash["id"] || hash[:id]
111
+ @state = hash["state"] || hash[:state]
112
+
113
+ history_data = hash["history"] || hash[:history] || []
114
+ @history = history_data.map do |item|
115
+ item.is_a?(Message) ? item : Message.from_hash(item)
116
+ end
117
+ end
118
+ end
119
+
120
+ # Response for getting session details
121
+ class SessionResponse < Base
122
+ attribute :id
123
+ attribute :history_count
124
+ attribute :has_state
125
+ attribute :created_at
126
+
127
+ def self.from_session(session)
128
+ new(
129
+ id: session.id,
130
+ history_count: session.history.length,
131
+ has_state: !session.state.nil?,
132
+ created_at: Time.now
133
+ )
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleAcp
4
+ module Models
5
+ # Type definitions, constants, and validations for ACP.
6
+ #
7
+ # Contains all the enumerated types and validation helpers
8
+ # used throughout the protocol.
9
+ module Types
10
+ # Agent name must follow RFC 1123 DNS label format
11
+ AGENT_NAME_PATTERN = /\A[a-z0-9]([-a-z0-9]*[a-z0-9])?\z/
12
+
13
+ # Maximum length for agent names
14
+ AGENT_NAME_MAX_LENGTH = 63
15
+
16
+ # Run execution modes.
17
+ module RunMode
18
+ # Synchronous execution (blocks until complete)
19
+ SYNC = "sync"
20
+ # Asynchronous execution (returns immediately)
21
+ ASYNC = "async"
22
+ # Streaming execution (SSE events)
23
+ STREAM = "stream"
24
+
25
+ # All valid modes
26
+ ALL = [SYNC, ASYNC, STREAM].freeze
27
+
28
+ # Check if a mode is valid.
29
+ #
30
+ # @param mode [String] mode to check
31
+ # @return [Boolean] true if valid
32
+ def self.valid?(mode)
33
+ ALL.include?(mode)
34
+ end
35
+ end
36
+
37
+ # Run status values.
38
+ module RunStatus
39
+ # Run created but not started
40
+ CREATED = "created"
41
+ # Run is executing
42
+ IN_PROGRESS = "in-progress"
43
+ # Run is waiting for client input
44
+ AWAITING = "awaiting"
45
+ # Run is being cancelled
46
+ CANCELLING = "cancelling"
47
+ # Run was cancelled
48
+ CANCELLED = "cancelled"
49
+ # Run completed successfully
50
+ COMPLETED = "completed"
51
+ # Run failed with error
52
+ FAILED = "failed"
53
+
54
+ # All valid statuses
55
+ ALL = [CREATED, IN_PROGRESS, AWAITING, CANCELLING, CANCELLED, COMPLETED, FAILED].freeze
56
+
57
+ # Terminal (final) statuses
58
+ TERMINAL = [COMPLETED, FAILED, CANCELLED].freeze
59
+
60
+ # Check if a status is valid.
61
+ #
62
+ # @param status [String] status to check
63
+ # @return [Boolean] true if valid
64
+ def self.valid?(status)
65
+ ALL.include?(status)
66
+ end
67
+
68
+ # Check if a status is terminal (run is finished).
69
+ #
70
+ # @param status [String] status to check
71
+ # @return [Boolean] true if terminal
72
+ def self.terminal?(status)
73
+ TERMINAL.include?(status)
74
+ end
75
+ end
76
+
77
+ # Link types for agent metadata.
78
+ module LinkType
79
+ # Link to source code repository
80
+ SOURCE_CODE = "source-code"
81
+ # Link to container image
82
+ CONTAINER_IMAGE = "container-image"
83
+ # Link to homepage
84
+ HOMEPAGE = "homepage"
85
+ # Link to documentation
86
+ DOCUMENTATION = "documentation"
87
+
88
+ # All valid link types
89
+ ALL = [SOURCE_CODE, CONTAINER_IMAGE, HOMEPAGE, DOCUMENTATION].freeze
90
+ end
91
+
92
+ # Dependency types for agent metadata.
93
+ module DependencyType
94
+ # Depends on another agent
95
+ AGENT = "agent"
96
+ # Depends on a tool
97
+ TOOL = "tool"
98
+ # Depends on a model
99
+ MODEL = "model"
100
+
101
+ # All valid dependency types
102
+ ALL = [AGENT, TOOL, MODEL].freeze
103
+ end
104
+
105
+ # Content encoding types.
106
+ module ContentEncoding
107
+ # Plain text (no encoding)
108
+ PLAIN = "plain"
109
+ # Base64 encoded
110
+ BASE64 = "base64"
111
+
112
+ # All valid encodings
113
+ ALL = [PLAIN, BASE64].freeze
114
+ end
115
+
116
+ # Event types for SSE streaming.
117
+ module EventType
118
+ # New message created
119
+ MESSAGE_CREATED = "message.created"
120
+ # Message part received
121
+ MESSAGE_PART = "message.part"
122
+ # Message completed
123
+ MESSAGE_COMPLETED = "message.completed"
124
+ # Run created
125
+ RUN_CREATED = "run.created"
126
+ # Run started executing
127
+ RUN_IN_PROGRESS = "run.in-progress"
128
+ # Run awaiting client input
129
+ RUN_AWAITING = "run.awaiting"
130
+ # Run completed successfully
131
+ RUN_COMPLETED = "run.completed"
132
+ # Run was cancelled
133
+ RUN_CANCELLED = "run.cancelled"
134
+ # Run failed
135
+ RUN_FAILED = "run.failed"
136
+ # Generic event
137
+ GENERIC = "generic"
138
+ # Error event
139
+ ERROR = "error"
140
+
141
+ # All valid event types
142
+ ALL = [
143
+ MESSAGE_CREATED, MESSAGE_PART, MESSAGE_COMPLETED,
144
+ RUN_CREATED, RUN_IN_PROGRESS, RUN_AWAITING,
145
+ RUN_COMPLETED, RUN_CANCELLED, RUN_FAILED,
146
+ GENERIC, ERROR
147
+ ].freeze
148
+ end
149
+
150
+ # Message role types.
151
+ module Role
152
+ # Message from user
153
+ USER = "user"
154
+ # Message from agent
155
+ AGENT = "agent"
156
+
157
+ # Check if a role is valid.
158
+ #
159
+ # @param role [String] role to check
160
+ # @return [Boolean] true if "user", "agent", or "agent/name"
161
+ def self.valid?(role)
162
+ role == USER || role == AGENT || role.to_s.start_with?("agent/")
163
+ end
164
+ end
165
+
166
+ class << self
167
+ # Validate an agent name.
168
+ #
169
+ # @param name [String, nil] name to validate
170
+ # @return [Boolean] true if valid RFC 1123 DNS label
171
+ def valid_agent_name?(name)
172
+ return false if name.nil? || name.empty?
173
+ return false if name.length > AGENT_NAME_MAX_LENGTH
174
+
175
+ AGENT_NAME_PATTERN.match?(name)
176
+ end
177
+
178
+ # Validate a UUID.
179
+ #
180
+ # @param value [String, nil] value to validate
181
+ # @return [Boolean] true if valid UUID format
182
+ def valid_uuid?(value)
183
+ return false if value.nil?
184
+
185
+ /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i.match?(value.to_s)
186
+ end
187
+
188
+ # Validate a URL.
189
+ #
190
+ # @param value [String, nil] value to validate
191
+ # @return [Boolean] true if valid HTTP/HTTPS URL
192
+ def valid_url?(value)
193
+ return false if value.nil?
194
+
195
+ uri = URI.parse(value.to_s)
196
+ uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
197
+ rescue URI::InvalidURIError
198
+ false
199
+ end
200
+
201
+ # Generate a new UUID.
202
+ #
203
+ # @return [String] UUID string
204
+ def generate_uuid
205
+ SecureRandom.uuid
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end