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.
- checksums.yaml +4 -4
- data/AGENTS.harness.md +49 -14
- data/CHANGELOG.md +42 -1
- data/README.md +60 -2
- data/Rakefile +7 -0
- data/docs/configuration.md +19 -0
- data/docs/declared_workloads.md +156 -0
- data/docs/feedback-links.md +48 -0
- data/docs/log-search.md +102 -0
- data/docs/openai.md +5 -1
- data/docs/template-search.md +46 -4
- data/docs/vertex.md +1 -0
- data/docs/workload-search.md +116 -0
- data/lib/coolhand/api_service.rb +9 -4
- data/lib/coolhand/base_interceptor.rb +7 -5
- data/lib/coolhand/configuration.rb +16 -2
- data/lib/coolhand/declarable.rb +178 -0
- data/lib/coolhand/declared_context.rb +106 -0
- data/lib/coolhand/errors.rb +2 -1
- data/lib/coolhand/log_queue.rb +179 -0
- data/lib/coolhand/log_service.rb +61 -0
- data/lib/coolhand/logger_service.rb +10 -18
- data/lib/coolhand/net_http_interceptor.rb +145 -139
- data/lib/coolhand/open_ai/batch_result_processor.rb +30 -29
- data/lib/coolhand/open_ai/webhook_validator.rb +27 -15
- data/lib/coolhand/optimization_feedback_link_service.rb +85 -0
- data/lib/coolhand/rails_auto_declare.rb +55 -0
- data/lib/coolhand/railtie.rb +11 -0
- data/lib/coolhand/read_query.rb +75 -0
- data/lib/coolhand/read_requests.rb +26 -13
- data/lib/coolhand/response_warnings.rb +39 -0
- data/lib/coolhand/template_service.rb +33 -33
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +9 -1
- data/lib/coolhand/workload_service.rb +50 -0
- data/lib/coolhand/write_requests.rb +61 -0
- data/lib/coolhand.rb +63 -12
- metadata +17 -2
|
@@ -16,7 +16,9 @@ module Coolhand
|
|
|
16
16
|
|
|
17
17
|
case batch_info["status"]
|
|
18
18
|
when "completed"
|
|
19
|
-
|
|
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 =
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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 << "
|
|
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? &&
|
|
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},
|
|
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 =
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
data/lib/coolhand/version.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|