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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+ require_relative "pagination"
5
+ require_relative "read_query"
6
+ require_relative "write_requests"
7
+
8
+ module Coolhand
9
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
10
+ WorkloadSearchResult = Struct.new(:workloads, :pagination, keyword_init: true)
11
+
12
+ # Lists workloads and upserts them by key. Requires the client's **private** key. There is no per-id
13
+ # endpoint. See docs/workload-search.md and docs/declared_workloads.md.
14
+ class WorkloadService < ApiService
15
+ include ReadQuery
16
+ include WriteRequests
17
+
18
+ ERROR_NOUN = "Workload"
19
+
20
+ def initialize
21
+ super("v2/workloads")
22
+ end
23
+
24
+ # Filters, windows, error semantics and server behaviour: docs/workload-search.md.
25
+ def search_workloads(search: nil, include_archived: nil, include_system: nil, include_templates: nil,
26
+ include_metrics: nil, days_back: nil, since: nil, until: nil, page: nil, per: nil)
27
+ query = {
28
+ search: search,
29
+ include_archived: include_archived,
30
+ include_system: include_system,
31
+ include_templates: include_templates,
32
+ include_metrics: include_metrics,
33
+ days_back: days_back,
34
+ **window_params(since, binding.local_variable_get(:until)),
35
+ page: page,
36
+ per: per
37
+ }.compact
38
+
39
+ fetch_list(query, WorkloadSearchResult, :workloads, page: page, per: per)
40
+ end
41
+
42
+ # Creates the workload with this `key`, or updates the existing one. The key is immutable; a new
43
+ # `name` renames the display name. Returns the workload Hash (the server answers 201 on create,
44
+ # 200 on update). Raises {Coolhand::HttpError} on a non-2xx response. See docs/declared_workloads.md.
45
+ def upsert(key:, name: nil, description: nil, reporter: nil)
46
+ body = { name: name, description: description, reporter: reporter }.compact
47
+ request_json(:put, by_key_url("upsert", key), ERROR_NOUN, body: body)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,61 @@
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
+ DOT_SEGMENTS = [".", ".."].freeze
17
+
18
+ protected
19
+
20
+ # Returns the parsed body, or nil for an empty one (204).
21
+ def request_json(verb, url, noun, body: nil)
22
+ raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
23
+
24
+ response = perform_write(verb, url, noun, body)
25
+
26
+ raise_http_error(response, noun) unless response.is_a?(Net::HTTPSuccess)
27
+
28
+ response.body.to_s.strip.empty? ? nil : parse_json_body(response.body, noun)
29
+ end
30
+
31
+ # `<endpoint>/by_key/<key>` for the upsert routes, which are globs: a key like "Parser#call/squish:L2"
32
+ # keeps its slashes and each segment is escaped, so `#` or `?` in a key cannot retarget the request.
33
+ def by_key_url(method_name, key)
34
+ trimmed = key.is_a?(String) ? key.strip : ""
35
+ segments = trimmed.split("/", -1)
36
+ if trimmed.empty? || segments.any? { |segment| segment.empty? || DOT_SEGMENTS.include?(segment) }
37
+ raise Error, "#{method_name}: key must be a non-empty string without empty or dot path segments"
38
+ end
39
+
40
+ URI.parse("#{api_endpoint}/by_key/#{segments.map { |segment| escape_path_segment(segment) }.join('/')}")
41
+ end
42
+
43
+ private
44
+
45
+ def perform_write(verb, url, noun, body)
46
+ uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
47
+ http = build_http(uri)
48
+
49
+ request = Net::HTTP.const_get(verb.to_s.capitalize).new(uri.request_uri)
50
+ apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
51
+ unless body.nil?
52
+ apply_headers(request, "Content-Type" => "application/json")
53
+ request.body = JSON.generate(body).force_encoding("UTF-8")
54
+ end
55
+
56
+ Coolhand.without_capture { http.request(request) }
57
+ rescue StandardError => e
58
+ raise Error, "#{noun} request failed: #{e.message}"
59
+ end
60
+ end
61
+ end
data/lib/coolhand.rb CHANGED
@@ -10,13 +10,22 @@ 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/declared_context"
14
+ require_relative "coolhand/declarable"
15
+ require_relative "coolhand/rails_auto_declare"
16
+ require_relative "coolhand/log_queue"
13
17
  require_relative "coolhand/base_interceptor"
