portage-cli 0.6.4 → 0.7.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,127 @@
1
+ require "json"
2
+ require_relative "setting"
3
+ require_relative "decisions"
4
+
5
+ module Portage
6
+ module Cli
7
+ # The confidence gate (docs/plans/system-one-decision-layer.md
8
+ # § Responsibilities 3) as `portage buy` uses it: one yes/no question put
9
+ # to a Decision::ModelBackends backend right before an unattended
10
+ # (`--yes`) completion — "is this checkout what the shopper asked for,
11
+ # and safe to complete without a person looking at it?"
12
+ #
13
+ # Default off. Nothing asks a model anything unless a backend is named,
14
+ # via `--decision-backend NAME` or PORTAGE_DECISION_BACKEND (`jev` or
15
+ # `laya`, Decision::ModelBackends::REGISTRY's keys). The threshold comes
16
+ # from `--min-confidence N` or PORTAGE_MIN_CONFIDENCE, else
17
+ # DEFAULT_THRESHOLD.
18
+ #
19
+ # This answers the plan's open question about how the gate relates to
20
+ # `Confirmer`: it runs in front of the completion, after `--yes` and the
21
+ # local PolicyCheck, and never in place of either. A low score holds the
22
+ # purchase and hands the checkout to the shopper. It never
23
+ # auto-approves anything `--yes` wouldn't already have allowed.
24
+ #
25
+ # Fails closed. An unknown backend name, a backend that isn't configured
26
+ # (no JEV_API_KEY, no Laya bridge), or a backend call that fails all hold
27
+ # the purchase, the same as a low score does. So does naming a backend
28
+ # without portage-ucp-decision installed: the backends live in that
29
+ # optional gem. Once a caller has asked for a confidence check, the
30
+ # check never passing is not "no opinion".
31
+ class ConfidenceCheck
32
+ BACKEND_ENV = "PORTAGE_DECISION_BACKEND".freeze
33
+ THRESHOLD_ENV = "PORTAGE_MIN_CONFIDENCE".freeze
34
+ DEFAULT_THRESHOLD = 0.8
35
+ QUESTION = "safe_to_complete".freeze
36
+ # Phrased so "yes" means "proceed": a noul answer's value is the
37
+ # probability of yes, and that is what ConfidenceGate thresholds.
38
+ INSTRUCTIONS = "The state is a checkout an agent built on a shopper's behalf: the shopper's search " \
39
+ "query, the merchant, the requested quantity, the checkout's line items and totals, and " \
40
+ "any warnings about where the checkout differs from the request. Answer yes only if the " \
41
+ "checkout clearly matches what the shopper asked for and is safe to complete without a " \
42
+ "person reviewing it first.".freeze
43
+
44
+ # @param backend [String, nil] a ModelBackends::REGISTRY key; nil
45
+ # defers to PORTAGE_DECISION_BACKEND.
46
+ # @param threshold [Float, nil] 0.0..1.0; nil defers to
47
+ # PORTAGE_MIN_CONFIDENCE, then DEFAULT_THRESHOLD.
48
+ # @param resolver [#call, nil] builds a backend from its name —
49
+ # injectable so specs never reach a real model. nil means
50
+ # ModelBackends.resolve, looked up only once a backend is needed.
51
+ # @raise [ArgumentError] for a threshold outside 0.0..1.0. An explicit
52
+ # `threshold:` is always checked, so a mistyped `--min-confidence`
53
+ # fails loudly. PORTAGE_MIN_CONFIDENCE is read, and so checked,
54
+ # only once a backend is enabled: a stale value in a shell that
55
+ # never uses the gate mustn't stop every `portage buy`.
56
+ def initialize(backend: nil, threshold: nil, resolver: nil)
57
+ @backend_name = Setting.resolve(override: backend, env: BACKEND_ENV)&.to_s&.strip
58
+ raw_threshold = enabled? ? Setting.resolve(override: threshold, env: THRESHOLD_ENV) : threshold
59
+ @threshold = parse_threshold(raw_threshold || DEFAULT_THRESHOLD)
60
+ @resolver = resolver
61
+ end
62
+
63
+ attr_reader :threshold
64
+
65
+ def enabled? = !@backend_name.nil?
66
+
67
+ # @param state [Hash] JSON-serializable. Never pass it a payment token.
68
+ # @return [Hash, nil] nil when disabled. Otherwise `proceed:`,
69
+ # `reason:`, `confidence:`, `threshold:`, `backend:` and `error:`.
70
+ # `reason` is nil when it proceeds, else why it held:
71
+ # `below_threshold`, `backend_error` (unknown, unconfigured or
72
+ # failed backend) or `not_installed` (no portage-ucp-decision).
73
+ # `error` is the detail behind the last two, nil otherwise.
74
+ def call(state)
75
+ return nil unless enabled?
76
+ return held("not_installed", not_installed_message) unless Decisions.available?
77
+
78
+ verdict = Portage::Ucp::Decision::ConfidenceGate.via_backend(
79
+ backend: resolve_backend, state: JSON.generate(state), question: QUESTION,
80
+ instructions: INSTRUCTIONS, threshold: @threshold
81
+ )
82
+ { proceed: verdict.proceed, reason: verdict.proceed ? nil : "below_threshold",
83
+ confidence: verdict.confidence, threshold: @threshold, backend: @backend_name, error: nil }
84
+ rescue StandardError => e
85
+ # Any failure, not only Decision::Error: this runs after a real
86
+ # checkout exists, so an escaped exception would drop the report,
87
+ # the hand-off and the history entry along with the purchase.
88
+ held("backend_error", e.message)
89
+ end
90
+
91
+ # What `portage doctor` reports: why the named backend would hold every
92
+ # `--yes` purchase, before one is attempted.
93
+ # @return [String, nil] nil when disabled or ready to answer.
94
+ def configuration_problem
95
+ return nil unless enabled?
96
+ return not_installed_message unless Decisions.available?
97
+
98
+ resolve_backend.configuration_problem
99
+ rescue Portage::Ucp::Decision::Error => e
100
+ e.message
101
+ end
102
+
103
+ private
104
+
105
+ def resolve_backend
106
+ (@resolver || Portage::Ucp::Decision::ModelBackends.method(:resolve)).call(@backend_name)
107
+ end
108
+
109
+ def held(reason, error)
110
+ { proceed: false, reason: reason, confidence: nil, threshold: @threshold, backend: @backend_name,
111
+ error: error }
112
+ end
113
+
114
+ def not_installed_message
115
+ "portage-ucp-decision is not installed — `gem install portage-ucp-decision` to use the " \
116
+ "#{@backend_name} confidence check."
117
+ end
118
+
119
+ def parse_threshold(raw)
120
+ value = Float(raw, exception: false)
121
+ return value if value&.between?(0.0, 1.0)
122
+
123
+ raise ArgumentError, "confidence threshold must be a number between 0.0 and 1.0, got #{raw.inspect}"
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,72 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # portage-cli's judgment calls (docs/plans/system-one-decision-layer.md).
6
+ # The rules live in portage-ucp core: Support::OfferRanking,
7
+ # Support::Escalation and PolicyGuard. portage-ucp-decision's
8
+ # OfferRanking, EscalationPolicy and PolicyCheck are typed wrappers
9
+ # around those same modules, so `buy` and `find` answer the same way
10
+ # whether or not that gem is installed.
11
+ #
12
+ # Every method returns a plain Hash whose `reason` is a String or nil,
13
+ # so a caller sees the same values in-process as in `--json`.
14
+ #
15
+ # Only the confidence gate needs the optional gem, because the model
16
+ # backends live there (see ConfidenceCheck and .available?).
17
+ module Decisions
18
+ # Loaded the way Resolver.build_adapter's callers treat an optional
19
+ # adapter gem: `require`, then rescue LoadError. It isn't in the
20
+ # gemspec, so `gem install portage-cli` stays light.
21
+ # @return [Boolean] whether portage-ucp-decision could be loaded.
22
+ # Memoized: `require` runs once per process.
23
+ def self.available?
24
+ return @available unless @available.nil?
25
+
26
+ @available = begin
27
+ require "portage/ucp/decision"
28
+ true
29
+ rescue LoadError
30
+ false
31
+ end
32
+ end
33
+
34
+ # @param offers [Array<Hash>] with `:checkout` (buyable) and `:amount`.
35
+ def self.rank(offers)
36
+ Portage::Ucp::Support::OfferRanking.rank(offers) { |offer| [offer[:checkout], offer[:amount]] }
37
+ end
38
+
39
+ # @param warnings [Array<String>] mismatches that should escalate —
40
+ # pass none when mismatches are only to be reported.
41
+ # @return [Hash] `escalate:`, `reason:`.
42
+ def self.escalation(checkout_status:, warnings: [])
43
+ reason = Portage::Ucp::Support::Escalation.reason(checkout_status: checkout_status, warnings: warnings)
44
+ { escalate: !reason.nil?, reason: reason&.to_s }
45
+ end
46
+
47
+ # The buyer's spend policy, checked by PolicyGuard.
48
+ #
49
+ # PolicyGuard skips both spend caps when `amount` is nil, which suits
50
+ # its own caller (an adapter that can't price a checkout up front).
51
+ # Here it would let a checkout with no `total` line past a configured
52
+ # cap while reporting `allowed: true`, so a missing total is denied as
53
+ # `total_unknown` whenever a cap exists.
54
+ # @param transaction_log [Portage::Ucp::Support::TransactionLog] what
55
+ # the rolling cap and velocity limit count.
56
+ # @return [Hash] `allowed:`, `reason:`.
57
+ def self.policy(amount:, currency:, merchant:, token_ref:,
58
+ transaction_log: Portage::Ucp::Support::TransactionLog.new)
59
+ policy = Portage::Ucp::Policy.load
60
+ if amount.nil? && (policy.per_transaction_cap || policy.rolling_cap)
61
+ return { allowed: false, reason: "total_unknown" }
62
+ end
63
+
64
+ Portage::Ucp::PolicyGuard.check!(amount: amount, currency: currency, merchant: merchant,
65
+ token_ref: token_ref, policy: policy, transaction_log: transaction_log)
66
+ { allowed: true, reason: nil }
67
+ rescue Portage::Ucp::PolicyViolationError => e
68
+ { allowed: false, reason: e.reason.to_s }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,4 +1,5 @@
1
1
  require "portage/ucp"
