coolhand 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,9 @@ module Coolhand
16
16
 
17
17
  case batch_info["status"]
18
18
  when "completed"
19
- process_completed_batch
19
+ # Downloads two files and POSTs one log per item — run it on the background log queue so
20
+ # the webhook response isn't held up for the whole batch.
21
+ LogQueue.submit(lane: :batch) { process_completed_batch_logging_errors }
20
22
  when "failed", "expired", "cancelled"
21
23
  handle_failed_batch
22
24
  when "in_progress", "validating", "finalizing"
@@ -30,6 +32,14 @@ module Coolhand
30
32
 
31
33
  private
32
34
 
35
+ # Runs on the batch lane's worker thread, where an escaping error would only reach
36
+ # Coolhand.log (silenceable) rather than the Rails log `call` reports to.
37
+ def process_completed_batch_logging_errors
38
+ process_completed_batch
39
+ rescue StandardError => e
40
+ Rails.logger.error("[Interceptor] Failed to process OpenAI batch results for #{event_data}: #{e.message}")
41
+ end
42
+
33
43
  def process_completed_batch
34
44
  input_file_id = batch_info["input_file_id"]
35
45
  return unless input_file_id
@@ -43,8 +53,12 @@ module Coolhand
43
53
  # Download and process results
44
54
  batch_response_items = download_batch_results(output_file_id)
45
55
 
56
+ request_items_by_id = batch_request_items.each_with_object({}) do |item, index|
57
+ index[item["custom_id"]] ||= item
58
+ end
59
+
46
60
  batch_response_items.each do |response_item|
47
- request_item = batch_request_items.detect { |item| item["custom_id"] == response_item["custom_id"] }
61
+ request_item = request_items_by_id[response_item["custom_id"]]
48
62
 
49
63
  next unless request_item
50
64
 
@@ -89,33 +103,20 @@ module Coolhand
89
103
 
90
104
  def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
91
105
  start_time:, end_time:)
92
- timestamp = Time.at(start_time).iso8601
93
- completed_at = Time.at(end_time).iso8601
94
- duration_ms = ((end_time - start_time) * 1000).to_i
95
-
96
- request_data = {
97
- raw_request: {
98
- id: request_id,
99
- timestamp: timestamp,
100
- method: method.to_s.downcase,
101
- url: BaseInterceptor.sanitize_url(url),
102
- headers: {},
103
- request_body: request_body,
104
- response_headers: {},
105
- response_body: response_body,
106
- status_code: status_code,
107
- duration_ms: duration_ms,
108
- completed_at: completed_at,
109
- is_streaming: false
110
- }
111
- }
112
-
113
- api_service = Coolhand::ApiService.new
114
- api_service.send_llm_request_log(request_data)
115
-
116
- Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
117
- rescue StandardError => e
118
- Coolhand.log "❌ Error sending complete request log: #{e.message}"
106
+ BaseInterceptor.send_complete_request_log(
107
+ request_id: request_id,
108
+ method: method,
109
+ url: url,
110
+ request_headers: {},
111
+ request_body: request_body,
112
+ response_headers: {},
113
+ response_body: response_body,
114
+ status_code: status_code,
115
+ start_time: Time.at(start_time),
116
+ end_time: Time.at(end_time),
117
+ duration_ms: ((end_time - start_time) * 1000).to_i,
118
+ is_streaming: false
119
+ )
119
120
  end
120
121
 
121
122
  def batch_info
@@ -7,6 +7,9 @@ require_relative "webhook_id_store"
7
7
  module Coolhand
8
8
  module OpenAi
9
9
  class WebhookValidator
10
+ SIGNATURE_VERSION_PREFIX = "v1,"
11
+ private_constant :SIGNATURE_VERSION_PREFIX
12
+
10
13
  attr_reader :request, :errors, :payload
11
14
 
12
15
  def initialize(request, webhook_secret)
@@ -41,27 +44,25 @@ module Coolhand
41
44
  def payload_valid?
42
45
  return true if @payload
43
46
 
44
- if should_enforce_strict_validation?
45
- @errors << "Empty webhook payload - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
46
- "not in development/test allowlist)"
47
- Rails.logger.error(@errors.last)
48
- false
49
- else
50
- Rails.logger.warn("Empty webhook payload - allowing in #{Rails.env} environment")
51
- true
52
- end
47
+ enforce_or_allow("Empty webhook payload",
48
+ "Empty webhook payload - allowing in #{Rails.env} environment")
53
49
  end
54
50
 
55
51
  def validate_in_non_production_env
52
+ enforce_or_allow("OpenAI webhook secret not configured",
53
+ "Webhook Secret is not configured - skipping signature verification in #{Rails.env}")
54
+ end
55
+
56
+ # Outside the development/test allowlist the webhook is rejected with `problem`; inside it the
57
+ # check is skipped with a warning.
58
+ def enforce_or_allow(problem, allow_warning)
56
59
  if should_enforce_strict_validation?
57
- @errors << "OpenAI webhook secret not configured - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
60
+ @errors << "#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
58
61
  "not in development/test allowlist)"
59
62
  Rails.logger.error(@errors.last)
60
63
  false
61
64
  else
62
- Rails.logger.warn(
63
- "Webhook Secret is not configured - skipping signature verification in #{Rails.env}"
64
- )
65
+ Rails.logger.warn(allow_warning)
65
66
  true
66
67
  end
67
68
  end
@@ -99,8 +100,7 @@ module Coolhand
99
100
  expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
100
101
 
101
102
  # An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
102
- signature_valid = !secret_bytes.empty? && webhook_signature.start_with?("v1,") &&
103
- secure_compare(webhook_signature[3..], expected_signature)
103
+ signature_valid = !secret_bytes.empty? && signature_matches?(webhook_signature, expected_signature)
104
104
 
105
105
  unless signature_valid
106
106
  @errors << "OpenAI webhook signature verification failed"
@@ -114,6 +114,18 @@ module Coolhand
114
114
  true
115
115
  end
116
116
 
117
+ # Standard Webhooks allows several space-separated signatures (e.g. during secret rotation);
118
+ # accept the request if any v1 entry matches. Every candidate is compared (`map`, not an
119
+ # `any?` block) so there is no early exit on the first match.
120
+ # Split as binary so a header that isn't valid UTF-8 is rejected rather than raising.
121
+ def signature_matches?(webhook_signature, expected_signature)
122
+ candidates = webhook_signature.b.split.select { |entry| entry.start_with?(SIGNATURE_VERSION_PREFIX) }
123
+ results = candidates.map do |entry|
124
+ secure_compare(entry.delete_prefix(SIGNATURE_VERSION_PREFIX), expected_signature)
125
+ end
126
+ results.any?
127
+ end
128
+
117
129
  # Checked only after the signature is confirmed valid, so an
118
130
  # unsigned/forged request can't poison the id-dedup store (or fail a
119
131
  # freshness check) and DoS a later legitimate webhook with the same id.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "api_service"
