Skip to content

feat(cli): follow payments live with payments list --watch - #111

Merged
thebrandonlucas merged 1 commit into
feat/cli-look-11-boltfrom
feat/cli-look-12-watch
Oct 23, 2026
Merged

thebrandonlucas merged 1 commit into
feat/cli-look-11-boltfrom
feat/cli-look-12-watch

Conversation

@thebrandonlucas

@thebrandonlucas thebrandonlucas commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Stack position: 12 of 13 in the look-and-feel stack. Base: feat/cli-look-11-bolt (#110).

Problem

There was no way to follow payments as they arrive short of rerunning payments list. Scripts that wanted to react to new or settled payments had to poll and diff the list themselves.

Behavior

voltage payments list --watch [--interval SECONDS] keeps reading the list, with all of payments list's filters, until Ctrl-C.

On a terminal, it shows a live table on the alternate screen, newest first:

Watching payments · every 5s · Ctrl-C to stop

  ID                                    TYPE    DIRECTION  STATUS     …  CREATED AT
● b2c1…                                 bolt11  receive    receiving  …  just now      ← new
● d97b9ff3-dcf0-4ad9-89dd-bb0e43ac829e  bolt11  receive    completed  …  9 minutes ago ← changed
  0d5de04d-1a8b-40ac-ae6d-1c933b6e645e  bolt11  receive    receiving  …  10 minutes ago
  5 results.
  • ● (yellow) marks payments that are new or changed since the last refresh, for one refresh. Statuses are colored as in feat(cli): show friendly amounts and relative times, and color tables #101.
  • The header says why the last refresh failed, if it did.
  • Ctrl-C restores the screen and cursor and prints Stopped watching payments. with exit code 130, as for any interrupt, without an error: label.

Piped, or with --json / --output ndjson, it writes one event envelope per payment, one per line: {"outcome":"event","event":"snapshot"|"new"|"updated","id":"…","data":{…payment…}}. The first read gives snapshot events, and later reads give only new or changed payments. A change means a new status or updated_at.

Errors: a transport error or 5xx is retried at the next interval; any other error (such as 401 or 403) ends the watch with its usual exit code.

Naming: the plan called this voltage payments watch. It is a flag on payments list instead, so it reuses that command's filters (--wallet, --statuses, --direction, …), scope, and auth without a second alias mechanism next to send and receive. A payments watch alias can be added later. Keypress q was left out; Ctrl-C ends the watch.

Design notes

  • ReadThen::Watch { interval_seconds } is planned from ApiInvocation::watch. WatchFlags (--watch, --interval) attach only to payments list: --interval requires --watch, --watch conflicts with --all, and the interval is at least 1 second.
  • watch::Seen is a pure function of the pages: it decides snapshot, new, or updated by ID.
  • Output::live_frame renders the table with the feat(cli): show friendly amounts and relative times, and color tables #101 renderer and prefixes each row's marker. A row's line is its payment's index plus one; the header is line 0. The Next cursor line is dropped from the live view.
  • LiveScreen is a guard that enters the alternate screen and hides the cursor, and undoes both on drop, including when Ctrl-C drops the command. Screen control goes straight to stdout, not through the color stream, which would strip it under NO_COLOR.
  • Reads during a watch use a quieted terminal, so no spinner draws over the table.
  • SubmissionState::watching lets startup report the Ctrl-C calmly.

Try it on staging

Setup: Try a pull request on staging (added by #97). Read-only.

voltage payments list --account me --watch --interval 2 --limit 10

Expect: the screen switches to a live table that refreshes every 2 seconds. In another terminal, create or pay an invoice (for example voltage payments receive --account me --wallet WALLET_ID --currency btc --kind bolt11 --amount 1000 --unit sats --execute), and the new payment appears with a yellow ●. Ctrl-C returns you to your normal screen with Stopped watching payments.; echo $? prints 130.

voltage payments list --account me --watch --interval 2 --limit 5 --json
voltage payments list --account me --watch --interval 2 --limit 5 | cat

Expect: five "event":"snapshot" lines, then a new or updated line as payments change. Ctrl-C to stop.

voltage payments list --account me --interval 2
voltage payments list --account me --watch --all

Expect: usage errors (--interval needs --watch; --watch can't be combined with --all).

Validation

  • cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (171 unit and 134 integration tests), and python3 scripts/check-coverage.py all pass.
  • Unit tests:
    • Seen: snapshot, then nothing for an unchanged page, then new and updated in list order; no ID means not followed.
    • Parsing: the default and set interval, --interval without --watch, --watch --all, a 0 interval, and --watch on another list are rejected.
    • The header, with and without a failure.
  • Integration tests:
    • Redirected: exactly snapshot A, new B, updated A; Ctrl-C gives exit 130 and Stopped watching payments. on stderr, alone.
    • Errors: a 503 is retried and the change after it still arrives; a 403 ends the watch with exit code 3.
    • Live, with all streams on a PTY: the alternate screen is entered and later left with the cursor restored; the snapshot frame has no markers; the frame where B appears marks B and A; later frames mark nothing; exit 130.
  • Checked by hand on staging (the live view and Ctrl-C).
  • CRAP: 0 of 776 measured functions at or above 20. The highest is 19.00 (src/cli.rs endpoint, up from 18.00 because it now attaches WatchFlags). The new watch is 8.0 and live_frame 6.0.

Docs

docs/guides/payments.md (new Watch payments section) and CHANGELOG.md.

@thebrandonlucas
thebrandonlucas added this pull request to stack #92 October 23, 2026 02:39
@thebrandonlucas
thebrandonlucas marked this pull request as ready for review October 23, 2026 02:46

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on 9f5a0a4 (base #110). The event stream is well formed: piped output, --json and NDJSON write one compact envelope per event, and --output-file appends and syncs each one. The live screen is restored before the stop message, Ctrl-C exits 130, untrusted text is scrubbed, --interval is at least 1 second, and --watch conflicts with --all.

P1: a watch on a saved browser login stops once its organization token expires. watch (src/api.rs:946) keeps sending with the Api built for the first read. Its Authorization holds the organization token from one exchange, which is short-lived (exchange checks its expires_in). When that token expires, every refresh gets a 401, which matches Err(error) => return Err(error) (:991), and the watch exits 3 with "Check the credential with voltage auth status". The PR's own staging example uses --account me, a saved login, while the tests use API keys only. On a 401, or before the token expires, resolve and exchange the login again, and add a test with a short expires_in.

P2: a 5xx without a JSON body ends the watch. The non-JSON error path in Api::send attaches no HTTP detail except on a 403, so http_status() is None and the error kind is Api, not Transport. The retry guard at :986 then does not match, and a gateway's HTML 502 or 503 ends the watch, although the docs say that a refresh failing with a 5xx is retried. The test passes only because its 503 has an empty body. Please add a case such as ResponseTemplate::new(502).set_body_string("<html>Bad Gateway</html>").

P2: a 429 ends the watch, and Retry-After is ignored. The loop sleeps a fixed interval (:969), never reads retry_after, and has no backoff after failures. That goes against quality.md's "Poll only with server-provided retry hints".

P2: only the first page is compared. Each refresh resends the same query. A payment that has fallen off the first page never produces updated, and a burst bigger than a page is never fully reported. --sort-order ASC or --cursor means new payments never show at all. Force sort_key=updated_at and sort_order=DESC, and reject --cursor, --sort-key and --sort-order with --watch, or document the limit.

P2: the live frame has no height limit. With the API's default of 100 items, the header, and the newest marked rows with it, scroll off a 40-row terminal. Trim the frame to the terminal's height.

Nonblocking:

  • --timeout does not bound a watch, which requests.md and quality.md imply it does, so the exception should be documented.
  • Stopped watching payments. bypasses --quiet.
  • The PTY test sleeps a real 2.5 seconds, which quality.md forbids.
  • The transport-error retry, the 401 path and the screen restore after a non-Ctrl-C error are untested.

Gate and CRAP on this head are running, and I will add the results.

Comment thread src/api.rs
Comment thread src/api.rs Outdated
@tee8z

tee8z commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Gate results for 9f5a0a4, which my change request said I would add. In nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (187 unit, 143 integration) and python3 scripts/check-coverage.py passed. CRAP: 812 measured functions, none at or above 20, highest endpoint at 18.00, which is up from 17 because of the new --watch flag branch. One step passed only on its second run. On the first, three integration tests failed: two terminal login tests (a_login_takes_the_only_organization_and_environment_without_asking, login_on_a_terminal_offers_no_profile_when_one_already_uses_the_login) and result_output_treats_an_early_closing_pipe_as_success_without_resubmitting. They pass on a rerun, and this PR does not touch them.

@thebrandonlucas

Copy link
Copy Markdown
Contributor Author

Review-body items, in 83aa1aa:

  • P1, token expiry; P2, HTML 5xx and 429; P2, Retry-After and backoff: see the threads.
  • P2, first page only: every watch read forces sort_key=updated_at and sort_order=DESC (watch::follow_updates, applied to the first read too, and overriding any --query). --watch now conflicts with --cursor, --sort-key, and --sort-order, as well as --all. payments.md says that more changes than one page holds between two refreshes are not all reported.
  • P2, frame height: the live frame keeps only the rows that fit the terminal (watch::first, with the newest updates on top) and ends with … N more, below the terminal's height.
  • --timeout: payments.md now says it limits each refresh's request, not the watch.
  • Stopped watching payments. and --quiet: --quiet now hides it; exit 130 is unchanged.
  • Real sleep in the PTY test: replaced. A drain helper reads the terminal on its own thread, and the test waits on a condition (three frames drawn, 10-second limit) before interrupting.
  • Untested paths:
    • 401: the new exchange test.
    • Screen restore after an error: a_live_watch_that_ends_in_an_error_puts_the_screen_back_first checks that the alternate screen is left before error:, with exit 3.
    • Transport retry: unit-tested in watch::retries (Error::transport). The HTML 502 and 429 run end to end.

payments list --watch reads the list again every --interval seconds
(default 5) until Ctrl-C, with the same filters. On a terminal it is a live
table on the alternate screen that marks payments new or changed since the
last refresh; Ctrl-C restores the screen and prints "Stopped watching
payments." with exit code 130. Piped or with --json it writes one event
envelope per payment: snapshot, then new or updated. A transport or server
error is retried at the next refresh; any other error ends the watch.

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved 5c45231 against feat/cli-look-11-bolt (#110 a0ab94e). My findings are fixed:

  • P1, token expiry: Call keeps the Resolver, and a 401 resolves the credential again once. resolve_organization exchanges again on every call, with no cached token, so a saved login gets a fresh organization token. The flag resets after each successful refresh, so the watch survives every expiry, and a second 401 in a row ends it. a_watch_on_a_saved_login_exchanges_again_when_its_token_expires covers it.
  • HTML 5xx and 429: Error.response_status records the status without adding an HTTP detail, and watch::retries covers transport failures, 429 and any 5xx. The end-to-end test sends an HTML 502 and a 429.
  • Retry-After and backoff: a failure waits for the server's hint, or else a jittered backoff that grows to 60 seconds and resets on success.
  • First page only: every read forces sort_key=updated_at and sort_order=DESC. --watch conflicts with --cursor, --sort-key and --sort-order as well as --all, and the docs state the one-page limit.
  • Frame height: rows beyond the terminal's height are cut, and the cut is counted.
  • Nonblocking notes: --timeout is documented as limiting each refresh. --quiet hides the stop message. The PTY test waits on a condition instead of a fixed sleep. Screen restore after an error has a test.

Nonblocking, new in this revision: a zero Retry-After gives no pause. retry_after() returns Duration::ZERO for Retry-After: 0, for x-retry-after-ms: 0, and for an HTTP date in the past, which clock skew can produce. A 429 or 5xx carrying such a header is retried immediately, with no pause, for as long as the server keeps answering that way. On a successful refresh the same header overrides --interval, so the watch can poll faster than asked. Retry-After is a minimum wait, so taking the longer of the two would fix it: next.retry_after.map_or(interval, |hint| hint.max(interval)) on success, and hint.max(backoff.pause()) on failure. A unit test or a Retry-After: 0 case in the retry test would pin it.

Validation on this head, in nix develop: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (192 unit, 146 integration) and python3 scripts/check-coverage.py passed. CI is green. I did not run the CRAP report for this re-review.

@thebrandonlucas
thebrandonlucas merged commit bccc197 into master Oct 23, 2026
7 checks passed
@thebrandonlucas
thebrandonlucas deleted the feat/cli-look-12-watch branch October 23, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants