|
|
| """
|
| Submit .ai-log/session.jsonl to grading server.
|
| Called by git pre-push hook or manually.
|
|
|
| After a successful submit, the live log is rotated:
|
| - Moved into .ai-log/archive/YYYY-MM-DD.jsonl (appended, never overwritten)
|
| - The live session.jsonl is recreated empty by the next hook write
|
|
|
| If the POST fails, the pending file is restored so nothing is lost.
|
| """
|
| import json
|
| import os
|
| import shutil
|
| import sys
|
| import time
|
| import urllib.request
|
| import urllib.error
|
| from datetime import datetime, timezone
|
| from pathlib import Path
|
|
|
| try:
|
| from dotenv import load_dotenv
|
| load_dotenv()
|
| except ImportError:
|
| pass
|
|
|
| SERVER_URL = os.environ.get("AI_LOG_SERVER", "")
|
| API_KEY = os.environ.get("AI_LOG_API_KEY", "")
|
| LOG_DIR = Path(os.environ.get("AI_LOG_DIR", ".ai-log"))
|
| LOG_FILE = LOG_DIR / "session.jsonl"
|
| ARCHIVE_DIR = LOG_DIR / "archive"
|
|
|
|
|
|
|
|
|
| BATCH_LIMIT = 500
|
|
|
|
|
| def _archive(pending: Path) -> None:
|
| """Append pending file to today's archive. Never overwrites existing data."""
|
| if not pending.exists() or pending.stat().st_size == 0:
|
| return
|
| ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
| today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| archive_file = ARCHIVE_DIR / f"{today}.jsonl"
|
| with open(pending, "rb") as src, open(archive_file, "ab") as dst:
|
| shutil.copyfileobj(src, dst)
|
|
|
|
|
| def _restore_pending(pending: Path) -> None:
|
| """Failure path: put pending back at LOG_FILE so the next push retries.
|
| If hook wrote new entries to LOG_FILE in the meantime, prepend pending."""
|
| if not pending.exists():
|
| return
|
| if LOG_FILE.exists():
|
|
|
| tmp = LOG_FILE.with_suffix(".merge.jsonl")
|
| with open(tmp, "wb") as out:
|
| with open(pending, "rb") as a:
|
| shutil.copyfileobj(a, out)
|
| with open(LOG_FILE, "rb") as b:
|
| shutil.copyfileobj(b, out)
|
| os.replace(tmp, LOG_FILE)
|
| pending.unlink()
|
| else:
|
| pending.rename(LOG_FILE)
|
|
|
|
|
| def main():
|
| if not SERVER_URL:
|
| print("[ai-log] AI_LOG_SERVER not set — skipping submission.", file=sys.stderr)
|
| sys.exit(0)
|
|
|
| if not LOG_FILE.exists() or LOG_FILE.stat().st_size == 0:
|
| print("[ai-log] No logs to submit.", file=sys.stderr)
|
| sys.exit(0)
|
|
|
|
|
|
|
| pending = LOG_FILE.with_name(f"session.pending.{int(time.time())}.jsonl")
|
| try:
|
| LOG_FILE.rename(pending)
|
| except FileNotFoundError:
|
| print("[ai-log] No logs to submit.", file=sys.stderr)
|
| sys.exit(0)
|
|
|
| entries = []
|
| leftover_lines = []
|
| with open(pending, encoding="utf-8") as f:
|
| for line in f:
|
| stripped = line.strip()
|
| if not stripped:
|
| continue
|
| if len(entries) >= BATCH_LIMIT:
|
| leftover_lines.append(line)
|
| continue
|
| try:
|
| entries.append(json.loads(stripped))
|
| except json.JSONDecodeError:
|
| pass
|
|
|
| if not entries:
|
|
|
| _archive(pending)
|
| pending.unlink()
|
| print("[ai-log] No valid entries to submit.", file=sys.stderr)
|
| sys.exit(0)
|
|
|
| payload = json.dumps({"entries": entries}, ensure_ascii=False).encode("utf-8")
|
| headers = {"Content-Type": "application/json"}
|
| if API_KEY:
|
| headers["Authorization"] = f"Bearer {API_KEY}"
|
| req = urllib.request.Request(
|
| SERVER_URL,
|
| data=payload,
|
| headers=headers,
|
| method="POST",
|
| )
|
|
|
| try:
|
| with urllib.request.urlopen(req, timeout=10) as resp:
|
| print(f"[ai-log] Submitted {len(entries)} entries → {resp.status}", file=sys.stderr)
|
| except urllib.error.URLError as e:
|
|
|
| _restore_pending(pending)
|
| print(f"[ai-log] Submit failed: {e} — logs kept locally.", file=sys.stderr)
|
| sys.exit(0)
|
|
|
|
|
| _archive(pending)
|
| pending.unlink()
|
|
|
| if leftover_lines:
|
|
|
|
|
| with open(LOG_FILE, "a", encoding="utf-8") as f:
|
| f.writelines(leftover_lines)
|
| print(
|
| f"[ai-log] {len(leftover_lines)} entries deferred to next push.",
|
| file=sys.stderr,
|
| )
|
|
|
|
|
| if __name__ == "__main__":
|
| main()
|
|
|