Skip to content

feat(upstream): hand the server the history collected before pairing - #115

Merged
GeiserX merged 2 commits into
mainfrom
feat/push-history-on-pairing
Aug 6, 2026
Merged

feat(upstream): hand the server the history collected before pairing#115
GeiserX merged 2 commits into
mainfrom
feat/push-history-on-pairing

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What

A Desktop that ran standalone for months and was then paired appeared on the fleet page starting from the day of pairing. Every earlier day it had recorded was absent from the total, and there was no way to get it there.

The first time a CashPilot server confirms this worker, Desktop now uploads its recorded daily balances to POST /api/workers/earnings-import (CashPilot v1.16.0+, GeiserX/CashPilot#256).

This is the Desktop half of CashPilot-Desktop-xjr.

It is a copy, not a migration

The local rows are read and left exactly where they are. That is not an implementation detail — it is the "if it unlinks, show only what this machine earned alone" behaviour. Nothing had to be built for it; it falls out of not moving anything. A test asserts the local history survives the push, because a later refactor that "tidied up" by deleting the uploaded rows would be silently destructive.

Why the server files it under a separate source

Both sides may have been reading the same provider account. Earnings are stored as cumulative balance readings, and an earned figure is the clamped delta between consecutive readings — so interleaving two samplers of one account makes every apparent drop clamp to zero, and the total comes out systematically understated. Each client's readings are differenced on their own and the results summed.

The details that are load-bearing

  • Only a confirmed worker imports. A client still presenting the shared enrolment key is refused by the server — every worker holds that key, so it cannot prove who is writing. Asking anyway would log a 403 every minute and teach the user to ignore the log.
  • The marker records which server received the history, not merely that it was sent, so pairing with a different server hands it over too.
  • A failed or partial upload is not recorded, so it retries on the next heartbeat. The import is idempotent — the server keys on (platform, source, date) and updates — so a retry costs a round trip and nothing else.
  • A server too old to have the endpoint answers 404. That is not transient, so it is asked once per run rather than once a minute, and not recorded as delivered — an upgraded server still gets the history. The flag is in memory, cleared by stopUpstream, so restarting or re-saving settings re-arms it; persisting it would make one old server a permanent verdict on that URL.
  • Historical readings carry no exchange rate. Desktop does not record what a currency was worth on a past day. Stamping today's rate onto a year-old reading would misprice it confidently, and a 0.0 would price it at nothing. Absent means unknown, which is the truth.

Verification

go build, go vet, gofmt clean; go test -race ./... green across all packages.

Every property is confirmed by negative control — each of these mutations fails the test that claims to catch it:

Mutation Test that fails
Remove the once-marker check TestHistoryIsHandedOverOnceAndOnlyOnce
Record the marker even when the push failed TestAFailedHandOverIsRetriedRatherThanRecorded
Treat the marker as a bare boolean TestPairingWithADifferentServerHandsItTheHistoryToo
Stop normalising a trailing slash TestATrailingSlashIsNotADifferentServer
Invent an FX rate of 0.0 TestHistoryReadingsConversion
Stop dropping blank platform/day rows TestHistoryReadingsConversion
Confirmed always true TestAnUnconfirmedWorkerDoesNotTryToImport
Remove the confirmed-gate entirely TestAnUnconfirmedWorkerDoesNotTryToImport
Emit an empty batch for an empty history TestAnEmptyHistoryIsRecordedWithoutPostingAnything

One of those controls passed on the first attempt, which meant the test was wrong rather than the code right: TestAnUnconfirmedWorkerDoesNotTryToImport originally covered only first contact, where the import fails anyway for want of a credential — so it would have passed against a build with no gate at all. It now also covers a worker that holds a key the server is still re-delivering, where only the gate itself stops the import. That is the case both mutations now fail.

Summary by CodeRabbit

  • New Features
    • Historical daily earnings are now synchronized to the server after successful worker pairing.
    • Transfers use authenticated, batched uploads and retain local earnings data.
    • Synchronization supports retries for temporary or partial failures and avoids duplicate submissions.
  • Bug Fixes
    • Improved compatibility with servers that do not support historical earnings imports.
    • Empty histories and skipped readings are handled without blocking pairing or synchronization.

A Desktop that ran standalone for months and was then paired appeared on
the fleet page starting from the day of pairing. Every earlier day it had
recorded was absent from the total, and there was no way to get it there.

The first time a CashPilot server confirms this worker, Desktop now
uploads its recorded daily balances to /api/workers/earnings-import.

It is a COPY, not a migration. The local rows are read and left exactly
where they are, so unlinking leaves this machine still showing precisely
what it earned on its own -- which is the behaviour that was asked for,
and it falls out of not moving anything rather than being implemented
separately.

The server files the readings under this client's own source rather than
merging them into its own series. That matters because earnings are
clamped deltas between consecutive balance readings: interleaving two
samplers of one provider account makes every apparent drop clamp to zero
and the total comes out systematically understated. Separate series are
differenced separately and then summed.

Details that are load-bearing:

* Only a CONFIRMED worker imports. A client still presenting the shared
  enrolment key is refused by the server -- every worker holds that key,
  so it cannot prove who is writing -- and asking anyway would log a 403
  every minute.
* The marker records WHICH server received the history, not merely that
  it was sent, so pairing with a different server hands it over too.
* A failed or partial upload is not recorded, so it retries on the next
  heartbeat; the import is idempotent, so a retry costs a round trip.
* A server too old to have the endpoint answers 404. That is not
  transient, so it is asked once per run rather than once a minute --
  and not recorded as delivered, so an upgraded server still gets it.
* Historical readings carry NO exchange rate. Desktop does not record
  what a currency was worth on a past day, and stamping today's rate
  onto a year-old reading would misprice it confidently.

Refs: CashPilot-Desktop-xjr
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@GeiserX, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d8c1cfda-6092-4e3b-9807-043db8bc9143

📥 Commits

Reviewing files that changed from the base of the PR and between f9a45d1 and 8c01b2e.

📒 Files selected for processing (3)
  • internal/config/config.go
  • upstream_client.go
  • upstream_history_test.go
📝 Walkthrough

Walkthrough

The desktop client imports up to 400 days of local daily balances after worker confirmation. It authenticates requests with the machine key, omits unknown exchange rates, retries failed transfers, suppresses unsupported servers, and records successful handovers per server.

Changes

Historical earnings synchronization

Layer / File(s) Summary
Import transport and payload handling
internal/upstream/import.go, internal/upstream/upstream.go, internal/upstream/import_test.go
Adds validated, authenticated earnings imports with chunking, response handling, SSRF checks, URL normalization, and payload serialization tests.
Pairing handover and completion tracking
internal/config/config.go, upstream_client.go, upstream_history_test.go, CHANGELOG.md
Pairs confirmed workers with local history imports, converts daily balances, retries incomplete transfers, handles unsupported endpoints, and persists the destination server after success.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes uploading pre-pairing history to the server.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/push-history-on-pairing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.20661% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.24%. Comparing base (5ad8d8e) to head (8c01b2e).

Files with missing lines Patch % Lines
internal/upstream/import.go 69.56% 7 Missing and 7 partials ⚠️
internal/config/config.go 33.33% 11 Missing and 1 partial ⚠️
upstream_client.go 94.33% 2 Missing and 1 partial ⚠️
internal/upstream/upstream.go 75.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #115      +/-   ##
==========================================
+ Coverage   73.74%   74.24%   +0.49%     
==========================================
  Files          17       18       +1     
  Lines        3634     3747     +113     
==========================================
+ Hits         2680     2782     +102     
- Misses        761      762       +1     
- Partials      193      203      +10     
Files with missing lines Coverage Δ
internal/upstream/upstream.go 82.22% <75.00%> (+0.82%) ⬆️
upstream_client.go 65.75% <94.33%> (+33.49%) ⬆️
internal/config/config.go 57.64% <33.33%> (-3.12%) ⬇️
internal/upstream/import.go 69.56% <69.56%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
upstream_client.go (1)

120-136: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset historyUnsupported after the loop goroutine has stopped.

stopUpstream clears the flag at Line 127, then cancels and waits at Lines 132-135. A sendUpstream call that is already in flight can set the flag back to true after the clear. startUpstream calls stopUpstream first, so the new loop then starts with a stale true and skips the import until the next restart. This is the exact case the re-arm exists for: a user who just upgraded the server and re-saved settings.

Move the reset after <-done.

🐛 Proposed fix
 func (a *App) stopUpstream() {
 	a.upstream.mu.Lock()
 	cancel, done := a.upstream.cancel, a.upstream.done
 	a.upstream.cancel, a.upstream.done = nil, nil
-	// Re-arm the earnings import. startUpstream calls this first, so every
-	// restart -- and every unpair -- gives an upgraded server another chance,
-	// which is exactly where a user who just upgraded theirs would expect it.
-	a.upstream.historyUnsupported = false
 	a.upstream.mu.Unlock()
-	if cancel == nil {
-		return
+	if cancel != nil {
+		cancel()
+		if done != nil {
+			<-done
+		}
 	}
-	cancel()
-	if done != nil {
-		<-done
-	}
+	// Re-arm the earnings import only once the old loop cannot write the flag
+	// again. startUpstream calls this first, so every restart -- and every
+	// unpair -- gives an upgraded server another chance.
+	a.upstream.mu.Lock()
+	a.upstream.historyUnsupported = false
+	a.upstream.mu.Unlock()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@upstream_client.go` around lines 120 - 136, Move the reset of
a.upstream.historyUnsupported in stopUpstream to after the cancellation and
completion wait, ensuring the loop goroutine has stopped before clearing the
flag. Keep the existing cancellation and done-channel handling unchanged.
🧹 Nitpick comments (1)
internal/upstream/import_test.go (1)

108-130: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Unsynchronized test observation state across three stub handlers. Each site records request details inside an httptest handler goroutine and reads them from the test goroutine. The only thing between the write and the read is a real TCP socket, which the race detector does not model as a happens-before edge, so go test -race can report a data race at any of these sites. importStub and pairingStub in upstream_history_test.go already show the intended pattern.

  • internal/upstream/import_test.go#L108-L130: add a sync.Mutex to stubServer, guard the calls, path, auth, and body writes in the handler, and read them through an accessor in every assertion.
  • upstream_history_test.go#L207-L221: guard the auth capture with a mutex and read it under the same lock at Line 219.
  • upstream_history_test.go#L340-L358: guard the calls counter with a mutex and read it under the same lock at Line 358.

As per coding guidelines: "Run and maintain Go tests with race detection and coverage measurement using go test -race -coverprofile=coverage.out ./...".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/upstream/import_test.go` around lines 108 - 130, Synchronize test
observation state across all three sites: in internal/upstream/import_test.go
lines 108-130, add a mutex to stubServer, guard handler writes to calls, path,
auth, and body, and expose an accessor that assertions use for reads; in
upstream_history_test.go lines 207-221, protect auth capture and read it under
the same mutex at line 219; in upstream_history_test.go lines 340-358, protect
the calls counter and read it under the same mutex at line 358. Run the Go suite
with race detection and coverage using the specified command.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@upstream_client.go`:
- Line 183: Update pushHistoryOnce to re-read a fresh configuration snapshot
immediately before the Manager.Save call that persists the upstream marker,
rather than reusing the cfg captured before the earnings import. Preserve all
settings that may have changed through SaveSettings or a.cfg.Save while
retaining the marker update.

---

Outside diff comments:
In `@upstream_client.go`:
- Around line 120-136: Move the reset of a.upstream.historyUnsupported in
stopUpstream to after the cancellation and completion wait, ensuring the loop
goroutine has stopped before clearing the flag. Keep the existing cancellation
and done-channel handling unchanged.

---

Nitpick comments:
In `@internal/upstream/import_test.go`:
- Around line 108-130: Synchronize test observation state across all three
sites: in internal/upstream/import_test.go lines 108-130, add a mutex to
stubServer, guard handler writes to calls, path, auth, and body, and expose an
accessor that assertions use for reads; in upstream_history_test.go lines
207-221, protect auth capture and read it under the same mutex at line 219; in
upstream_history_test.go lines 340-358, protect the calls counter and read it
under the same mutex at line 358. Run the Go suite with race detection and
coverage using the specified command.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 15f17e3f-70ba-404c-8ca4-50b55cc64c27

📥 Commits

Reviewing files that changed from the base of the PR and between 5ad8d8e and f9a45d1.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • internal/config/config.go
  • internal/upstream/import.go
  • internal/upstream/import_test.go
  • internal/upstream/upstream.go
  • upstream_client.go
  • upstream_history_test.go

Comment thread upstream_client.go
…lobber settings

pushHistoryOnce read the config, uploaded up to 400 days of earnings over
the network, then saved. Manager.Save writes the WHOLE AppConfig, so
anything the user changed on the settings screen while the upload was in
flight was silently discarded -- and that upload is the slowest thing the
app does unprompted.

Manager.Update applies a mutation and persists under the write lock as
one read-modify-write, which closes the window rather than narrowing it.
Re-reading just before the save would have left a smaller version of the
same bug.

Save is still right for the settings form, which legitimately writes the
whole object; Update is for a single field written by background work.

Proven by control: reverting to the whole-config save of the stale
snapshot fails the new test with DisplayCurrency back at USD, exactly the
reported symptom.

Reported by CodeRabbit on PR #115.
@GeiserX
GeiserX merged commit 0369f13 into main Aug 6, 2026
6 checks passed
@GeiserX
GeiserX deleted the feat/push-history-on-pairing branch August 6, 2026 02:09
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.

1 participant