5
+ require_relative "write_requests"
6
+
7
+ module Coolhand
8
+ # Requires the client's **private** key; the public key gets a 401. Unlike the logging writes,
9
+ # every method raises {Coolhand::Error} / {Coolhand::HttpError} on failure. See docs/feedback-links.md.
10
+ class OptimizationFeedbackLinkService < ApiService
11
+ include WriteRequests
12
+
13
+ ERROR_NOUN = "Feedback link"
14
+ # The server rejects more ids than this per request with a 422.
15
+ BULK_BATCH_SIZE = 100
16
+
17
+ def initialize
18
+ super("v2/optimizations")
19
+ end
20
+
21
+ # Returns `{ id:, optimization_id:, feedback_id:, note:, created_at: }`; `id` is the link's hashid.
22
+ def link_feedback(optimization_id, feedback_id, note: nil)
23
+ validate_id!("link_feedback", "feedback_id", feedback_id)
24
+ url = links_url("link_feedback", optimization_id)
25
+ ensure_object!(request_json(:post, url, ERROR_NOUN, body: { feedback_id: feedback_id.strip, note: note }.compact))
26
+ end
27
+
28
+ # Lists over 100 ids are sent in batches; a failing batch raises and earlier ones stay applied,
29
+ # so repeating the call is safe. Returns `{ linked:, already_linked:, errored:, not_found: }` summed
30
+ # across batches.
31
+ def bulk_link_feedback(optimization_id, feedback_ids, note: nil)
32
+ unless feedback_ids.is_a?(Array) && !feedback_ids.empty?
33
+ raise Error, "bulk_link_feedback: feedback_ids must be a non-empty array of hashids"
34
+ end
35
+
36
+ feedback_ids.each { |id| validate_id!("bulk_link_feedback", "feedback_ids entries", id) }
37
+ feedback_ids = feedback_ids.map(&:strip)
38
+ url = links_url("bulk_link_feedback", optimization_id)
39
+
40
+ totals = { linked: 0, already_linked: 0, errored: 0, not_found: [] }
41
+ feedback_ids.each_slice(BULK_BATCH_SIZE) do |batch|
42
+ result = request_json(:post, url, ERROR_NOUN, body: { feedback_ids: batch, note: note }.compact)
43
+ merge_bulk_result!(totals, result)
44
+ end
45
+ totals
46
+ end
47
+
48
+ # `link_id` is the `id` returned by {#link_feedback}, not the feedback's. Returns true.
49
+ def unlink_feedback(optimization_id, link_id)
50
+ validate_id!("unlink_feedback", "link_id", link_id)
51
+ url = URI.parse("#{links_url('unlink_feedback', optimization_id)}/#{escape_path_segment(link_id.strip)}")
52
+ request_json(:delete, url, ERROR_NOUN)
53
+ true
54
+ end
55
+
56
+ private
57
+
58
+ def merge_bulk_result!(totals, result)
59
+ ensure_object!(result)
60
+
61
+ %i[linked already_linked errored].each { |key| totals[key] += result[key].to_i }
62
+ totals[:not_found].concat(Array(result[:not_found]))
63
+ end
64
+
65
+ def ensure_object!(result)
66
+ raise Error, "#{ERROR_NOUN} response was not a JSON object" unless result.is_a?(Hash)
67
+
68
+ result
69
+ end
70
+
71
+ def links_url(method_name, optimization_id)
72
+ validate_id!(method_name, "optimization_id", optimization_id)
73
+ URI.parse("#{api_endpoint}/#{escape_path_segment(optimization_id.strip)}/feedback_links")
74
+ end
75
+
76
+ # A blank id would resolve to a different route and a bare dot segment would retarget the
77
+ # request; neither 404s, so both are rejected before any request is made.
78
+ def validate_id!(method_name, label, value)
79
+ trimmed = value.is_a?(String) ? value.strip : ""
80
+ return unless trimmed.empty? || [".", ".."].include?(trimmed)
81
+
82
+ raise Error, "#{method_name}: #{label} must be a non-empty hashid"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Opt-in (`config.auto_declare`) declaration of the workload and template for LLM calls made from a
5
+ # Rails job or controller action, keyed "JobClass#perform" / "ControllerClass#action". Installed from
6
+ # {Railtie} once the app has configured Coolhand. Kept free of any Rails require: it only touches
7
+ # ActiveSupport and ActiveJob when a target is enabled, and only through their own load hooks.
8
+ module RailsAutoDeclare
9
+ REPORTER = "rails"
10
+ TARGETS = %i[active_job controller].freeze
11
+
12
+ @installed = []
13
+
14
+ module_function
15
+
16
+ def install(targets = Coolhand.configuration.auto_declare)
17
+ TARGETS.each do |target|
18
+ next unless targets.include?(target) && !@installed.include?(target)
19
+
20
+ @installed << target
21
+ send(:"hook_#{target}")
22
+ end
23
+ end
24
+
25
+ def hook_active_job
26
+ ActiveSupport.on_load(:active_job) do
27
+ around_perform do |job, block|
28
+ Coolhand::RailsAutoDeclare.declare(job.class, "perform", &block)
29
+ end
30
+ end
31
+ end
32
+
33
+ def hook_controller
34
+ ActiveSupport.on_load(:action_controller) do
35
+ around_action do |controller, block|
36
+ Coolhand::RailsAutoDeclare.declare(controller.class, controller.action_name, &block)
37
+ end
38
+ end
39
+ end
40
+
41
+ # Runs the block inside a declared context. Anonymous classes have no stable key, so they are left
42
+ # undeclared rather than named after an object id.
43
+ def declare(klass, method_name, &block)
44
+ key = "#{klass.name}##{method_name}"
45
+ return yield if klass.name.nil? || !Coolhand.configuration.enabled || !DeclaredContext.valid_key?(key)
46
+
47
+ Coolhand.with_context(reporter: REPORTER, workload: { key: key }, template: { key: key }, &block)
48
+ end
49
+
50
+ # Testing-only: forget which targets were hooked.
51
+ def reset!
52
+ @installed.clear
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Loaded by lib/coolhand.rb only when Rails is present, so the gem keeps no Rails dependency.
5
+ class Railtie < ::Rails::Railtie
6
+ # After the app's initializers, so `config.auto_declare` has been set by `Coolhand.configure`.
7
+ config.after_initialize do
8
+ Coolhand::RailsAutoDeclare.install
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "uri"
6
+ require_relative "errors"
7
+ require_relative "pagination"
8
+
9
+ module Coolhand
10
+ # URL building shared by the read services. Everything goes through `URI.encode_www_form`, so a
11
+ # `+` UTC offset reaches the server as `%2B` instead of being read as a space.
12
+ module ReadQuery
13
+ protected
14
+
15
+ def list_url(query)
16
+ uri = URI.parse(api_endpoint)
17
+ uri.query = URI.encode_www_form(query) unless query.empty?
18
+ uri
19
+ end
20
+
21
+ # GETs a list endpoint and wraps the JSON array and its pagination headers in `result_class`,
22
+ # keyed by `rows_key` (the field the result struct names its rows by).
23
+ def fetch_list(query, result_class, rows_key, page:, per:)
24
+ noun = self.class::ERROR_NOUN
25
+ rows, response = get_json_with_headers(list_url(query), noun)
26
+ raise Error, "#{noun} response was not a JSON array" unless rows.is_a?(Array)
27
+
28
+ result_class.new(
29
+ rows_key => rows,
30
+ pagination: Pagination.from_headers(response, items: rows, page: page, per: per)
31
+ ).freeze
32
+ end
33
+
34
+ def resource_url(id, blank_message, query = {})
35
+ raise Error, blank_message unless id.is_a?(String)
36
+
37
+ trimmed = id.strip
38
+ # A blank id resolves to the index route (bare array, not one record); a bare dot segment
39
+ # retargets the request at another path. Neither would 404, so both are rejected here.
40
+ raise Error, blank_message if trimmed.empty? || [".", ".."].include?(trimmed)
41
+
42
+ uri = URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
43
+ uri.query = URI.encode_www_form(query) unless query.empty?
44
+ uri
45
+ end
46
+
47
+ # `until` is a reserved word, so a method taking an `until:` keyword cannot read it by name;
48
+ # callers pass `binding.local_variable_get(:until)`.
49
+ def window_params(since, until_)
50
+ { since: time_param(:since, since), until: time_param(:until, until_) }
51
+ end
52
+
53
+ # Accepts Time, DateTime, Date or an ISO8601 String. Strings go through untouched so the server
54
+ # stays the one validator. Raises ArgumentError before any request for anything else.
55
+ def time_param(name, value)
56
+ case value
57
+ when nil, String then value
58
+ when DateTime then utc_iso8601(value.to_time)
59
+ when Time then utc_iso8601(value)
60
+ when Date then value.iso8601
61
+ else
62
+ raise ArgumentError, "#{name} must be a Time, DateTime, Date or ISO8601 String, got #{value.class}"
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ # Whole seconds keep the plain form; a fractional instant keeps its microseconds so a window bound
69
+ # is not silently truncated.
70
+ def utc_iso8601(time)
71
+ utc = time.getutc
72
+ utc.subsec.zero? ? utc.iso8601 : utc.iso8601(6)
73
+ end
74
+ end
75
+ end
@@ -6,9 +6,10 @@ require "json"
6
6
  require_relative "errors"
