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
-
#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.
173
174
175
176
177
178
179
|
# File 'lib/clacky/extension/api_extension.rb', line 173
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.
171
172
173
|
# File 'lib/clacky/extension/api_extension.rb', line 171
def params
@params
end
|
#req ⇒ Object
Returns the value of attribute req.
171
172
173
|
# File 'lib/clacky/extension/api_extension.rb', line 171
def req
@req
end
|
#res ⇒ Object
Returns the value of attribute res.
171
172
173
|
# File 'lib/clacky/extension/api_extension.rb', line 171
def res
@res
end
|
#route ⇒ Object
Returns the value of attribute route.
171
172
173
|
# File 'lib/clacky/extension/api_extension.rb', line 171
def route
@route
end
|
Class Method Details
.class_timeout ⇒ Object
76
77
78
|
# File 'lib/clacky/extension/api_extension.rb', line 76
def class_timeout
@class_timeout
end
|
.compile_pattern(pattern) ⇒ Object
161
162
163
164
165
166
167
168
|
# File 'lib/clacky/extension/api_extension.rb', line 161
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
100
101
102
|
# File 'lib/clacky/extension/api_extension.rb', line 100
def ext_dir
@ext_dir
end
|
.ext_dir=(value) ⇒ Object
104
105
106
|
# File 'lib/clacky/extension/api_extension.rb', line 104
def ext_dir=(value)
@ext_dir = value
end
|
.ext_id ⇒ Object
92
93
94
|
# File 'lib/clacky/extension/api_extension.rb', line 92
def ext_id
@ext_id
end
|
.ext_id=(value) ⇒ Object
96
97
98
|
# File 'lib/clacky/extension/api_extension.rb', line 96
def ext_id=(value)
@ext_id = value
end
|
.inherited(subclass) ⇒ Object
108
109
110
|
# File 'lib/clacky/extension/api_extension.rb', line 108
def meta
@meta ||= {}
end
|
112
113
114
|
# File 'lib/clacky/extension/api_extension.rb', line 112
def meta=(value)
@meta = value || {}
end
|
.normalize_pattern(pattern) ⇒ Object
154
155
156
157
158
159
|
# File 'lib/clacky/extension/api_extension.rb', line 154
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/extension/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 at ext.yml top level for the framework to honor this.
127
128
129
|
# File 'lib/clacky/extension/api_extension.rb', line 127
def public_endpoint(pattern)
public_paths << normalize_pattern(pattern)
end
|
.public_paths ⇒ Object
80
81
82
|
# File 'lib/clacky/extension/api_extension.rb', line 80
def public_paths
@public_paths ||= []
end
|
.register(ext_id, klass) ⇒ Object
50
51
52
|
# File 'lib/clacky/extension/api_extension.rb', line 50
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).
46
47
48
|
# File 'lib/clacky/extension/api_extension.rb', line 46
def registry
@registry ||= {}
end
|
.reset_registry! ⇒ Object
54
55
56
57
|
# File 'lib/clacky/extension/api_extension.rb', line 54
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.
87
88
89
90
|
# File 'lib/clacky/extension/api_extension.rb', line 87
def reset_routes!
@routes = []
@public_paths = []
end
|
.routes ⇒ Object
Per-subclass state — inherited classes carry their own routes/options.
72
73
74
|
# File 'lib/clacky/extension/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.
118
119
120
121
122
123
|
# File 'lib/clacky/extension/api_extension.rb', line 118
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
245
246
247
|
# File 'lib/clacky/extension/api_extension.rb', line 245
def agent_config
@http_server&.instance_variable_get(:@agent_config)
end
|
#config ⇒ Object
237
238
239
|
# File 'lib/clacky/extension/api_extension.rb', line 237
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.
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
|
# File 'lib/clacky/extension/api_extension.rb', line 257
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
223
224
225
226
227
|
# File 'lib/clacky/extension/api_extension.rb', line 223
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".
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
|
# File 'lib/clacky/extension/api_extension.rb', line 318
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
204
205
206
207
208
|
# File 'lib/clacky/extension/api_extension.rb', line 204
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
229
230
231
|
# File 'lib/clacky/extension/api_extension.rb', line 229
def ext_dir
self.class.ext_dir
end
|
#ext_id ⇒ Object
233
234
235
|
# File 'lib/clacky/extension/api_extension.rb', line 233
def ext_id
self.class.ext_id
end
|
#invoke ⇒ Object
181
182
183
|
# File 'lib/clacky/extension/api_extension.rb', line 181
def invoke
instance_exec(&route.block)
end
|
#json(*args, **kwargs) ⇒ Object
---- handler context (white-listed access to host process) ----
187
188
189
190
191
192
193
194
195
196
197
198
|
# File 'lib/clacky/extension/api_extension.rb', line 187
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
210
211
212
213
214
215
216
217
|
# File 'lib/clacky/extension/api_extension.rb', line 210
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
342
343
344
|
# File 'lib/clacky/extension/api_extension.rb', line 342
def logger
@logger ||= ScopedLogger.new(self.class.ext_id)
end
|
#query ⇒ Object
219
220
221
|
# File 'lib/clacky/extension/api_extension.rb', line 219
def query
@query ||= req.query || {}
end
|
#registry ⇒ Object
249
250
251
|
# File 'lib/clacky/extension/api_extension.rb', line 249
def registry
@http_server&.instance_variable_get(:@registry)
end
|
#server_start_time ⇒ Object
338
339
340
|
# File 'lib/clacky/extension/api_extension.rb', line 338
def server_start_time
@http_server&.instance_variable_get(:@start_time)
end
|
#session_manager ⇒ Object
241
242
243
|
# File 'lib/clacky/extension/api_extension.rb', line 241
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.
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
|
# File 'lib/clacky/extension/api_extension.rb', line 279
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
200
201
202
|
# File 'lib/clacky/extension/api_extension.rb', line 200
def text(str, status: 200)
raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
end
|