14
18
  require_relative "coolhand/net_http_interceptor"
15
19
  require_relative "coolhand/api_service"
16
20
  require_relative "coolhand/logger_service"
17
21
  require_relative "coolhand/feedback_service"
18
22
  require_relative "coolhand/template_service"
23
+ require_relative "coolhand/optimization_feedback_link_service"
24
+ require_relative "coolhand/workload_service"
25
+ require_relative "coolhand/log_service"
19
26
  require_relative "coolhand/webhook_interceptor"
27
+ # Only when Rails is already loaded; the gem has no Rails dependency.
28
+ require_relative "coolhand/railtie" if defined?(Rails::Railtie)
20
29
 
21
30
  # The main module for the Coolhand gem.
22
31
  # It provides the configuration interface and initializes the patching.
@@ -31,6 +40,7 @@ module Coolhand
31
40
  def reset_configuration!
32
41
  @configuration = Configuration.new
33
42
  Thread.current[:coolhand_capture_override] = nil
43
+ DeclaredContext.clear!
34
44
  end
35
45
 
36
46
  # Provides a block to configure the gem.
@@ -70,20 +80,32 @@ module Coolhand
70
80
  end
71
81
  end
72
82
 
73
- def without_capture
74
- previous = Thread.current[:coolhand_capture_override]
75
- Thread.current[:coolhand_capture_override] = false
76
- yield
77
- ensure
78
- Thread.current[:coolhand_capture_override] = previous
83
+ def without_capture(&block)
84
+ override_capture(false, &block)
79
85
  end
80
86
 
81
- def with_capture
82
- previous = Thread.current[:coolhand_capture_override]
83
- Thread.current[:coolhand_capture_override] = true
84
- yield
85
- ensure
86
- Thread.current[:coolhand_capture_override] = previous
87
+ def with_capture(&block)
88
+ override_capture(true, &block)
89
+ end
90
+
91
+ # Declares the workload/template that LLM calls captured inside the block belong to. Nested
92
+ # blocks override per field (`reporter`, `workload`, `template`), innermost wins. `workload` and
93
+ # `template` are Hashes with `id:` (an existing record) or `key:` (find-or-created), plus optional
94
+ # `name:` (and `description:` on a workload). See docs/declared_workloads.md.
95
+ #
96
+ # Example:
97
+ # Coolhand.with_context(reporter: "squishling", workload: { key: "Parser#call" }) { client.chat(...) }
98
+ def with_context(reporter: nil, workload: nil, template: nil, &block)
99
+ raise ArgumentError, "Coolhand.with_context requires a block" unless block
100
+
101
+ DeclaredContext.with(reporter: reporter, workload: workload, template: template, &block)
102
+ end
103
+
104
+ # Blocks until log requests queued by async logging have been sent, or the timeout (in
105
+ # seconds) elapses. Returns true if everything was sent. Pending logs are also flushed
106
+ # automatically at process exit.
107
+ def flush(timeout: LogQueue::DEFAULT_FLUSH_TIMEOUT)
108
+ LogQueue.flush(timeout)
87
109
  end
88
110
 
89
111
  # A simple logger that respects the 'silent' configuration option.
@@ -113,6 +135,24 @@ module Coolhand
113
135
  TemplateService.new
114
136
  end
115
137
 
