portage-cli 0.6.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +216 -0
- data/README.md +263 -7
- data/lib/portage/cli/buy.rb +529 -104
- data/lib/portage/cli/checkout_handoff.rb +6 -18
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +104 -1
- data/lib/portage/cli/find.rb +10 -9
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +26 -27
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +367 -27
- metadata +46 -5
|
@@ -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
|
data/lib/portage/cli/history.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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)
|
|
@@ -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
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# docs/plans/handoff-reconcile.md Phase 3's "macos" notify channel — a
|
|
4
|
+
# native notification banner for a settled handoff. Opt-in (see
|
|
5
|
+
# ReconcileNotify), and a no-op off Darwin.
|
|
6
|
+
#
|
|
7
|
+
# `system("osascript", "-e", script)` in array form, never an
|
|
8
|
+
# interpolated shell string — same posture as CheckoutHandoff's
|
|
9
|
+
# `open`/`xdg-open`. The injection surface that remains is the
|
|
10
|
+
# AppleScript source itself: `title`/`message` come from a merchant
|
|
11
|
+
# name and an amount, both untrusted, so they're never spliced into the
|
|
12
|
+
# script raw. #applescript_string escapes backslashes and double quotes
|
|
13
|
+
# before interpolating, so a merchant name like `foo" & do shell script
|
|
14
|
+
# "rm -rf ~" & "` can't break out of its quoted string and run as
|
|
15
|
+
# AppleScript.
|
|
16
|
+
class MacosNotifier
|
|
17
|
+
def call(title:, message:)
|
|
18
|
+
return false unless RbConfig::CONFIG["host_os"] =~ /darwin/i
|
|
19
|
+
|
|
20
|
+
script = "display notification #{applescript_string(message)} with title #{applescript_string(title)}"
|
|
21
|
+
!!system("osascript", "-e", script)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def applescript_string(text)
|
|
29
|
+
# Block form, not a replacement string: gsub treats "\\" in a
|
|
30
|
+
# replacement *string* as a backreference escape, which would
|
|
31
|
+
# collapse a literal backslash right back down to one.
|
|
32
|
+
escaped = text.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\"' }
|
|
33
|
+
"\"#{escaped}\""
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/portage/cli/notifier.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "portage/ucp"
|
|
5
|
+
require "portage/ucp/support/connection"
|
|
2
6
|
require_relative "config"
|
|
7
|
+
require_relative "setting"
|
|
8
|
+
require_relative "user_agent"
|
|
3
9
|
|
|
4
10
|
module Portage
|
|
5
11
|
module Cli
|
|
@@ -12,15 +18,17 @@ module Portage
|
|
|
12
18
|
# speaks HTTP.
|
|
13
19
|
#
|
|
14
20
|
# Default off. Precedence for the webhook URL (open decision #1,
|
|
15
|
-
# resolved, same
|
|
21
|
+
# resolved, same Setting as CheckoutHandoff's auto-open toggle): a
|
|
16
22
|
# per-invocation `webhook_url:` override (portage buy --notify-webhook)
|
|
17
23
|
# beats PORTAGE_NOTIFY_WEBHOOK_URL, which beats ~/.portage/config.json's
|
|
18
24
|
# "notify_webhook_url" (Config).
|
|
19
25
|
class Notifier
|
|
20
|
-
include Portage::Ucp::Support::HttpClient
|
|
21
|
-
|
|
22
26
|
ENV_VAR = "PORTAGE_NOTIFY_WEBHOOK_URL".freeze
|
|
23
27
|
CONFIG_KEY = "notify_webhook_url".freeze
|
|
28
|
+
TIMEOUT = 5
|
|
29
|
+
# Enough of an error body to name the problem, without pasting a whole
|
|
30
|
+
# HTML error page into the report.
|
|
31
|
+
BODY_EXCERPT = 200
|
|
24
32
|
|
|
25
33
|
def initialize(webhook_url: nil, config: Config.load)
|
|
26
34
|
@override = webhook_url
|
|
@@ -28,12 +36,7 @@ module Portage
|
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
def webhook_url
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
env = ENV.fetch(ENV_VAR, nil)
|
|
34
|
-
return env unless env.nil? || env.empty?
|
|
35
|
-
|
|
36
|
-
@config.get(CONFIG_KEY)
|
|
39
|
+
Setting.resolve(override: @override, env: ENV_VAR, config: @config, config_key: CONFIG_KEY)
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def enabled? = !webhook_url.to_s.empty?
|
|
@@ -42,34 +45,30 @@ module Portage
|
|
|
42
45
|
# raises out of `Buy#call` — the checkout itself is a real, correct
|
|
43
46
|
# outcome independent of whether this delivery succeeded.
|
|
44
47
|
#
|
|
48
|
+
# Success is any 2xx, whatever the body: Slack's incoming webhooks
|
|
49
|
+
# answer `ok` as plain text, which a JSON parse would have misreported
|
|
50
|
+
# as a failed delivery. Short timeouts, since a hand-off is waiting on
|
|
51
|
+
# this and Net::HTTP's defaults would stall it for up to two minutes.
|
|
52
|
+
#
|
|
45
53
|
# @return [String, nil] the delivery failure message, or nil when
|
|
46
54
|
# disabled or on a successful POST.
|
|
47
55
|
def call(payload)
|
|
48
56
|
return nil unless enabled?
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
nil
|
|
58
|
+
response = post(URI(webhook_url), JSON.generate(payload))
|
|
59
|
+
return nil if response.is_a?(Net::HTTPSuccess)
|
|
60
|
+
|
|
61
|
+
"webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
|
|
52
62
|
rescue StandardError => e
|
|
53
|
-
e.message
|
|
63
|
+
"webhook POST failed: #{e.message}"
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
private
|
|
57
67
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
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"
|
|
68
|
+
def post(uri, body)
|
|
69
|
+
Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: TIMEOUT,
|
|
70
|
+
read_timeout: TIMEOUT) do |http|
|
|
71
|
+
http.post(uri.request_uri, body, UserAgent.headers.merge("Content-Type" => "application/json"))
|
|
73
72
|
end
|
|
74
73
|
end
|
|
75
74
|
end
|