7
7
 
8
8
  module Coolhand
9
- # The GET half of {ApiService}, split out to keep that class inside this repo's 200-line budget.
9
+ # The GET half of {ApiService}, plus the HTTP helpers {WriteRequests} shares, split out to keep
10
+ # that class inside this repo's 200-line budget.
10
11
  #
11
- # Reads raise where writes log-and-return-nil, on purpose: a write is instrumentation inline in
12
+ # Reads raise where the logging writes log-and-return-nil, on purpose: a write is instrumentation inline in
12
13
  # the host app's request, while a read's caller must tell a 404 from a timeout from an empty result.
13
14
  module ReadRequests
14
15
  # 60s is deliberate, not a slip: writes allow 5, but the server bounds each *statement* at 10s
@@ -32,13 +33,7 @@ module Coolhand
32
33
 
33
34
  response = perform_get(url, noun)
34
35
 
35
- unless response.is_a?(Net::HTTPSuccess)
36
- raise HttpError.new(
37
- "#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
38
- status: response.code.to_i,
39
- body: retained_error_body(response.body)
40
- )
41
- end
36
+ raise_http_error(response, noun) unless response.is_a?(Net::HTTPSuccess)
42
37
 
43
38
  [parse_json_body(response.body, noun), response]
44
39
  end
@@ -47,10 +42,7 @@ module Coolhand
47
42
 
48
43
  def perform_get(url, noun)
