portage-cli 0.7.0 → 0.7.3

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,5 +1,8 @@
1
1
  require "portage/ucp"
2
+ require "uri"
2
3
  require_relative "confidence_check"
4
+ require_relative "user_agent"
5
+ require_relative "proxy_settings"
3
6
 
4
7
  module Portage
5
8
  module Cli
@@ -15,8 +18,9 @@ module Portage
15
18
  class Doctor
16
19
  Finding = Struct.new(:check, :message, keyword_init: true)
17
20
 
18
- def initialize(adapter_class: nil)
21
+ def initialize(adapter_class: nil, proxy_settings: ProxySettings.new)
19
22
  @adapter_class = adapter_class
23
+ @proxy_settings = proxy_settings
20
24
  end
21
25
 
22
26
  def call
@@ -26,6 +30,9 @@ module Portage
26
30
  signing_keys_finding,
27
31
  payment_handlers_finding,
28
32
  decision_backend_finding,
33
+ user_agent_finding,
34
+ proxy_finding,
35
+ *proxy_doctor_findings,
29
36
  *capability_findings
30
37
  ].compact
31
38
  end
@@ -71,6 +78,80 @@ module Portage
71
78
  Finding.new(check: "decision_backend", message: e.message)
72
79
  end
73
80
 
81
+ # PORTAGE_USER_AGENT / config.json's "user_agent" (UserAgent) is sent
82
+ # as-is on every outbound request. Net::HTTP raises ArgumentError on a
83
+ # header value containing CR/LF rather than send it, so a bad override
84
+ # would otherwise surface for the first time mid-checkout instead of
85
+ # here.
86
+ def user_agent_finding
87
+ value = Portage::Cli::UserAgent.value
88
+ return unless value =~ /[\r\n]/
89
+
90
+ Finding.new(check: "user_agent",
91
+ message: "Configured User-Agent (PORTAGE_USER_AGENT or user_agent in " \
92
+ "~/.portage/config.json) contains a newline — every outbound request will " \
93
+ "raise instead of sending.")
94
+ end
95
+
96
+ # Phase 0 of docs/plans/proxy-support.md: every raw Net::HTTP.start call
97
+ # site in portage-cli/portage-ucp/the adapter gems resolves its proxy
98
+ # from Ruby stdlib's own `:ENV` default, which — confirmed against a
99
+ # real local proxy — only ever reads `http_proxy`/`HTTP_PROXY`, for
100
+ # *both* http and https targets, and never `https_proxy`/`HTTPS_PROXY`.
101
+ # That's surprising enough (and the failure silent enough — requests
102
+ # just go direct) that it's worth a doctor check on both sides: report
103
+ # the effective proxy when one is actually active, and flag the classic
104
+ # footgun of setting only HTTPS_PROXY and expecting it to do anything
105
+ # here.
106
+ def proxy_finding
107
+ http_proxy = ENV["http_proxy"] || ENV.fetch("HTTP_PROXY", nil)
108
+ return https_only_proxy_finding if !http_proxy && https_env_proxy_set?
109
+
110
+ return unless http_proxy
111
+
112
+ Finding.new(check: "proxy",
113
+ message: "Outbound requests proxy through #{redact_proxy_url(http_proxy)} " \
114
+ "(from #{ENV['http_proxy'] ? 'http_proxy' : 'HTTP_PROXY'}, used for both http:// " \
115
+ "and https:// targets)#{no_proxy_suffix}.")
116
+ end
117
+
118
+ def https_only_proxy_finding
119
+ Finding.new(check: "proxy",
120
+ message: "HTTPS_PROXY/https_proxy is set but http_proxy/HTTP_PROXY is not — Ruby's " \
121
+ "Net::HTTP only ever reads http_proxy for its :ENV proxy mode (for both http:// " \
122
+ "and https:// targets), so every portage-cli/portage-ucp/adapter call site is " \
123
+ "proxying nothing right now. Set http_proxy (it covers https:// targets too) if " \
124
+ "that traffic should go through a proxy. (portage-ucp-client's own UCP/MCP tool " \
125
+ "calls are the one exception — they're Faraday-based and do read https_proxy.)")
126
+ end
127
+
128
+ def https_env_proxy_set?
129
+ !(ENV["https_proxy"].to_s.empty? && ENV["HTTPS_PROXY"].to_s.empty?)
130
+ end
131
+
132
+ def no_proxy_suffix
133
+ no_proxy = ENV["no_proxy"] || ENV.fetch("NO_PROXY", nil)
134
+ no_proxy ? "; no_proxy=#{no_proxy}" : ""
135
+ end
136
+
137
+ # `http://***@host:port` — never the real credentials (see the plan's
138
+ # "Credentials stay safe" constraint).
139
+ def redact_proxy_url(url)
140
+ uri = URI.parse(url.include?("://") ? url : "http://#{url}")
141
+ userinfo = uri.userinfo ? "***@" : ""
142
+ port = uri.port ? ":#{uri.port}" : ""
143
+ "#{uri.scheme}://#{userinfo}#{uri.host}#{port}"
144
+ rescue URI::InvalidURIError
145
+ "(unparseable proxy URL)"
146
+ end
147
+
148
+ # Phase 2's own checks against whatever ProxySettings resolved
149
+ # (flags/env/config.json), kept separate from proxy_finding above —
150
+ # see ProxyDoctor's own comment for why the two don't merge.
151
+ def proxy_doctor_findings
152
+ ProxyDoctor.new(proxy_settings: @proxy_settings).findings
153
+ end
154
+
74
155
  def payment_handlers_finding
75
156
  return unless Array(config.payment_handlers).empty?
76
157
 
@@ -106,3 +187,8 @@ module Portage
106
187
  end
107
188
  end
108
189
  end
190
+
191
+ # Loaded after Doctor closes — ProxyDoctor calls Doctor::Finding.new, so
192
+ # Doctor has to exist first (this file requires proxy_settings, not
193
+ # proxy_doctor, at the top for exactly this reason).
194
+ require_relative "proxy_doctor"
@@ -5,6 +5,7 @@ require "portage/ucp/client"
5
5
  require_relative "search_backends"
6
6
  require_relative "probe_cache"
7
7
  require_relative "decisions"
8
+ require_relative "user_agent"
8
9
 
9
10
  module Portage
10
11
  module Cli
@@ -128,7 +129,7 @@ module Portage
128
129
  end
129
130
 
130
131
  def discover(origin)
131
- Portage::Ucp::Client.discover(origin)
132
+ Portage::Ucp::Client.discover(origin, headers: UserAgent.headers)
132
133
  rescue StandardError
133
134
  nil
134
135
  end
@@ -0,0 +1,50 @@
1
+ module Portage
2
+ module Cli
3
+ class HandoffReconciler
4
+ # Adapts a wire hash (from #get_checkout/#get_order) to what
5
+ # OrderLedger#record/PurchaseJournal#record_checkout duck-type against
6
+ # (Portage::Ucp::Order/Checkout value objects) — see
7
+ # docs/plans/handoff-reconcile.md, "The journal needs a
8
+ # hash-to-value-object step". Deliberately not a second, parallel
9
+ # entry shape: both still go through PurchaseJournal#record_checkout /
10
+ # OrderLedger#record's own logic (`to_wire_h`) — these just wrap the
11
+ # already-wire-shaped hash to answer the handful of methods each one
12
+ # calls.
13
+ WireOrder = Struct.new(:wire) do
14
+ def id = wire["id"]
15
+ def to_wire_h = wire
16
+ end
17
+
18
+ WireCheckout = Struct.new(:wire) do
19
+ def currency = wire["currency"]
20
+ def order = wire["order"] && WireOrderRef.new(wire["order"])
21
+
22
+ def line_items
23
+ Array(wire["line_items"]).map { |li| WireLineItem.new(li) }
24
+ end
25
+ end
26
+
27
+ WireOrderRef = Struct.new(:wire) do
28
+ def id = wire["id"]
29
+ end
30
+
31
+ WireLineItem = Struct.new(:wire) do
32
+ def item = WireItem.new(wire["item"] || {})
33
+ def quantity = wire["quantity"]
34
+
35
+ def totals
36
+ Array(wire["totals"]).map { |t| WireTotal.new(t) }
37
+ end
38
+ end
39
+
40
+ WireItem = Struct.new(:wire) do
41
+ def id = wire["id"]
42
+ end
43
+
44
+ WireTotal = Struct.new(:wire) do
45
+ def type = wire["type"]
46
+ def amount = wire["amount"]
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,249 @@
1
+ require "time"
2
+ require "uri"
3
+ require "portage/ucp"
4
+ require "portage/ucp/client"
5
+ require "portage/ucp/journal"
6
+ require_relative "user_agent"
7
+ require_relative "homepage_fetch"
8
+ require_relative "handoff_spend_mode"
9
+ require_relative "reconcile_notifier"
10
+ require_relative "permissive_authenticator"
11
+ require_relative "handoff_reconciler/wire_adapters"
12
+
13
+ module Portage
14
+ module Cli
15
+ # docs/plans/handoff-reconcile.md Phase 1 — resolves a single pending
16
+ # `settled_by: "shopper"` TransactionLog record: did the shopper finish
17
+ # the checkout `portage buy` handed them, in the browser, on their own?
18
+ #
19
+ # Never infers success from a vanished checkout, a 404, or a plain
20
+ # expiry with no answer — those settle `failed` with `resolution:
21
+ # "unknown"`/`"expired"`, never `complete`. Only a `completed` status the
22
+ # store itself reports settles `complete`. Idempotent: a record that's
23
+ # already terminal (`complete`/`failed`) is a no-op, so `portage buy
24
+ # --wait` and a cron `portage orders reconcile` racing each other never
25
+ # double-settle.
26
+ class HandoffReconciler
27
+ # Raised when the store can't be reached again at all (no native UCP
28
+ # manifest, no matching/configured platform adapter) — the record
29
+ # stays pending; a later run (or a shorter-lived credential/env
30
+ # problem being fixed) may still resolve it.
31
+ class ReconnectError < StandardError; end
32
+
33
+ # `checkout_status` is the store's own raw status while a record stays
34
+ # pending (`incomplete`, `ready_for_complete`, …) — nil once settled,
35
+ # or when the checkout was never found at all. Phase 3's
36
+ # `HandoffWaiter` reads it to know when to emit a `handoff_status`
37
+ # NDJSON event under `portage buy --wait --json`.
38
+ Result = Struct.new(:idempotency_key, :settled, :status, :resolution, :order_id, :amount, :currency,
39
+ :checkout_status, :note, keyword_init: true) do
40
+ def to_h
41
+ { idempotency_key: idempotency_key, settled: settled, status: status, resolution: resolution,
42
+ order_id: order_id, amount: amount, currency: currency, checkout_status: checkout_status,
43
+ note: note }.compact
44
+ end
45
+ end
46
+
47
+ # @param spend_mode [String] one of HandoffSpendMode::MODES — Phase 2.
48
+ def initialize(transaction_log: Portage::Ucp::Support::TransactionLog.new,
49
+ order_ledger: Portage::Ucp::Support::OrderLedger.new,
50
+ journal: Portage::Ucp::Journal::PurchaseJournal.new,
51
+ notifier: ReconcileNotifier.new, spend_mode: HandoffSpendMode.resolve, clock: -> { Time.now })
52
+ @transaction_log = transaction_log
53
+ @order_ledger = order_ledger
54
+ @journal = journal
55
+ @notifier = notifier
56
+ @spend_mode = spend_mode
57
+ @clock = clock
58
+ end
59
+
60
+ # @param record [Hash] a TransactionLog record, as returned by
61
+ # `#find`/`#all` — expected to have `settled_by: "shopper"`, but
62
+ # this checks rather than assumes (see .each_pending_shopper_record).
63
+ # @return [Result]
64
+ def call(record)
65
+ if terminal?(record)
66
+ return Result.new(idempotency_key: record["idempotency_key"], settled: false, status: record["status"],
67
+ note: "already settled")
68
+ end
69
+ unless record["settled_by"] == "shopper"
70
+ return Result.new(idempotency_key: record["idempotency_key"], settled: false,
71
+ note: "not a shopper handoff")
72
+ end
73
+
74
+ reconcile(record)
75
+ end
76
+
77
+ # Every `pending`, `settled_by: "shopper"` record in the log — what
78
+ # `portage orders reconcile` (no `--checkout`) iterates.
79
+ def self.each_pending_shopper_record(transaction_log)
80
+ return enum_for(:each_pending_shopper_record, transaction_log) unless block_given?
81
+
82
+ transaction_log.each_record do |record|
83
+ yield record if record["settled_by"] == "shopper" && record["status"] == "pending"
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def terminal?(record) = Portage::Ucp::Support::TransactionLog::TERMINAL_STATUSES.include?(record["status"])
90
+
91
+ def reconcile(record)
92
+ session = reconnect(record["store_url"])
93
+ checkout = session.get_checkout(checkout_id: record["checkout_id"])
94
+ settle_from_checkout(record, checkout)
95
+ rescue StandardError => e
96
+ # A reconnect failure (no automated path back into the store) and a
97
+ # transport error mid-call are both treated exactly like "not
98
+ # found" — see the class comment and the plan's outcome table.
99
+ # Distinguishing a 404 from a timeout from "can't reconnect at all"
100
+ # buys nothing here: either way, this run learned nothing new about
101
+ # whether the shopper paid, and the record's own `expires_at` is
102
+ # still the only thing allowed to turn that into a settle.
103
+ settle_not_found(record, note: e.message)
104
+ end
105
+
106
+ def settle_from_checkout(record, checkout)
107
+ case checkout && checkout["status"]
108
+ when "completed" then settle_complete(record, checkout)
109
+ when "canceled" then settle_failed(record, resolution: nil)
110
+ when nil then settle_not_found(record)
111
+ else
112
+ if past_expiry?(record)
113
+ settle_failed(record, resolution: "expired")
114
+ else
115
+ still_pending(record, checkout_status: checkout["status"])
116
+ end
117
+ end
118
+ end
119
+
120
+ def settle_not_found(record, note: nil)
121
+ return still_pending(record, note: note) unless past_expiry?(record)
122
+
123
+ settle_failed(record, resolution: "unknown", note: note)
124
+ end
125
+
126
+ def past_expiry?(record)
127
+ expires_at = record["expires_at"]
128
+ return false unless expires_at
129
+
130
+ Time.parse(expires_at) < @clock.call
131
+ rescue ArgumentError
132
+ false
133
+ end
134
+
135
+ def still_pending(record, note: nil, checkout_status: nil)
136
+ Result.new(idempotency_key: record["idempotency_key"], settled: false, status: "pending",
137
+ checkout_status: checkout_status, note: note)
138
+ end
139
+
140
+ def settle_failed(record, resolution:, note: nil)
141
+ updated = @transaction_log.complete(idempotency_key: record["idempotency_key"], status: "failed",
142
+ resolution: resolution)
143
+ notify(record, updated, result: "failed")
144
+ Result.new(idempotency_key: record["idempotency_key"], settled: true, status: "failed",
145
+ resolution: resolution, note: note)
146
+ end
147
+
148
+ # The store's own reported amount, at settle time — not the
149
+ # handoff-time snapshot on `record` (the shopper may have changed
150
+ # quantity/shipping in the browser; see the plan's "Non-negotiable
151
+ # constraints").
152
+ def settle_complete(record, checkout)
153
+ amount = Portage::Ucp::Support::Totals.amount(checkout["totals"])
154
+ currency = checkout["currency"]
155
+ counts = @spend_mode != "warn"
156
+
157
+ updated = @transaction_log.complete(idempotency_key: record["idempotency_key"], status: "complete",
158
+ amount: amount, currency: currency, counts_toward_caps: counts)
159
+
160
+ order_id = record_order(record, checkout)
161
+ record_journal(record, checkout)
162
+ notify(record, updated, result: "complete", order_id: order_id)
163
+
164
+ Result.new(idempotency_key: record["idempotency_key"], settled: true, status: "complete", order_id: order_id,
165
+ amount: amount, currency: currency)
166
+ end
167
+
168
+ # Never blocks the settle above on this — the transaction record is
169
+ # already `complete` by the time this runs. A failure here just means
170
+ # the order snapshot/journal entry didn't get written this round; the
171
+ # money is still correctly recorded either way.
172
+ def record_order(record, checkout)
173
+ order = checkout["order"]
174
+ return nil unless order && order["id"]
175
+
176
+ session = reconnect(record["store_url"])
177
+ order_wire = session.get_order(order_id: order["id"])
178
+ return order["id"] unless order_wire
179
+
180
+ @order_ledger.record(idempotency_key: record["idempotency_key"], order: WireOrder.new(order_wire))
181
+ order["id"]
182
+ rescue StandardError
183
+ order["id"]
184
+ end
185
+
186
+ def record_journal(record, checkout)
187
+ return unless @journal && checkout["order"]
188
+
189
+ @journal.record_checkout(shop: record["shop"], source: "handoff_reconcile",
190
+ checkout: WireCheckout.new(checkout), idempotency_key: record["idempotency_key"])
191
+ rescue StandardError
192
+ nil
193
+ end
194
+
195
+ # Best-effort, same posture as Buy#hand_off's own notifier call — a
196
+ # failed webhook POST never raises out of #call/#reconcile.
197
+ def notify(record, updated, result:, order_id: nil)
198
+ payload = { event: "checkout_reconciled", result: result, reason: record["handoff_reason"],
199
+ checkout_id: record["checkout_id"], shop: record["shop"], store_url: record["store_url"],
200
+ amount: updated["amount"], currency: updated["currency"], order_id: order_id,
201
+ resolution: updated["resolution"] }
202
+ if result == "complete" && record["handoff_reason"] == "policy_blocked" &&
203
+ @spend_mode != "warn"
204
+ payload[:cap_overridden_by_shopper] =
205
+ true
206
+ end
207
+ @notifier.call(payload.compact)
208
+ rescue StandardError
209
+ nil
210
+ end
211
+
212
+ # --- reconnecting to the store (see the plan's "Reconciling needs the
213
+ # store again") ---
214
+
215
+ def reconnect(store_url)
216
+ raise ReconnectError, "no store_url on this record" if store_url.to_s.empty?
217
+
218
+ uri = URI.parse(store_url)
219
+ native_session(uri) || adapter_session(uri) ||
220
+ raise(ReconnectError, "no automated path back into #{store_url}")
221
+ end
222
+
223
+ def native_session(uri)
224
+ Portage::Ucp::Client.discover(uri.to_s, headers: UserAgent.headers)
225
+ rescue Portage::Ucp::Client::DiscoveryError
226
+ nil
227
+ end
228
+
229
+ # Mirrors Buy's own own-store loopback fallback (#adapter_flow) — a
230
+ # later process re-discovers the platform from the homepage the same
231
+ # way, and must not assume the original process's session or
232
+ # credentials still exist, only that this platform's env vars are
233
+ # still set (see the plan's non-negotiable constraint).
234
+ def adapter_session(uri)
235
+ body, headers = HomepageFetch.call(uri)
236
+ platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
237
+ return nil unless platform
238
+
239
+ env = Portage::Ucp::Resolver.env_for(platform)
240
+ return nil unless Portage::Ucp::Resolver.missing_env(platform, env).empty?
241
+
242
+ adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
243
+ Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
244
+ rescue StandardError
245
+ nil
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,28 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/handoff-reconcile.md Phase 2 — how a shopper-paid,
7
+ # reconciled handoff counts toward the buyer's own spend cap/velocity
8
+ # limit (Portage::Ucp::PolicyGuard). Same Setting precedence as
9
+ # CheckoutHandoff/Notifier: flag > PORTAGE_HANDOFF_SPEND_MODE > config.json's
10
+ # "handoff_spend_mode". Unset, or set to anything not in MODES, is
11
+ # `block`, the safer default (a shopper's own completed purchase is real
12
+ # spend; hiding it from the cap would just let the next *agent* purchase
13
+ # blow through it unnoticed).
14
+ module HandoffSpendMode
15
+ ENV_VAR = "PORTAGE_HANDOFF_SPEND_MODE".freeze
16
+ CONFIG_KEY = "handoff_spend_mode".freeze
17
+ MODES = %w[block warn precheck].freeze
18
+ DEFAULT = "block".freeze
19
+
20
+ module_function
21
+
22
+ def resolve(override: nil, config: Config.load)
23
+ mode = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
24
+ MODES.include?(mode) ? mode : DEFAULT
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/handoff-reconcile.md Phase 3, decision 5 — the ceiling on
7
+ # `portage buy --wait`'s poll loop (see HandoffWaiter). Same Setting
8
+ # precedence as HandoffSpendMode/CheckoutHandoff: flag (--wait-timeout)
9
+ # > PORTAGE_HANDOFF_WAIT_TIMEOUT > config.json's "handoff_wait_timeout".
10
+ # Unset is 30 minutes; `off` (or `0`) removes the ceiling entirely, so
11
+ # the wait runs until the checkout's own `expires_at` instead.
12
+ module HandoffWaitTimeout
13
+ ENV_VAR = "PORTAGE_HANDOFF_WAIT_TIMEOUT".freeze
14
+ CONFIG_KEY = "handoff_wait_timeout".freeze
15
+ DEFAULT = 1800 # 30 minutes.
16
+ OFF_VALUES = %w[off 0].freeze
17
+ DURATION_UNITS = { "s" => 1, "m" => 60, "h" => 3600 }.freeze
18
+
19
+ module_function
20
+
21
+ # @return [Integer, nil] seconds to wait, or nil for "no ceiling" —
22
+ # the caller then waits only on the checkout's own `expires_at`.
23
+ def resolve(override: nil, config: Config.load)
24
+ raw = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
25
+ raw.nil? ? DEFAULT : parse(raw)
26
+ end
27
+
28
+ def parse(raw)
29
+ text = raw.to_s.strip.downcase
30
+ return nil if OFF_VALUES.include?(text)
31
+
32
+ seconds = parse_duration(text)
33
+ seconds&.positive? ? seconds : DEFAULT
34
+ end
35
+
36
+ # Accepts a bare integer (seconds) or a suffixed duration (`30m`,
37
+ # `1h`, `45s`) — `--wait-timeout` is a human-typed flag, not a raw
38
+ # second count like `--rolling-window-seconds`.
39
+ def parse_duration(text)
40
+ return text.to_i if text.match?(/\A\d+\z/)
41
+
42
+ match = text.match(/\A(\d+)([smh])\z/)
43
+ return nil unless match
44
+
45
+ match[1].to_i * DURATION_UNITS.fetch(match[2])
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,103 @@
1
+ require "time"
2
+ require_relative "handoff_reconciler"
3
+ require_relative "handoff_wait_timeout"
4
+
5
+ module Portage
6
+ module Cli
7
+ # docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`'s poll
8
+ # loop. Polls a single pending shopper record through HandoffReconciler
9
+ # with backoff (2s -> 30s, plus jitter) until it settles or its deadline
10
+ # passes. Every decision about what a poll *means* stays
11
+ # HandoffReconciler's; this only decides when to poll again and when to
12
+ # stop.
13
+ #
14
+ # Ctrl-C: Ruby's default SIGINT handling raises Interrupt on the main
15
+ # thread, most likely while this is asleep between polls. Caught here so
16
+ # it never crashes the process with a backtrace — the record is already
17
+ # pending in the transaction log either way (see the plan's
18
+ # non-negotiable "the handoff itself never waits on reconcile"), so
19
+ # there's nothing to undo. It's left exactly as it is for a later
20
+ # `portage orders reconcile`; this never settles on interrupt.
21
+ class HandoffWaiter
22
+ INITIAL_BACKOFF = 2
23
+ MAX_BACKOFF = 30
24
+
25
+ def initialize(reconciler:, transaction_log:, wait_timeout_override: nil, clock: -> { Time.now },
26
+ sleeper: ->(seconds) { Kernel.sleep(seconds) })
27
+ @reconciler = reconciler
28
+ @transaction_log = transaction_log
29
+ @wait_timeout_override = wait_timeout_override
30
+ @clock = clock
31
+ @sleeper = sleeper
32
+ end
33
+
34
+ # @param record [Hash] the pending TransactionLog record to poll —
35
+ # typically fresh from the same log this was built with.
36
+ # @yield [Symbol, HandoffReconciler::Result] `:status` on every
37
+ # store-reported checkout status change (never on the first poll's
38
+ # initial status), `:settled` exactly once, when the loop ends
39
+ # because the record actually settled (never on a timeout or
40
+ # interrupt).
41
+ # @return [HandoffReconciler::Result] the last poll's result — settled
42
+ # if it settled, otherwise still pending (deadline reached or
43
+ # interrupted).
44
+ def call(record, &on_event)
45
+ deadline = compute_deadline(record)
46
+ backoff = INITIAL_BACKOFF
47
+ last_checkout_status = nil
48
+ loop do
49
+ current = @transaction_log.find(record["idempotency_key"]) || record
50
+ result = @reconciler.call(current)
51
+ last_checkout_status = emit_status(result, last_checkout_status, on_event)
52
+ return emit_settled(result, on_event) if result.settled
53
+ return result if @clock.call >= deadline
54
+
55
+ @sleeper.call(backoff_with_jitter(backoff))
56
+ backoff = [backoff * 2, MAX_BACKOFF].min
57
+ end
58
+ rescue Interrupt
59
+ HandoffReconciler::Result.new(idempotency_key: record["idempotency_key"], settled: false, status: "pending",
60
+ note: "wait interrupted")
61
+ end
62
+
63
+ private
64
+
65
+ def emit_status(result, last_checkout_status, on_event)
66
+ return last_checkout_status unless result.checkout_status && result.checkout_status != last_checkout_status
67
+
68
+ on_event&.call(:status, result)
69
+ result.checkout_status
70
+ end
71
+
72
+ def emit_settled(result, on_event)
73
+ on_event&.call(:settled, result)
74
+ result
75
+ end
76
+
77
+ # The earlier of `handoff_wait_timeout` (default 30m, `off` removes
78
+ # it) and the checkout's own `expires_at`. If somehow neither is set
79
+ # (no expires_at on the record, and the timeout resolved to nil), the
80
+ # default timeout still applies as a floor — this loop must not spin
81
+ # forever with no deadline at all.
82
+ def compute_deadline(record)
83
+ started = @clock.call
84
+ timeout = HandoffWaitTimeout.resolve(override: @wait_timeout_override)
85
+ candidates = [(started + timeout if timeout), expires_at(record)].compact
86
+ candidates.min || (started + HandoffWaitTimeout::DEFAULT)
87
+ end
88
+
89
+ def expires_at(record)
90
+ value = record["expires_at"]
91
+ return nil unless value
92
+
93
+ Time.parse(value)
94
+ rescue ArgumentError, TypeError
95
+ nil
96
+ end
97
+
98
+ def backoff_with_jitter(backoff)
99
+ backoff + Random.rand(backoff * 0.25)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,41 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "portage/ucp/support/connection"
4
+ require_relative "user_agent"
5
+
6
+ module Portage
7
+ module Cli
8
+ # Shared "fetch this store's homepage, for platform sniffing" logic —
9
+ # originally private to Buy (#fetch_homepage), extracted so
10
+ # HandoffReconciler's own adapter-fallback reconnect (see
11
+ # docs/plans/handoff-reconcile.md, "Reconciling needs the store again")
12
+ # doesn't duplicate it.
13
+ module HomepageFetch
14
+ REDIRECT_LIMIT = 5
15
+
16
+ # @return [Array(String, Hash), Array(nil, Hash)] the body and response
17
+ # headers, or [nil, {}] on any failure/redirect exhaustion — same
18
+ # "no automated path, not a crash" posture as the rest of the buy
19
+ # flow.
20
+ def self.call(uri, limit: REDIRECT_LIMIT)
21
+ return [nil, {}] if limit.zero?
22
+
23
+ response = Portage::Ucp::Support::Connection.start(uri, route: :store, open_timeout: 5,
24
+ read_timeout: 5) do |http|
25
+ http.get(uri.request_uri, UserAgent.headers)
26
+ end
27
+
28
+ case response
29
+ when Net::HTTPRedirection
30
+ call(URI.join(uri, response["location"]), limit: limit - 1)
31
+ when Net::HTTPSuccess
32
+ [response.body, response.to_hash]
33
+ else
34
+ [nil, {}]
35
+ end
36
+ rescue StandardError
37
+ [nil, {}]
38
+ end
39
+ end
40
+ end
41
+ end