Class: Clacky::ApiExtension
- Inherits:
-
Object
- Object
- Clacky::ApiExtension
- 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
Direct Known Subclasses
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- ALLOWED_SESSION_SOURCES =
"ext" groups the session under the sidebar's folded extension entry and gives it a dedicated cleanup pool; extensions cannot claim other sources.
%w[manual ext].freeze
Instance Attribute Summary collapse
-
#params ⇒ Object
readonly
Returns the value of attribute params.
-
#req ⇒ Object
readonly
Returns the value of attribute req.
-
#res ⇒ Object
readonly
Returns the value of attribute res.
-
#route ⇒ Object
readonly
Returns the value of attribute route.
Class Method Summary collapse
- .class_timeout ⇒ Object
- .compile_pattern(pattern) ⇒ Object
- .ext_dir ⇒ Object
- .ext_dir=(value) ⇒ Object
- .ext_id ⇒ Object
- .ext_id=(value) ⇒ Object
- .inherited(subclass) ⇒ Object
- .meta ⇒ Object
- .meta=(value) ⇒ Object
- .normalize_pattern(pattern) ⇒ Object
-
.pending_subclasses ⇒ Object
Captures every subclass at the moment its
classbody finishes being required — the loader pops the most recent one off this list to bind an ext_id/dir without relying on ObjectSpace scans. -
.public_endpoint(pattern) ⇒ Object
Mark a route as not requiring access-key auth.
- .public_paths ⇒ Object
- .register(ext_id, klass) ⇒ Object
-
.registry ⇒ Object
Keyed by ext_id — every extension contributes at most one API unit (declared as
contributes.api: <path>in ext.yml). - .reset_registry! ⇒ Object
-
.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. -
.routes ⇒ Object
Per-subclass state — inherited classes carry their own routes/options.
-
.timeout(seconds) ⇒ Object
Set a default timeout (seconds) for every handler in this class.
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, project_id: 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
- #project_manager ⇒ 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.
178 179 180 181 182 183 184 |
# File 'lib/clacky/extension/api_extension.rb', line 178 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 (readonly)
Returns the value of attribute params.
176 177 178 |
# File 'lib/clacky/extension/api_extension.rb', line 176 def params @params end |
#req ⇒ Object (readonly)
Returns the value of attribute req.
176 177 178 |
# File 'lib/clacky/extension/api_extension.rb', line 176 def req @req end |
#res ⇒ Object (readonly)
Returns the value of attribute res.
176 177 178 |
# File 'lib/clacky/extension/api_extension.rb', line 176 def res @res end |
#route ⇒ Object (readonly)
Returns the value of attribute route.
176 177 178 |
# File 'lib/clacky/extension/api_extension.rb', line 176 def route @route end |
Class Method Details
.class_timeout ⇒ Object
81 82 83 |
# File 'lib/clacky/extension/api_extension.rb', line 81 def class_timeout @class_timeout end |
.compile_pattern(pattern) ⇒ Object
166 167 168 169 170 171 172 173 |
# File 'lib/clacky/extension/api_extension.rb', line 166 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
105 106 107 |
# File 'lib/clacky/extension/api_extension.rb', line 105 def ext_dir @ext_dir end |
.ext_dir=(value) ⇒ Object
109 110 111 |
# File 'lib/clacky/extension/api_extension.rb', line 109 def ext_dir=(value) @ext_dir = value end |
.ext_id ⇒ Object
97 98 99 |
# File 'lib/clacky/extension/api_extension.rb', line 97 def ext_id @ext_id end |
.ext_id=(value) ⇒ Object
101 102 103 |
# File 'lib/clacky/extension/api_extension.rb', line 101 def ext_id=(value) @ext_id = value end |
.inherited(subclass) ⇒ Object
71 72 73 74 |
# File 'lib/clacky/extension/api_extension.rb', line 71 def inherited(subclass) super Clacky::ApiExtension.pending_subclasses << subclass end |
.meta ⇒ Object
113 114 115 |
# File 'lib/clacky/extension/api_extension.rb', line 113 def @meta ||= {} end |
.meta=(value) ⇒ Object
117 118 119 |
# File 'lib/clacky/extension/api_extension.rb', line 117 def (value) @meta = value || {} end |
.normalize_pattern(pattern) ⇒ Object
159 160 161 162 163 164 |
# File 'lib/clacky/extension/api_extension.rb', line 159 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.
67 68 69 |
# File 'lib/clacky/extension/api_extension.rb', line 67 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.
132 133 134 |
# File 'lib/clacky/extension/api_extension.rb', line 132 def public_endpoint(pattern) public_paths << normalize_pattern(pattern) end |
.public_paths ⇒ Object
85 86 87 |
# File 'lib/clacky/extension/api_extension.rb', line 85 def public_paths @public_paths ||= [] end |
.register(ext_id, klass) ⇒ Object
55 56 57 |
# File 'lib/clacky/extension/api_extension.rb', line 55 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).
51 52 53 |
# File 'lib/clacky/extension/api_extension.rb', line 51 def registry @registry ||= {} end |
.reset_registry! ⇒ Object
59 60 61 62 |
# File 'lib/clacky/extension/api_extension.rb', line 59 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.
92 93 94 95 |
# File 'lib/clacky/extension/api_extension.rb', line 92 def reset_routes! @routes = [] @public_paths = [] end |
.routes ⇒ Object
Per-subclass state — inherited classes carry their own routes/options.
77 78 79 |
# File 'lib/clacky/extension/api_extension.rb', line 77 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.
123 124 125 126 127 128 |
# File 'lib/clacky/extension/api_extension.rb', line 123 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
257 258 259 |
# File 'lib/clacky/extension/api_extension.rb', line 257 def agent_config @http_server&.instance_variable_get(:@agent_config) end |
#config ⇒ Object
249 250 251 |
# File 'lib/clacky/extension/api_extension.rb', line 249 def config self.class.["config"] || {} end |
#create_session(name: nil, prompt: nil, working_dir: nil, profile: "general", source: :manual, display_message: nil, project_id: 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.
When project_id is given, the project's working_dir is inherited
(unless an explicit working_dir overrides it) and the session is
associated with the project.
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 |
# File 'lib/clacky/extension/api_extension.rb', line 276 def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general", source: :manual, display_message: nil, project_id: nil) error!("server not ready", status: 503) unless @http_server src = source.to_s unless ALLOWED_SESSION_SOURCES.include?(src) error!("invalid source '#{src}': allowed values are #{ALLOWED_SESSION_SOURCES.join(", ")}", status: 400) end if project_id project_id = project_id.to_s.strip project_id = nil if project_id.empty? end project = project_id ? project_manager&.find(project_id) : nil error!("Project not found", status: 404) if project_id && project.nil? working_dir = File.(project[:working_dir]) if working_dir.nil? && project && project[:working_dir].to_s.strip != "" session_id = @http_server.send( :build_session, name: name, working_dir: working_dir, profile: profile, source: source ) if project_id agent = nil registry.with_session(session_id) { |s| agent = s[:agent] } if agent agent.project_id = project_id session_manager&.save(agent.to_session_data) end end submit_task(session_id, prompt, display_message: ) if prompt && !prompt.strip.empty? @http_server.send(:broadcast_session_update, session_id) session_id end |
#data_path(*parts) ⇒ Object
236 237 238 239 240 |
# File 'lib/clacky/extension/api_extension.rb', line 236 def data_path(*parts) base = Clacky::ExtensionLoader.data_dir_for(self.class.ext_id) 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".
363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/clacky/extension/api_extension.rb', line 363 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
217 218 219 220 221 |
# File 'lib/clacky/extension/api_extension.rb', line 217 def error!(, status: 400, **extra) payload = { error: .to_s } payload.merge!(extra) unless extra.empty? raise Halt.new(status, JSON.generate(payload), "application/json; charset=utf-8") end |
#ext_dir ⇒ Object
241 242 243 |
# File 'lib/clacky/extension/api_extension.rb', line 241 def ext_dir self.class.ext_dir end |
#ext_id ⇒ Object
245 246 247 |
# File 'lib/clacky/extension/api_extension.rb', line 245 def ext_id self.class.ext_id end |
#invoke ⇒ Object
186 187 188 |
# File 'lib/clacky/extension/api_extension.rb', line 186 def invoke instance_exec(&route.block) end |
#json(*args, **kwargs) ⇒ Object
---- handler context (white-listed access to host process) ----
192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/clacky/extension/api_extension.rb', line 192 def json(*args, **kwargs) if args.empty? # Treat kwargs as the body: json(foo: 1, bar: 2) # For non-200 status, pass an explicit hash: json({foo: 1}, status: 422) 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
223 224 225 226 227 228 229 230 |
# File 'lib/clacky/extension/api_extension.rb', line 223 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
387 388 389 |
# File 'lib/clacky/extension/api_extension.rb', line 387 def logger @logger ||= ScopedLogger.new(self.class.ext_id) end |
#project_manager ⇒ Object
265 266 267 |
# File 'lib/clacky/extension/api_extension.rb', line 265 def project_manager @http_server&.instance_variable_get(:@project_manager) end |
#query ⇒ Object
232 233 234 |
# File 'lib/clacky/extension/api_extension.rb', line 232 def query @query ||= req.query || {} end |
#registry ⇒ Object
261 262 263 |
# File 'lib/clacky/extension/api_extension.rb', line 261 def registry @http_server&.instance_variable_get(:@registry) end |
#send_data(bytes, content_type:, filename: nil, status: 200) ⇒ Object
209 210 211 212 213 214 215 |
# File 'lib/clacky/extension/api_extension.rb', line 209 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
383 384 385 |
# File 'lib/clacky/extension/api_extension.rb', line 383 def server_start_time @http_server&.instance_variable_get(:@start_time) end |
#session_manager ⇒ Object
253 254 255 |
# File 'lib/clacky/extension/api_extension.rb', line 253 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.
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 |
# File 'lib/clacky/extension/api_extension.rb', line 324 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: ) session_id end |