Skip to content

feat: Add sync client for device synchronization#111

Open
mkcash wants to merge 1 commit into
ActivityWatch:masterfrom
mkcash:feature/sync-client
Open

feat: Add sync client for device synchronization#111
mkcash wants to merge 1 commit into
ActivityWatch:masterfrom
mkcash:feature/sync-client

Conversation

@mkcash
Copy link
Copy Markdown

@mkcash mkcash commented May 26, 2026

Summary

Adds a sync client to aw-client for device synchronization.

Features

  • SyncClient class for programmatic sync
  • CLI tool: python -m aw_client.sync sync --source URL --target URL
  • Bidirectional sync support

Usage

python -m aw_client.sync sync --source http://device-a:5600 --target http://device-b:5600

Closes #35

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 26, 2026

Greptile Summary

This PR adds a new aw_client/sync.py module introducing a SyncClient class and a CLI entry point (python -m aw_client.sync) for syncing bucket data between ActivityWatch instances on different devices.

  • The sync client targets /api/0/sync/export, /api/0/sync/import, and /api/0/sync/status endpoints that do not exist in aw-server — the live endpoints are /api/0/export and /api/0/import (already used by ActivityWatchClient.export_all and import_bucket).
  • sync_bidirectional accepts a single api_key shared across both devices, but each aw-server instance generates its own local key, so one sync direction will always fail authentication.

Confidence Score: 2/5

Not safe to merge — the client calls endpoints that don't exist in aw-server, and bidirectional sync will fail authentication when the two devices have different API keys (the common case).

The core sync functionality will not work: every HTTP call goes to /api/0/sync/* paths that aw-server does not expose, and the bidirectional helper forces a single API key across two servers that normally each have their own. These are not edge-case regressions but fundamental blockers for the feature's stated purpose.

aw_client/sync.py requires attention — both the endpoint paths and the API key handling need to be revisited before the feature can work end-to-end.

Important Files Changed

Filename Overview
aw_client/sync.py New sync client that calls non-existent aw-server endpoints (/api/0/sync/*), uses a single shared API key for bidirectional sync between devices that will typically have different keys, and duplicates HTTP logic already in ActivityWatchClient.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI (aw_client.sync)
    participant SC as SyncClient
    participant SRC as Source aw-server
    participant TGT as Target aw-server

    CLI->>SC: sync(source_url, target_url)
    SC->>SRC: GET /api/0/sync/export
    SRC-->>SC: "{ buckets: {...} }"
    SC->>TGT: POST /api/0/sync/import
    TGT-->>SC: "{ imported: N, skipped: M }"
    SC-->>CLI: "{ exported_buckets, imported, skipped }"

    note over SC,TGT: Bidirectional sync repeats in reverse
    SC->>TGT: GET /api/0/sync/export
    TGT-->>SC: "{ buckets: {...} }"
    SC->>SRC: POST /api/0/sync/import
    SRC-->>SC: "{ imported: N, skipped: M }"
Loading

Reviews (1): Last reviewed commit: "feat: Add sync client for device synchro..." | Re-trigger Greptile

Comment thread aw_client/sync.py
Comment on lines +37 to +44
logger.info(f"Exporting from {self.source_url}")
return self._get(f"{self.source_url}/api/0/sync/export")

def import_to_target(self, data: dict) -> dict:
"""Import data to the target server."""
logger.info(f"Importing to {self.target_url}")
result = self._post(f"{self.target_url}/api/0/sync/import", data)
logger.info(f"Imported {result.get('imported', 0)} events, skipped {result.get('skipped', 0)}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Wrong API endpoint paths

SyncClient calls /api/0/sync/export, /api/0/sync/import, and /api/0/sync/status, but these endpoints do not exist in aw-server. The existing ActivityWatchClient in client.py uses /api/0/export for export and /api/0/import for import (see export_all() and import_bucket()). Every call through this client will receive a 404 unless a corresponding aw-server change is shipped simultaneously — and there is no mention of it in this PR.

Comment thread aw_client/sync.py
Comment on lines +7 to +8
from datetime import datetime, timezone
from typing import Dict, List, Optional
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unused imports — datetime and timezone are imported but never referenced anywhere in the module.

Suggested change
from datetime import datetime, timezone
from typing import Dict, List, Optional
from typing import Dict, List, Optional

Comment thread aw_client/sync.py
Comment on lines +105 to +107
if not args.source or not args.target:
print("Error: --source and --target are required for sync")
return
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 CLI error path exits with code 0 instead of a non-zero code. Using return after printing an error message leaves $? as 0, which breaks shell scripts that check for failure via if ! python -m aw_client.sync sync ....

Suggested change
if not args.source or not args.target:
print("Error: --source and --target are required for sync")
return
if not args.source or not args.target:
print("Error: --source and --target are required for sync")
raise SystemExit(1)

Comment thread aw_client/sync.py
Comment on lines +62 to +75
client_ab = SyncClient(device_a, device_b, api_key)
client_ba = SyncClient(device_b, device_a, api_key)

logger.info("Syncing A -> B")
result_ab = client_ab.sync()

logger.info("Syncing B -> A")
result_ba = client_ba.sync()

return {
"a_to_b": result_ab,
"b_to_a": result_ba,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Single shared API key for bidirectional sync

sync_bidirectional accepts one api_key and passes it to both SyncClient(device_a, device_b, api_key) and SyncClient(device_b, device_a, api_key). In practice, each aw-server instance generates its own local API key (see load_local_server_api_key in client.py), so the two devices will typically have different keys. One of the two sync directions will authenticate against the wrong key and receive 401 errors. The function signature should accept separate keys for each device, or the caller should be warned about this constraint.

Comment thread aw_client/sync.py
Comment on lines +15 to +58
class SyncClient:
"""Client for syncing ActivityWatch data between devices."""

def __init__(self, source_url: str, target_url: str, api_key: Optional[str] = None):
self.source_url = source_url.rstrip("/")
self.target_url = target_url.rstrip("/")
self.headers = {}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"

def _get(self, url: str) -> dict:
resp = requests.get(url, headers=self.headers, timeout=30)
resp.raise_for_status()
return resp.json()

def _post(self, url: str, data: dict) -> dict:
resp = requests.post(url, json=data, headers=self.headers, timeout=60)
resp.raise_for_status()
return resp.json()

def export_from_source(self) -> dict:
"""Export all data from the source server."""
logger.info(f"Exporting from {self.source_url}")
return self._get(f"{self.source_url}/api/0/sync/export")

def import_to_target(self, data: dict) -> dict:
"""Import data to the target server."""
logger.info(f"Importing to {self.target_url}")
result = self._post(f"{self.target_url}/api/0/sync/import", data)
logger.info(f"Imported {result.get('imported', 0)} events, skipped {result.get('skipped', 0)}")
return result

def sync(self) -> dict:
"""Full sync: export from source, import to target."""
data = self.export_from_source()
result = self.import_to_target(data)
result["exported_buckets"] = len(data.get("buckets", {}))
return result

def status(self, url: Optional[str] = None) -> dict:
"""Get sync status from a server."""
base = (url or self.source_url).rstrip("/")
return self._get(f"{base}/api/0/sync/status")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Duplicates HTTP client logic already in ActivityWatchClient

SyncClient hand-rolls its own _get/_post methods and manages auth headers manually, duplicating what ActivityWatchClient in client.py already provides (including export_all() and import_bucket()). Building SyncClient on top of two ActivityWatchClient instances would reuse connection handling, auth, error logging via always_raise_for_request_errors, and the Content-type header that this implementation omits on POST requests.

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