feat(client): Mac aider-watch client (B1-B4: scaffolding + api_client + buffer + aiderw)

This commit is contained in:
Your Name
2026-04-20 09:51:53 +08:00
parent e1539a813e
commit 36610e2744
19 changed files with 633 additions and 0 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1,3 @@
[console_scripts]
aider-watch = aider_watch_client.cli:main
aiderw = aider_watch_client.aiderw:main

View File

@@ -0,0 +1,5 @@
requests>=2.31
python-ulid>=3.0
[dev]
pytest>=8.0

View File

@@ -0,0 +1 @@
aider_watch_client

View File

@@ -0,0 +1,2 @@
# aider-watch-client | 2026-04-20 @ Asia/Taipei
__version__ = "0.2.0"

View 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:
"""batchredact → 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())

View 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

View File

@@ -0,0 +1,56 @@
# aider-watch-client buffer | 2026-04-20 @ Asia/Taipei
"""JSONL buffer 當 awoooi API 不可達時緩衝 eventslaunchd 定期呼 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

View 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())

View 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)

View 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 []

View 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

View 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())

View 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"]

View File

@@ -0,0 +1 @@
# 2026-04-20 @ Asia/Taipei

View 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")

View 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 批