2
+ require_relative "confidence_check"
2
3
 
3
4
  module Portage
4
5
  module Cli
@@ -24,6 +25,7 @@ module Portage
24
25
  rate_limiter_finding,
25
26
  signing_keys_finding,
26
27
  payment_handlers_finding,
28
+ decision_backend_finding,
27
29
  *capability_findings
28
30
  ].compact
29
31
  end
@@ -54,6 +56,21 @@ module Portage
54
56
  Finding.new(check: "signing_keys", message: "No signing_keys configured — the manifest ships unsigned.")
55
57
  end
56
58
 
59
+ # The confidence gate's backend, checked only once one is selected
60
+ # (PORTAGE_DECISION_BACKEND): the gate is off by default, and a
61
+ # missing key for a backend nobody chose is noise. Once one is
62
+ # selected, anything short of ready holds every `--yes` purchase, so
63
+ # it's flagged here rather than first surfacing mid-checkout. Covers
64
+ # the gem not being installed, an unknown backend name, a missing
65
+ # JEV_API_KEY, a missing Laya bridge, and a bad PORTAGE_MIN_CONFIDENCE.
66
+ def decision_backend_finding
67
+ problem = ConfidenceCheck.new.configuration_problem
68
+ problem && Finding.new(check: "decision_backend",
69
+ message: "#{problem} — until then every `portage buy --yes` is held for the shopper.")
70
+ rescue ArgumentError => e
71
+ Finding.new(check: "decision_backend", message: e.message)
72
+ end
73
+
57
74
  def payment_handlers_finding
58
75
  return unless Array(config.payment_handlers).empty?
59
76
 
@@ -4,6 +4,7 @@ require "portage/ucp/client"
4
4
 
5
5
  require_relative "search_backends"
6
6
  require_relative "probe_cache"
7
+ require_relative "decisions"
7
8
 
8
9
  module Portage
9
10
  module Cli
@@ -166,14 +167,13 @@ module Portage
166
167
  amount: amount, currency: currency, url: field(product, "url") }
167
168
  end
168
169
 
169
- # Buyable first, then cheapest, then unpriced. Sorting on price alone
170
- # would float a browse-only store above one you can actually check out
171
- # from, which is the wrong answer to "buy me this".
172
- def rank(offers)
173
- offers.sort_by do |offer|
174
- [offer[:checkout] ? 0 : 1, offer[:amount] ? 0 : 1, offer[:amount] || 0]
175
- end
176
- end
170
+ # Buyable first, then cheapest, then unpriced (see Decisions.rank —
171
+ # core's Support::OfferRanking, the rule portage-ucp-decision's
172
+ # OfferRanking wraps), so an agent loop ranking its own candidate list
173
+ # gets the same order this command prints. Sorting on price alone
174
+ # would float a browse-only store above one you can actually check
175
+ # out from, which is the wrong answer to "buy me this".
176
+ def rank(offers) = Decisions.rank(offers)
177
177
 
178
178
  # --- Shapes ---
179
179
 
@@ -9,6 +9,13 @@ module Portage
9
9
  # history list` can answer "what did I already look for" and "what did I
10
10
  # already buy" without re-running anything, and `portage history clear`
11
11
  # can wipe either or both.
12
+ #
13
+ # A purchase entry is one checkout `portage buy` created, whatever came
14
+ # of it: `outcome` is the report's own (`purchased`, `dry_run`,
15
+ # `policy_blocked`, ...), so "what did I already buy" is the entries
16
+ # whose outcome is `purchased`, and every other entry still carries the
17
+ # `checkout_url` to finish it by hand. Entries written before `outcome`
18
+ # existed have only `checkout_status` and `message`.
12
19
  class History
13
20
  PATH = File.join(Dir.home, ".portage", "history.json").freeze
14
21
  MAX_ENTRIES = 200
@@ -18,14 +25,24 @@ module Portage
18
25
  @now = now.to_i
19
26
  end
20
27
 
21
- def record_purchase(url:, query:, checkout:, message:, checkout_status: nil, products: [])
22
- append("purchases", { "url" => url, "query" => query, "checkout" => checkout,
23
- "checkout_status" => checkout_status, "message" => message,
24
- "products" => products, "at" => @now })
28
+ # @param items [Array<Hash>] what the checkout holds (`id`, `title`,
29
+ # `quantity`), not the search results it was picked from.
30
+ # @param total [Integer, nil] minor units of `currency`.
31
+ # rubocop:disable Metrics/ParameterLists -- all keywords; one entry's fields
32
+ def record_purchase(url:, query:, outcome:, message:, source: nil, checkout_id: nil, checkout_status: nil,
33
+ checkout_url: nil, total: nil, currency: nil, items: [])
34
+ # rubocop:enable Metrics/ParameterLists
35
+ append("purchases", { "url" => url, "query" => query, "outcome" => outcome, "source" => source,
36
+ "checkout_id" => checkout_id, "checkout_status" => checkout_status,
37
+ "checkout_url" => checkout_url, "total" => total, "currency" => currency,
38
+ "items" => items, "message" => message, "at" => @now })
25
39
  end
26
40
 
27
- def record_search(query:, offer_count:, message:)
28
- append("searches", { "query" => query, "offer_count" => offer_count, "message" => message, "at" => @now })
41
+ # @param url [String, nil] the store, for a `portage buy` that never
42
+ # reached a checkout there; nil for a cross-store `portage find`.
43
+ def record_search(query:, offer_count:, message:, url: nil)
44
+ append("searches", { "query" => query, "url" => url, "offer_count" => offer_count, "message" => message,
45
+ "at" => @now }.compact)
29
46
  end
30
47
 
31
48
  def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
@@ -1,5 +1,8 @@
1
1
  require "net/http"
2
+ require "json"
3
+ require "uri"
2
4
  require_relative "config"
5
+ require_relative "setting"
3
6
 
4
7
  module Portage
5
8
  module Cli
@@ -12,15 +15,17 @@ module Portage
12
15
  # speaks HTTP.
13
16
  #
14
17
  # Default off. Precedence for the webhook URL (open decision #1,
15
- # resolved, same shape as CheckoutHandoff's auto-open toggle): a
18
+ # resolved, same Setting as CheckoutHandoff's auto-open toggle): a
16
19
  # per-invocation `webhook_url:` override (portage buy --notify-webhook)
17
20
  # beats PORTAGE_NOTIFY_WEBHOOK_URL, which beats ~/.portage/config.json's
18
21
  # "notify_webhook_url" (Config).
19
22
  class Notifier
20
- include Portage::Ucp::Support::HttpClient
21
-
22
23
  ENV_VAR = "PORTAGE_NOTIFY_WEBHOOK_URL".freeze
23
24
  CONFIG_KEY = "notify_webhook_url".freeze
25
+ TIMEOUT = 5
26
+ # Enough of an error body to name the problem, without pasting a whole
27
+ # HTML error page into the report.
28
+ BODY_EXCERPT = 200
24
29
 
25
30
  def initialize(webhook_url: nil, config: Config.load)
26
31
  @override = webhook_url
@@ -28,12 +33,7 @@ module Portage
28
33
  end
29
34
 
30
35
  def webhook_url
31
- return @override unless @override.nil?
32
-
33
- env = ENV.fetch(ENV_VAR, nil)
34
- return env unless env.nil? || env.empty?
35
-
36
- @config.get(CONFIG_KEY)
36
+ Setting.resolve(override: @override, env: ENV_VAR, config: @config, config_key: CONFIG_KEY)
37
37
  end
38
38
 
39
39
  def enabled? = !webhook_url.to_s.empty?
@@ -42,34 +42,30 @@ module Portage
42
42
  # raises out of `Buy#call` — the checkout itself is a real, correct
43
43
  # outcome independent of whether this delivery succeeded.
44
44
  #
45
+ # Success is any 2xx, whatever the body: Slack's incoming webhooks
46
+ # answer `ok` as plain text, which a JSON parse would have misreported
47
+ # as a failed delivery. Short timeouts, since a hand-off is waiting on
48
+ # this and Net::HTTP's defaults would stall it for up to two minutes.
49
+ #
45
50
  # @return [String, nil] the delivery failure message, or nil when
46
51
  # disabled or on a successful POST.
47
52
  def call(payload)
48
53
  return nil unless enabled?
49
54
 
50
- json_request(Net::HTTP::Post, webhook_url, body: payload)
51
- nil
55
+ response = post(URI(webhook_url), JSON.generate(payload))
56
+ return nil if response.is_a?(Net::HTTPSuccess)
57
+
58
+ "webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
52
59
  rescue StandardError => e
53
- e.message
60
+ "webhook POST failed: #{e.message}"
54
61
  end
55
62
 
56
63
  private
57
64
 
58
- def api_error_class
59
- NotifyApiError
60
- end
61
-
62
- # Raised (internally, always rescued by #call) when the webhook POST
63
- # itself fails (non-2xx) — kept distinct from a plain network error
64
- # only in that it carries the response body/status, same split
65
- # Confirmer::WebhookApiError draws against a raw StandardError.
66
- class NotifyApiError < Portage::Ucp::Error
67
- include Portage::Ucp::Support::ApiError
68
-
69
- private
70
-
71
- def api_label
72
- "Notifier"
65
+ def post(uri, body)
66
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
67
+ open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
68
+ http.post(uri.request_uri, body, "Content-Type" => "application/json")
73
69
  end
74
70
  end
75
71
  end
@@ -0,0 +1,44 @@
1
+ module Portage
2
+ module Cli
3
+ # How portage-cli resolves a standing preference, in one place
4
+ # (docs/plans/checkout-handoff-delivery.md open decision #1): a
5
+ # per-invocation override (a flag) beats its PORTAGE_* env var, which
6
+ # beats its ~/.portage/config.json key (Config). CheckoutHandoff,
7
+ # Notifier, Buy and ConfidenceCheck all read their settings through it.
8
+ #
9
+ # nil or a blank string counts as unset at every level, so an exported
10
+ # but empty `PORTAGE_AUTO_OPEN_CHECKOUT=` falls through to config.json
11
+ # rather than silently meaning "off".
12
+ module Setting
13
+ TRUE_VALUES = %w[1 true yes].freeze
14
+
15
+ module_function
16
+
17
+ # @param override [Object, nil] the flag's value, nil when not passed.
18
+ # @param env [String, nil] the env var's name.
19
+ # @param config [Config, nil]
20
+ # @param config_key [String, nil]
21
+ # @return [Object, nil] the value from the first level that's set.
22
+ def resolve(override: nil, env: nil, config: nil, config_key: nil)
23
+ return override if set?(override)
24
+
25
+ from_env = env && ENV.fetch(env, nil)
26
+ return from_env if set?(from_env)
27
+
28
+ from_config = config_key && config&.get(config_key)
29
+ from_config if set?(from_config)
30
+ end
31
+
32
+ # The same precedence, read as yes/no: true, or a string in
33
+ # TRUE_VALUES (any case). Anything else, unset included, is no.
34
+ def flag?(**)
35
+ TRUE_VALUES.include?(resolve(**).to_s.strip.downcase)
36
+ end
37
+
38
+ def set?(value)
39
+ !(value.nil? || (value.is_a?(String) && value.strip.empty?))
40
+ end
41
+ private_class_method :set?
42
+ end
43
+ end
44
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.6.4".freeze
3
+ VERSION = "0.7.0".freeze
4
4
  end
5
5
  end