138
+ # Creates a new WorkloadService instance, for listing workloads (with optional cost and
139
+ # performance metrics). Needs the private API key.
140
+ def workload_service
141
+ WorkloadService.new
142
+ end
143
+
144
+ # Creates a new LogService instance, for reading logs back out of Coolhand. Distinct from
145
+ # {logger_service}, which writes them. Needs the private API key.
146
+ def log_service
147
+ LogService.new
148
+ end
149
+
150
+ # Creates a new OptimizationFeedbackLinkService instance, for linking feedback to an
151
+ # optimization as evidence. Needs the private API key.
152
+ def optimization_feedback_link_service
153
+ OptimizationFeedbackLinkService.new
154
+ end
155
+
116
156
  def required_field?(value)
117
157
  return false if value.nil?
118
158
  return false if value.respond_to?(:empty?) && value.empty?
@@ -120,5 +160,16 @@ module Coolhand
120
160
 
121
161
  true
122
162
  end
163
+
164
+ private
165
+
166
+ # Forces capture on or off for the block on this thread, restoring the previous override after.
167
+ def override_capture(enabled)
168
+ previous = Thread.current[:coolhand_capture_override]
169
+ Thread.current[:coolhand_capture_override] = enabled
170
+ yield
171
+ ensure
172
+ Thread.current[:coolhand_capture_override] = previous
173
+ end
123
174
  end
124
175
  end
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.7.0
4
+ version: 0.9.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-20 00:00:00.000000000 Z
12
+ date: 2026-10-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -47,32 +47,47 @@ files:
47
47
  - SECURITY.md
48
48
  - docs/anthropic.md
49
49
  - docs/configuration.md
50
+ - docs/declared_workloads.md
50
51
  - docs/elevenlabs.md
52
+ - docs/feedback-links.md
51
53
  - docs/feedback.md
54
+ - docs/log-search.md
52
55
  - docs/openai.md
53
56
  - docs/template-search.md
54
57
  - docs/vertex.md
58
+ - docs/workload-search.md
55
59
  - lib/coolhand.rb
56
60
  - lib/coolhand/api_service.rb
57
61
  - lib/coolhand/base_interceptor.rb
58
62
  - lib/coolhand/collector.rb
59
63
  - lib/coolhand/configuration.rb
64
+ - lib/coolhand/declarable.rb
65
+ - lib/coolhand/declared_context.rb
60
66
  - lib/coolhand/default_exclude_api_patterns.yml
61
67
  - lib/coolhand/default_intercept_addresses.yml
62
68
  - lib/coolhand/default_intercept_path_patterns.yml
63
69
  - lib/coolhand/errors.rb
64
70
  - lib/coolhand/feedback_service.rb
71
+ - lib/coolhand/log_queue.rb
72
+ - lib/coolhand/log_service.rb
65
73
  - lib/coolhand/logger_service.rb
66
74
  - lib/coolhand/net_http_interceptor.rb
67
75
  - lib/coolhand/open_ai/batch_result_processor.rb
68
76
  - lib/coolhand/open_ai/webhook_id_store.rb
69
77
  - lib/coolhand/open_ai/webhook_validator.rb
78
+ - lib/coolhand/optimization_feedback_link_service.rb
70
79
  - lib/coolhand/pagination.rb
80
+ - lib/coolhand/rails_auto_declare.rb
81
+ - lib/coolhand/railtie.rb
82
+ - lib/coolhand/read_query.rb
71
83
  - lib/coolhand/read_requests.rb
84
+ - lib/coolhand/response_warnings.rb
72
85
  - lib/coolhand/template_service.rb
73
86
  - lib/coolhand/version.rb
74
87
  - lib/coolhand/vertex/batch_result_processor.rb
75
88
  - lib/coolhand/webhook_interceptor.rb
89
+ - lib/coolhand/workload_service.rb
90
+ - lib/coolhand/write_requests.rb
76
91
  - sig/coolhand/ruby.rbs
77
92
  homepage: https://coolhandlabs.com/
78
93
  licenses: