Class: Clacky::ApiExtension
- Inherits:
-
Object
- Object
- Clacky::ApiExtension
show all
- Defined in:
- lib/clacky/extension/api_extension.rb
Overview
Base class for HTTP API extensions declared in an ext.yml container as
contributes.api: <path/to/handler.rb>. Subclasses use a tiny route DSL
(get/post/put/patch/delete) to expose endpoints under
/api/ext/<ext_id>/
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/ext/local/my-dashboard/api/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
-
#send_data(bytes, content_type:, filename: nil, status: 200) ⇒ Object
-
#server_start_time ⇒ Object
-
#session_manager ⇒ Object
-
#submit_task(session_id, prompt, display_message: nil, interrupt: false) ⇒ 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.
174
175
176
177
178
179
180
|
# File 'lib/clacky/extension/api_extension.rb', line 174
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.
172
173
174
|
# File 'lib/clacky/extension/api_extension.rb', line 172
def params
@params
end
|
#req ⇒ Object
Returns the value of attribute req.
172
173
174
|
# File 'lib/clacky/extension/api_extension.rb', line 172
def req
@req
end
|
#res ⇒ Object
Returns the value of attribute res.
172
173
174
|
# File 'lib/clacky/extension/api_extension.rb', line 172
def res
@res
end
|
#route ⇒ Object
Returns the value of attribute route.
172
173
174
|
# File 'lib/clacky/extension/api_extension.rb', line 172
def route
@route
end
|
Class Method Details
.class_timeout ⇒ Object
77
78
79
|
# File 'lib/clacky/extension/api_extension.rb', line 77
def class_timeout
@class_timeout
end
|
.compile_pattern(pattern) ⇒ Object
162
163
164
165
166
167
168
169
|
# File 'lib/clacky/extension/api_extension.rb', line 162
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
101
102
103
|
# File 'lib/clacky/extension/api_extension.rb', line 101
def ext_dir
@ext_dir
end
|
.ext_dir=(value) ⇒ Object
105
106
107
|
# File 'lib/clacky/extension/api_extension.rb', line 105
def ext_dir=(value)
@ext_dir = value
end
|
.ext_id ⇒ Object
93
94
95
|
# File 'lib/clacky/extension/api_extension.rb', line 93
def ext_id
@ext_id
end
|
.ext_id=(value) ⇒ Object
97
98
99
|
# File 'lib/clacky/extension/api_extension.rb', line 97
def ext_id=(value)
@ext_id = value
end
|
.inherited(subclass) ⇒ Object
109
110
111
|
# File 'lib/clacky/extension/api_extension.rb', line 109
def meta
@meta ||= {}
end
|
113
114
115
|
# File 'lib/clacky/extension/api_extension.rb', line 113
def meta=(value)
@meta = value || {}
end
|
.normalize_pattern(pattern) ⇒ Object
155
156
157
158
159
160
|
# File 'lib/clacky/extension/api_extension.rb', line 155
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.
63
64
65
|
# File 'lib/clacky/extension/api_extension.rb', line 63
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 at ext.yml top level for the framework to honor this.
128
129
130
|
# File 'lib/clacky/extension/api_extension.rb', line 128
def public_endpoint(pattern)
public_paths << normalize_pattern(pattern)
end
|
.public_paths ⇒ Object
81
82
83
|
# File 'lib/clacky/extension/api_extension.rb', line 81
def public_paths
@public_paths ||= []
end
|
.register(ext_id, klass) ⇒ Object
51
52
53
|
# File 'lib/clacky/extension/api_extension.rb', line 51
def register(ext_id, klass)
registry[ext_id] = klass
end
|
.registry ⇒ Object
Keyed by ext_id — every extension contributes at most one API unit
(declared as contributes.api: <path> in ext.yml).
47
48
49
|
# File 'lib/clacky/extension/api_extension.rb', line 47
def registry
@registry ||= {}
end
|
.reset_registry! ⇒ Object
55
56
57
58
|
# File 'lib/clacky/extension/api_extension.rb', line 55
def reset_registry!
@registry = {}
@pending_subclasses = []
end
|
.reset_routes! ⇒ Object
Clears accumulated route/public-path state so a force-reload (load) of
a named-constant handler re-registers its routes from scratch instead of
appending duplicates onto the reopened class.
88
89
90
91
|
# File 'lib/clacky/extension/api_extension.rb', line 88
def reset_routes!
@routes = []
@public_paths = []
end
|
.routes ⇒ Object
Per-subclass state — inherited classes carry their own routes/options.
73
74
75
|
# File 'lib/clacky/extension/api_extension.rb', line 73
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.
119
120
121
122
123
124
|
# File 'lib/clacky/extension/api_extension.rb', line 119
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
254
255
256
|
# File 'lib/clacky/extension/api_extension.rb', line 254
def agent_config
@http_server&.instance_variable_get(:@agent_config)
end
|
#config ⇒ Object
246
247
248
|
# File 'lib/clacky/extension/api_extension.rb', line 246
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.
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
|
# File 'lib/clacky/extension/api_extension.rb', line 266
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
232
233
234
235
236
|
# File 'lib/clacky/extension/api_extension.rb', line 232
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".
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
|
# File 'lib/clacky/extension/api_extension.rb', line 327
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
213
214
215
216
217
|
# File 'lib/clacky/extension/api_extension.rb', line 213
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
238
239
240
|
# File 'lib/clacky/extension/api_extension.rb', line 238
def ext_dir
self.class.ext_dir
end
|
#ext_id ⇒ Object
242
243
244
|
# File 'lib/clacky/extension/api_extension.rb', line 242
def ext_id
self.class.ext_id
end
|
#invoke ⇒ Object
182
183
184
|
# File 'lib/clacky/extension/api_extension.rb', line 182
def invoke
instance_exec(&route.block)
end
|
#json(*args, **kwargs) ⇒ Object
---- handler context (white-listed access to host process) ----
188
189
190
191
192
193
194
195
196
197
198
199
|
# File 'lib/clacky/extension/api_extension.rb', line 188
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
219
220
221
222
223
224
225
226
|
# File 'lib/clacky/extension/api_extension.rb', line 219
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
351
352
353
|
# File 'lib/clacky/extension/api_extension.rb', line 351
def logger
@logger ||= ScopedLogger.new(self.class.ext_id)
end
|
#query ⇒ Object
228
229
230
|
# File 'lib/clacky/extension/api_extension.rb', line 228
def query
@query ||= req.query || {}
end
|
#registry ⇒ Object
258
259
260
|
# File 'lib/clacky/extension/api_extension.rb', line 258
def registry
@http_server&.instance_variable_get(:@registry)
end
|
#send_data(bytes, content_type:, filename: nil, status: 200) ⇒ Object
205
206
207
208
209
210
211
|
# File 'lib/clacky/extension/api_extension.rb', line 205
def send_data(bytes, content_type:, filename: nil, status: 200)
disposition = filename ? "attachment; filename=\"#{filename}\"" : "attachment"
raise Halt.new(status, bytes, content_type, extra_headers: {
"Content-Disposition" => disposition,
"Content-Length" => bytes.bytesize.to_s
})
end
|
#server_start_time ⇒ Object
347
348
349
|
# File 'lib/clacky/extension/api_extension.rb', line 347
def server_start_time
@http_server&.instance_variable_get(:@start_time)
end
|
#session_manager ⇒ Object
250
251
252
|
# File 'lib/clacky/extension/api_extension.rb', line 250
def session_manager
@http_server&.instance_variable_get(:@session_manager)
end
|
#submit_task(session_id, prompt, display_message: nil, interrupt: false) ⇒ Object
Submit a prompt to an existing session for execution.
Returns the session_id on success.
Raises Halt (409) if the session is already running and interrupt: false.
When interrupt: true, supersedes the current turn (raises AgentInterrupted
on the running thread, waits up to 2s for it to exit) then runs the new task.
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
|
# File 'lib/clacky/extension/api_extension.rb', line 288
def submit_task(session_id, prompt, display_message: nil, interrupt: false)
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)
if session[:status] == :running
error!("session is busy", status: 409) unless interrupt
@http_server.send(:interrupt_session, session_id)
old_thread = nil
reg.with_session(session_id) { |s| old_thread = s[:thread] }
old_thread&.join(2)
end
@http_server.send(:run_session_task, session_id, prompt, display_message: display_message)
session_id
end
|
#text(str, status: 200) ⇒ Object
201
202
203
|
# File 'lib/clacky/extension/api_extension.rb', line 201
def text(str, status: 200)
raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
end
|