Skip to content

fix(aw-sync): sanitize whitespace hostnames on import; make per-bucket errors non-fatal - #697

Merged
ErikBjare merged 5 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/aw-sync-whitespace-hostname
Sep 17, 2026
Merged

ErikBjare merged 5 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/aw-sync-whitespace-hostname

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #692.

Problem

Pulling a peer whose bucket hostname contains whitespace (e.g. POCO F8 Ultra from Android devices before ActivityWatch/aw-android#272 sanitized device names) fails with a 400 from create_bucket (#658 rejects new buckets with whitespace hostnames). The 400 propagated via ? and aborted the entire sync pass, leaving every subsequent peer unseen (#688 blast radius).

Fix

Part 1 — Sanitize on import (Option 1 from #692, with the legacy-ID lookup):

get_or_create_sync_bucket now checks for an existing unsanitized legacy bucket first (so any device already pulled before this fix keeps its established ID and avoids a full re-import fork per ActivityWatch/activitywatch#1373). If no legacy bucket exists, the hostname and derived ID are sanitized with aw-android's exact algorithm (sanitize_hostname(), byte-identical to DeviceHostname.kt: lowercase, [^a-z0-9_-]+_, trim _) before calling create_bucket. "POCO F8 Ultra""poco_f8_ultra", so Android's hostname-column migration lands on the same ID. $aw.sync.origin keeps the raw hostname. The sanitizer also runs when the hostname field has whitespace even if the derived ID is already clean.

Part 2 — Per-bucket and per-peer non-fatal errors (Option 3 from #692 / #688):

Tests

sanitize_hostname("POCO F8 Ultra") == "poco_f8_ultra"          ← unit, matches DeviceHostname.kt
test_whitespace_hostname_pull_creates_sanitized_bucket
test_whitespace_hostname_pull_reuses_legacy_unsanitized_bucket
test_whitespace_hostname_sanitizes_even_when_id_is_clean       ← origin clean, hostname dirty
test_unusable_datastore_does_not_panic                         ← renamed + updated
test_non_string_sync_origin_does_not_panic                     ← renamed + updated

Related: #658, #685, #688, #693, ActivityWatch/aw-android#272, ActivityWatch/activitywatch#1373.

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob The legacy-ID lookup is right — existing unsanitized → existing sanitized → create sanitized is exactly the resolution order #692's correction asked for, $aw.sync.origin keeps the raw hostname, and both paths are tested. One blocking item and two small ones.

Blocking: the sanitizer must match aw-android's, or this defers the fork rather than closing it

This PR sanitizes with replace(char::is_whitespace, "_"):

"POCO F8 Ultra"  →  "POCO_F8_Ultra"

aw-android's sanitizeDeviceHostname (DeviceHostname.kt) is lowercase(Locale.ROOT).replace(Regex("[^a-z0-9_-]+"), "_").trim('_'):

"POCO F8 Ultra"  →  "poco_f8_ultra"

Trace what happens the day ActivityWatch/aw-android#272 lands and the phone migrates its bucket hostname column to poco_f8_ultra:

  1. sync_origin = "poco_f8_ultra", so new_id = …-synced-from-poco_f8_ultra
  2. get_bucket(new_id)NoSuchBucket (the existing one is …-synced-from-POCO_F8_Ultra)
  3. new_id.contains(char::is_whitespace)false → else branch → create …-synced-from-poco_f8_ultra
  4. Resume-from-newest starts from nothing → full re-import → every event twice in /timeline

That is the discussions#1373 symptom again, just moved to the Android migration date — and by then every desktop that ran this PR has a POCO_F8_Ultra bucket that no future ID will match.

Fix is small and only possible before merge: use Android's exact algorithm (lowercase, [^a-z0-9_-]+_, trim _), so today's desktop creates …-synced-from-poco_f8_ultra and Android's migration lands on the same ID with no lookup needed. Worth a shared sanitize_hostname() in aw-sync with a doc comment saying it must stay byte-identical to DeviceHostname.kt, plus a test asserting "POCO F8 Ultra" → "poco_f8_ultra".

Small: per-peer isolation is still missing, and step 3 depends on it

sync_wrapper.rs, pull_all:

for remote in selected {
    pull_db(client, &remote.hostname, &remote.path)?;
}

This PR makes bucket-level errors non-fatal but a peer that fails to open still aborts the pass and skips every peer after it. Plan step 3 (#693 minimal: open peers read-only, skip on user_version mismatch) needs this to be warn + continue so a skipped peer has somewhere to go — otherwise "skip peer" becomes "abort pass". Three lines; since this is already the non-fatal-errors PR, it belongs here rather than waiting for #688.

Small: sanitize on the hostname, not only the ID

The branch condition is new_id.contains(char::is_whitespace). If sync_origin came from $aw.sync.origin metadata (clean) while bucket_from.hostname still has whitespace, new_id is clean, the else branch runs, final_hostname keeps the whitespace, and create_bucket 400s anyway. Narrow, but the guard should be bucket_from.hostname.contains(char::is_whitespace) || new_id.contains(char::is_whitespace).

With the first item fixed I have nothing else on this one.

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.56904% with 56 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.32%. Comparing base (656f3c9) to head (bff7a2a).
⚠️ Report is 121 commits behind head on master.

Files with missing lines Patch % Lines
aw-sync/tests/sync.rs 77.41% 35 Missing ⚠️
aw-sync/src/sync_wrapper.rs 0.00% 16 Missing ⚠️
aw-sync/src/sync.rs 92.53% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #697      +/-   ##
==========================================
+ Coverage   70.81%   79.32%   +8.50%     
==========================================
  Files          51       74      +23     
  Lines        2916     7821    +4905     
==========================================
+ Hits         2065     6204    +4139     
- Misses        851     1617     +766     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI-green and mergeable — waiting only on a maintainer click.

This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed all three.

Blocking — sanitizer now matches aw-android. Shared sanitize_hostname() is byte-identical to DeviceHostname.kt (trim, lowercase, [^a-z0-9_-]+_, trim _). "POCO F8 Ultra""poco_f8_ultra", not "POCO_F8_Ultra". $aw.sync.origin still keeps the raw hostname. Tests lock the Android DeviceHostnameTest.kt cases plus the POCO example, and assert the whitespace-only form is not created.

Per-peer isolation. pull_all is now warn + continue on pull_db failure, so a peer that fails to open does not abort the pass. Step 3 (#693 skip-on-mismatch) has somewhere to go.

Sanitize on hostname, not only ID. Guard is bucket_from.hostname.contains(whitespace) || new_id.contains(whitespace). Added a test where $aw.sync.origin is already poco_f8_ultra while hostname is still POCO F8 Ultra.

Pushed as 67eeee7. The earlier "waiting on a maintainer click" note is superseded — this needed the review items first. CI is running.

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

All three items confirmed in 67eeee7. CI is green on all platforms.

Blocking — sanitizer matches aw-android: sanitize_hostname() does trim → lowercase → [^a-z0-9_-]+_ → trim_, byte-identical to DeviceHostname.kt. "POCO F8 Ultra""poco_f8_ultra" not "POCO_F8_Ultra". $aw.sync.origin still keeps the raw hostname. Tests lock all the Android DeviceHostnameTest.kt cases plus the POCO example, and assert the whitespace-only form is never created.

Per-peer isolation: pull_all uses warn + continue on pull_db failure; a peer that fails to open does not abort the pass. #693 skip-on-mismatch has somewhere to go.

Sanitize on hostname not only ID: guard is bucket_from.hostname.contains(whitespace) || new_id.contains(whitespace). test_whitespace_hostname_sanitizes_even_when_id_is_clean covers the exact case where $aw.sync.origin is already poco_f8_ultra while hostname is still POCO F8 Ultra.

One trivial nit (not blocking): in test_unusable_datastore_does_not_panic the first assert !result.is_err() || result.is_ok() is a tautology (!is_err() == is_ok()), so it never fails. The second assert result.is_ok() is the live check. Harmless, but worth knowing so it doesn't confuse a future reader.

Ready to merge.

Comment thread aw-sync/src/sync.rs Outdated
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

P1 from the AI review: a hostname like " * " sanitizes to unknown and would have created -synced-from-unknown, mixing every such remote. That's the same provenance hole the existing hostname == "unknown" guard exists to close, but the guard never saw it because the source hostname wasn't the sentinel yet.

7d6cfd9 refuses that pull; per-bucket skip keeps the rest of the pass. Test: test_whitespace_hostname_that_sanitizes_to_unknown_is_skipped_on_pull.

P2 (Locale.ROOT vs Unicode lowercase) left as-is — ASCII is what DeviceHostname.kt tests, and that's the fork this PR has to close.

CI running on 7d6cfd9. Still pull-only here; needs a maintainer click once green.

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob Now CONFLICTING against master — #698 landed and rewrote the ds_localremote block in sync_run that this touches. Needs a rebase; since it needs a push anyway for the sanitizer (match DeviceHostname.kt byte-for-byte) and the per-peer continue in pull_all, fold all three into one push. Note #678 may go in ahead of this now that it is green and mergeable — rebase over both if so.

@ErikBjare

Copy link
Copy Markdown
Member

@greptileai review

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the remaining timestamp issue only affects diagnostics for uncommon pre-epoch data.

Summary

This PR sanitizes imported bucket hostnames using the Android-compatible algorithm, preserves legacy destination IDs where possible, and isolates bucket and peer failures while continuing healthy synchronization work.

  • Unconditionally normalizes hostname case, punctuation, and whitespace before creating new sync buckets.
  • Reuses legacy unsanitized destination buckets to avoid unnecessary history forks.
  • Returns an error when every attempted bucket or peer fails, while allowing partial success.
  • Adds regression coverage for hostname variants, legacy reuse, unknown origins, and total-failure reporting.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Remote bucket] --> B[Derive raw destination ID]
  B --> C{Legacy raw bucket exists?}
  C -->|Yes| D[Reuse legacy bucket]
  C -->|No| E[Sanitize hostname and origin]
  E --> F{Sanitized bucket exists?}
  F -->|Yes| G[Reuse sanitized bucket]
  F -->|No| H[Create sanitized bucket]
  D --> I[Sync events]
  G --> I
  H --> I
  I --> J{Bucket sync succeeds?}
  J -->|Yes| K[Count success and continue]
  J -->|No| L[Warn and continue]
  K --> M{Any bucket succeeded?}
  L --> M
  M -->|Yes or none attempted| N[Return success]
  M -->|All attempted failed| O[Return error]
Loading

Reviews (2) · Last reviewed commit: "fix(aw-sync): sanitize hostnames uncondi..."

Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto master (8037907) over #698's maybe_setup_local_remote rewrite. MERGEABLE. CI in flight.

Folded into this one push as asked:

  • sanitizer still byte-identical to DeviceHostname.kt ("POCO F8 Ultra"poco_f8_ultra)
  • pull_all per-peer continue (warn, do not abort the pass)
  • refuse pull when hostname sanitizes to unknown

Did not restack onto #678: hunks still do not overlap (reconcile_updated_events in sync_one vs sanitizer/pull_all), and stacking would dump #678's commits into this PR. If #678 merges first this should stay mergeable; I will rebase again only if it does not.

Comment thread aw-sync/tests/sync.rs Outdated
Comment thread aw-sync/src/sync.rs Outdated
@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob Rebase confirmed — on current master, sanitizer/per-peer continue/unknown-skip/all four tests intact. Greptile's re-review (maintainer-triggered, so that path is confirmed working) raised three things; two need changes before this merges.

Required 1 — sanitize always, not only on whitespace (sync.rs:338)

Greptile is right and it is the same fork I blocked on earlier, in case-only form. The guard is hostname.contains(whitespace) || new_id.contains(whitespace), but DeviceHostname.kt also lowercases and replaces punctuation. So PIXEL8 (no whitespace) is left as PIXEL8 today; when ActivityWatch/aw-android#272 migrates that phone's bucket hostname to pixel8, new_id becomes …-synced-from-pixel8, the legacy lookup finds nothing, and the history forks — exactly what the whitespace fix was written to prevent.

Fix: compute sanitized = sanitize_hostname(raw) unconditionally. Lookup order stays raw ID → sanitized ID; create under the sanitized ID whenever it differs from raw. Then Android's migration always lands on an existing bucket regardless of which character class differed. Add PIXEL8 → pixel8 and a dotted desktop hostname to the test cases.

(Consequence to state in a comment, not fix: desktop peers with dots — erb-m2.localdomain — will create …-synced-from-erb-m2_localdomain for new imports; existing ones are found via the raw lookup. That is a display wart the (device_id, id) identity work in ActivityWatch/activitywatch#302 removes; it is not a reason to keep the fork open.)

Required 2 — total failure must not report success (sync.rs:534)

With warn + continue on every bucket and every peer, a pass where the destination server is down — every insert fails, every peer "skipped" — now returns Ok(()). Before this PR that was an Err, the daemon exited, and the supervisor surfaced it. This PR silently turns "everything broke" into "success", which is the #682 failure mode reintroduced via #688's fix.

Fix: track whether anything succeeded. In both sync_datastores and pull_all: if attempted > 0 && succeeded == 0 { return Err("all N buckets/peers failed; last error: …") }. Partial failure stays non-fatal (that is the point of #688); total failure is a whole-run condition and must reach the supervisor. #699's SyncReport will carry the detail later; this is the minimum so the CLI/JNI callers can tell the difference now.

Note, not required — sync.rs:368 origins that collide after normalization

Two distinct raw origins that sanitize to the same string (device / device ) share a destination bucket and the first one's $aw.sync.origin. Real, but it is the pre-existing "two devices with the same hostname collide" problem with a slightly wider net; the fix is device_id identity (ActivityWatch/activitywatch#302), not this PR. Cheap mitigation if you want it: when the raw-lookup hits, warn! if the found bucket's $aw.sync.origin differs from the current raw origin.

With 1 and 2, nothing further from me.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@ErikBjare Done on fcd851c.

1. Sanitize always. Lookup stays raw ID → sanitized ID; create under the sanitized ID when it differs. Tests: PIXEL8pixel8, erb-m2.localdomainerb-m2_localdomain, plus legacy PIXEL8 reuse.

2. Total failure returns Err. sync_datastores and pull_all track attempted/succeeded; if attempted > 0 && succeeded == 0 { return Err(...) }. Partial skip stays non-fatal (" * " + healthy sibling still Ok). One-bucket total-failure tests now expect Err.

Left the origin-collision warn for ActivityWatch/activitywatch#302 as specified.

@ErikBjare

Copy link
Copy Markdown
Member

Verified on fcd851c: sanitize_hostname applied unconditionally (case-only differences now land on the same bucket Android will migrate to), and total failure returns Err in both sync_datastores and pull_all while partial failure stays non-fatal. Nothing further — ready for Erik after #678.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Acknowledged. Head is still fcd851c (the SHA you verified). CI green, MERGEABLE against current master. Pull-only on this org, so I cannot self-merge.

Order remains #700#678 → this.

Post-merge rebase preview (not pushed — stacking now would dump #700/#678 into this PR):

Once those two land I will rebase, fold the deferred tautological-assertion cleanup, and this is your click.

Resolved the three Greptile threads (two fixed in fcd851c; origin-collision deferred to ActivityWatch/activitywatch#302 as specified).

Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/tests/sync.rs
@TimeToBuildBob

TimeToBuildBob commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI code review

This PR adds hostname sanitization to aw-sync's bucket import path, making per-bucket and per-peer failures non-fatal, and updates tests accordingly. It introduces a sanitize_hostname function that mirrors aw-android's algorithm, uses it to derive sanitized bucket IDs and hostnames when creating destination buckets, and changes sync_datastores and pull_all to warn-and-continue on individual failures while still returning an error if all attempts fail.

Safe to merge — no P0/P1 findings

Confidence 5/5

No thread-worthy findings. Advisory notes follow; they are retained without opening review threads.

1 advisory finding (summary-only, not scored)

These P2 guard, heuristic, trade-off, or documentation claims are retained for judgment without opening review threads.

⚠️ P2 mediumaw-sync/tests/sync.rs:430

The test test_whitespace_hostname_that_sanitizes_to_unknown_is_skipped_on_pull creates a bucket with hostname " * " and expects it to be skipped. The sanitizer returns "unknown" for " * " because after trimming it's "*", which is not alphanumeric, so it becomes _, then trimmed to empty, then "unknown". The test asserts that no bucket with -synced-from-unknown is created and that a healthy sibling syncs. This test is correct. However, the test test_unknown_hostname_on_pull_returns_error (existing) uses hostname "unknown" and expects an error. With the new code, sanitize_hostname("unknown") returns "unknown", and the guard sanitized_hostname == "unknown" triggers, returning an error. That test still passes. No issue.

How this was verified: Read the test and the sanitizer logic.

Consensus: 2/3 passes agreed — pass 2 looked and disagreed
Distinct keys: 1 (general)

Files changed (5) — the diff as I read it
  • aw-sync/src/lib.rs — Exports the new sanitize_hostname function from the sync module.
  • aw-sync/src/sync.rs — Adds sanitize_hostname, rewrites get_or_create_sync_bucket to sanitize IDs/hostnames with legacy lookup, and makes per-bucket failures non-fatal in sync_datastores.
  • aw-sync/src/sync_wrapper.rs — Makes per-peer failures non-fatal in pull_all, returning an error only if all peers fail.
  • aw-sync/tests/sync.rs — Adds tests for sanitized bucket creation, legacy bucket reuse, unknown sentinel skipping, and updates existing tests for non-fatal behavior.
  • aw-sync/tests/sync_roundtrip.rs — Updates expected bucket ID in roundtrip test to use sanitized lowercase hostname.

Reviewed 8efcf9cf4dd9 · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 104s · about this reviewer

Maintainer commands

@TimeToBuildBob review (own line) — fresh review · @TimeToBuildBob fix — a worker acts on the findings. Once per comment; 👀 = received.

@ErikBjare

Copy link
Copy Markdown
Member

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto master over #700 (d2ae2e6). MERGEABLE. CI in flight on 8efcf9c.

Conflict was the previewed adjacent insert in aw-sync/src/sync.rs: kept both #700's open_peer_datastore/utf8_db_path and this PR's sanitize_hostname.

Folded the deferred one-line: a skipped sync_one now warns that dest may already contain a partial write. The tautological assert in test_unusable_datastore_does_not_panic was already gone in fcd851c (rewritten to expect_err when total-failure became Err).

Local: cargo test -p aw-sync green (lib 27, sync 17, roundtrip 3).

#678 still open and CLEAN; pairwise this vs #678 was clean before, so it should stay mergeable if that lands first. Still pull-only — needs a maintainer click after CI.

Did not re-trigger Greptile (your 14:09 request was on fcd851c; this head is new).

TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 16, 2026
The supervised daemon walks remotes inside sync_run. A single unreadable
peer used `?` and skipped every peer after it, then exited non-zero and
burned aw-qt/aw-tauri's restart budget (ActivityWatch#688).

Partial failure is now warn+continue. Total failure (every peer failed)
is still Err so a down destination is not reported as success.

Git-Session-Id: fcff5116-c5d0-5209-9804-33fc54c5b5a5

ActivityWatch#697 isolates host-layout pull_all; this is the daemon path (ActivityWatch#685 parked).
@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob Resolve conflicts

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob CONFLICTING again — #699 and #678 both landed this morning (master is 3d3b9dd). Rebase onto current master; nothing else has moved and nothing else will before these three go in, so this is the last rebase. Order after rebase: #697#703 (same sync_run region) → #702 any time.

…t errors non-fatal

Two issues block pulling peers whose bucket hostname contains whitespace
(e.g. "POCO F8 Ultra" from Android devices before aw-android#272):

1. `get_or_create_sync_bucket` derived the destination ID from the raw
   hostname. On aw-server-rust destinations, `create_bucket` rejects new
   buckets with whitespace hostnames (ActivityWatch#658), so the first pull of such a
   peer returns HTTP 400, which propagated as `?` and aborted the whole
   sync pass (every subsequent peer also skipped).

Fix: before creating a new bucket, sanitize whitespace → `_` in both the
bucket ID and the hostname field. The legacy (unsanitized) ID is checked
first so any existing pre-sanitization import is reused, avoiding the full
re-import fork (#1373).

2. A single bucket failure with `?` in `sync_datastores` aborted the
   entire pass. All remaining peers were skipped. Blast radius: daemon
   returns Err → exits → supervisor restarts → same failure → budget
   exhausted → ActivityWatch#688.

Fix: per-bucket errors in `sync_datastores` are now non-fatal (warn +
continue). A broken or invalid bucket is skipped; healthy buckets still
sync. Updated two tests that relied on the old fatal-error behavior.

Fixes ActivityWatch#692. Related: ActivityWatch#658, ActivityWatch#685, ActivityWatch#688, ActivityWatch/aw-android#272.

Git-Session-Id: c8bb
Whitespace-only replace ("POCO F8 Ultra" → "POCO_F8_Ultra") would fork
the destination the day aw-android#272 migrates the phone's hostname
column to poco_f8_ultra. Use Android's exact algorithm (lowercase,
[^a-z0-9_-]+ → _, trim _) via a shared sanitize_hostname() that must
stay byte-identical to DeviceHostname.kt.

Also:
- Trigger sanitization when the source hostname has whitespace even if
  the derived ID is already clean ($aw.sync.origin can be sanitized
  while bucket.hostname is not; create_bucket would 400 on the field).
- pull_all: per-peer warn+continue so a failed open does not abort the
  pass and skip every peer after it (ActivityWatch#688 / ActivityWatch#693).

Git-Session-Id: 2097767
Android maps empty/punctuation-only names to the "unknown" sentinel.
A hostname like " * " contains whitespace so the sanitizer runs, then
becomes "unknown", and get_or_create would create -synced-from-unknown
— mixing every such remote into one destination. The hostname=="unknown"
guard in sync_datastores never sees this because the source hostname is
not yet the sentinel.

Refuse on pull; per-bucket warn+continue skips the junk bucket and a
healthy sibling still syncs.

Git-Session-Id: 2679931
…l error

Always run DeviceHostname.kt sanitization, not only on whitespace, so
PIXEL8 and erb-m2.localdomain land on the same IDs Android's migration
will use. Lookup stays raw then sanitized; create under the sanitized
ID when it differs.

sync_datastores and pull_all still skip individual buckets/peers, but
return Err when every attempt fails so a down destination is not
reported as success.

Git-Session-Id: 82e7d9e5-0c8a-58c0-b129-a46f96ef6953
A failed sync_one can leave dest with a partial chunk. The per-bucket
warn+continue now names that, so a skipped bucket is not mistaken for
an untouched skip.

Git-Session-Id: pm-697-rebase-2026-09-16
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/aw-sync-whitespace-hostname branch from 8efcf9c to bff7a2a Compare September 17, 2026 08:57
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto 3d3b9dd (#678 + #699 now in base). MERGEABLE. CI in flight on bff7a2a (8efcf9cbff7a2a; the diff vs. previous head is only the rebase — the five PR commits are unchanged in content, just replayed).

Ordering noted: #697#703#702. Still pull-only — ready for a maintainer click once CI is green.

@ErikBjare

Copy link
Copy Markdown
Member

Re-verified on bff7a2a after the rebase over #699 + #678: on current master, unconditional sanitize_hostname (:438), attempted > 0 && succeeded == 0 → Err in both sync_datastores (:653) and pull_all (:81), unknown-after-sanitize skip, all four tests present — and sync_run returns Result<SyncReport, …>, so it is integrated with #699's report rather than just rebased over it. Nothing further; ready for Erik on green. #703 next.

@ErikBjare
ErikBjare merged commit d1a61b1 into ActivityWatch:master Sep 17, 2026
7 checks passed
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Closing the loop on this dispatch — the rebase ask was delivered on bff7a2a and this merged at 09:15:50Z (merge commit d1a61b12, merged by @ErikBjare).

Post-merge verification (not previously on this thread):

  • CI on the merge commit d1a61b12: 7 success, 4 path-skipped (no failures).
  • ActivityWatch/aw-server-rust#703 landed after it — master is now b0fab735, CI green except Code coverage still running.
  • All review threads (Greptile + in-band AI review) were resolved or dismissed with reasons before merge; nothing outstanding.

No further action from Bob on this PR: the sanitizer / per-peer-continue work is on master, and per your note on ActivityWatch/activitywatch#1445 the aw-tauri pin bump + cut are yours.

TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 17, 2026
…e-import after Android hostname migration

Closes ActivityWatch#707.

When both the raw-ID and sanitized-ID lookups miss in
`get_or_create_sync_bucket`, fall back to scanning the destination for a
`-synced-from-` bucket whose base ID matches and whose `$aw.sync.origin`
sanitizes to the same value.

This recovers the case where a desktop imported an Android peer before ActivityWatch#697
landed (`…-synced-from-POCO F8 Ultra` with `$aw.sync.origin = "POCO F8 Ultra"`),
then the phone ran ActivityWatch/aw-android#273 and its staging hostname became
`poco_f8_ultra` (sanitized, no `$aw.sync.origin` on first-hand buckets). The
two direct lookups both produced `…-synced-from-poco_f8_ultra`, missed, and
created a new bucket — causing a full re-import (every event twice in /timeline).

The fallback scan finds the legacy bucket by matching sanitized origins and
reuses it. When two distinct pre-ActivityWatch#697 buckets share the same sanitized origin
(ambiguous), the function returns an error rather than silently merging distinct
histories (ActivityWatch#697 :368).

Two tests added:
- `test_pre697_origin_scan_resumes_legacy_bucket`: desktop with
  `…-synced-from-POCO F8 Ultra` + `$aw.sync.origin` receives a post-migration
  pull and resumes without creating a fork.
- `test_pre697_origin_scan_refuses_ambiguous_candidates`: two legacy buckets
  with different raw origins that sanitize identically trigger an error.

Git-Session-Id: 3846
ErikBjare pushed a commit that referenced this pull request Sep 18, 2026
…r Android hostname migration (#708)

* fix(aw-sync): scan pre-#697 buckets by origin to avoid re-import after Android hostname migration

Closes #707.

When both the raw-ID and sanitized-ID lookups miss in
`get_or_create_sync_bucket`, fall back to scanning the destination for a
`-synced-from-` bucket whose base ID matches and whose `$aw.sync.origin`
sanitizes to the same value.

This recovers the case where a desktop imported an Android peer before #697
landed (`…-synced-from-POCO F8 Ultra` with `$aw.sync.origin = "POCO F8 Ultra"`),
then the phone ran ActivityWatch/aw-android#273 and its staging hostname became
`poco_f8_ultra` (sanitized, no `$aw.sync.origin` on first-hand buckets). The
two direct lookups both produced `…-synced-from-poco_f8_ultra`, missed, and
created a new bucket — causing a full re-import (every event twice in /timeline).

The fallback scan finds the legacy bucket by matching sanitized origins and
reuses it. When two distinct pre-#697 buckets share the same sanitized origin
(ambiguous), the function returns an error rather than silently merging distinct
histories (#697 :368).

Two tests added:
- `test_pre697_origin_scan_resumes_legacy_bucket`: desktop with
  `…-synced-from-POCO F8 Ultra` + `$aw.sync.origin` receives a post-migration
  pull and resumes without creating a fork.
- `test_pre697_origin_scan_refuses_ambiguous_candidates`: two legacy buckets
  with different raw origins that sanitize identically trigger an error.

Git-Session-Id: 3846

* test(aw-sync): prove resume cursor + ambiguous-skip doesn't abort peer sync

Two tightenings per #707 review:

1. test_pre697_origin_scan_resumes_legacy_bucket: seed the legacy bucket
   with one event at T0, put T0-1h and T0+1h in the source. After sync,
   assert exactly 2 events (existing + new). 3 would mean the cursor was
   ignored and the full history was re-imported.

2. test_pre697_origin_scan_refuses_ambiguous_candidates: add a second,
   healthy source bucket (aw-watcher-window). sync_datastores must return
   Ok overall — the ambiguous android bucket is skipped (warn+continue),
   not a fatal abort. Assert the healthy bucket received events; assert
   neither ambiguous legacy bucket was written to.

Git-Session-Id: eacd

* test(aw-sync): pin ambiguity refusal with a non-empty ambiguous source bucket

The refusal test asserted both legacy candidates received zero events, but the
ambiguous source bucket held no events — so a regression that silently reused
one candidate would also have copied nothing and the assertions would still
pass. Insert an event into the ambiguous source bucket (plus a premise guard
that it really has one) so only an actual skip keeps both legacy buckets at 0.

Git-Session-Id: 9059e8cf-aa8c-5d86-bc5d-0167df2a66b0
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.

Pulling a peer whose bucket hostname contains whitespace 400s and aborts the whole sync pass (activated by #685)

2 participants