Class: Clacky::ApiExtension
- Inherits:
-
Object
- Object
- Clacky::ApiExtension
show all
- Defined in:
- lib/clacky/api_extension.rb
Overview
Base class for user-defined HTTP API extensions loaded from
~/.clacky/api_ext//handler.rb. Subclasses use a tiny route DSL
(get/post/put/patch/delete) to expose endpoints under
/api/ext//
The framework wires up access-key auth, timeouts, JSON error envelopes,
path-parameter parsing, and a curated handler context — extension authors
only fill in business logic.
Minimal example (~/.clacky/api_ext/my-dashboard/handler.rb):
class MyDashboardExt < Clacky::ApiExtension
get "/summary" do
json(sessions: session_manager.list.size)
end
end
Mounted automatically at: GET /api/ext/my-dashboard/summary
Defined Under Namespace
Classes: Halt, Route, ScopedLogger
Constant Summary
collapse
- HTTP_METHODS =
%i[get post put patch delete].freeze
- MAX_TIMEOUT =
600
- DEFAULT_TIMEOUT =
10
Instance Attribute Summary collapse
Class Method Summary
collapse
Instance Method Summary
collapse
-
#agent_config ⇒ Object
-
#config ⇒ Object
-
#create_session(name: nil, prompt: nil, working_dir: nil, profile: "general", source: :manual, display_message: nil) ⇒ Object
Create a brand-new session and optionally kick off its first task.
-
#data_path(*parts) ⇒ Object
-
#dispatch_to_session(session_id, prompt, model: nil, forbidden_tools: []) ⇒ Hash
Run a one-off side task on an existing session's agent and return its reply text SYNCHRONOUSLY, without polluting the main conversation.
-
#error!(message, status: 400, **extra) ⇒ Object
-
#ext_dir ⇒ Object
-
#ext_id ⇒ Object
-
#initialize(req:, res:, route:, params:, http_server:) ⇒ ApiExtension
constructor
A new instance of ApiExtension.
-
#invoke ⇒ Object
-
#json(*args, **kwargs) ⇒ Object
---- handler context (white-listed access to host process) ----.
-
#json_body ⇒ Object
-
#logger ⇒ Object
-
#query ⇒ Object
-
#registry ⇒ Object
-
#server_start_time ⇒ Object
-
#session_manager ⇒ Object
-
#submit_task(session_id, prompt, display_message: nil) ⇒ Object
Submit a prompt to an existing session for execution.
-
#text(str, status: 200) ⇒ Object
Constructor Details
#initialize(req:, res:, route:, params:, http_server:) ⇒ ApiExtension
Returns a new instance of ApiExtension.
165
166
167
168
169
170
171
|
# File 'lib/clacky/api_extension.rb', line 165
def initialize(req:, res:, route:, params:, http_server:)
@req = req
@res = res
@route = route
@params = params
@http_server = http_server
end
|
Instance Attribute Details
#params ⇒ Object
Returns the value of attribute params.
163
164
165
|
# File 'lib/clacky/api_extension.rb', line 163
def params
@params
end
|
#req ⇒ Object
Returns the value of attribute req.
163
164
165
|
# File 'lib/clacky/api_extension.rb', line 163
def req
@req
end
|
#res ⇒ Object
Returns the value of attribute res.
163
164
165
|
# File 'lib/clacky/api_extension.rb', line 163
def res
@res
end
|
#route ⇒ Object
Returns the value of attribute route.
163
164
165
|
# File 'lib/clacky/api_extension.rb', line 163
def route
@route
end
|
Class Method Details
.class_timeout ⇒ Object
76
77
78
|
# File 'lib/clacky/api_extension.rb', line 76
def class_timeout
@class_timeout
end
|
.compile_pattern(pattern) ⇒ Object
153
154
155
156
157
158
159
160
|
# File 'lib/clacky/api_extension.rb', line 153
def compile_pattern(pattern)
param_names = []
regex_str = pattern.gsub(%r{:([a-zA-Z_][a-zA-Z0-9_]*)}) do |_match|
param_names << Regexp.last_match(1).to_sym
"([^/]+)"
end
[Regexp.new("\\A#{regex_str}\\z"), param_names]
end
|
.ext_dir ⇒ Object
92
93
94
|
# File 'lib/clacky/api_extension.rb', line 92
def ext_dir
@ext_dir
end
|
.ext_dir=(value) ⇒ Object
96
97
98
|
# File 'lib/clacky/api_extension.rb', line 96
def ext_dir=(value)
@ext_dir = value
end
|
.ext_id ⇒ Object
84
85
86
|
# File 'lib/clacky/api_extension.rb', line 84
def ext_id
@ext_id
end
|
.ext_id=(value) ⇒ Object
88
89
90
|
# File 'lib/clacky/api_extension.rb', line 88
def ext_id=(value)
@ext_id = value
end
|
.inherited(subclass) ⇒ Object
100
101
102
|
# File 'lib/clacky/api_extension.rb', line 100
def meta
@meta ||= {}
end
|
104
105
106
|
# File 'lib/clacky/api_extension.rb', line 104
def meta=(value)
@meta = value || {}
end
|
.normalize_pattern(pattern) ⇒ Object
146
147
148
149
150
151
|
# File 'lib/clacky/api_extension.rb', line 146
def normalize_pattern(pattern)
pattern = pattern.to_s
pattern = "/#{pattern}" unless pattern.start_with?("/")
pattern = pattern.chomp("/")
pattern.empty? ? "/" : pattern
end
|
.pending_subclasses ⇒ Object
Captures every subclass at the moment its class body finishes being
required — the loader pops the most recent one off this list to bind
an ext_id/dir without relying on ObjectSpace scans.
62
63
64
|
# File 'lib/clacky/api_extension.rb', line 62
def pending_subclasses
@pending_subclasses ||= []
end
|
.public_endpoint(pattern) ⇒ Object
Mark a route as not requiring access-key auth. Caller must also
declare public: true in meta.yml for the framework to honor this.
119
120
121
|
# File 'lib/clacky/api_extension.rb', line 119
def public_endpoint(pattern)
public_paths << normalize_pattern(pattern)
end
|
.public_paths ⇒ Object
80
81
82
|
# File 'lib/clacky/api_extension.rb', line 80
def public_paths
@public_paths ||= []
end
|
.register(ext_id, klass) ⇒ Object
50
51
52
|
# File 'lib/clacky/api_extension.rb', line 50
def register(ext_id, klass)
registry[ext_id] = klass
end
|
.registry ⇒ Object
Registry of all loaded ApiExtension subclasses, keyed by extension id
(== directory name == mount prefix segment).
46
47
48
|
# File 'lib/clacky/api_extension.rb', line 46
def registry
@registry ||= {}
end
|
.reset_registry! ⇒ Object
54
55
56
57
|
# File 'lib/clacky/api_extension.rb', line 54
def reset_registry!
@registry = {}
@pending_subclasses = []
end
|
.routes ⇒ Object
Per-subclass state — inherited classes carry their own routes/options.
72
73
74
|
# File 'lib/clacky/api_extension.rb', line 72
def routes
@routes ||= []
end
|
.timeout(seconds) ⇒ Object
Set a default timeout (seconds) for every handler in this class.
Per-route override available via get "/x", timeout: 30 do ... end.
110
111
112
113
114
115
|
# File 'lib/clacky/api_extension.rb', line 110
def timeout(seconds)
raise ArgumentError, "timeout must be > 0" unless seconds.is_a?(Numeric) && seconds > 0
raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if seconds > MAX_TIMEOUT
@class_timeout = seconds.to_f
end
|
Instance Method Details
#agent_config ⇒ Object
237
238
239
|
# File 'lib/clacky/api_extension.rb', line 237
def agent_config
@http_server&.instance_variable_get(:@agent_config)
end
|
#config ⇒ Object
229
230
231
|
# File 'lib/clacky/api_extension.rb', line 229
def config
self.class.meta["config"] || {}
end
|
#create_session(name: nil, prompt: nil, working_dir: nil, profile: "general", source: :manual, display_message: nil) ⇒ Object
Create a brand-new session and optionally kick off its first task.
Returns the new session_id. When a prompt is given, the task is
submitted immediately (the session starts running); display_message
controls the user-facing bubble shown in place of the raw prompt.
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
|
# File 'lib/clacky/api_extension.rb', line 249
def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
source: :manual, display_message: nil)
error!("server not ready", status: 503) unless @http_server
session_id = @http_server.send(
:build_session,
name: name,
working_dir: working_dir,
profile: profile,
source: source
)
submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
session_id
end
|
#data_path(*parts) ⇒ Object
215
216
217
218
219
|
# File 'lib/clacky/api_extension.rb', line 215
def data_path(*parts)
base = File.join(self.class.ext_dir, "data")
FileUtils.mkdir_p(base)
File.join(base, *parts.map(&:to_s))
end
|
#dispatch_to_session(session_id, prompt, model: nil, forbidden_tools: []) ⇒ Hash
Run a one-off side task on an existing session's agent and return its
reply text SYNCHRONOUSLY, without polluting the main conversation.
Unlike submit_task (which enqueues a turn into the live conversation and
returns immediately), this forks the session's agent — reusing its cached
context and unified billing — runs the task to completion on the fork, and
returns the fork's final reply. The main conversation is never touched.
Strategy A (parent-busy → skip): if the session is currently running, or the
server is at its concurrency limit, this returns { busy: true } without
running. Callers (e.g. periodic analysis) should treat that as "try later".
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
|
# File 'lib/clacky/api_extension.rb', line 302
def dispatch_to_session(session_id, prompt, model: nil, forbidden_tools: [])
reg = registry
error!("server not ready", status: 503) unless reg
unless reg.exist?(session_id)
reg.ensure(session_id)
error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
end
return { busy: true } if reg.respond_to?(:running_full?) && reg.running_full?
session = reg.get(session_id)
return { busy: true } if session[:status] == :running
agent = session[:agent]
error!("session agent not available", status: 503) unless agent
{ text: agent.run_detached(prompt, model: model, forbidden_tools: forbidden_tools) }
end
|
#error!(message, status: 400, **extra) ⇒ Object
196
197
198
199
200
|
# File 'lib/clacky/api_extension.rb', line 196
def error!(message, status: 400, **)
payload = { error: message.to_s }
payload.merge!() unless .empty?
raise Halt.new(status, JSON.generate(payload), "application/json; charset=utf-8")
end
|
#ext_dir ⇒ Object
221
222
223
|
# File 'lib/clacky/api_extension.rb', line 221
def ext_dir
self.class.ext_dir
end
|
#ext_id ⇒ Object
225
226
227
|
# File 'lib/clacky/api_extension.rb', line 225
def ext_id
self.class.ext_id
end
|
#invoke ⇒ Object
173
174
175
|
# File 'lib/clacky/api_extension.rb', line 173
def invoke
instance_exec(&route.block)
end
|
#json(*args, **kwargs) ⇒ Object
---- handler context (white-listed access to host process) ----
179
180
181
182
183
184
185
186
187
188
189
190
|
# File 'lib/clacky/api_extension.rb', line 179
def json(*args, **kwargs)
if args.empty?
raise Halt.new(200, JSON.generate(kwargs), "application/json; charset=utf-8")
elsif args.size == 1
status = kwargs[:status] || 200
raise Halt.new(status, JSON.generate(args[0]), "application/json; charset=utf-8")
else
raise ArgumentError, "json: expected (hash) or (key: value, ...)"
end
end
|
#json_body ⇒ Object
202
203
204
205
206
207
208
209
|
# File 'lib/clacky/api_extension.rb', line 202
def json_body
@json_body ||= begin
return {} if req.body.nil? || req.body.empty?
JSON.parse(req.body)
rescue JSON::ParserError
{}
end
end
|
#logger ⇒ Object
326
327
328
|
# File 'lib/clacky/api_extension.rb', line 326
def logger
@logger ||= ScopedLogger.new(self.class.ext_id)
end
|
#query ⇒ Object
211
212
213
|
# File 'lib/clacky/api_extension.rb', line 211
def query
@query ||= req.query || {}
end
|
#registry ⇒ Object
241
242
243
|
# File 'lib/clacky/api_extension.rb', line 241
def registry
@http_server&.instance_variable_get(:@registry)
end
|
#server_start_time ⇒ Object
322
323
324
|
# File 'lib/clacky/api_extension.rb', line 322
def server_start_time
@http_server&.instance_variable_get(:@start_time)
end
|
#session_manager ⇒ Object
233
234
235
|
# File 'lib/clacky/api_extension.rb', line 233
def session_manager
@http_server&.instance_variable_get(:@session_manager)
end
|
#submit_task(session_id, prompt, display_message: nil) ⇒ Object
Submit a prompt to an existing session for execution.
The session must be idle; returns the session_id on success.
Raises Halt (409) if the session is already running.
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
|
# File 'lib/clacky/api_extension.rb', line 269
def submit_task(session_id, prompt, display_message: nil)
reg = registry
error!("server not ready", status: 503) unless reg
unless reg.exist?(session_id)
reg.ensure(session_id)
error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
end
session = reg.get(session_id)
error!("session is busy", status: 409) if session[:status] == :running
@http_server.send(:run_session_task, session_id, prompt, display_message: display_message)
session_id
end
|
#text(str, status: 200) ⇒ Object
192
193
194
|
# File 'lib/clacky/api_extension.rb', line 192
def text(str, status: 200)
raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
end
|