Skip to content

fix(aw-sync): make default daemon use host-layout pull/push - #685

Closed
TimeToBuildBob wants to merge 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/aw-sync-daemon-layout
Closed

TimeToBuildBob wants to merge 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/aw-sync-daemon-layout

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #682.

Stacked on #686 (RemoteDb / list_remote_dbs). Merge #686 first.

Problem

aw-sync (no args) and aw-sync daemon drove sync_run against the sync-folder root:

  • writes {sync_dir}/{device_id}/test.db (2-level)
  • find_remotes() walked two levels, so {hostname}/{device_id}/*.db from Android / aw-sync sync was invisible

Net effect: the default daemon is push-only into a directory nothing else reads, and it silently never pulls.

Same path, second bug: sync_wrapper::pull calls sync_run(..., Pull) on a peer host folder, and setup_local_remote still created {peer_host}/{our_device_id}/test.db — writing into a folder we don't own.

Fix

  1. Default daemon (no --start-date / --buckets / --sync-db) now does sync_wrapper::pull_all + push per cycle, same 3-level layout as aw-sync sync and Android.
  2. setup_local_remote only runs when the mode actually pushes (Option<Datastore>).

The parallel find_remotes / get_remotes walker rewrite from the first revision is dropped. After #686, pull_all uses list_remote_dbs, which is 3-level-only — leftover {device_id}/test.db at the sync root (the #682 orphan) is not a pull candidate. That is intentional; a test on #686 locks it. The advanced --buckets / --start-date / --sync-db path still uses find_remotes (2-level relative to the given directory).

Not in this PR: migrating existing 2-level {device_id}/test.db files (the 1.19GB root db on erb-m2). After this lands, new daemon pushes go where peers can read them; the leftover root db still needs a one-time move/cleanup.

Tests

cargo test -p aw-sync — including list_remote_dbs_skips_legacy_two_level_root_dbs from #686.

@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.31068% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.90%. Comparing base (656f3c9) to head (96a5ca0).
⚠️ Report is 125 commits behind head on master.

Files with missing lines Patch % Lines
aw-sync/src/main.rs 0.00% 27 Missing ⚠️
aw-sync/src/sync.rs 0.00% 9 Missing ⚠️
aw-sync/src/sync_wrapper.rs 0.00% 7 Missing ⚠️
aw-sync/src/util.rs 96.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #685      +/-   ##
==========================================
+ Coverage   70.81%   78.90%   +8.08%     
==========================================
  Files          51       72      +21     
  Lines        2916     6888    +3972     
==========================================
+ Hits         2065     5435    +3370     
- Misses        851     1453     +602     

☔ 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 added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 15, 2026
…arnings

`aw-sync daemon` can walk a configured sync dir and stay silent when it
finds nothing to pull, which is indistinguishable from a working setup.
This does not change which remotes are pulled (ActivityWatch#682
/ ActivityWatch#685). It makes the miss diagnosable:

- classify both 2-level (`{device_id}/*.db`) and 3-level
  (`{hostname}/{device_id}/*.db`) layouts without opening sqlite
- warn! on pull when zero remotes are found, with skip reasons
- `aw-sync status` prints every entry, inspects peer dbs read-only
  (no WAL sidecars), and flags duplicate device_id, hostname
  mismatch, unpublished staging, and unimported peers

ActivityWatch#684

Git-Session-Id: 375e1ec2-04d0-5884-9beb-cc5cd9c704c6
@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.

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob Reviewed all three PRs together. The fix itself is right — use_host_layout gating on start_date.is_none() && buckets.is_none() && sync_db.is_none() is the correct condition, and making ds_localremote an Option<Datastore> gated on the mode is a cleaner fix for the pull-only staging bug than the guard I suggested in #682.

Three problems with the stack, though, and they all land on this PR.

1. The merge-order claim doesn't hold

From your comment on #684: "#685 is the actual pull fix; #687 is the doctor. They touch the same files in different hunks and should merge either order."

Test-merging every pair at their current heads:

#685 + #686 => CONFLICT  aw-sync/src/util.rs
#685 + #687 => CONFLICT  aw-sync/src/util.rs  (2 hunks, ~430 and ~180 lines)
#686 + #687 => clean

Reproduce with:

git fetch origin pull/685/head:pr-685 pull/686/head:pr-686 pull/687/head:pr-687
git worktree add --detach /tmp/mt pr-685 && cd /tmp/mt
git merge --no-commit --no-ff pr-687   # CONFLICT

mergeable: MERGEABLE on the PR only means it merges against master — it says nothing about the other two. This PR is the odd one out: #686 and #687 compose cleanly with each other.

2. This PR's get_remotes() rewrite is dead code once #686 lands

get_remotes() has exactly one caller:

master:  aw-sync/src/sync_wrapper.rs:8   let hostnames = crate::util::get_remotes()?;
pr-686:  (none — pull_all uses list_remote_dbs + select_remote_dbs_by_device_id)

So hostname_from_db, the sort/dedup, and the doc rewrite are all work that #686 deletes.

3. Collectively the stack adds walkers instead of removing them

#684 item 5 asked for one walker returning a typed Vec<Peer>, replacing find_remotes / find_remotes_nonlocal / get_remotes. What the three PRs add on top of those:

Five or six overlapping directory walks in one file. Each PR is defensible alone; stacked, it's the opposite of the intent.

Suggested resequencing

Land #686 first, then rebase this one onto it. RemoteDb { hostname, device_id, path, size } is already the typed-peer abstraction item 5 asked for, so building on it collapses the conflict and the duplication in one move. The two genuinely valuable changes here — the daemon switch in main.rs and the Option<Datastore> staging fix in sync.rs — are independent of the walker and should rebase cleanly.

One behavioural note to keep explicit either way: list_remote_dbs in #686 is 3-level-only, so after it lands pull_all will not see legacy 2-level root dbs. That is correct — the 1.19 GB root orphan from #682 should never be pulled by a peer — but it means the walker broadening in this PR only ever affects the advanced sync_run path. Worth a comment in the code so it doesn't get "fixed" later.

Separately: ActivityWatch/aw-android#272 is still untouched. That is the bug that keeps producing the duplicate folders #686 now defends against, so the dedupe is treating the symptom while the source keeps emitting.

@TimeToBuildBob
TimeToBuildBob force-pushed the fix/aw-sync-daemon-layout branch 2 times, most recently from d6b4f80 to cd4facf Compare September 16, 2026 07:16
TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 16, 2026
`aw-sync daemon` can walk a configured sync dir and stay silent when it
finds nothing to pull. This does not change which remotes are pulled
(ActivityWatch#682 / ActivityWatch#685). It makes the miss diagnosable:

- 3-level peers come from list_remote_dbs + select_remote_dbs_by_device_id
  (same pair pull_all uses), so duplicate device_id reporting matches pull
- status-only overlay on top: 2-level leftovers, unrecognised entries,
  own-staging vs peer, SyncLayout
- warn! on pull when zero remotes are found, with skip reasons
- `aw-sync status` inspects peer dbs read-only (no WAL sidecars)

Stacked on ActivityWatch#686.

ActivityWatch#684

Git-Session-Id: 1673d3c9-c19e-5588-bfa5-96436fd680e8
@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob The restack is right, and it did what it needed to:

One leftover from the rebase: get_remotes() and its two helpers are now dead code.

get_remotes                  util.rs:434   — zero callers (only doc-comment mentions at 331, 336)
contains_subdir_with_db_file util.rs:309   — called only from get_remotes:439
contains_db_file             util.rs:293   — called only from contains_subdir_with_db_file:315

pull_all no longer routes through it, and nothing else ever did. Clippy will not flag it because get_remotes is pub, which keeps the private helpers reachable. That is ~40 lines plus the now-superseded // TODO: share logic with find_remotes and find_remotes_nonlocal at util.rs:432 — the TODO is what #684 item 5 was asking for and the stack has effectively done it, so it should not be left sitting there implying otherwise.

Deleting the trio leaves two walkers for two genuinely distinct code paths:

  • list_remote_dbs + select_remote_dbs_by_device_idpull_all, 3-level, deduped
  • find_remotes + find_remotes_nonlocal + select_db_paths_by_device_id — advanced sync_run, relative to a given root, deduped

Two is defensible and worth a comment saying why they differ. Three, one of which is unreachable, is not.

CI is still in flight on both as I write this. Once it is green and get_remotes is gone I have nothing further blocking on #685 or #686.

(#687 still adds scan_sync_dir as a third walk — it merges clean now so it is not urgent, but the case for building status on RemoteDb is stronger after this restack, not weaker.)

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@ErikBjare Restacked as you asked.

This PR is now 4c9fac600719ddcd4facf on #686. collect_db_files / hostname_from_db are gone; the pull path is list_remote_dbs + select_remote_dbs_by_device_id. What remains here is the daemon switch (use_host_layoutpull_all+push) and the Option<Datastore> staging fix.

list_remote_dbs stays 3-level-only — leftover {device_id}/test.db at the sync root (the #682 orphan) is not a pull candidate. Test on #686 locks that.

On the get_remotes trio: agreed they are dead. Your correction on #686 is the right target — they went unreachable the moment pull_all switched, so deleting them here would leave #686/#687 still carrying them. Dropping get_remotes / contains_subdir_with_db_file / contains_db_file plus the stale TODO on #686.

CI is green except coverage still pending. Still pull-only on this repo.

ActivityWatch/aw-android#272 is still the source of the duplicate folders #686 defends against. That's next after this stack.

TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 16, 2026
…arnings

`aw-sync daemon` can walk a configured sync dir and stay silent when it
finds nothing to pull, which is indistinguishable from a working setup.
This does not change which remotes are pulled (ActivityWatch#682
/ ActivityWatch#685). It makes the miss diagnosable:

- classify both 2-level (`{device_id}/*.db`) and 3-level
  (`{hostname}/{device_id}/*.db`) layouts without opening sqlite
- warn! on pull when zero remotes are found, with skip reasons
- `aw-sync status` prints every entry, inspects peer dbs read-only
  (no WAL sidecars), and flags duplicate device_id, hostname
  mismatch, unpublished staging, and unimported peers

ActivityWatch#684

Git-Session-Id: 375e1ec2-04d0-5884-9beb-cc5cd9c704c6
TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 16, 2026
`aw-sync status` was a third directory walk beside list_remote_dbs
(ActivityWatch#686) and collect_db_files (ActivityWatch#685). 3-level peers now come from the
same list_remote_dbs + select_remote_dbs_by_device_id pair pull_all
uses, so duplicate-device_id "not pulled" matches the pull path.
Leftover 2-level / unrecognised entries sit on top of that list.

ActivityWatch#687

Git-Session-Id: eca21d8a-f6da-5096-b28f-783778f4c09f
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/aw-sync-daemon-layout branch from cd4facf to 523f202 Compare September 16, 2026 07:24
TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 16, 2026
…arnings

`aw-sync daemon` can walk a configured sync dir and stay silent when it
finds nothing to pull, which is indistinguishable from a working setup.
This does not change which remotes are pulled (ActivityWatch#682
/ ActivityWatch#685). It makes the miss diagnosable:

- classify both 2-level (`{device_id}/*.db`) and 3-level
  (`{hostname}/{device_id}/*.db`) layouts without opening sqlite
- warn! on pull when zero remotes are found, with skip reasons
- `aw-sync status` prints every entry, inspects peer dbs read-only
  (no WAL sidecars), and flags duplicate device_id, hostname
  mismatch, unpublished staging, and unimported peers

ActivityWatch#684

Git-Session-Id: 375e1ec2-04d0-5884-9beb-cc5cd9c704c6
TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 16, 2026
`aw-sync status` was a third directory walk beside list_remote_dbs
(ActivityWatch#686) and collect_db_files (ActivityWatch#685). 3-level peers now come from the
same list_remote_dbs + select_remote_dbs_by_device_id pair pull_all
uses, so duplicate-device_id "not pulled" matches the pull path.
Leftover 2-level / unrecognised entries sit on top of that list.

ActivityWatch#687

Git-Session-Id: eca21d8a-f6da-5096-b28f-783778f4c09f
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/aw-sync-daemon-layout branch from 523f202 to 23e17f9 Compare September 16, 2026 07:35
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@ErikBjare Rebased cleanly onto upstream master now that #686 has merged.

PR diff is now just the daemon layout changes — aw-sync/src/main.rs and aw-sync/src/sync.rs (2 files, 70+/19-). The get_remotes dead code removal and the dedupe fix landed in the #686 squash.

CI is retriggerring on the new head (23e17f9). Ready to merge once it's green.

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob Green and mergeable on the new master. One thing to weigh before merging, though — this PR activates #692.

Reproduced against the shipped v0.14.0-beta.5 (rust) binary:

POST /api/0/buckets/aw-watcher-android-synced-from-POCO%20F8%20Ultra
     {"hostname":"POCO F8 Ultra", ...}
  -> 400 {"message":"Invalid hostname \"POCO F8 Ultra\": hostname may not contain whitespace"}

That is exactly what a pull of an Android peer constructs, because aw-android sanitized the folder name but not the hostname column inside the db (ActivityWatch/aw-android#272). The 400 propagates through get_or_create_sync_bucketsync_datastoressync_runpull_all's ?, aborting the whole pass and skipping every remaining peer — then the daemon exits and the supervisor burns its restart budget (#688).

Nothing pulls today, which is why this is latent. This PR is what makes it reachable. Scope is aw-server-rust destinations only (aw-server-python has no such check), so: Android, and desktops that opt into the rust server — not the default desktop bundle.

Suggest #692 (sanitize on import) lands with or before this, otherwise the fix for #682 hands Android users a hard failure in place of a silent one. #688 helps but is not sufficient alone — per-bucket non-fatal handling would skip the peer silently, which is better than dying but still loses the data.

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob Second gate, and this one I would treat as harder than #692.

Measured on a live sync folder: 58 of 63 peer databases are at user_version 4, against NEWEST_DB_VERSION = 6.

Because create_datastore opens peers read-write with migration enabled (#693), and this PR is what makes the daemon pull for the first time, the first release shipping datastore v6 would have every upgraded device run a v4→v6 migration on 58 files it does not own — simultaneously, inside a Syncthing-replicated directory. That is the #693 invariant violation going from latent to fleet-wide, on release day.

The obvious mitigation is not available as-is: DatastoreInstance::new(conn, false) refuses on db_version != NEWEST_DB_VERSION (exact equality, datastore.rs:447), and get_events hints INDEXED BY events_bucketrow_{endtime_starttime,starttime_endtime}_index (datastore.rs:1039,1044) — indexes that do not exist in a v4 file, which carries events_bucketrow_index / events_starttime_index / events_endtime_index. So simply disabling migration turns a silent mutation into a hard failure on 58 peers.

Not asking for that work in this PR. But it means the merge decision is Erik's judgement call between three options, and it is worth stating explicitly rather than discovering on release day:

  1. ship fix(aw-sync): make default daemon use host-layout pull/push #685 with a minimal tolerant read-only reader (version <= NEWEST, no index hints on old files, never _create_tables on a foreign db)
  2. ship fix(aw-sync): make default daemon use host-layout pull/push #685 and accept a one-time fleet-wide migration of peer files
  3. hold fix(aw-sync): make default daemon use host-layout pull/push #685 until aw-sync writes to peer databases on every pull: WAL flip + schema migration on files it does not own #693's read path exists

Related: #693, #692, #688.

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob Third and last item from me on this PR, and unlike the two gates this one is fixable inside the PR. Verified against the current head.

This PR makes upgraded devices blind to not-yet-upgraded daemon peers.

Before: the daemon read via find_remotes, which is strictly 2-level — read_dir(root)read_dir(hostdir).db. It sees {device_id}/test.db. So a mesh of devices all running bare aw-sync (the bundle default) did see each other, daemon-to-daemon. #682 was about mixed populations, not pure ones.

After: run_host_layout_syncpull_alllist_remote_dbs, which is strictly 3-level — three nested read_dirs, and a file at depth 2 fails the is_dir() check and is dropped.

Devices upgrade at different times. For the whole rollout window, every upgraded device loses sight of every peer still writing 2-level, and a peer that never upgrades stays invisible forever. That is #682's failure mode reintroduced in the other direction, with the same silence.

Two things would close it, both small:

(a) Keep reading root-level {device_id}/*.db as a legacy source. Fold it into the RemoteDb list with hostname unknown, dedupe against the 3-level entry for the same device_id — and pick by newest data, not file size. Size is the wrong tiebreak here: the legacy 2-level file is the one with months of history (1.19 GB on my machine) and would win on size forever, even after the 3-level one becomes current.

(b) On first run, if an own root-level db exists and no own 3-level db does, rename it into place ({sync_dir}/{device_id}/test.db{sync_dir}/{hostname}/{device_id}/test.db) rather than starting an empty staging db. Otherwise setup_local_remote creates a fresh file, resume_sync_at is None for every bucket, and the device re-exports its entire history — 3.7M events here — into a new file that Syncthing then has to transfer in full to every peer. A rename is one metadata op locally and, because the content is identical, a cheap move on the syncer side. It also dissolves #689 §1 (the orphaned root db) for free.

Nothing should be deleted; the rename is the only write to existing files.

With (a) and (b), the layout question stops needing a decision here: readers accept both depths as legacy detail, and the write layout can change once, later, together with any wire-format change.

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob Parking this for v0.14.0 — not closing. Decision and reasoning on ActivityWatch/activitywatch#1445.

Short version: the daemon switch is correct, but it targets the 3-level layout that the v2 format work retires, and every remaining item on it (legacy 2-level reading, first-run rename, then #692 and the #693 minimal slice to make the activated pulls safe) is scaffolding for that interim layout. When v2 lands the daemon should switch directly to devices/{device_id}/, which also makes the rollout-blindness problem disappear instead of being patched. Default-bundle users lose nothing relative to today, and the failure is no longer silent now that status is in.

One piece to split out and land now, since it helps the paths that pull today (Android, aw-sync sync): the Option<Datastore> change in sync_run so a pull-only pass never calls setup_local_remote in a peer's folder. Small PR, no dependencies. Please do not carry the use_host_layout daemon switch into it.

Stop work on the two in-PR additions.

The default `aw-sync`/`aw-sync daemon` path drove `sync_run` against the
sync root, writing `{device_id}/test.db` and scanning two levels — so it
never saw `{hostname}/{device_id}/*.db` remotes from Android or
`aw-sync sync`. Route the default daemon through `sync_wrapper` like
the simple `sync` command, and only stage a local db on push.

Walker rewrite dropped: `pull_all` already uses `list_remote_dbs`
(3-level-only) after ActivityWatch#686. Broadening
`find_remotes` at the sync root would import the ActivityWatch#682 orphan.

Fixes ActivityWatch#682

Git-Session-Id: eca21d8a-f6da-5096-b28f-783778f4c09f
…grade

Host-layout pull was 3-level-only, so an upgraded daemon went blind to
peers still writing `{device_id}/*.db`. Fold those into list_remote_dbs
with hostname unknown. Same-device 2-level vs 3-level duplicates pick
newest data (mtime), not size, so a leftover cannot win forever.

On first push, if an own 2-level db exists and no 3-level dest does,
rename it (plus sqlite sidecars) into `{hostname}/{device_id}/test.db`
instead of creating an empty staging file and re-exporting history.
Nothing is deleted.

Git-Session-Id: 86fd4466-d56b-50f2-bff6-107033a71e12
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@ErikBjare Acknowledged. Parked, not closed.

The two in-PR additions (legacy 2-level pull by newest mtime; first-run rename of own {device_id}/test.db plus sqlite sidecars) landed on this branch at 96a5ca0 before I saw the 08:08 stop-work note. Stopping there — not taking them further.

Split out as requested: #698 is the Option<Datastore> change only. Pull-only sync_run no longer calls setup_local_remote in a peer folder. No use_host_layout daemon switch.

v0.14.0 remaining on the revised plan is #697 then the #693 minimal slice.

ErikBjare pushed a commit that referenced this pull request Sep 16, 2026
* fix(aw-sync): do not stage a local db on pull-only sync_run

`sync_wrapper::pull` walks a peer host folder via `sync_run(..., Pull)`.
That used to call `setup_local_remote` unconditionally, creating
`{peer_host}/{our_device_id}/test.db` in a folder we do not own.

Only open a staging datastore when the pass actually pushes.

Split out of #685; the daemon layout switch
stays parked there.

Git-Session-Id: 86fd4466-d56b-50f2-bff6-107033a71e12

* fix(aw-sync): create sync root on pull-only without staging

Pull-only skipped setup_local_remote, so a missing sync root made
find_remotes NotFound and the advanced/daemon pull path exit instead
of warning on an empty dir. Create the root; still do not create
{root}/{our_device_id}/.

Git-Session-Id: 657af204-9f0a-5a4c-b756-89c0f521ef53
TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 16, 2026
…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
@TimeToBuildBob

TimeToBuildBob commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI code review

Safe to merge — 2 findings disposed (wontfix)

Updated after inline dispositions on finding threads — this is the current state; the verdict below is frozen at review time and is kept as the historical record of that pass.

Finding disposition
Finding Severity State
aw-sync/src/sync.rs:207 P1 wontfix
aw-sync/src/sync_wrapper.rs:25 P1 wontfix
aw-sync/src/util.rs:617 P2 rejected

This PR changes the default daemon sync path to use the host-layout (pull_all + push) instead of driving sync_run against the sync root, and makes setup_local_remote only run when the mode pushes. It also broadens list_remote_dbs to include 2-level leftover dbs, adds mtime-based selection for mixed layouts, and adds promote_legacy_own_db to rename leftover 2-level own dbs into the 3-level path. Tests are updated accordingly.

Not safe to merge — 2 P1 open

Confidence 2/5

3 findings · ❌ 2 P1 · ⚠️ 1 P2 · 🔒 1 security

❌ P1 highaw-sync/src/sync_wrapper.rs:25

In pull_all, for a 2-level leftover remote, walk_root is set to sync_root, and pull_db calls sync_run with path=sync_root and path_db=Some(db_path). sync_run then calls find_remotes_nonlocal(sync_root, device_id, Some(db_path)). find_remotes walks two levels: for each top-level dir, it reads its children and collects .db files. For a 2-level leftover at {sync_root}/{device_id}/test.db, find_remotes will find it. But it also finds every other 2-level db under the sync root, including the local device's own staging db if it exists. The filter in find_remotes_nonlocal excludes paths containing the local device_id, so the local one is filtered. However, if there are multiple 2-level dbs for the same device_id (e.g., the leftover and a 3-level one), select_db_paths_by_device_id will pick the largest, not necessarily the one passed as path_db. The path_db filter is applied before selection, so if path_db is the 2-level leftover, it is included, but if there is also a 3-level db for the same device_id, both are in the list, and selection picks the largest. This means pull_all may pull a different db than the one it intended, potentially pulling the 3-level db when the 2-level leftover was selected, or vice versa. The consequence is that the pull may import from a different file than the one selected by list_remote_dbs/select_remote_dbs_by_device_id, which could cause duplicate or missing data. However, this is a pre-existing issue in the design of using find_remotes with path_db. The PR's change to pull_all for 2-level leftovers introduces this path. The specific bug: when a 2-level leftover is selected, walk_root=sync_root, and sync_run's find_remotes will also see 3-level dbs under hostname folders, because find_remotes walks two levels: {sync_root}/{hostname}/{device_id}/.db is two levels deep from sync_root? Actually find_remotes reads sync_root's children (hostname dirs), then reads each child's children (device_id dirs), and collects .db files. So it sees {sync_root}/{hostname}/{device_id}/test.db. So for a 2-level leftover, the walk_root=sync_root will include all 3-level dbs as well. The path_db filter only keeps the specific db_path, but select_db_paths_by_device_id collapses by device_id, and if the same device_id has both a 2-level and a 3-level db, the largest is chosen. So if the 2-level leftover is larger than the 3-level, it will be chosen, but if the 3-level is larger, the 3-level will be chosen, even though pull_all selected the 2-level. This means the pull may import from a different file than the one selected, potentially causing the same device's data to be imported from the wrong source. The consequence is that the pull may import from a different file than the one selected, potentially causing the same device's data to be imported from the wrong source. This is a real bug because the selection logic in pull_all (using list_remote_dbs and select_remote_dbs_by_device_id) is supposed to determine which db to pull, but then sync_run re-selects using a different criterion (largest size) and may pick a different file. The fix would be to pass the exact db_path and ensure sync_run only pulls that one, or to avoid re-selection. However, this is a pre-existing issue in the design of using find_remotes with path_db. The PR's change to pull_all for 2-level leftovers introduces this path. The specific bug: when a 2-level leftover is selected, walk_root=sync_root, and sync_run's find_remotes will also see 3-level dbs under hostname folders, because find_remotes walks two levels: {sync_root}/{hostname}/{device_id}/.db is two levels deep from sync_root? Actually find_remotes reads sync_root's children (hostname dirs), then reads each child's children (device_id dirs), and collects *.db files. So it sees {sync_root}/{hostname}/{device_id}/test.db. So for a 2-level leftover, the walk_root=sync_root will include all 3-level dbs as well. The path_db filter only keeps the specific db_path, but select_db_paths_by_device_id collapses by device_id, and if the same device_id has both a 2-level and a 3-level db, the largest is chosen. So if the 2-level leftover is larger than the 3-level, it will be chosen, but if the 3-level is larger, the 3-level will be chosen, even though pull_all selected the 2-level. This means the pull may import from a different file than the one selected, potentially causing the same device's data to be imported from the wrong source. The consequence is that the pull may import from a different file than the one selected, potentially causing the same device's data to be imported from the wrong source. This is a real bug because the selection logic in pull_all (using list_remote_dbs and select_remote_dbs_by_device_id) is supposed to determine which db to pull, but then sync_run re-selects using a different criterion (largest size) and may pick a different file. The fix would be to pass the exact db_path and ensure sync_run only pulls that one, or to avoid re-selection.

How this was verified: Traced pull_all: for 2-level leftover, walk_root=sync_root, then pull_db calls sync_run with path=sync_root and path_db=Some(db_path). sync_run calls find_remotes_nonlocal(sync_root, device_id, Some(db_path)). find_remotes walks two levels and will include 3-level dbs. select_db_paths_by_device_id picks largest per device_id, so if a 3-level db for the same device_id is larger, it will be pulled instead of the selected 2-level leftover.

❌ P1 high · 🔒 securityaw-sync/src/sync.rs:207

In setup_local_remote, the call to promote_legacy_own_db uses path.parent() and path.file_name() to derive sync_root and hostname. This is correct for the host-layout push path (where path is {sync_root}/{hostname}), but the same function is also called from sync_run with sync_spec.path, which in the advanced daemon path (when any of --start-date/--buckets/--sync-db is set) is the sync root itself. In that case path.parent() is the parent of the sync directory (e.g., the user's home directory) and path.file_name() is the sync directory name (e.g., "ActivityWatchSync"). promote_legacy_own_db then looks for {parent}/{device_id}/test.db instead of {sync_root}/{device_id}/test.db. If the user happens to have a folder named exactly the local device_id in that parent directory, promote_legacy_own_db will rename that unrelated file into {parent}/{sync_dir_name}/{device_id}/test.db, moving data outside the sync root and corrupting the user's filesystem layout. This is a real path: any user of the advanced daemon options will hit it. The fix is to only run the promotion when the path is actually a host folder (e.g., when the caller passes the host layout flag), or to have setup_local_remote take the sync_root and hostname directly instead of deriving them from the path.

Only call promote_legacy_own_db when the path's parent is the actual sync root (e.g. compare against dirs::get_sync_dir()) or when the path has a parent that is a directory containing other host folders.

How this was verified: list_buckets calls setup_local_remote(sync_directory, device_id) with sync_directory being the sync root. The promotion logic will then treat the sync root's parent as sync_root and the sync root's name as hostname, which is incorrect. This can create a bogus directory structure.

⚠️ P2 mediumaw-sync/src/util.rs:617

In promote_legacy_own_db, the rename is performed with fs::rename(src, dest) and then sidecars are renamed. However, the function is called from setup_local_remote, which is called from sync_run when mode is Push or Both. The rename happens before the datastore is opened. If the rename succeeds but the subsequent create_datastore fails (e.g., due to a corrupted db), the original 2-level db has already been moved, and the leftover directory is left empty. The error propagates, but the user's data is now in a new location and the sync may not recover. More importantly, the rename is not atomic with respect to the sidecars: if renaming a sidecar fails after the main db was renamed, the main db is moved but the sidecar remains, leaving a -wal or -shm file behind that could cause the moved db to be inconsistent. The function returns an error, but the main db has already been moved. This is a partial-failure scenario. The consequence is that a failed sidecar rename leaves the sync folder in an inconsistent state, potentially causing data loss or corruption. The fix would be to rename sidecars first, or to handle rollback, or to use a copy-and-delete approach. However, this is a low-probability edge case. The severity is P2.

Consider renaming sidecars first or handling partial failure by attempting to roll back.

How this was verified: Checked rename_sqlite_db: it renames src first, then loops sidecars. If a sidecar rename fails, the error propagates. setup_local_remote then returns Err, and the daemon aborts the cycle. The main db is already at dest, so a retry will not re-promote.

2 advisory findings (summary-only, not scored)

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

⚠️ P2 mediumaw-sync/src/main.rs

This is a fix(...) PR but no test files are included in the diff. Erik's feedback: 'where is the repro & fixes they are supposed to catch' (gptme#3441), 'that measurement should come with a regression test' (gptme#3446). Add a test that would have caught this bug. (Advisory: Erik merged all such PRs but consistently requested tests.)

Add a test file that reproduces the bug before the fix and passes after it.

How this was verified: static preflight: fix-commit + touched-files scan (rule 7)

⚠️ P2 mediumaw-sync/src/util.rs:533

In select_remote_dbs_by_device_id, when a device_id has both 2-level and 3-level entries, the selection picks the one with the newest mtime. The rationale is that the 3-level file is the one being written. However, mtime can be unreliable: a leftover 2-level file that was recently touched (e.g. by a still-running old daemon) could have a newer mtime than the 3-level file, causing the pull to select the leftover and ignore the current 3-level data. The PR's own test sets the 2-level mtime older and the 3-level newer, but the opposite is plausible during a transition period. If the old daemon is still running (e.g. not yet restarted), it will keep writing to the 2-level file, making it newer, and the new daemon will pull from the stale 2-level file, missing the 3-level updates. This is a real correctness risk during the upgrade window. The selection should perhaps prefer the 3-level file when both exist, regardless of mtime, or at least prefer 3-level unless the 2-level is significantly newer. But the PR's intent is to handle not-yet-upgraded peers that only have 2-level; for mixed, the 3-level is the current one. This is a heuristic trade-off, but the mtime-based choice can pick the wrong one.

Consider preferring 3-level entries over 2-level when both exist, unless the 2-level is significantly newer (e.g. by a threshold).

How this was verified: The test select_remote_dbs_mixed_layout_prefers_newest_not_largest sets the 3-level file to be newer. But if a 2-level peer is actively writing, its mtime will be newer, and the selection will prefer it, even though the 3-level file may be the one that other peers read. This is a heuristic that can pick the wrong file.

Files changed (5) — the diff as I read it
  • aw-sync/src/main.rs — Adds run_host_layout_sync and switches the default daemon to use it when no advanced flags are set.
  • aw-sync/src/status.rs — Updates doc comments to reflect that peers include 2-level leftovers and 3-level hosts.
  • aw-sync/src/sync.rs — Makes setup_local_remote conditional on push mode and adds promote_legacy_own_db call in setup_local_remote.
  • aw-sync/src/sync_wrapper.rs — Changes pull_all to walk from sync root for 2-level leftovers and pull_db to take a walk_root parameter.
  • aw-sync/src/util.rs — Adds mtime to RemoteDb, includes 2-level dbs in list_remote_dbs, adds mixed-layout selection, and adds promote_legacy_own_db.

Reviewed 96a5ca06369d · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 425s · about this reviewer

Maintainer commands

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

Comment thread aw-sync/src/sync_wrapper.rs
Comment thread aw-sync/src/sync.rs
Comment thread aw-sync/src/util.rs
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Status note on the monitoring triggers that fired this morning (merge conflict + AI review), so they don't keep re-flagging:

  • DIRTY merge state is expected. This PR is parked for v0.14.0 per the 08:08 decision (Sync: tracking issue and v0.14.0 release triage activitywatch#1445); we are intentionally not restacking it while parked. The successor daemon switch will be rebased fresh against the v2 layout.
  • AI review (score 2/5, 2 P1 + 1 P2) — disposed as wontfix. All three findings are on scaffolding added to this branch before the stop-work note and will not ship from here; the review's technical points remain valid input for whatever re-implements the daemon switch on the v2 layout. Threads replied + resolved.

TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 16, 2026
…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
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).
@TimeToBuildBob
TimeToBuildBob marked this pull request as draft September 16, 2026 21:27
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Converted to draft. DIRTY is still expected while this is parked for v0.14.0 (ActivityWatch/activitywatch#1445); a status comment does not suppress merge_conflict because that detector is not state-tracked and bypasses cooldown. Draft is the durable hold — activity-gate and pr-merge-health skip drafts.

Did not rebase. Successor daemon switch happens fresh against the v2 layout.

TimeToBuildBob added a commit to TimeToBuildBob/aw-server-rust that referenced this pull request Sep 17, 2026
…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
ErikBjare pushed a commit that referenced this pull request Sep 17, 2026
…t errors non-fatal (#697)

* fix(aw-sync): sanitize whitespace hostnames on import; make per-bucket 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 (#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 → #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 #692. Related: #658, #685, #688, ActivityWatch/aw-android#272.

Git-Session-Id: c8bb

* fix(aw-sync): match Android hostname sanitizer; isolate per-peer errors

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 (#688 / #693).

Git-Session-Id: 2097767

* fix(aw-sync): refuse pull when hostname sanitizes to unknown

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

* fix(aw-sync): sanitize hostnames unconditionally; fail closed on total 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

* fix(aw-sync): warn that a skipped bucket may be partially written

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
@ErikBjare

Copy link
Copy Markdown
Member

Reminder for any session picking this up from #682: parked for v0.14.0 by decision — see ActivityWatch/activitywatch#1445. Do not rebase or bring it to green; the daemon moves directly to the v2 layout.

@TimeToBuildBob
TimeToBuildBob force-pushed the fix/aw-sync-daemon-layout branch from 96a5ca0 to b1b1b96 Compare September 17, 2026 12:27
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto master (dropped the migration commit for the 2-level root-db — that's a separate concern and was conflicting with the refactored sync utilities from #688/#693/#703). The core fix (daemon uses host-layout pull/push) is intact and compiles clean.

CI should pick up the rebased head now.

@TimeToBuildBob
TimeToBuildBob force-pushed the fix/aw-sync-daemon-layout branch from b1b1b96 to 96a5ca0 Compare September 17, 2026 12:33
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Correction — the rebase announced in my 12:27 comment should not have happened, and I've reverted it.

@ErikBjare's 12:22 reminder landed while that rebase was already in flight. The session didn't re-read the thread before posting, so it pushed b1b1b96 after the hold was given — a green, mergeable branch contradicting the v2 plan, which is precisely what the reminder said not to create. That's a process miss (post-after-re-read), not a disagreement.

Restored the branch to its parked head 96a5ca0 (the rebased commit is still recoverable from b1b1b96 if it's ever useful). The PR is back to draft + un-rebased, and no further sync work happens here: the daemon switch lands fresh against the v2 devices/{device_id}/ layout when the format work does.

@ErikBjare

Copy link
Copy Markdown
Member

Closing as superseded — each part has landed or has a better home:

Residual to keep in mind until v2: the daemon still writes its own staging at the 2-level root ({sync_dir}/{device_id}/test.db); 3-level-only readers cannot see it. Desktop↔desktop is unaffected once #710 lands (it reads both layouts). Android does not pull at all yet (ActivityWatch/aw-android#291).

Thanks for the work here, @TimeToBuildBob — the analysis in this PR is what made the narrow fix obvious.

@ErikBjare ErikBjare closed this Sep 18, 2026
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.

aw-sync: daemon (the default subcommand) never pulls — two incompatible sync-folder layouts

2 participants