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.



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

#paramsObject (readonly)

Returns the value of attribute params.



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

def params
  @params
end

#reqObject (readonly)

Returns the value of attribute req.



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

def req
  @req
end

#resObject (readonly)

Returns the value of attribute res.



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

def res
  @res
end

#routeObject (readonly)

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_timeoutObject



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_dirObject



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_idObject



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



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

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

.metaObject



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

def meta
  @meta ||= {}
end

.meta=(value) ⇒ Object



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



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_pathsObject



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

.registryObject

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

.routesObject

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.

Raises:

  • (ArgumentError)


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_configObject



245
246
247
# File 'lib/clacky/extension/api_extension.rb', line 245

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

#configObject



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

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



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

Raises:



204
205
206
207
208
# File 'lib/clacky/extension/api_extension.rb', line 204

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



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

def ext_dir
  self.class.ext_dir
end

#ext_idObject



233
234
235
# File 'lib/clacky/extension/api_extension.rb', line 233

def ext_id
  self.class.ext_id
end

#invokeObject



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



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

#loggerObject



342
343
344
# File 'lib/clacky/extension/api_extension.rb', line 342

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

#queryObject



219
220
221
# File 'lib/clacky/extension/api_extension.rb', line 219

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

#registryObject



249
250
251
# File 'lib/clacky/extension/api_extension.rb', line 249

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

#server_start_timeObject



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_managerObject



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

Raises:



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