49
44
  uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
50
- http = Net::HTTP.new(uri.host, uri.port)
51
- http.use_ssl = (uri.scheme == "https")
52
- http.open_timeout = READ_OPEN_TIMEOUT
53
- http.read_timeout = READ_TIMEOUT
45
+ http = build_http(uri)
54
46
 
55
47
  request = Net::HTTP::Get.new(uri.request_uri)
56
48
  apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
@@ -62,6 +54,27 @@ module Coolhand
62
54
  raise Error, "#{noun} request failed: #{e.message}"
63
55
  end
64
56
 
57
+ def build_http(uri)
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.use_ssl = (uri.scheme == "https")
60
+ http.open_timeout = READ_OPEN_TIMEOUT
61
+ http.read_timeout = READ_TIMEOUT
62
+ http
63
+ end
64
+
65
+ def raise_http_error(response, noun)
66
+ raise HttpError.new(
67
+ "#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
68
+ status: response.code.to_i,
69
+ body: retained_error_body(response.body)
70
+ )
71
+ end
72
+
73
+ # Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
74
+ def escape_path_segment(value)
75
+ URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
76
+ end
77
+
65
78
  def retained_error_body(body)
66
79
  return body if body.nil? || body.length <= RETAINED_ERROR_BODY_LIMIT
67
80
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Prints the `warnings` a Coolhand log response carries, for {ApiService}.
5
+ #
6
+ # Warnings come from the response body and are written to the host app's stdout, so they are
7
+ # untrusted: each is coerced to a string, stripped of control characters and length-capped, and
8
+ # the number printed per response is bounded.
9
+ module ResponseWarnings
10
+ MAX_WARNING_LENGTH = 500
11
+ MAX_WARNINGS = 5
12
+
13
+ protected
14
+
15
+ # The server ingests a log normally but ignores a declared workload/template it cannot honour
16
+ # (e.g. over the public-key cap), and says so here.
17
+ def log_response_warnings(result)
18
+ warnings = result.is_a?(Hash) ? Array(result[:warnings]) : []
19
+ warnings.first(MAX_WARNINGS).each { |warning| log "⚠️ Coolhand: #{sanitize_warning(warning)}" }
20
+
21
+ omitted = warnings.size - MAX_WARNINGS
22
+ log "⚠️ Coolhand: ... and #{omitted} more warning(s) omitted" if omitted.positive?
23
+ end
24
+
25
+ private
26
+
27
+ # Control characters and line/paragraph separators are replaced with a space, not dropped: a
28
+ # newline would forge a log line and ESC starts a terminal escape sequence. Unicode format
29
+ # characters (bidi overrides, zero-width) are invisible and can reorder or hide text in a
30
+ # terminal, so they are removed outright.
31
+ def sanitize_warning(warning)
32
+ text = warning.to_s.dup.force_encoding("UTF-8").scrub
33
+ text = text.gsub(/\p{Cf}+/, "").gsub(/[[:cntrl:]\p{Zl}\p{Zp}]+/, " ").strip
34
+ return text if text.length <= MAX_WARNING_LENGTH
35
+
36
+ "#{text[0, MAX_WARNING_LENGTH]}... [truncated]"
37
+ end
38
+ end
39
+ end
@@ -1,19 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
3
  require_relative "api_service"
5
4
  require_relative "pagination"
5
+ require_relative "read_query"
6
+ require_relative "write_requests"
6
7
 
7
8
  module Coolhand
8
9
  # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
10
  TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
10
11
 
11
- # Read-only. Requires the client's **private** key — the public key is write-only here and is
12
- # rejected like an invalid one.
12
+ # Reads templates and upserts them by key. Requires the client's **private** key — the public key is
13
+ # write-only here and is rejected like an invalid one.
13
14
  #
14
15
  # Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
15
- # See docs/template-search.md.
16
+ # See docs/template-search.md and docs/declared_workloads.md.
16
17
  class TemplateService < ApiService
18
+ include ReadQuery
19
+ include WriteRequests
20
+
17
21
  ERROR_NOUN = "Template"
18
22
  BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
19
23
 
@@ -23,54 +27,50 @@ module Coolhand
23
27
 
24
28
  # Filters, error semantics and server behaviour: docs/template-search.md.
25
29
  def search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil,
26
- include_system: nil, page: nil, per: nil)
30
+ include_system: nil, include_metrics: nil, days_back: nil, since: nil, until: nil, page: nil, per: nil)
27
31
  query = {
28
32
  search: search,
29
33
  workload_id: workload_id,
30
34
  status: status,
31
35
  include_deprecated: include_deprecated,
32
36
  include_system: include_system,
37
+ include_metrics: include_metrics,
38
+ days_back: days_back,
39
+ **window_params(since, binding.local_variable_get(:until)),
33
40
  page: page,
34
41
  per: per
35
42
  }.compact
36
43
 
37
- templates, response = get_json_with_headers(list_url(query), ERROR_NOUN)
38
- raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
39
-
40
- TemplateSearchResult.new(
41
- templates: templates,
42
- pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
43
- ).freeze
44
+ fetch_list(query, TemplateSearchResult, :templates, page: page, per: per)
44
45
  end
45
46
 
46
47
  # Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
47
- # list reaches deprecated and system templates by id with no opt-in flag.
48
- def get_template(id)
49
- get_json(resource_url(id), ERROR_NOUN)
48
+ # list reaches deprecated and system templates by id with no opt-in flag. Carries `metrics`
49
+ # unless `include_metrics: false`, which also skips the window validation.
50
+ def get_template(id, include_metrics: nil, days_back: nil, since: nil, until: nil)
51
+ query = { include_metrics: include_metrics, days_back: days_back,
52
+ **window_params(since, binding.local_variable_get(:until)) }.compact
53
+ get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
50
54
  end
51
55
 
52
- private
53
-
54
- def list_url(query)
55
- uri = URI.parse(api_endpoint)
56
- uri.query = URI.encode_www_form(query) unless query.empty?
57
- uri
56
+ # Creates the template with this `key`, or updates the existing one. The key is immutable; a new
57
+ # `name` renames the display name. `workload` (`{ id: }` or `{ key: }`, the latter find-or-created)
58
+ # is needed only when the template is new; an existing one keeps its workload. Returns the template
59
+ # Hash. Raises {Coolhand::HttpError} on a non-2xx response. See docs/declared_workloads.md.
60
+ def upsert(key:, workload: nil, name: nil, reporter: nil)
61
+ body = { name: name, reporter: reporter, **workload_params(workload) }.compact
62
+ request_json(:put, by_key_url("upsert", key), ERROR_NOUN, body: body)
58
63
  end
59
64
 
60
- def resource_url(id)
61
- raise Error, BLANK_ID_MESSAGE unless id.is_a?(String)
62
-
63
- trimmed = id.strip
64
- # A blank id resolves to the index route (bare array, not one template); a bare dot segment
65
- # retargets the request at another path. Neither would 404, so both are rejected here.
66
- raise Error, BLANK_ID_MESSAGE if trimmed.empty? || [".", ".."].include?(trimmed)
65
+ private
67
66
 
68
- URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
69
- end
67
+ def workload_params(workload)
68
+ return {} if workload.nil?
69
+ unless workload.is_a?(Hash)
70
+ raise ArgumentError, "upsert: workload must be a Hash with :id or :key, got #{workload.class}"
71
+ end
70
72
 
71
- # Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
72
- def escape_path_segment(value)
73
- URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
73
+ { workload_id: workload[:id] || workload["id"], workload_key: workload[:key] || workload["key"] }
74
74
  end
75
75
  end
76
76
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -31,7 +31,7 @@ module Coolhand
31
31
  when "JOB_STATE_PENDING", "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
32
32
  Rails.logger.info("[Interceptor] Vertex batch #{batch_info} still processing")
33
33
  when "JOB_STATE_SUCCEEDED"
34
- process_completed_batch(batch_results)
34
+ LogQueue.submit(lane: :batch) { process_completed_batch_logging_errors(batch_results) }
35
35
  when "JOB_STATE_FAILED"
36
36
  handle_failed_batch
37
37
  else
@@ -43,6 +43,14 @@ module Coolhand
43
43
 
44
44
  private
45
45
 
46
+ # Runs on the batch lane's worker thread, where an escaping error would only reach
47
+ # Coolhand.log (silenceable) rather than the Rails log `call` reports to.
48
+ def process_completed_batch_logging_errors(batch_results)
49
+ process_completed_batch(batch_results)
50
+ rescue StandardError => e
51
+ Rails.logger.error("[Interceptor] Failed to process Vertex batch results for #{batch_info}: #{e.message}")
52
+ end
53
+
46
54
  def process_completed_batch(batch_results)
47
55
  name = batch_info["name"].to_s.sub(%r{\A/+}, "")
48
56
  unless name.match?(VALID_NAME_PATTERN)