Class: Clacky::ApiExtension

Inherits:
Object
  • Object
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

Direct Known Subclasses

ExtStudioExt, MeetingExt

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

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

#paramsObject (readonly)

Returns the value of attribute params.



172
173
174
# File 'lib/clacky/extension/api_extension.rb', line 172

def params
  @params
end

#reqObject (readonly)

Returns the value of attribute req.



172
173
174
# File 'lib/clacky/extension/api_extension.rb', line 172

def req
  @req
end

#resObject (readonly)

Returns the value of attribute res.



172
173
174
# File 'lib/clacky/extension/api_extension.rb', line 172

def res
  @res
end

#routeObject (readonly)

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_timeoutObject



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_dirObject



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_idObject



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



67
68
69
70
# File 'lib/clacky/extension/api_extension.rb', line 67

def inherited(subclass)
  super
  Clacky::ApiExtension.pending_subclasses << subclass
end

.metaObject



109
110
111
# File 'lib/clacky/extension/api_extension.rb', line 109

def meta
  @meta ||= {}
end

.meta=(value) ⇒ Object



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_subclassesObject

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_pathsObject



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

.registryObject

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

.routesObject

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.

Raises:

  • (ArgumentError)


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_configObject



254
255
256
# File 'lib/clacky/extension/api_extension.rb', line 254

def agent_config
  @http_server&.instance_variable_get(:@agent_config)
end

#configObject



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".

Parameters:

  • session_id (String)
  • prompt (String)
  • model (String, nil) (defaults to: nil)

    "lite" for the lite companion, nil = current

  • forbidden_tools (Array<String>) (defaults to: [])

    tool names blocked in the fork

Returns:

  • (Hash)

    { text: "..." } on success, or { busy: true } when skipped



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

Raises:



213
214
215
216
217
# File 'lib/clacky/extension/api_extension.rb', line 213

def error!(message, status: 400, **extra)
  payload = { error: message.to_s }
  payload.merge!(extra) unless extra.empty?
  raise Halt.new(status, JSON.generate(payload), "application/json; charset=utf-8")
end

#ext_dirObject



238
239
240
# File 'lib/clacky/extension/api_extension.rb', line 238

def ext_dir
  self.class.ext_dir
end

#ext_idObject



242
243
244
# File 'lib/clacky/extension/api_extension.rb', line 242

def ext_id
  self.class.ext_id
end

#invokeObject



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?
    # 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_bodyObject



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

#loggerObject



351
352
353
# File 'lib/clacky/extension/api_extension.rb', line 351

def logger
  @logger ||= ScopedLogger.new(self.class.ext_id)
end

#queryObject



228
229
230
# File 'lib/clacky/extension/api_extension.rb', line 228

def query
  @query ||= req.query || {}
end

#registryObject



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

Raises:



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_timeObject



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_managerObject



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

Raises:



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