Add opt-in server sync
This commit is contained in:
@@ -27,6 +27,7 @@ Works on **Arch Linux + Wayland** and is designed to be portable to other projec
|
||||
| `log_commit.py` | Called by git hook; do not run directly usually |
|
||||
| `zed_tracker.py` | Background tracker for Zed activity |
|
||||
| `generate_report.py` | Generate a markdown hours report |
|
||||
| `sync.py` | Opt-in incremental sync of local logs to a Zed Hours server |
|
||||
| `README.md` | This file |
|
||||
|
||||
---
|
||||
@@ -124,6 +125,34 @@ python3 tools/time_tracking/generate_report.py --today
|
||||
|
||||
---
|
||||
|
||||
## Optional server sync
|
||||
|
||||
The tracker remains local-first. To opt in to sending the locally recorded heartbeats and commits to a compatible server, set these environment variables:
|
||||
|
||||
```bash
|
||||
export ZED_HOURS_SERVER_URL="https://hours.example.com"
|
||||
export ZED_HOURS_SERVER_TOKEN="your-bearer-token"
|
||||
export ZED_HOURS_DEVICE_ID="laptop-1"
|
||||
```
|
||||
|
||||
Then run from the tracked project root:
|
||||
|
||||
```bash
|
||||
python3 tools/time_tracking/sync.py
|
||||
```
|
||||
|
||||
The command posts heartbeats to `/api/v1/sync/zed-heartbeats` and commits to `/api/v1/sync/zed-commits`. It sends `device_id`, `project_slug`, and records in batches. The slug defaults to the project root directory name and can be overridden without exposing the absolute local project path:
|
||||
|
||||
```bash
|
||||
python3 tools/time_tracking/sync.py \
|
||||
--project-root /path/to/project \
|
||||
--project-slug customer-portal
|
||||
```
|
||||
|
||||
Progress is recorded locally in `.zed-hours/sync-state.json`. Retrying after a failed request is safe: each record has a deterministic, device-specific `source_id`, allowing the server to treat duplicate submissions as idempotent.
|
||||
|
||||
---
|
||||
|
||||
## How activity detection works on Wayland
|
||||
|
||||
Wayland does not expose a global active-window API for security reasons. The tracker tries several methods, in order:
|
||||
@@ -180,6 +209,6 @@ The scripts detect the project root automatically from the current working direc
|
||||
## Notes
|
||||
|
||||
- This tool is intentionally simple and local. It is not a replacement for a commercial time tracker, but it is a reliable fallback when you forget to log hours.
|
||||
- The tracker does not send data anywhere.
|
||||
- The tracker does not send data anywhere unless you explicitly run `sync.py` with `ZED_HOURS_SERVER_URL`, `ZED_HOURS_SERVER_TOKEN`, and `ZED_HOURS_DEVICE_ID` configured.
|
||||
- Heartbeat granularity is one minute by default. Tune it in `config.json`.
|
||||
- Because of Wayland security, window detection may not give the exact filename. The filesystem fallback compensates for this.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in sync of local Zed Hours JSONL logs to a Zed Hours server.
|
||||
|
||||
The command sends only records that have not been acknowledged locally. Every
|
||||
outgoing record has a deterministic, device-scoped source_id, so replaying a
|
||||
batch after a network failure is safe when the server enforces source_id
|
||||
uniqueness.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
HEARTBEATS_ENDPOINT = "/api/v1/sync/zed-heartbeats"
|
||||
COMMITS_ENDPOINT = "/api/v1/sync/zed-commits"
|
||||
STATE_FILE_NAME = "sync-state.json"
|
||||
DEFAULT_BATCH_SIZE = 100
|
||||
|
||||
|
||||
def get_project_root(project_root: Path | None) -> Path:
|
||||
"""Resolve an explicit root, then use the Git root or current directory."""
|
||||
if project_root is not None:
|
||||
return project_root.resolve()
|
||||
try:
|
||||
root = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
return Path(root).resolve()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
def load_state(state_path: Path) -> dict[str, Any]:
|
||||
if not state_path.exists():
|
||||
return {"version": 1, "files": {}}
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
if isinstance(state, dict) and isinstance(state.get("files"), dict):
|
||||
return state
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
print(f"Warning: ignoring invalid sync state: {state_path}", file=sys.stderr)
|
||||
return {"version": 1, "files": {}}
|
||||
|
||||
|
||||
def save_state(state_path: Path, state: dict[str, Any]) -> None:
|
||||
temporary_path = state_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary_path.replace(state_path)
|
||||
|
||||
|
||||
def source_id(device_id: str, record: dict[str, Any]) -> str:
|
||||
"""Return a stable ID derived from the device and canonical record content."""
|
||||
content = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(f"{device_id}\0{content}".encode()).hexdigest()
|
||||
|
||||
|
||||
def outgoing_record(record: dict[str, Any], device_id: str) -> dict[str, Any]:
|
||||
"""Remove local absolute-path fields and attach the idempotency key."""
|
||||
sanitized = {key: value for key, value in record.items() if key not in {"project", "repo", "source_id"}}
|
||||
sanitized["source_id"] = source_id(device_id, sanitized)
|
||||
return sanitized
|
||||
|
||||
|
||||
def read_batch(log_path: Path, offset: int, batch_size: int, device_id: str) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Read one batch and its final byte offset, skipping malformed JSON lines."""
|
||||
records: list[dict[str, Any]] = []
|
||||
next_offset = offset
|
||||
with log_path.open("rb") as log_file:
|
||||
log_file.seek(offset)
|
||||
while len(records) < batch_size:
|
||||
line = log_file.readline()
|
||||
if not line:
|
||||
break
|
||||
next_offset = log_file.tell()
|
||||
try:
|
||||
parsed = json.loads(line.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
print(f"Warning: skipping malformed JSONL record in {log_path}", file=sys.stderr)
|
||||
continue
|
||||
if not isinstance(parsed, dict):
|
||||
print(f"Warning: skipping non-object JSONL record in {log_path}", file=sys.stderr)
|
||||
continue
|
||||
records.append(outgoing_record(parsed, device_id))
|
||||
return records, next_offset
|
||||
|
||||
|
||||
def post_records(server_url: str, endpoint: str, token: str, device_id: str, project_slug: str, records: list[dict[str, Any]]) -> None:
|
||||
payload = json.dumps(
|
||||
{"device_id": device_id, "project_slug": project_slug, "records": records},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
sync_request = request.Request(
|
||||
f"{server_url.rstrip('/')}{endpoint}",
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with request.urlopen(sync_request, timeout=30) as response:
|
||||
if not 200 <= response.status < 300:
|
||||
raise RuntimeError(f"server returned HTTP {response.status}")
|
||||
except error.HTTPError as exc:
|
||||
response_body = exc.read().decode("utf-8", errors="replace").strip()
|
||||
detail = f": {response_body}" if response_body else ""
|
||||
raise RuntimeError(f"server returned HTTP {exc.code}{detail}") from exc
|
||||
except error.URLError as exc:
|
||||
raise RuntimeError(f"could not reach sync server: {exc.reason}") from exc
|
||||
|
||||
|
||||
def sync_log(
|
||||
log_path: Path,
|
||||
endpoint: str,
|
||||
state: dict[str, Any],
|
||||
state_path: Path,
|
||||
server_url: str,
|
||||
token: str,
|
||||
device_id: str,
|
||||
project_slug: str,
|
||||
batch_size: int,
|
||||
) -> int:
|
||||
if not log_path.exists():
|
||||
print(f"No log file: {log_path}")
|
||||
return 0
|
||||
|
||||
files_state = state["files"]
|
||||
file_key = log_path.name
|
||||
offset = files_state.get(file_key, {}).get("offset", 0)
|
||||
if not isinstance(offset, int) or offset < 0 or offset > log_path.stat().st_size:
|
||||
offset = 0
|
||||
|
||||
synced = 0
|
||||
while True:
|
||||
records, next_offset = read_batch(log_path, offset, batch_size, device_id)
|
||||
if not records:
|
||||
if next_offset != offset:
|
||||
files_state[file_key] = {"offset": next_offset}
|
||||
save_state(state_path, state)
|
||||
return synced
|
||||
|
||||
post_records(server_url, endpoint, token, device_id, project_slug, records)
|
||||
files_state[file_key] = {"offset": next_offset}
|
||||
save_state(state_path, state)
|
||||
synced += len(records)
|
||||
offset = next_offset
|
||||
|
||||
|
||||
def required_setting(name: str) -> str:
|
||||
value = os.environ.get(name, "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{name} must be set")
|
||||
return value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Sync local Zed Hours logs to a server.")
|
||||
parser.add_argument("--project-root", type=Path, default=None, help="Project root directory")
|
||||
parser.add_argument("--project-slug", default=None, help="Server project identifier (defaults to project root name)")
|
||||
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help="Records per request (default: 100)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.batch_size < 1:
|
||||
parser.error("--batch-size must be at least 1")
|
||||
|
||||
try:
|
||||
server_url = required_setting("ZED_HOURS_SERVER_URL")
|
||||
token = required_setting("ZED_HOURS_SERVER_TOKEN")
|
||||
device_id = required_setting("ZED_HOURS_DEVICE_ID")
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
project_root = get_project_root(args.project_root)
|
||||
project_slug = args.project_slug or project_root.name
|
||||
if not project_slug:
|
||||
parser.error("--project-slug must not be empty")
|
||||
|
||||
log_dir = project_root / ".zed-hours"
|
||||
state_path = log_dir / STATE_FILE_NAME
|
||||
state = load_state(state_path)
|
||||
|
||||
try:
|
||||
heartbeat_count = sync_log(
|
||||
log_dir / "heartbeats.jsonl", HEARTBEATS_ENDPOINT, state, state_path,
|
||||
server_url, token, device_id, project_slug, args.batch_size,
|
||||
)
|
||||
commit_count = sync_log(
|
||||
log_dir / "commits.jsonl", COMMITS_ENDPOINT, state, state_path,
|
||||
server_url, token, device_id, project_slug, args.batch_size,
|
||||
)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
print(f"Sync failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Synced {heartbeat_count} heartbeat(s) and {commit_count} commit(s) for project '{project_slug}'.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user