feat: Add sync client for device synchronization#111
Conversation
Greptile SummaryThis PR adds a new
Confidence Score: 2/5Not 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
Sequence DiagramsequenceDiagram
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 }"
Reviews (1): Last reviewed commit: "feat: Add sync client for device synchro..." | Re-trigger Greptile |
| 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)}") |
There was a problem hiding this comment.
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.
| from datetime import datetime, timezone | ||
| from typing import Dict, List, Optional |
| if not args.source or not args.target: | ||
| print("Error: --source and --target are required for sync") | ||
| return |
There was a problem hiding this comment.
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 ....
| 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) |
| 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, | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| 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") | ||
|
|
There was a problem hiding this comment.
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.
Summary
Adds a sync client to aw-client for device synchronization.
Features
SyncClientclass for programmatic syncpython -m aw_client.sync sync --source URL --target URLUsage
Closes #35