diff --git a/scripts/aider_watch_client/aider_watch_client.egg-info/PKG-INFO b/scripts/aider_watch_client/aider_watch_client.egg-info/PKG-INFO new file mode 100644 index 00000000..293b4cd3 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client.egg-info/PKG-INFO @@ -0,0 +1,9 @@ +Metadata-Version: 2.4 +Name: aider-watch-client +Version: 0.2.0 +Summary: Mac client for aider-watch (posts events to awoooi API) +Requires-Python: >=3.11 +Requires-Dist: requests>=2.31 +Requires-Dist: python-ulid>=3.0 +Provides-Extra: dev +Requires-Dist: pytest>=8.0; extra == "dev" diff --git a/scripts/aider_watch_client/aider_watch_client.egg-info/SOURCES.txt b/scripts/aider_watch_client/aider_watch_client.egg-info/SOURCES.txt new file mode 100644 index 00000000..f3bdfa47 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client.egg-info/SOURCES.txt @@ -0,0 +1,17 @@ +pyproject.toml +aider_watch_client/__init__.py +aider_watch_client/aiderw.py +aider_watch_client/api_client.py +aider_watch_client/buffer.py +aider_watch_client/cli.py +aider_watch_client/config.py +aider_watch_client/parsers.py +aider_watch_client/redactor.py +aider_watch_client.egg-info/PKG-INFO +aider_watch_client.egg-info/SOURCES.txt +aider_watch_client.egg-info/dependency_links.txt +aider_watch_client.egg-info/entry_points.txt +aider_watch_client.egg-info/requires.txt +aider_watch_client.egg-info/top_level.txt +tests/test_api_client.py +tests/test_buffer.py \ No newline at end of file diff --git a/scripts/aider_watch_client/aider_watch_client.egg-info/dependency_links.txt b/scripts/aider_watch_client/aider_watch_client.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/scripts/aider_watch_client/aider_watch_client.egg-info/entry_points.txt b/scripts/aider_watch_client/aider_watch_client.egg-info/entry_points.txt new file mode 100644 index 00000000..f617a06d --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +aider-watch = aider_watch_client.cli:main +aiderw = aider_watch_client.aiderw:main diff --git a/scripts/aider_watch_client/aider_watch_client.egg-info/requires.txt b/scripts/aider_watch_client/aider_watch_client.egg-info/requires.txt new file mode 100644 index 00000000..851cf3d4 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client.egg-info/requires.txt @@ -0,0 +1,5 @@ +requests>=2.31 +python-ulid>=3.0 + +[dev] +pytest>=8.0 diff --git a/scripts/aider_watch_client/aider_watch_client.egg-info/top_level.txt b/scripts/aider_watch_client/aider_watch_client.egg-info/top_level.txt new file mode 100644 index 00000000..b742e18e --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client.egg-info/top_level.txt @@ -0,0 +1 @@ +aider_watch_client diff --git a/scripts/aider_watch_client/aider_watch_client/__init__.py b/scripts/aider_watch_client/aider_watch_client/__init__.py new file mode 100644 index 00000000..cf98cfde --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client/__init__.py @@ -0,0 +1,2 @@ +# aider-watch-client | 2026-04-20 @ Asia/Taipei +__version__ = "0.2.0" diff --git a/scripts/aider_watch_client/aider_watch_client/aiderw.py b/scripts/aider_watch_client/aider_watch_client/aiderw.py new file mode 100644 index 00000000..99a8f016 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client/aiderw.py @@ -0,0 +1,179 @@ +# aider-watch-client aiderw | 2026-04-20 @ Asia/Taipei +"""aider subprocess wrapper — capture events, dispatch to awoooi (fallback buffer).""" +from __future__ import annotations +import atexit, json, os, subprocess, sys, threading, time +from datetime import datetime +from pathlib import Path +from ulid import ULID +from aider_watch_client import config, buffer +from aider_watch_client.api_client import post_events +from aider_watch_client.parsers import ( + parse_stdout_banner, parse_chat_history, git_numstat, git_diff_head, +) +from aider_watch_client.redactor import redact + +IDLE_THRESHOLD_SEC = 120 + + +def _now_iso() -> str: + return datetime.now(config.TAIPEI).isoformat() + + +def _emit_live(ev: dict) -> None: + try: + with config.LIVE_LOG.open("a") as f: + red = redact(ev.get("payload", {})) + f.write(f"[{ev['ts']}] {ev['type']} " + f"{json.dumps(red, ensure_ascii=False, default=str)[:200]}\n") + except Exception: + pass + + +def _dispatch(events: list[dict]) -> None: + """batch:redact → live.log → HTTP POST;失敗進 buffer。""" + redacted = [{**e, "payload": redact(e.get("payload", {}))} for e in events] + for e in redacted: + _emit_live(e) + if not post_events(redacted): + buffer.write(redacted) + + +def _pre_git_sha(cwd: Path) -> str | None: + try: + r = subprocess.run(["git","-C",str(cwd),"rev-parse","HEAD"], + capture_output=True, text=True, timeout=3) + return r.stdout.strip() if r.returncode == 0 else None + except Exception: + return None + + +def main(argv: list[str] | None = None) -> int: + argv = list(argv if argv is not None else sys.argv[1:]) + config.ensure_dirs() + cwd = Path.cwd() + sid = str(ULID()) + start_ts = _now_iso() + start_epoch = time.time() + stdout_buf: list[str] = [] + last_output = [time.time()] + error_count = [0] + pre_sha = _pre_git_sha(cwd) + host = os.environ.get("AIDER_WATCH_HOSTNAME") or os.uname().nodename + aider_bin = os.environ.get("AIDER_BIN") or str(Path.home() / ".local/bin/aider") + + # subprocess start + try: + proc = subprocess.Popen([aider_bin] + argv, cwd=str(cwd), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1) + except FileNotFoundError: + print(f"aider binary not found at {aider_bin}", file=sys.stderr) + return 127 + + # session_start ASAP + _dispatch([{ + "ts": start_ts, "session_id": sid, "host": host, + "type": "session_start", + "payload": {"cwd": str(cwd), "model": "unknown", + "aider_args": argv, "aider_pid": proc.pid, + "cli_version": "unknown"}, + }]) + + # stdout reader + def _reader(): + for line in proc.stdout: # type: ignore[arg-type] + sys.stdout.write(line); sys.stdout.flush() + stdout_buf.append(line) + last_output[0] = time.time() + lo = line.lower() + if "error" in lo or "exception" in lo: + error_count[0] += 1 + t = threading.Thread(target=_reader, daemon=True); t.start() + + # silent watcher + silent_fired = [False] + def _silent_watch(): + while proc.poll() is None: + time.sleep(10) + idle = time.time() - last_output[0] + if idle >= IDLE_THRESHOLD_SEC and not silent_fired[0]: + silent_fired[0] = True + tail = "".join(stdout_buf[-1:])[-80:] if stdout_buf else "" + _dispatch([{"ts": _now_iso(), "session_id": sid, "host": host, + "type": "silent_timeout", + "payload": {"idle_sec": int(idle), + "last_output_tail": tail}}]) + w = threading.Thread(target=_silent_watch, daemon=True); w.start() + + # atexit safety + emergency_fired = [False] + def _emergency(): + if emergency_fired[0]: + return + emergency_fired[0] = True + _dispatch([{"ts": _now_iso(), "session_id": sid, "host": host, + "type": "session_end", + "payload": {"duration_sec": int(time.time() - start_epoch), + "tokens_sent": 0, "tokens_received": 0, + "cost_usd": 0, "files_changed": 0, + "error_count": error_count[0], "exit_code": -999, + "wrapper_crash": True}}]) + atexit.register(_emergency) + + exit_code = proc.wait() + emergency_fired[0] = True # 正常結束,關閉保險 + + duration = int(time.time() - start_epoch) + stdout_text = "".join(stdout_buf) + + events_tail: list[dict] = [] + + # canonical file edits from git diff + if pre_sha: + for e in git_numstat(cwd, pre_sha, "HEAD"): + head = git_diff_head(cwd, e["path"], pre_sha, 3) + events_tail.append({ + "ts": _now_iso(), "session_id": sid, "host": host, + "type": "file_edit", + "payload": {"path": e["path"], "lines_added": e["lines_added"], + "lines_deleted": e["lines_deleted"], + "diff_head": head, + "is_new_file": e.get("is_new_file", False)}, + }) + + # tokens from chat_history + tokens_sent = tokens_recv = 0 + chat_md = cwd / ".aider.chat.history.md" + if chat_md.exists(): + stats = parse_chat_history(chat_md.read_text(errors="ignore")) + tokens_sent = stats["tokens_sent"]; tokens_recv = stats["tokens_received"] + + if exit_code != 0: + events_tail.append({ + "ts": _now_iso(), "session_id": sid, "host": host, + "type": "error", + "payload": {"kind": "non_zero_exit", + "message": f"aider exited with {exit_code}", + "context_50chars": stdout_text[-50:]}, + }) + + events_tail.append({ + "ts": _now_iso(), "session_id": sid, "host": host, + "type": "session_end", + "payload": {"duration_sec": duration, + "tokens_sent": tokens_sent, + "tokens_received": tokens_recv, "cost_usd": 0, + "files_changed": len([e for e in events_tail + if e["type"] == "file_edit"]), + "error_count": error_count[0], + "exit_code": exit_code}, + }) + + if events_tail: + _dispatch(events_tail) + + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/aider_watch_client/aider_watch_client/api_client.py b/scripts/aider_watch_client/aider_watch_client/api_client.py new file mode 100644 index 00000000..8f3363b3 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client/api_client.py @@ -0,0 +1,42 @@ +# aider-watch-client api_client | 2026-04-20 @ Asia/Taipei +"""HTTP POST events to awoooi with HMAC-SHA256 sign + exponential backoff. +失敗永不 raise — caller 檢查 return bool 決定是否走 buffer。""" +from __future__ import annotations +import hmac, hashlib, json, time +from typing import Any +import requests +from aider_watch_client.config import get + + +def sign_body(body: bytes, secret: str) -> str: + """回傳 'sha256=' 給 X-Aider-Signature header。""" + return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + + +def post_events(events: list[dict[str, Any]]) -> bool: + """POST batch to awoooi aider/events endpoint. + True = 2xx; False = 失敗,caller 走 buffer。絕不 raise。""" + url = get("AIDER_API_URL", required=True) + secret = get("AIDER_WEBHOOK_SECRET", required=True) + body = json.dumps({"events": events}, ensure_ascii=False).encode() + sig = sign_body(body, secret) + backoff = 1 + for _ in range(3): + try: + r = requests.post( + url, data=body, + headers={"Content-Type": "application/json", + "X-Aider-Signature": sig, + "X-Aider-Client": "aiderw/0.2.0"}, + timeout=5, + ) + if 200 <= r.status_code < 300: + return True + if r.status_code == 429 or r.status_code >= 500: + time.sleep(backoff); backoff *= 4 + continue + # 400/401/403 不重試 — client 端 payload 問題 + return False + except Exception: + time.sleep(backoff); backoff *= 4 + return False diff --git a/scripts/aider_watch_client/aider_watch_client/buffer.py b/scripts/aider_watch_client/aider_watch_client/buffer.py new file mode 100644 index 00000000..2b275796 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client/buffer.py @@ -0,0 +1,56 @@ +# aider-watch-client buffer | 2026-04-20 @ Asia/Taipei +"""JSONL buffer 當 awoooi API 不可達時緩衝 events;launchd 定期呼 flush()。""" +from __future__ import annotations +import json +from pathlib import Path +from typing import Callable + +# Late-import to allow tests to monkey-patch +import aider_watch_client.config as _config +BUFFER_DIR: Path = _config.BUFFER_DIR + +from ulid import ULID + + +def _ensure(): + _config.ensure_dirs() + + +def _get_buffer_dir() -> Path: + # test 可 monkey-patch 這個 module-level BUFFER_DIR + import aider_watch_client.buffer as _self + return _self.BUFFER_DIR + + +def write(events: list[dict]) -> Path: + """批次寫一個 pending file。""" + bd = _get_buffer_dir() + bd.mkdir(parents=True, exist_ok=True) + fp = bd / f"pending_{ULID()}.jsonl" + with fp.open("w") as f: + for ev in events: + f.write(json.dumps(ev, ensure_ascii=False, default=str) + "\n") + return fp + + +def flush(post_fn: Callable[[list[dict]], bool]) -> int: + """把 buffer 下所有 pending file 重送;成功刪檔,失敗保留。回傳成功筆數。""" + bd = _get_buffer_dir() + bd.mkdir(parents=True, exist_ok=True) + total = 0 + for fp in list(bd.glob("pending_*.jsonl")): + lines = [l for l in fp.read_text().splitlines() if l.strip()] + if not lines: + fp.unlink(); continue + batch = [json.loads(l) for l in lines] + ok_all = True + # API max 50 per request + for i in range(0, len(batch), 50): + chunk = batch[i:i+50] + if not post_fn(chunk): + ok_all = False + break + total += len(chunk) + if ok_all: + fp.unlink() + return total diff --git a/scripts/aider_watch_client/aider_watch_client/cli.py b/scripts/aider_watch_client/aider_watch_client/cli.py new file mode 100644 index 00000000..cf9b924d --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client/cli.py @@ -0,0 +1,55 @@ +# aider-watch-client cli | 2026-04-20 @ Asia/Taipei +"""aider-watch subcommands: doctor, flush.""" +from __future__ import annotations +import argparse, subprocess, sys +from aider_watch_client import config, buffer +from aider_watch_client.api_client import post_events + + +def cmd_doctor(_a) -> int: + ok = True + print("== aider-watch doctor ==") + for k in ("AIDER_API_URL", "AIDER_WEBHOOK_SECRET"): + v = config.get(k) + mark = "ok" if v else "FAIL" + print(f" env {k:30s} {mark}") + if not v: + ok = False + # API reachability + try: + import requests + url = config.get("AIDER_API_URL", required=True) + # use healthz if available; otherwise try the url itself (HEAD won't post events) + health = url.replace("/aider/events", "/healthz") + r = requests.get(health, timeout=3) + mark = "ok" if r.status_code < 500 else "FAIL" + print(f" API reachable {mark} {r.status_code} ({health})") + if r.status_code >= 500: + ok = False + except Exception as e: + print(f" API reachable FAIL {e}") + ok = False + # launchd + res = subprocess.run(["launchctl", "list"], capture_output=True, text=True) + mark = "ok" if "com.awoooi.aider-flush" in res.stdout else "FAIL" + print(f" launchd aider-flush {mark}") + return 0 if ok else 1 + + +def cmd_flush(_a) -> int: + n = buffer.flush(post_fn=post_events) + print(f"flushed {n} events") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(prog="aider-watch") + sub = p.add_subparsers(dest="cmd", required=True) + sub.add_parser("doctor").set_defaults(func=cmd_doctor) + sub.add_parser("flush").set_defaults(func=cmd_flush) + a = p.parse_args() + return a.func(a) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/aider_watch_client/aider_watch_client/config.py b/scripts/aider_watch_client/aider_watch_client/config.py new file mode 100644 index 00000000..e57879b6 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client/config.py @@ -0,0 +1,42 @@ +# aider-watch-client config | 2026-04-20 @ Asia/Taipei +"""讀 ~/.aider-watch.env + 常數路徑。""" +from __future__ import annotations +import os +from pathlib import Path +from datetime import timezone, timedelta + +HOME = Path.home() +WATCH_ROOT = HOME / "aider-watch" +BUFFER_DIR = WATCH_ROOT / "buffer" +LIVE_LOG = WATCH_ROOT / "live.log" +LOGS_DIR = WATCH_ROOT / "logs" +ENV_FILE = HOME / ".aider-watch.env" +TAIPEI = timezone(timedelta(hours=8)) +_loaded = False + + +def load_env() -> None: + global _loaded + if _loaded or not ENV_FILE.exists(): + _loaded = True + return + for line in ENV_FILE.read_text().splitlines(): + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + k, _, v = s.partition("=") + os.environ.setdefault(k.strip(), v.strip()) + _loaded = True + + +def get(name: str, default: str = "", required: bool = False) -> str: + load_env() + v = os.environ.get(name, default) + if required and not v: + raise RuntimeError(f"{name} not set in {ENV_FILE}") + return v + + +def ensure_dirs() -> None: + for d in (WATCH_ROOT, BUFFER_DIR, LOGS_DIR): + d.mkdir(parents=True, exist_ok=True) diff --git a/scripts/aider_watch_client/aider_watch_client/parsers.py b/scripts/aider_watch_client/aider_watch_client/parsers.py new file mode 100644 index 00000000..8e00ec82 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client/parsers.py @@ -0,0 +1,88 @@ +# aider-watch-client parsers | 2026-04-20 @ Asia/Taipei +"""從 aider stdout / chat_history.md / git diff 抽事件資訊。""" +from __future__ import annotations +import re +import subprocess +from pathlib import Path + +_BANNER_MODEL = re.compile(r"^\s*Model:\s*(\S+)", re.MULTILINE) +_BANNER_VER = re.compile(r"^\s*Aider v([\d\.]+)", re.MULTILINE) +_TOKENS_LINE = re.compile( + r"Tokens:\s+([\d\.]+)([km]?)\s+sent,\s+([\d\.]+)([km]?)\s+received", + re.IGNORECASE, +) + + +def _parse_num(n: str, suffix: str) -> int: + v = float(n) + if suffix.lower() == "k": + v *= 1000 + elif suffix.lower() == "m": + v *= 1_000_000 + return int(v) + + +def parse_stdout_banner(stdout: str) -> dict: + m_model = _BANNER_MODEL.search(stdout) + m_ver = _BANNER_VER.search(stdout) + return { + "model": m_model.group(1) if m_model else None, + "cli_version": m_ver.group(1) if m_ver else None, + } + + +def parse_chat_history(md: str) -> dict: + total_sent = 0 + total_recv = 0 + for m in _TOKENS_LINE.finditer(md): + total_sent += _parse_num(m.group(1), m.group(2)) + total_recv += _parse_num(m.group(3), m.group(4)) + return {"tokens_sent": total_sent, "tokens_received": total_recv, + "file_edits": []} + + +def parse_git_diff_stat(diff_stat: str) -> list[dict]: + out = [] + for line in diff_stat.strip().splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + add, dele, path = parts + try: + a = int(add) if add != "-" else 0 + d = int(dele) if dele != "-" else 0 + except ValueError: + continue + out.append({"path": path, "lines_added": a, "lines_deleted": d, + "is_new_file": d == 0 and a > 0}) + return out + + +def git_numstat(cwd: Path, pre_sha: str, post_sha: str = "HEAD") -> list[dict]: + try: + r = subprocess.run( + ["git", "-C", str(cwd), "diff", "--numstat", pre_sha, post_sha], + capture_output=True, text=True, timeout=10) + if r.returncode != 0: + return [] + return parse_git_diff_stat(r.stdout) + except Exception: + return [] + + +def git_diff_head(cwd: Path, path: str, pre_sha: str, n: int = 3) -> list[str]: + try: + r = subprocess.run( + ["git", "-C", str(cwd), "diff", "-U0", pre_sha, "--", path], + capture_output=True, text=True, timeout=5) + picks = [] + for l in r.stdout.splitlines(): + if l.startswith("+++") or l.startswith("---"): + continue + if l.startswith("@@") or l.startswith("+") or l.startswith("-"): + picks.append(l) + if len(picks) >= n: + break + return picks + except Exception: + return [] diff --git a/scripts/aider_watch_client/aider_watch_client/redactor.py b/scripts/aider_watch_client/aider_watch_client/redactor.py new file mode 100644 index 00000000..26a806b0 --- /dev/null +++ b/scripts/aider_watch_client/aider_watch_client/redactor.py @@ -0,0 +1,28 @@ +# aider-watch-client secret redactor | 2026-04-20 @ Asia/Taipei +"""與 server secret_redactor.py 同步。Mac 端進 buffer 前先遮罩(defense in depth)。""" +from __future__ import annotations +import re +from typing import Any + +_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"sk-or-v1-[A-Za-z0-9]{36,}"), "openrouter"), + (re.compile(r"sk-ant-api\d{2}-[A-Za-z0-9_\-]{12,}"), "anthropic"), + (re.compile(r"sk-[A-Za-z0-9]{40,}"), "openai"), + (re.compile(r"ghp_[A-Za-z0-9]{36}"), "github"), + (re.compile(r"AIza[0-9A-Za-z_\-]{35}"), "google"), + (re.compile(r"\b\d{8,10}:[A-Za-z0-9_\-]{35}\b"), "telegram"), + (re.compile(r"AKIA[0-9A-Z]{16}"), "aws"), +] + + +def redact(obj: Any) -> Any: + if isinstance(obj, str): + s = obj + for pat, kind in _PATTERNS: + s = pat.sub(f"", s) + return s + if isinstance(obj, dict): + return {k: redact(v) for k, v in obj.items()} + if isinstance(obj, list): + return [redact(x) for x in obj] + return obj diff --git a/scripts/aider_watch_client/bin/aiderw b/scripts/aider_watch_client/bin/aiderw new file mode 100755 index 00000000..0ebd71e0 --- /dev/null +++ b/scripts/aider_watch_client/bin/aiderw @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +# aider-watch-client shim | 2026-04-20 @ Asia/Taipei +import sys +from aider_watch_client.aiderw import main +sys.exit(main()) diff --git a/scripts/aider_watch_client/pyproject.toml b/scripts/aider_watch_client/pyproject.toml new file mode 100644 index 00000000..f300b95f --- /dev/null +++ b/scripts/aider_watch_client/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "aider-watch-client" +version = "0.2.0" +description = "Mac client for aider-watch (posts events to awoooi API)" +requires-python = ">=3.11" +dependencies = ["requests>=2.31", "python-ulid>=3.0"] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[project.scripts] +aiderw = "aider_watch_client.aiderw:main" +aider-watch = "aider_watch_client.cli:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["aider_watch_client*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/scripts/aider_watch_client/tests/__init__.py b/scripts/aider_watch_client/tests/__init__.py new file mode 100644 index 00000000..4e35e92d --- /dev/null +++ b/scripts/aider_watch_client/tests/__init__.py @@ -0,0 +1 @@ +# 2026-04-20 @ Asia/Taipei diff --git a/scripts/aider_watch_client/tests/test_api_client.py b/scripts/aider_watch_client/tests/test_api_client.py new file mode 100644 index 00000000..38404e3f --- /dev/null +++ b/scripts/aider_watch_client/tests/test_api_client.py @@ -0,0 +1,23 @@ +# 2026-04-20 @ Asia/Taipei +import hmac, hashlib +from aider_watch_client.api_client import sign_body + + +def test_sign_body_format(): + body = b'{"events":[]}' + sig = sign_body(body, "secret") + assert sig.startswith("sha256=") + expected = "sha256=" + hmac.new(b"secret", body, hashlib.sha256).hexdigest() + assert sig == expected + + +def test_sign_body_deterministic(): + assert sign_body(b"same", "secret") == sign_body(b"same", "secret") + + +def test_sign_body_different_secrets(): + assert sign_body(b"body", "sec1") != sign_body(b"body", "sec2") + + +def test_sign_body_different_bodies(): + assert sign_body(b"a", "s") != sign_body(b"b", "s") diff --git a/scripts/aider_watch_client/tests/test_buffer.py b/scripts/aider_watch_client/tests/test_buffer.py new file mode 100644 index 00000000..ac17cde6 --- /dev/null +++ b/scripts/aider_watch_client/tests/test_buffer.py @@ -0,0 +1,53 @@ +# 2026-04-20 @ Asia/Taipei +import json +from aider_watch_client import buffer + + +def test_write_reads_back(tmp_path, monkeypatch): + monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path) + ev = {"type":"session_start","session_id":"s1", + "ts":"2026-04-20T10:00:00+08:00","host":"ogt-mac","payload":{}} + buffer.write([ev]) + files = list(tmp_path.glob("pending_*.jsonl")) + assert len(files) == 1 + lines = [json.loads(l) for l in files[0].read_text().splitlines() if l.strip()] + assert lines[0]["session_id"] == "s1" + + +def test_flush_all_drained(tmp_path, monkeypatch): + monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path) + ev = {"type":"session_start","session_id":"s1", + "ts":"2026-04-20T10:00:00+08:00","host":"ogt-mac","payload":{}} + buffer.write([ev]) + sent = [] + def fake_post(evs): sent.extend(evs); return True + n = buffer.flush(post_fn=fake_post) + assert n == 1 + assert len(sent) == 1 + assert list(tmp_path.glob("pending_*.jsonl")) == [] + + +def test_flush_keeps_on_fail(tmp_path, monkeypatch): + monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path) + ev = {"type":"session_start","session_id":"s1", + "ts":"2026-04-20T10:00:00+08:00","host":"ogt-mac","payload":{}} + buffer.write([ev]) + n = buffer.flush(post_fn=lambda evs: False) + assert n == 0 + assert len(list(tmp_path.glob("pending_*.jsonl"))) == 1 + + +def test_flush_chunks_oversize(tmp_path, monkeypatch): + """>50 events → chunked.""" + monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path) + events = [{"type":"raw","session_id":f"s{i}", + "ts":"2026-04-20T10:00:00+08:00","host":"m","payload":{}} + for i in range(120)] + buffer.write(events) + posted_sizes = [] + def fake_post(evs): + posted_sizes.append(len(evs)) + return True + n = buffer.flush(post_fn=fake_post) + assert n == 120 + assert posted_sizes == [50, 50, 20] # 切成 3 批