feat(client): Mac aider-watch client (B1-B4: scaffolding + api_client + buffer + aiderw)
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[console_scripts]
|
||||
aider-watch = aider_watch_client.cli:main
|
||||
aiderw = aider_watch_client.aiderw:main
|
||||
@@ -0,0 +1,5 @@
|
||||
requests>=2.31
|
||||
python-ulid>=3.0
|
||||
|
||||
[dev]
|
||||
pytest>=8.0
|
||||
@@ -0,0 +1 @@
|
||||
aider_watch_client
|
||||
@@ -0,0 +1,2 @@
|
||||
# aider-watch-client | 2026-04-20 @ Asia/Taipei
|
||||
__version__ = "0.2.0"
|
||||
179
scripts/aider_watch_client/aider_watch_client/aiderw.py
Normal file
179
scripts/aider_watch_client/aider_watch_client/aiderw.py
Normal file
@@ -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())
|
||||
42
scripts/aider_watch_client/aider_watch_client/api_client.py
Normal file
42
scripts/aider_watch_client/aider_watch_client/api_client.py
Normal file
@@ -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=<hex>' 給 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
|
||||
56
scripts/aider_watch_client/aider_watch_client/buffer.py
Normal file
56
scripts/aider_watch_client/aider_watch_client/buffer.py
Normal file
@@ -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
|
||||
55
scripts/aider_watch_client/aider_watch_client/cli.py
Normal file
55
scripts/aider_watch_client/aider_watch_client/cli.py
Normal file
@@ -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())
|
||||
42
scripts/aider_watch_client/aider_watch_client/config.py
Normal file
42
scripts/aider_watch_client/aider_watch_client/config.py
Normal file
@@ -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)
|
||||
88
scripts/aider_watch_client/aider_watch_client/parsers.py
Normal file
88
scripts/aider_watch_client/aider_watch_client/parsers.py
Normal file
@@ -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 []
|
||||
28
scripts/aider_watch_client/aider_watch_client/redactor.py
Normal file
28
scripts/aider_watch_client/aider_watch_client/redactor.py
Normal file
@@ -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"<redacted:{kind}>", 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
|
||||
5
scripts/aider_watch_client/bin/aiderw
Executable file
5
scripts/aider_watch_client/bin/aiderw
Executable file
@@ -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())
|
||||
23
scripts/aider_watch_client/pyproject.toml
Normal file
23
scripts/aider_watch_client/pyproject.toml
Normal file
@@ -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"]
|
||||
1
scripts/aider_watch_client/tests/__init__.py
Normal file
1
scripts/aider_watch_client/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 2026-04-20 @ Asia/Taipei
|
||||
23
scripts/aider_watch_client/tests/test_api_client.py
Normal file
23
scripts/aider_watch_client/tests/test_api_client.py
Normal file
@@ -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")
|
||||
53
scripts/aider_watch_client/tests/test_buffer.py
Normal file
53
scripts/aider_watch_client/tests/test_buffer.py
Normal file
@@ -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 批
|
||||
Reference in New Issue
Block a user