File size: 5,080 Bytes
6686473 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | #!/usr/bin/env python3
"""
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"
# Match server-side MAX_BATCH_ENTRIES so we never get a 422.
# If the local file has more than this, we submit the oldest BATCH_LIMIT
# and leave the rest for the next push.
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():
# Concat: pending (older) + LOG_FILE (newer) → LOG_FILE
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)
# Atomic rename closes the race window: hook writes that arrive after this
# land in a fresh LOG_FILE, not in the batch we're about to POST.
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 # drop unparseable line
if not entries:
# Nothing to send; archive whatever was there (probably junk) and bail.
_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:
# Failure: restore the whole pending (including leftover) for next push.
_restore_pending(pending)
print(f"[ai-log] Submit failed: {e} — logs kept locally.", file=sys.stderr)
sys.exit(0) # Don't block push on server error
# Success: archive the submitted batch, then handle any leftover.
_archive(pending)
pending.unlink()
if leftover_lines:
# More than BATCH_LIMIT entries existed; put the rest back so the
# next push picks them up.
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()
|