coolhand 0.6.0 → 0.8.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.
@@ -1,8 +1,8 @@
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
6
 
7
7
  module Coolhand
8
8
  # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
@@ -14,6 +14,8 @@ module Coolhand
14
14
  # Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
15
15
  # See docs/template-search.md.
16
16
  class TemplateService < ApiService
17
+ include ReadQuery
18
+
17
19
  ERROR_NOUN = "Template"
18
20
  BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
19
21
 
@@ -23,13 +25,16 @@ module Coolhand
23
25
 
24
26
  # Filters, error semantics and server behaviour: docs/template-search.md.
25
27
  def search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil,
26
- include_system: nil, page: nil, per: nil)
28
+ include_system: nil, include_metrics: nil, days_back: nil, since: nil, until: nil, page: nil, per: nil)
27
29
  query = {
28
30
  search: search,
29
31
  workload_id: workload_id,
30
32
  status: status,
31
33
  include_deprecated: include_deprecated,
32
34
  include_system: include_system,
35
+ include_metrics: include_metrics,
36
+ days_back: days_back,
37
+ **window_params(since, binding.local_variable_get(:until)),
33
38
  page: page,
34
39
  per: per
35
40
  }.compact
@@ -44,33 +49,12 @@ module Coolhand
44
49
  end
45
50
 
46
51
  # 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)
50
- end
51
-
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
58
- end
59
-
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)
67
-
68
- URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
69
- end
70
-
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\-._~]/)
52
+ # list reaches deprecated and system templates by id with no opt-in flag. Carries `metrics`
53
+ # unless `include_metrics: false`, which also skips the window validation.
54
+ def get_template(id, include_metrics: nil, days_back: nil, since: nil, until: nil)
55
+ query = { include_metrics: include_metrics, days_back: days_back,
56
+ **window_params(since, binding.local_variable_get(:until)) }.compact
57
+ get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
74
58
  end
75
59
  end
76
60
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.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)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+ require_relative "pagination"
5
+ require_relative "read_query"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ WorkloadSearchResult = Struct.new(:workloads, :pagination, keyword_init: true)
10
+
11
+ # Read-only. Requires the client's **private** key. There is no per-id endpoint.
12
+ # See docs/workload-search.md.
13
+ class WorkloadService < ApiService
14
+ include ReadQuery
15
+
16
+ ERROR_NOUN = "Workload"
17
+
18
+ def initialize
19
+ super("v2/workloads")
20
+ end
21
+
22
+ # Filters, windows, error semantics and server behaviour: docs/workload-search.md.
23
+ def search_workloads(search: nil, include_archived: nil, include_system: nil, include_templates: nil,
24
+ include_metrics: nil, days_back: nil, since: nil, until: nil, page: nil, per: nil)
25
+ query = {
26
+ search: search,
27
+ include_archived: include_archived,
28
+ include_system: include_system,
29
+ include_templates: include_templates,
30
+ include_metrics: include_metrics,
31
+ days_back: days_back,
32
+ **window_params(since, binding.local_variable_get(:until)),
33
+ page: page,
34
+ per: per
35
+ }.compact
36
+
37
+ workloads, response = get_json_with_headers(list_url(query), ERROR_NOUN)
38
+ raise Error, "#{ERROR_NOUN} response was not a JSON array" unless workloads.is_a?(Array)
39
+
40
+ WorkloadSearchResult.new(
41
+ workloads: workloads,
42
+ pagination: Pagination.from_headers(response, items: workloads, page: page, per: per)
43
+ ).freeze
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "errors"
7
+ require_relative "read_requests"
8
+
9
+ module Coolhand
10
+ # POST/DELETE counterpart of {ReadRequests} for callers that act on the response: raises on any
11
+ # failure where {ApiService#send_request} logs and returns nil. Timeouts are the read ones, for
12
+ # the same server-side statement-bound reason. Mix into an {ApiService} subclass: it relies on
13
+ # `api_key`, `apply_headers` and `format_error_body` from there and the shared HTTP helpers from
14
+ # {ReadRequests}.
15
+ module WriteRequests
16
+ protected
17
+
18
+ # Returns the parsed body, or nil for an empty one (204).
19
+ def request_json(verb, url, noun, body: nil)
20
+ raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
21
+
22
+ response = perform_write(verb, url, noun, body)
23
+
24
+ raise_http_error(response, noun) unless response.is_a?(Net::HTTPSuccess)
25
+
26
+ response.body.to_s.strip.empty? ? nil : parse_json_body(response.body, noun)
27
+ end
28
+
29
+ private
30
+
31
+ def perform_write(verb, url, noun, body)
32
+ uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
33
+ http = build_http(uri)
34
+
35
+ request = Net::HTTP.const_get(verb.to_s.capitalize).new(uri.request_uri)
36
+ apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
37
+ unless body.nil?
38
+ apply_headers(request, "Content-Type" => "application/json")
39
+ request.body = JSON.generate(body).force_encoding("UTF-8")
40
+ end
41
+
42
+ Coolhand.without_capture { http.request(request) }
43
+ rescue StandardError => e
44
+ raise Error, "#{noun} request failed: #{e.message}"
45
+ end
46
+ end
47
+ end
data/lib/coolhand.rb CHANGED
@@ -10,12 +10,16 @@ require_relative "coolhand/errors"
10
10
  require_relative "coolhand/version"
11
11
  require_relative "coolhand/configuration"
12
12
  require_relative "coolhand/collector"
13
+ require_relative "coolhand/log_queue"
13
14
  require_relative "coolhand/base_interceptor"
14
15
  require_relative "coolhand/net_http_interceptor"
15
16
  require_relative "coolhand/api_service"
16
17
  require_relative "coolhand/logger_service"
17
18
  require_relative "coolhand/feedback_service"
18
19
  require_relative "coolhand/template_service"
20
+ require_relative "coolhand/optimization_feedback_link_service"
21
+ require_relative "coolhand/workload_service"
22
+ require_relative "coolhand/log_service"
19
23
  require_relative "coolhand/webhook_interceptor"
20
24
 
21
25
  # The main module for the Coolhand gem.
@@ -86,11 +90,22 @@ module Coolhand
86
90
  Thread.current[:coolhand_capture_override] = previous
87
91
  end
88
92
 
93
+ # Blocks until log requests queued by async logging have been sent, or the timeout (in
94
+ # seconds) elapses. Returns true if everything was sent. Pending logs are also flushed
95
+ # automatically at process exit.
96
+ def flush(timeout: LogQueue::DEFAULT_FLUSH_TIMEOUT)
97
+ LogQueue.flush(timeout)
98
+ end
99
+
89
100
  # A simple logger that respects the 'silent' configuration option.
90
101
  def log(message)
91
102
  return if configuration.silent
92
103
 
93
104
  puts "COOLHAND: #{message}"
105
+ rescue StandardError
106
+ # Logging must never raise into the host app (e.g. a closed stdout) — least of all from
107
+ # the interceptor's ensure block, where it would replace the host's own response.
108
+ nil
94
109
  end
95
110
 
96
111
  # Creates a new FeedbackService instance
@@ -109,6 +124,24 @@ module Coolhand
109
124
  TemplateService.new
110
125
  end
111
126
 
127
+ # Creates a new WorkloadService instance, for listing workloads (with optional cost and
128
+ # performance metrics). Needs the private API key.
129
+ def workload_service
130
+ WorkloadService.new
131
+ end
132
+
133
+ # Creates a new LogService instance, for reading logs back out of Coolhand. Distinct from
134
+ # {logger_service}, which writes them. Needs the private API key.
135
+ def log_service
136
+ LogService.new
137
+ end
138
+
139
+ # Creates a new OptimizationFeedbackLinkService instance, for linking feedback to an
140
+ # optimization as evidence. Needs the private API key.
141
+ def optimization_feedback_link_service
142
+ OptimizationFeedbackLinkService.new
143
+ end
144
+
112
145
  def required_field?(value)
113
146
  return false if value.nil?
114
147
  return false if value.respond_to?(:empty?) && value.empty?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-09-13 00:00:00.000000000 Z
12
+ date: 2026-10-14 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -48,10 +48,13 @@ files:
48
48
  - docs/anthropic.md
49
49
  - docs/configuration.md
50
50
  - docs/elevenlabs.md
51
+ - docs/feedback-links.md
51
52
  - docs/feedback.md
53
+ - docs/log-search.md
52
54
  - docs/openai.md
53
55
  - docs/template-search.md
54
56
  - docs/vertex.md
57
+ - docs/workload-search.md
55
58
  - lib/coolhand.rb
56
59
  - lib/coolhand/api_service.rb
57
60
  - lib/coolhand/base_interceptor.rb
@@ -62,17 +65,23 @@ files:
62
65
  - lib/coolhand/default_intercept_path_patterns.yml
63
66
  - lib/coolhand/errors.rb
64
67
  - lib/coolhand/feedback_service.rb
68
+ - lib/coolhand/log_queue.rb
69
+ - lib/coolhand/log_service.rb
65
70
  - lib/coolhand/logger_service.rb
66
71
  - lib/coolhand/net_http_interceptor.rb
67
72
  - lib/coolhand/open_ai/batch_result_processor.rb
68
73
  - lib/coolhand/open_ai/webhook_id_store.rb
69
74
  - lib/coolhand/open_ai/webhook_validator.rb
75
+ - lib/coolhand/optimization_feedback_link_service.rb
70
76
  - lib/coolhand/pagination.rb
77
+ - lib/coolhand/read_query.rb
71
78
  - lib/coolhand/read_requests.rb
72
79
  - lib/coolhand/template_service.rb
73
80
  - lib/coolhand/version.rb
74
81
  - lib/coolhand/vertex/batch_result_processor.rb
75
82
  - lib/coolhand/webhook_interceptor.rb
83
+ - lib/coolhand/workload_service.rb
84
+ - lib/coolhand/write_requests.rb
76
85
  - sig/coolhand/ruby.rbs
77
86
  homepage: https://coolhandlabs.com/
78
87
  licenses: