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 |
|
| `log_commit.py` | Called by git hook; do not run directly usually |
|
||||||
| `zed_tracker.py` | Background tracker for Zed activity |
|
| `zed_tracker.py` | Background tracker for Zed activity |
|
||||||
| `generate_report.py` | Generate a markdown hours report |
|
| `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 |
|
| `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
|
## 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:
|
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
|
## 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.
|
- 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`.
|
- 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.
|
- 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