Files
MediaCrawler/mac-agent-os-main/05_tools/00_setup/guardd/guardd.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

1887 lines
78 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
guardd — AgentOS 联邦式协同守护进程
职责:每 5 分钟执行一轮 9 模块循环,自动化跨机器协同。
安装方式:launchd (macOS) 或 crontab
周期:300 秒(可配置)
所有模块使用规则引擎,不调用 LLM,0 token 消耗。
"""
# 版本号从 01_core/VERSION 读取(唯一来源)
try:
ver_file = Path(__file__).resolve().parent.parent.parent.parent / "01_core" / "VERSION"
if ver_file.exists():
for line in ver_file.read_text().splitlines():
if line.startswith("GUARDD_VERSION="):
version = line.split("=", 1)[1].strip()
break
else:
version = "2.3.0"
else:
version = "2.3.0"
except Exception:
version = "2.3.0"
import fcntl
import http.server
import json
import logging
import logging.handlers
import os
import re
import shutil
import signal
import stat
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime, timezone, timedelta
from pathlib import Path
from urllib.parse import urlparse
# 确保 modules/ 目录在 Python 路径中
_guardd_dir = Path(__file__).resolve().parent
if str(_guardd_dir) not in sys.path:
sys.path.insert(0, str(_guardd_dir))
# 调度引擎模块
from modules.task_store import TaskStore
from modules.priority_queue import PriorityQueue
from modules.slot_manager import BrowserSlotManager, AccountBusyError
from modules.executor import Executor
from modules.scheduler import Scheduler
from modules.heartbeat import HeartbeatReporter
from modules.oracle_sync import OracleSync
_guardd_start_time = time.time()
# ── 路径常量 ──────────────────────────────────────────────
AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(Path.home() / "workbuddy-agent-os" / "agent-sync")))
AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(Path.home() / "workbuddy-agent-os" / "agent-local")))
# ── 改造: 心跳数据写本机 local 目录, 不再污染 Gitee ──
DIR_CROSS = AGENT_LOCAL / "runtime" / "guardd"
HOSTNAME = os.uname().nodename
# ── 缓存 hostname 到文件, 防止 IP 变化导致身份漂移 ──
_CACHED_HOSTNAME_FILE = AGENT_LOCAL / "identity" / "cached_hostname"
_CACHED_HOSTNAME_FILE.parent.mkdir(parents=True, exist_ok=True)
if _CACHED_HOSTNAME_FILE.exists():
cached = _CACHED_HOSTNAME_FILE.read_text().strip()
if cached:
HOSTNAME = cached
else:
_CACHED_HOSTNAME_FILE.write_text(HOSTNAME, encoding="utf-8")
# ── 从 registry 解析注册名 ──
_REGISTRY_DIR = AGENT_SYNC / "04_memory" / "cross_machine" / "registry"
def _resolve_hostname(fallback=HOSTNAME):
"""通过缓存/MACHINE_UID/IP映射 三级降级解析注册名"""
# 1. 缓存优先 — 防止 IP 变化导致身份漂移
cache_file = AGENT_LOCAL / "identity" / "cached_hostname"
if cache_file.exists():
cached = cache_file.read_text().strip()
if cached:
return cached
uid = ""
uid_file = AGENT_LOCAL / "identity" / "machine_uid"
if uid_file.exists():
uid = uid_file.read_text().strip()
raw = os.uname().nodename
# 2. IP→hostname 映射(仅在无缓存时使用)
ip_to_name = {
"192.168.31.225": "chengzigedeAir",
"192.168.31.226": "chengzigedeAir",
}
if raw in ip_to_name:
return ip_to_name[raw]
if uid and _REGISTRY_DIR.exists():
for f in _REGISTRY_DIR.iterdir():
if f.suffix != ".json":
continue
try:
data = json.loads(f.read_text())
if data.get("uid") == uid:
return data.get("hostname", fallback)
except:
pass
return fallback
HOSTNAME = _resolve_hostname()
CROSS_MACHINE = AGENT_SYNC / "04_memory" / "cross_machine"
DIR_EVENTS = DIR_CROSS / "events"
DIR_STATUS = DIR_CROSS / "status"
DIR_REGISTRY = DIR_CROSS / "registry"
DIR_TASKS = CROSS_MACHINE / "tasks"
DIR_TASKS_PENDING = DIR_TASKS / "pending"
DIR_TASKS_COMPLETED = DIR_TASKS / "completed"
DIR_ENCRYPTED = CROSS_MACHINE / "encrypted"
DIR_ENCRYPTED_PENDING = DIR_ENCRYPTED / "pending"
DIR_ENCRYPTED_PROCESSED = DIR_ENCRYPTED / "processed"
DIR_KNOWLEDGE = CROSS_MACHINE / "knowledge"
DIR_REGISTRY = CROSS_MACHINE / "registry"
DIR_SECRETS = AGENT_LOCAL / "identity" / "secrets"
DIR_IDENTITY = AGENT_LOCAL / "identity"
DIR_GUARDD_LOG = AGENT_LOCAL / "runtime" / "guardd"
DIR_LOCAL_MEMORY = AGENT_LOCAL / "memory"
DIR_SUBMISSIONS = AGENT_LOCAL / "submissions"
DIR_SUBMISSIONS_TRIAGE = DIR_SUBMISSIONS / "memory_triage"
DIR_KNOWLEDGE_SYSTEM = AGENT_SYNC / "03_knowledge" / "99_system"
DIR_SUBMISSIONS_KNOWLEDGE = AGENT_SYNC / "03_knowledge" / "01_submissions"
LOG_FILE = DIR_GUARDD_LOG / "guardd.log"
LAST_RUN_FILE = DIR_GUARDD_LOG / "last_run.json"
ERROR_LOG_FILE = DIR_GUARDD_LOG / "errors.log"
VERSIONS_FILE = DIR_KNOWLEDGE / "versions.json"
# ── 任务管理器(v7 新增)──────────────────────────────────
# 管理通过 HTTP API 接收的任务进程
_task_lock = threading.Lock()
_running_tasks = {} # run_id → {proc, cmd, status, start_time, machine}
TASK_LOG_DIR = AGENT_LOCAL / "runtime" / "guardd" / "tasks"
TASK_LOG_DIR.mkdir(parents=True, exist_ok=True)
def _task_log_path(run_id: str) -> Path:
return TASK_LOG_DIR / f"{run_id}.log"
def _start_task(run_id: str, cmd: str, machine: str = "") -> dict:
"""创建子进程执行命令,返回任务信息"""
log_file = _task_log_path(run_id)
with open(log_file, "w") as f:
f.write(f"[{datetime.now()}] START: {cmd}\n")
try:
proc = subprocess.Popen(
cmd, shell=True, stdout=open(log_file, "a"), stderr=subprocess.STDOUT,
preexec_fn=os.setsid # 独立进程组,方便 kill
)
task = {
"run_id": run_id,
"cmd": cmd,
"machine": machine,
"pid": proc.pid,
"status": "running",
"start_time": datetime.now(timezone.utc).isoformat(),
"proc": proc,
}
with _task_lock:
_running_tasks[run_id] = task
logger.info(f" 🚀 任务已启动 [{run_id}] PID={proc.pid} cmd={cmd[:80]}")
return {"status": "accepted", "run_id": run_id, "pid": proc.pid}
except Exception as e:
logger.error(f" ❌ 任务启动失败 [{run_id}]: {e}")
return {"status": "error", "error": str(e)}
def _stop_task(run_id: str) -> dict:
"""停止任务(SIGTERM → 2s → SIGKILL)"""
with _task_lock:
task = _running_tasks.get(run_id)
if not task:
return {"status": "error", "error": f"任务 {run_id} 不存在"}
proc = task.get("proc")
if not proc or proc.poll() is not None:
task["status"] = "completed" if proc and proc.returncode == 0 else "crashed"
return {"status": "already_stopped", "run_id": run_id}
try:
# SIGTERM 先礼貌终止
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
time.sleep(2)
if proc.poll() is None:
# 还没死 → SIGKILL
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
task["status"] = "cancelled"
with _task_lock:
_running_tasks[run_id] = task
with open(_task_log_path(run_id), "a") as f:
f.write(f"[{datetime.now()}] CANCELLED\n")
logger.info(f" ⏹ 任务已停止 [{run_id}] PID={proc.pid}")
return {"status": "stopped", "run_id": run_id}
except Exception as e:
return {"status": "error", "error": str(e)}
def _get_tasks() -> list:
"""返回所有任务状态列表(不含 proc 对象)"""
with _task_lock:
# 更新已结束进程的状态
for rid, t in list(_running_tasks.items()):
proc = t.get("proc")
if proc and proc.poll() is not None:
t["status"] = "completed" if proc.returncode == 0 else "failed"
t["exit_code"] = proc.returncode
t["end_time"] = datetime.now(timezone.utc).isoformat()
return [
{k: v for k, v in t.items() if k != "proc"}
for t in _running_tasks.values()
]
# ── 采集 Chrome 管理(v4.5 新增)──────────────────────────
# 手动启停采集用调试 Chrome 守护(com.agentos.chrome-debug)。
# 设计:KeepAlive=true 提供运行期自愈(脚本崩溃自动拉起);
# stop = bootout 彻底卸载 + 杀进程;start = bootstrap 重新加载。
_CHROME_LABEL = "com.agentos.chrome-debug"
_CHROME_PLIST_SRC = _guardd_dir / "com.agentos.chrome-debug.plist"
_CHROME_PLIST_DST = Path.home() / "Library" / "LaunchAgents" / "com.agentos.chrome-debug.plist"
_CHROME_PROFILE_DIR = AGENT_LOCAL / "runtime" / "chrome-douyin-profile"
def _chrome_9222_alive() -> bool:
"""9222 CDP 端口是否在线"""
try:
import urllib.request as _ur
with _ur.urlopen("http://127.0.0.1:9222/json/version", timeout=2) as r:
return r.status == 200
except Exception:
return False
def _chrome_service_loaded() -> bool:
"""launchd 服务是否已加载"""
r = subprocess.run(
["launchctl", "print", f"gui/{os.getuid()}/{_CHROME_LABEL}"],
capture_output=True, timeout=5,
)
return r.returncode == 0
def _chrome_pids() -> list:
r = subprocess.run(
["pgrep", "-f", "remote-debugging-port=9222"],
capture_output=True, timeout=5,
)
return [p for p in r.stdout.decode().strip().split() if p]
def api_chrome_status() -> dict:
"""查询采集 Chrome 状态(CDP 在线 + launchd 服务 + profile)"""
profile_ok = (_CHROME_PROFILE_DIR / "Default" / "Cookies").exists()
return {
"running": _chrome_9222_alive(),
"service_loaded": _chrome_service_loaded(),
"chrome_pids": _chrome_pids(),
"profile_ok": profile_ok,
"profile_dir": str(_CHROME_PROFILE_DIR),
}
def api_chrome_start() -> dict:
"""启动采集 Chrome 守护(bootstrap 加载,RunAtLoad 自动拉起 Chrome)"""
try:
_CHROME_PROFILE_DIR.mkdir(parents=True, exist_ok=True)
plist = _CHROME_PLIST_DST
if not plist.exists():
if _CHROME_PLIST_SRC.exists():
content = _CHROME_PLIST_SRC.read_text(encoding="utf-8")
# 仓库 plist 硬编码了本机用户名,复制到其他机器时替换
content = content.replace("/Users/chengzige", str(Path.home()))
plist.parent.mkdir(parents=True, exist_ok=True)
plist.write_text(content, encoding="utf-8")
logger.info(f" 已从仓库安装 chrome-debug plist: {plist}")
if _chrome_service_loaded():
# 已加载 → kickstart 重启(-k 先杀后启)
subprocess.run(
["launchctl", "kickstart", "-k", f"gui/{os.getuid()}/{_CHROME_LABEL}"],
capture_output=True, timeout=10,
)
else:
subprocess.run(
["launchctl", "bootstrap", f"gui/{os.getuid()}", str(plist)],
capture_output=True, timeout=10,
)
# 等最多 20 秒 9222 就绪
for _ in range(20):
if _chrome_9222_alive():
break
time.sleep(1)
return {
"status": "ok",
"running": _chrome_9222_alive(),
"service_loaded": _chrome_service_loaded(),
}
except Exception as e:
logger.error(f"/chrome/start 失败: {e}")
return {"status": "error", "message": str(e)}
def api_chrome_stop() -> dict:
"""停止采集 Chrome:bootout 卸载守护 + 杀健康检查脚本 + 杀调试 Chrome"""
try:
if _chrome_service_loaded():
subprocess.run(
["launchctl", "bootout", f"gui/{os.getuid()}/{_CHROME_LABEL}"],
capture_output=True, timeout=10,
)
# 双保险:杀守护脚本本身 + 杀调试 Chrome 进程
subprocess.run(["pkill", "-f", "chrome_debug.sh"], capture_output=True, timeout=5)
subprocess.run(["pkill", "-f", "remote-debugging-port=9222"], capture_output=True, timeout=5)
time.sleep(1)
return {
"status": "ok",
"running": _chrome_9222_alive(),
"service_loaded": _chrome_service_loaded(),
}
except Exception as e:
logger.error(f"/chrome/stop 失败: {e}")
return {"status": "error", "message": str(e)}
# ── HTTP 任务服务器(v7 新增)─────────────────────────────
class TaskHTTPHandler(http.server.BaseHTTPRequestHandler):
"""guardd HTTP API — 端口 9090"""
def log_message(self, fmt, *args):
logger.debug(f"HTTP: {args[0]} {args[1]}")
def _send_json(self, code, data):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/")
if path == "/health":
self._send_json(200, {
"hostname": HOSTNAME,
"version": version,
"uptime_sec": round(time.time() - _guardd_start_time),
"running_tasks": len([t for t in _get_tasks() if t.get("status") == "running"]),
"total_tasks": len(_get_tasks()),
})
elif path == "/tasks":
self._send_json(200, _get_tasks())
elif path == "/scheduler/tasks":
self._send_json(200, api_scheduler_status())
elif path == "/scheduler/queue":
from modules.priority_queue import PriorityQueue
status = api_scheduler_status()
self._send_json(200, {"queue": status.get("queue", [])})
elif path == "/scheduler/slots":
status = api_scheduler_status()
self._send_json(200, status.get("slots", {}))
elif path == "/accounts/status":
"""返回本机所有账号的登录状态(对等原则:所有机器统一实现)
判断逻辑(和 matrix_mgmt._check_login_status_by_hint 一致):
1. 查 guardd 任务日志 → 根据上次执行结果判断
2. 无日志时 → 检查 cookies.sqlite 文件存在性兜底
"""
try:
import yaml
oracle_path = AGENT_SYNC / "ORACLE.yaml"
accounts = {}
identities_dir = AGENT_LOCAL / "tools" / "matrix" / "identities"
tasks_dir = TASK_LOG_DIR
if oracle_path.exists():
oracle = yaml.safe_load(oracle_path.read_text())
# 只处理分配给本机的账号
this_machine = HOSTNAME
details = {}
for entry in oracle.get("accounts", []):
machine = entry.get("machine") or entry.get("assigned_machine", "")
if machine != this_machine and machine != os.uname().nodename:
continue
platforms = entry.get("platforms", {})
hint = entry.get("identity", "")
if not hint:
for plat, aid in platforms.items():
accounts[aid] = "no_identity"
continue
# 1. 查日志
log_status = "no_log"
log_detail = ""
log_time = ""
if tasks_dir.exists():
candidate = None
try:
for f in tasks_dir.iterdir():
if not f.name.endswith(".log") or hint not in f.name:
continue
mtime = f.stat().st_mtime
if candidate is None or mtime > candidate[0]:
candidate = (mtime, f)
except PermissionError:
pass
if candidate:
mtime, log_file = candidate
log_time = datetime.fromtimestamp(mtime).strftime("%m-%d %H:%M")
try:
text = log_file.read_text(encoding="utf-8", errors="replace")
err_lines = [l.strip() for l in text.split("\n") if any(k in l for k in ["❌", "Error", "error", "failed", "短信", "登录", "✅", "SUCCESS"])]
log_detail = err_lines[0][:80] if err_lines else ""
if any(kw in text for kw in ["短信验证", "auto_verify 返回 False", "需手动登录", "sms_login", "SmsRecovery"]):
log_status = "sms_skip"
if not log_detail: log_detail = "短信验证待处理"
elif "✅" in text and "🛑 浏览器已关闭" in text:
log_status = "success"
if not log_detail: log_detail = "执行成功 浏览器已关闭"
elif "❌" in text or "Error" in text or "failed" in text.lower():
log_status = "failed"
if not log_detail: log_detail = "执行失败"
else:
log_status = "running"
log_detail = "执行中"
except Exception:
pass
# 2. 检查 cookies 文件
cookie_path = identities_dir / hint / "user_data" / "cookies.sqlite"
has_cookie = cookie_path.exists() and cookie_path.stat().st_size >= 100
# 3. 综合判断 + 记录原因
for plat, aid in platforms.items():
if log_status == "success":
accounts[aid] = "logged_in"
details[aid] = f"执行成功 {log_time}"
elif log_status == "sms_skip":
accounts[aid] = "sms_skip"
details[aid] = f"{log_detail} {log_time}" if log_detail else f"短信待验证 {log_time}"
elif log_status == "failed":
accounts[aid] = "need_login" if has_cookie else "no_cookie"
detail_str = log_detail or ("有Cookie但上次登录失败" if has_cookie else "无Cookie")
details[aid] = f"{detail_str} {log_time}".strip()
elif log_status == "running":
accounts[aid] = "running"
details[aid] = f"执行中 {log_time}"
else:
accounts[aid] = "logged_in" if has_cookie else "no_cookie"
details[aid] = "有Cookie文件" if has_cookie else "无Cookie文件"
self._send_json(200, {
"hostname": HOSTNAME,
"machine_uid": MACHINE_UID,
"accounts": accounts,
"details": details,
"updated_at": datetime.now(timezone.utc).isoformat(),
"source": "file_check",
})
except Exception as e:
logger.error(f"/accounts/status 处理失败: {e}")
self._send_json(200, {"error": str(e), "accounts": {}})
elif path == "/accounts/profiles":
try:
home = __import__("pathlib").Path.home()
pf = home / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
if pf.exists():
profiles = json.loads(pf.read_text())
else:
profiles = {}
self._send_json(200, {
"hostname": HOSTNAME,
"machine_uid": MACHINE_UID,
"profiles": profiles,
})
except Exception as e:
logger.error(f"/accounts/profiles 读取失败: {e}")
self._send_json(500, {"error": str(e)})
elif path == "/recordings":
try:
home = __import__("pathlib").Path.home()
rec_dir = home / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "recordings"
rec_list = []
if rec_dir.exists():
for f in sorted(rec_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
rec_list.append({
"name": f.name,
"size": f.stat().st_size,
"mtime": f.stat().st_mtime,
"machine": HOSTNAME,
})
# 读录制文件名时附上文件内容
name = parsed.query.replace("name=", "") if "name=" in parsed.query else ""
if name:
rec_file = rec_dir / name
if rec_file.exists():
content = json.loads(rec_file.read_text())
content["_machine"] = HOSTNAME
self._send_json(200, {"recordings": rec_list, "detail": content})
return
self._send_json(200, {"recordings": rec_list, "machine": HOSTNAME})
except Exception as e:
logger.error(f"/recordings 读取失败: {e}")
self._send_json(500, {"error": str(e)})
elif path == "/chrome/status":
self._send_json(200, api_chrome_status())
else:
self._send_json(404, {"error": "not_found"})
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/")
# 读取请求体
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode() if length else "{}"
try:
data = json.loads(body)
except json.JSONDecodeError:
self._send_json(400, {"error": "invalid_json"})
return
if path == "/task":
cmd = data.get("cmd", "")
run_id = data.get("run_id", str(uuid.uuid4().hex[:12]))
machine = data.get("machine", HOSTNAME)
if not cmd:
self._send_json(400, {"error": "cmd 必填"})
return
result = _start_task(run_id, cmd, machine)
self._send_json(200, result)
elif path.startswith("/task/") and path.endswith("/stop"):
run_id = path.split("/")[2]
result = _stop_task(run_id)
self._send_json(200, result)
elif path == "/scheduler/submit":
result = api_scheduler_submit(data)
self._send_json(200, result)
elif path == "/task/pause":
_init_scheduler()
task_id = data.get("task_id", "")
ok = _scheduler.pause_task(task_id)
self._send_json(200, {"status": "ok" if ok else "error", "task_id": task_id})
elif path == "/task/resume":
_init_scheduler()
task_id = data.get("task_id", "")
ok = _scheduler.resume_task(task_id)
self._send_json(200, {"status": "ok" if ok else "error", "task_id": task_id})
elif path == "/queue/reorder":
_init_scheduler()
task_id = data.get("task_id", "")
new_priority = data.get("priority")
move_to_front = data.get("move_to_front", False)
ok = _scheduler.reorder_queue(task_id, new_priority, move_to_front)
self._send_json(200, {"status": "ok" if ok else "error", "task_id": task_id})
elif path == "/scheduler/stop":
from modules.task_store import STATUS_FAILED
_init_scheduler()
if _scheduler:
_scheduler.kill_active()
self._send_json(200, {"status": "ok"})
elif path == "/scheduler/reset":
"""重置调度器:杀活跃任务+清队列+杀浏览器(保留 task_store 历史)"""
_init_scheduler()
if _scheduler:
_scheduler.kill_active()
# 清空优先级队列
_scheduler.queue = PriorityQueue()
import os
for p in ["camoufox", "firefox", "mc run"]:
os.system('pkill -f ' + p + ' 2>/dev/null')
self._send_json(200, {"status": "ok", "message": "reset: tasks killed, browsers killed, queue cleared"})
elif path == "/scheduler/clear-all":
"""清空所有任务(杀死运行中 + 清空队列 + 清空 task_store)"""
_init_scheduler()
if _scheduler:
_scheduler.clear_all()
self._send_json(200, {"status": "ok", "message": "所有任务已清空"})
elif path == "/chrome/start":
self._send_json(200, api_chrome_start())
elif path == "/chrome/stop":
self._send_json(200, api_chrome_stop())
else:
self._send_json(404, {"error": "not_found"})
def _run_http_server():
"""在后台线程启动 HTTP 服务器"""
server = http.server.HTTPServer(("0.0.0.0", 9090), TaskHTTPHandler)
logger.info(f" 🌐 HTTP 任务服务器已启动: http://0.0.0.0:9090")
server.serve_forever()
# ── 日志配置(带轮转:单文件最大 10MB,保留 3 个备份)────
_log_handler = logging.handlers.RotatingFileHandler(
str(LOG_FILE), maxBytes=10 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
_log_handler,
logging.StreamHandler(sys.stdout),
],
)
logger = logging.getLogger("guardd")
# ── 机器 UID(用于 Push 认证)───────────────────────────
MACHINE_UID_FILE = DIR_IDENTITY / "machine_uid"
MACHINE_UID = ""
try:
DIR_IDENTITY.mkdir(parents=True, exist_ok=True)
if MACHINE_UID_FILE.exists():
MACHINE_UID = MACHINE_UID_FILE.read_text(encoding="utf-8").strip()
else:
MACHINE_UID = str(uuid.uuid4())
MACHINE_UID_FILE.write_text(MACHINE_UID, encoding="utf-8")
logger.info(f" 新生成机器 UID: {MACHINE_UID[:8]}...")
except Exception as e:
logger.warning(f" UID 文件读写失败: {e}")
MACHINE_UID = HOSTNAME # 降级到 hostname
# ── WPRA v2.0: 本机机器命名空间 ──────────────────────
DIR_MACHINES = CROSS_MACHINE / "machines"
DIR_MY_MACHINE = DIR_MACHINES / MACHINE_UID
DIR_MY_EVENTS = DIR_MY_MACHINE / "events"
# ── 确保运行时目录存在 ───────────────────────────────
for d in [DIR_GUARDD_LOG, DIR_SUBMISSIONS_TRIAGE, DIR_TASKS_PENDING,
DIR_TASKS_COMPLETED, DIR_ENCRYPTED_PENDING, DIR_ENCRYPTED_PROCESSED,
DIR_MY_MACHINE, DIR_MY_EVENTS]:
d.mkdir(parents=True, exist_ok=True)
# ════════════════════════════════════════════════════════════
# 辅助函数
# ════════════════════════════════════════════════════════════
def _read_json(path):
"""安全读取 JSON 文件,不存在或无效返回 None"""
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
def _write_event(event_type, payload):
"""写入一条事件到 events/{date}/{uuid}.json"""
today = datetime.now().strftime("%Y-%m-%d")
event_dir = DIR_EVENTS / today
event_dir.mkdir(parents=True, exist_ok=True)
event = {
"id": str(uuid.uuid4()),
"type": event_type,
"hostname": HOSTNAME,
"timestamp": datetime.now(timezone.utc).isoformat(),
"payload": payload,
}
path = event_dir / f"{event['id']}.json"
path.write_text(json.dumps(event, indent=2, ensure_ascii=False), encoding="utf-8")
return event["id"]
# ════════════════════════════════════════════════════════════
# WPRA v2.0 辅助函数
# ════════════════════════════════════════════════════════════
def _read_file_version(path):
"""读取文件的 file_version,不存在或无效返回 0"""
data = _read_json(path)
if isinstance(data, dict):
return data.get("file_version", 0)
return 0
def _write_machine_event(event_type, payload):
"""写入一条事件到 machines/{MACHINE_UID}/events/{date}.jsonl
使用 JSONL 格式 (append-only),比单文件更高效
"""
today = datetime.now().strftime("%Y-%m-%d")
event_file = DIR_MY_EVENTS / f"{today}.jsonl"
event = {
"event_id": str(uuid.uuid4()),
"machine_uid": MACHINE_UID,
"machine_name": HOSTNAME,
"type": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": payload,
}
with open(event_file, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
return event["event_id"]
def _ensure_machine_identity():
"""确保 MACHINE.yaml 存在,首次运行时自动创建"""
identity_file = DIR_MY_MACHINE / "MACHINE.yaml"
if identity_file.exists():
return
import yaml
identity = {
"schema_version": "2.0",
"file_schema": "machine-identity-v1",
"machine_uid": MACHINE_UID,
"machine_name": HOSTNAME,
"created_at": datetime.now(timezone.utc).isoformat(),
"role": "workstation",
"notes": f"Auto-registered by guardd v{version}",
}
identity_file.write_text(
yaml.dump(identity, default_flow_style=False, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
logger.info(f" ✅ 机器身份已注册: {HOSTNAME} ({MACHINE_UID[:8]}...)")
def _push_to_dashboard(heartbeat_data):
"""反向连接: 将本机心跳+事件推送到 Dashboard
优先级:
1. 环境变量 GUARDD_DASHBOARD_URL
2. agent-local/config.yaml 中的 dashboard_url 字段
3. 空 → 不推送(本机独立运行)
"""
dashboard_url = os.environ.get("GUARDD_DASHBOARD_URL", "")
# 回退: 读本机 config.yaml
if not dashboard_url:
config_file = AGENT_LOCAL / "config.yaml"
if config_file.exists():
try:
import yaml
cfg = yaml.safe_load(config_file.read_text())
if cfg and isinstance(cfg, dict):
dashboard_url = str(cfg.get("dashboard_url", "")).strip()
except:
pass
if not dashboard_url:
return
# 读取最近事件用于时间线
today = datetime.now().strftime("%Y-%m-%d")
recent_events = []
event_dir_today = DIR_EVENTS / today
if event_dir_today.exists():
for f in sorted(event_dir_today.iterdir())[-10:]:
if f.suffix == ".json":
try:
recent_events.append(json.loads(f.read_text()))
except:
pass
try:
import urllib.request
payload = json.dumps({
"uid": MACHINE_UID,
"hostname": HOSTNAME,
"heartbeat": heartbeat_data,
"version": version,
"events": recent_events,
"uptime": round(time.time() - _guardd_start_time) if _guardd_start_time else 0,
}).encode("utf-8")
req = urllib.request.Request(
f"{dashboard_url}/api/push/heartbeat",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=5)
except Exception as e:
logger.debug(f" 推送 Dashboard 失败: {e}") # debug 级别, 不刷屏
def _is_file_older_than(path, days=30):
"""判断文件是否超过指定天数"""
try:
mtime = path.stat().st_mtime
age = time.time() - mtime
return age > days * 86400
except OSError:
return False
def _safe_move(src, dst_dir):
"""安全移动文件到目标目录"""
dst_dir.mkdir(parents=True, exist_ok=True)
dst = dst_dir / src.name
# 避免覆盖
if dst.exists():
dst = dst_dir / f"{src.stem}_{int(time.time())}{src.suffix}"
shutil.move(str(src), str(dst))
return dst
def _get_local_versions():
"""读取本机已记录的版本信息,不存在返回空 dict"""
local_ver = AGENT_LOCAL / "runtime" / "guardd" / "local_versions.json"
return _read_json(local_ver) or {}
def _save_local_versions(versions):
"""保存本机版本信息"""
path = AGENT_LOCAL / "runtime" / "guardd" / "local_versions.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(versions, indent=2, ensure_ascii=False), encoding="utf-8")
# ════════════════════════════════════════════════════════════
# 模块 1:状态上报 (heartbeat)
# ════════════════════════════════════════════════════════════
# 每 300s 将本机状态写入 cross_machine/status/{hostname}/heartbeat.json
# 其他机器通过读取此文件判断本机是否在线、正在做什么
# ────────────────────────────────────────────────────────────
def module_heartbeat():
"""采集系统状态并写入 heartbeat.json(精简版:只传在线状态 + slot 状态)"""
status_dir = DIR_STATUS / HOSTNAME
status_dir.mkdir(parents=True, exist_ok=True)
# 读取上次 last_run.json 中的 current_task
last_run = _read_json(LAST_RUN_FILE)
current_task = None
if last_run:
current_task = last_run.get("current_task")
# 采集 slot 状态(精简版)
slot_info = {"used": 0, "max": 3, "slots": []}
if _slot_manager:
try:
full = _slot_manager.get_usage()
slot_info["used"] = full.get("used", 0)
slot_info["max"] = full.get("max", 3)
for s in full.get("slots", []):
slot_info["slots"].append({
"id": s.get("account_id", ""),
"pid": s.get("pid"),
"health": s.get("health", "unknown"),
"elapsed": s.get("elapsed_sec", 0),
})
except Exception:
pass
heartbeat = {
"hostname": HOSTNAME,
"version": version,
"online": True,
"last_seen": datetime.now(timezone.utc).isoformat(),
"slots": slot_info,
"current_task": current_task,
}
path = status_dir / "heartbeat.json"
path.write_text(json.dumps(heartbeat, indent=2, ensure_ascii=False), encoding="utf-8")
# ── 同时写入 v4 格式 status/live/{uid}.json (向后兼容) ──
live_data = {**heartbeat, "_uid": MACHINE_UID, "_hostname": HOSTNAME,
"_received_at": datetime.now(timezone.utc).isoformat()}
live_json = json.dumps(live_data, indent=2, ensure_ascii=False)
# 写入本地
live_dir_local = DIR_STATUS / "live"
live_dir_local.mkdir(parents=True, exist_ok=True)
(live_dir_local / f"{MACHINE_UID}.json").write_text(live_json, encoding="utf-8")
# 同时写入 cross_machine(Dashboard 读这个目录)
live_dir_cross = CROSS_MACHINE / "status" / "live"
live_dir_cross.mkdir(parents=True, exist_ok=True)
(live_dir_cross / f"{MACHINE_UID}.json").write_text(live_json, encoding="utf-8")
# ── WPRA v2.0 心跳写入已移除(v4.2.0)──
# 心跳数据仅写入:
# - status/{HOSTNAME}/heartbeat.json(本地状态目录)
# - status/live/{MACHINE_UID}.json(实时状态)
# 不再写入 cross_machine/machines/{UID}/heartbeat.json,避免 Git 污染。
# Dashboard 通过 guardd 的反向连接推送获取心跳。
#
# 记录心跳事件
_write_machine_event("heartbeat", {"slots": f"{slot_info['used']}/{slot_info['max']}"})
logger.info(f" 心跳已上报 — slots:{slot_info['used']}/{slot_info['max']} 在线")
# ── 确保 MACHINE.yaml 存在(首次运行时自动创建)──
_ensure_machine_identity()
# ── 实时推送到 Dashboard (反向连接) ──
_push_to_dashboard(heartbeat)
# ── Git 同步: 将本机数据推送到远程, 拉取其他机器的数据 ──
# ── 不再往 Gitee 写心跳文件, 改用本机 local 存储 ──
# _git_sync()
# ── 改为写本地状态文件 ──
# ── Override 同步: 检查本机新账号并自动补全 ──
_sync_account_override()
# ── 版本检查: 对比 required-version, 落后则自动升级 ──
_check_version()
def _git_sync():
"""Git 同步: 只 add 本机命名空间的文件 + 全局松散文件
WPRA v2.0 核心规则:
- 不再使用 git add -A(会 staging 其他机器的文件)
- 使用 glob 查找本机文件后精确 add
- 每个文件只被一个写入者管控,消除 git conflict
"""
import glob as _glob
try:
repo = AGENT_SYNC
repo_str = str(repo)
# 先 pull, 避免 push 冲突
# --autostash: 自动 stash 脏工作区再 rebase,修复 heartbeat 等模块写文件后 pull 失败的问题
subprocess.run(["git", "pull", "--rebase", "--autostash"], capture_output=True, text=True,
timeout=30, cwd=repo_str)
# 精确查找本机文件 → 精确 git add
# 使用 glob 展开路径,确保 git 看到的是真实存在的文件
rel_repo = repo_str
add_globs = [
f"04_memory/cross_machine/machines/{MACHINE_UID}/**",
f"04_memory/cross_machine/data/*/{MACHINE_UID}.json",
f"04_memory/cross_machine/status/live/{MACHINE_UID}.json",
f"04_memory/cross_machine/status/{HOSTNAME}/**",
f"04_memory/cross_machine/events/**",
f"04_memory/cross_machine/tasks/**",
]
for g in add_globs:
full = os.path.join(rel_repo, g)
for matched in _glob.glob(full, recursive=True):
if os.path.isfile(matched):
rel = os.path.relpath(matched, rel_repo)
subprocess.run(["git", "add", rel], capture_output=True, text=True,
timeout=15, cwd=repo_str)
# 只在有变动时才 commit
r = subprocess.run(["git", "status", "--porcelain"], capture_output=True,
text=True, timeout=10, cwd=repo_str)
if r.stdout.strip():
subprocess.run(["git", "commit", "-m",
f"sync({HOSTNAME}): guardd {datetime.now().strftime('%Y-%m-%d %H:%M')}"],
capture_output=True, text=True, timeout=15, cwd=repo_str)
# push 到所有远程
remotes = subprocess.run(["git", "remote"], capture_output=True,
text=True, timeout=10, cwd=repo_str)
for remote in remotes.stdout.strip().splitlines():
subprocess.run(["git", "push", remote, "main"], capture_output=True,
text=True, timeout=30, cwd=repo_str)
except Exception as e:
logger.warning(f" Git sync 异常: {e}")
def _sync_account_override():
"""从 ORACLE.yaml 同步 override: 检测本机新账号并自动补全
流程:
1. 读取 ORACLE.yaml(联邦宪法,Gitee 同步)
2. 读取 accounts.override.yaml(本机私有)
3. 找到 ORACLE 中 machine=本机 但 override 中不存在的账号 ID
4. 自动添加到 override(phone 用 mask 占位,用户后续可改)
废除 accounts_registry.yaml(v4.2.0),所有账号分配统一在 ORACLE.yaml 中管理。
"""
import yaml
ORACLE_PATH = AGENT_SYNC / "ORACLE.yaml"
OVERRIDE_PATH = AGENT_LOCAL / "tools" / "matrix" / "config" / "accounts.override.yaml"
if not ORACLE_PATH.exists():
logger.warning(" ORACLE.yaml 不存在,跳过 override 同步")
return
try:
oracle = yaml.safe_load(ORACLE_PATH.read_text()) or {}
ovr = yaml.safe_load(OVERRIDE_PATH.read_text()) if OVERRIDE_PATH.exists() else {"version": "1.0", "hostname": HOSTNAME, "accounts": []}
ovr_ids = {a["id"] for a in ovr.get("accounts", [])}
new_accounts = []
for acct in oracle.get("accounts", []):
# 只处理本机归属的账号
if acct.get("machine", "") != HOSTNAME:
continue
# ORACLE 中一个 identity 可能绑定多个平台(douyin + xiaohongshu)
platforms = acct.get("platforms", {})
for platform, account_id in platforms.items():
if account_id in ovr_ids:
continue # 已在 override 中
# 自动补全
phone_mask = acct.get("phone", "")
if len(phone_mask) > 7:
phone_mask = phone_mask[:3] + "****" + phone_mask[-4:]
new_entry = {
"id": account_id,
"phone": phone_mask,
"platform": platform,
"identity": acct.get("identity", ""),
"enabled": True,
}
ovr.setdefault("accounts", []).append(new_entry)
new_accounts.append(account_id)
logger.info(f" 📝 自动补全 override: {account_id} ({platform})")
if new_accounts:
OVERRIDE_PATH.parent.mkdir(parents=True, exist_ok=True)
OVERRIDE_PATH.write_text(
yaml.dump(ovr, default_flow_style=False, allow_unicode=True, sort_keys=False)
)
logger.info(f" ✅ override 已更新: 新增 {len(new_accounts)} 个账号: {', '.join(new_accounts)}")
else:
logger.info(" ✅ override 同步检查: 无新账号")
except Exception as e:
logger.warning(f" Override 同步异常: {e}")
def _check_version():
"""版本检查: 落后则自动拉取最新代码"""
req_file = DIR_CROSS / "guardd-required-version.txt"
if not req_file.exists():
return
try:
required = req_file.read_text().strip()
if version != required:
logger.info(f" 版本落后: 当前={version}, 要求={required}, 正在升级...")
# 拉取最新代码
subprocess.run(["git", "pull", "--autostash"], capture_output=True, text=True,
timeout=30, cwd=str(AGENT_SYNC))
logger.info(f" 升级完成, 请重启 guardd")
except Exception as e:
logger.warning(f" Version check 异常: {e}")
# ════════════════════════════════════════════════════════════
# 模块 2:任务检查 (task_worker)
# ════════════════════════════════════════════════════════════
# 扫描 tasks/pending/ 中 target_host 为自己的任务
# 执行后将文件移到 completed/
# ────────────────────────────────────────────────────────────
def module_task_worker():
"""执行分配至本机的跨机任务"""
if not DIR_TASKS_PENDING.exists():
return
task_schemas = {
"sync_knowledge": {"desc": "同步知识库变更", "handler": "_handle_sync_knowledge"},
"notify_event": {"desc": "通知事件收到", "handler": "_handle_notify_event"},
"run_script": {"desc": "远程执行脚本", "handler": None}, # 需要安全沙箱,暂不实现
}
found = 0
for f in sorted(DIR_TASKS_PENDING.iterdir()):
if not f.name.endswith(".json"):
continue
task = _read_json(f)
if not task:
_safe_move(f, DIR_TASKS_COMPLETED)
continue
target = task.get("target_host", "")
if target and target != HOSTNAME:
continue # 不是给本机的
found += 1
task_id = task.get("id", f.stem)
task_type = task.get("type", "unknown")
logger.info(f" 执行任务 [{task_id}]: type={task_type}")
# 标记为 in_progress(写入 current_task)
last_run = _read_json(LAST_RUN_FILE) or {}
last_run["current_task"] = task_id
LAST_RUN_FILE.write_text(
json.dumps(last_run, indent=2, ensure_ascii=False), encoding="utf-8"
)
# 根据 task_type 处理
success = False
if task_type in task_schemas and task_schemas[task_type]["handler"]:
handler_name = task_schemas[task_type]["handler"]
handler = globals().get(handler_name)
if handler:
try:
handler(task)
success = True
except Exception as e:
logger.error(f" 任务 {task_id} 执行失败: {e}")
_write_event("task_failed", {"task_id": task_id, "error": str(e)})
else:
# 未知任务类型:标记完成但不执行
success = True
logger.info(f" 任务 {task_id} 类型 '{task_type}' 无需执行")
# 清除 current_task
last_run["current_task"] = None
LAST_RUN_FILE.write_text(
json.dumps(last_run, indent=2, ensure_ascii=False), encoding="utf-8"
)
# 标记完成
task["status"] = "completed"
task["completed_at"] = datetime.now(timezone.utc).isoformat()
task["completed_by"] = HOSTNAME
(DIR_TASKS_PENDING / f.name).write_text(
json.dumps(task, indent=2, ensure_ascii=False), encoding="utf-8"
)
_safe_move(DIR_TASKS_PENDING / f.name, DIR_TASKS_COMPLETED)
_write_event("task_completed", {"task_id": task_id, "type": task_type, "success": success})
if found == 0:
logger.info(" 无待处理任务")
def _handle_sync_knowledge(task):
"""处理知识同步任务——记录事件即可,实际同步由 NutSync 处理"""
_write_event("knowledge_sync_ack", {
"task_id": task.get("id"),
"source": task.get("source_host"),
"acknowledged": True,
})
def _handle_notify_event(task):
"""处理通知事件——写入本地日志"""
notice = task.get("payload", {})
logger.info(f" 收到跨机通知: {notice.get('message', '')}")
# ════════════════════════════════════════════════════════════
# 模块 3:版本检查 (upgrade_checker)
# ════════════════════════════════════════════════════════════
# 读取 knowledge/versions.json,对比本地组件版本
# 有更新 → 非 breaking 自动执行,breaking 待确认
# ────────────────────────────────────────────────────────────
def module_upgrade_checker():
"""检查版本清单,发现新版本时触发更新事件 + 非破坏性更新自动执行"""
remote_versions = _read_json(VERSIONS_FILE)
if not remote_versions:
logger.info(" 版本清单不可用,跳过")
return
local_versions = _get_local_versions()
components = remote_versions.get("components", {})
updates_found = []
auto_upgraded = []
for name, spec in components.items():
local_ver = local_versions.get(name, {}).get("version", "0.0.0")
remote_ver = spec.get("current", "0.0.0")
if remote_ver != local_ver:
is_breaking = spec.get("breaking_change", False)
updates_found.append({
"component": name,
"from": local_ver,
"to": remote_ver,
"breaking": is_breaking,
"description": spec.get("description", ""),
})
# 自动执行非破坏性更新
if not is_breaking:
install_script = spec.get("install_script", "")
if install_script:
script_path = AGENT_SYNC / install_script
if script_path.exists():
try:
subprocess.run(["bash", str(script_path)], check=True,
capture_output=True, timeout=120)
auto_upgraded.append(name)
logger.info(f" ✅ 自动升级 {name}: {local_ver}→{remote_ver}")
_write_event("component_upgraded", {
"component": name, "from": local_ver, "to": remote_ver
})
except subprocess.CalledProcessError as e:
logger.error(f" ❌ 自动升级 {name} 失败: {e.stderr.decode()[:200]}")
except FileNotFoundError:
logger.info(f" ⏭ 升级脚本不存在: {install_script}")
else:
logger.info(f" ℹ️ {name} 有更新 {local_ver}→{remote_ver},无安装脚本")
if updates_found:
if auto_upgraded:
logger.info(f" 已自动升级: {', '.join(auto_upgraded)}")
breaking = [u for u in updates_found if u['breaking']]
if breaking:
logger.info(f" ⚠️ 破坏性更新待处理: {', '.join(b['component'] for b in breaking)}")
_write_event("upgrades_available", {
"updates": updates_found, "auto_upgraded": auto_upgraded,
"check_time": datetime.now(timezone.utc).isoformat(),
})
new_local = {}
for name, spec in components.items():
new_local[name] = {"version": spec["current"],
"updated_at": datetime.now(timezone.utc).isoformat()}
_save_local_versions(new_local)
else:
logger.info(" 所有组件已最新")
# ════════════════════════════════════════════════════════════
# 模块 4:记忆提炼上报 (memory_triage)
# ════════════════════════════════════════════════════════════
# 扫描 agent-local/memory/ 中的原始记忆
# 规则过滤器:筛掉含本机路径/配置的记忆
# 保留通用方法论/概念/经验 → 写入 submissions/memory_triage/
# ────────────────────────────────────────────────────────────
def module_memory_triage():
"""过滤本地记忆并推送通用内容到提交箱"""
if not DIR_LOCAL_MEMORY.exists():
return
# 已处理的文件追踪
processed_file = AGENT_LOCAL / "runtime" / "guardd" / "triage_processed.txt"
processed = set()
if processed_file.exists():
processed = set(processed_file.read_text(encoding="utf-8").splitlines())
# 本机特有模式 —— 含这些内容的记忆不共享
LOCAL_PATTERNS = [
re.compile(r"/Users/" + os.getlogin() + r"/"),
re.compile(HOSTNAME, re.IGNORECASE),
re.compile(r"agent-local/"),
re.compile(r"runtime/guardd"),
re.compile(r"API[_-]?KEY[=:]?\s*\w{8,}", re.IGNORECASE),
re.compile(r"sk-[a-zA-Z0-9]{20,}"), # OpenAI style keys
re.compile(r"AKID[\\w]+"), # 阿里云/腾讯云 AK
re.compile(r"password[=:]?\s*\\S+", re.IGNORECASE),
re.compile(r"secret[=:]?\s*\\S+", re.IGNORECASE),
]
# 机器通用内容关键词(含这些关键词的更可能跨机有价值)
GENERAL_KEYWORDS = [
"方法论", "workflow", "template", "最佳实践", "经验",
"总结", "lesson", "心得", "技巧", "tip", "指南",
"principle", "原则", "策略", "strategy", "architecture",
]
new_files = []
for f in DIR_LOCAL_MEMORY.rglob("*"):
if not f.is_file() or f.name.startswith("."):
continue
if str(f) in processed:
continue
if f.suffix not in (".md", ".txt", ".json", ".yaml", ".yml"):
continue
try:
content = f.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
# 规则过滤器:含本机特有模式 → 跳过
if any(p.search(content) for p in LOCAL_PATTERNS):
logger.info(f" 跳过本地记忆: {f.name} (包含本机特有内容)")
processed.add(str(f))
continue
# 检查是否包含通用关键词
has_general = any(kw in content for kw in GENERAL_KEYWORDS)
if not has_general and len(content) < 100:
logger.info(f" 跳过本地记忆: {f.name} (无通用内容)")
processed.add(str(f))
continue
# 写入提交箱
dest = DIR_SUBMISSIONS_TRIAGE / f.name
# 避免重名
if dest.exists():
dest = DIR_SUBMISSIONS_TRIAGE / f"{f.stem}_{int(time.time())}{f.suffix}"
dest.write_text(content, encoding="utf-8")
new_files.append(f.name)
processed.add(str(f))
# 更新已处理记录
if new_files:
processed_file.write_text("\n".join(sorted(processed)), encoding="utf-8")
_write_event("memory_triage", {
"files": new_files,
"count": len(new_files),
})
logger.info(f" 提交 {len(new_files)} 条通用记忆到 submissions/memory_triage/")
else:
logger.info(" 无新记忆需要提炼")
# ════════════════════════════════════════════════════════════
# 模块 5:知识同步检查 (knowledge_sync)
# ════════════════════════════════════════════════════════════
# 检测 03_knowledge/ 的 CHANGELOG.md 最新变更
# 扫描 agent-local/submissions/ 推送到 03_knowledge/01_submissions/
# ────────────────────────────────────────────────────────────
def module_knowledge_sync():
"""检测知识库变更并推送本地提交"""
# ── 检测知识库变更 ──
changelog_path = AGENT_SYNC / "03_knowledge" / "CHANGELOG.md"
last_check_file = AGENT_LOCAL / "runtime" / "guardd" / "knowledge_last_check.txt"
if changelog_path.exists():
try:
# 读取 CHANGELOG 第一行内容作为变更指纹
changelog_content = changelog_path.read_text(encoding="utf-8")
# 取最新日期行
date_lines = re.findall(r"^##\s+(\d{4}-\d{2}-\d{2})", changelog_content, re.MULTILINE)
latest_date = date_lines[0] if date_lines else "unknown"
last_date = ""
if last_check_file.exists():
last_date = last_check_file.read_text(encoding="utf-8").strip()
if latest_date != last_date and latest_date != "unknown":
_write_event("knowledge_updated", {
"latest_date": latest_date,
"previous_date": last_date or "none",
})
last_check_file.write_text(latest_date, encoding="utf-8")
logger.info(f" 知识库有新变更: {latest_date}")
else:
logger.info(f" 知识库无新变更 (上次: {last_date})")
except OSError as e:
logger.error(f" 读取 CHANGELOG 失败: {e}")
else:
logger.info(" CHANGELOG.md 不存在,跳过")
# ── 推送本地提交箱 → 知识库 submissions ──
if not DIR_SUBMISSIONS.exists():
return
pushed = 0
for sub_dir in DIR_SUBMISSIONS.iterdir():
if not sub_dir.is_dir() or sub_dir.name.startswith("."):
continue
for f in sub_dir.glob("*"):
if not f.is_file() or f.name.startswith("."):
continue
# 推送到 03_knowledge/01_submissions/{hostname}/
target_dir = DIR_SUBMISSIONS_KNOWLEDGE / HOSTNAME / sub_dir.name
target_dir.mkdir(parents=True, exist_ok=True)
dest = target_dir / f.name
if not dest.exists():
shutil.copy2(str(f), str(dest))
pushed += 1
logger.info(f" 推送: {sub_dir.name}/{f.name}")
if pushed > 0:
_write_event("submissions_pushed", {"count": pushed, "hostname": HOSTNAME})
logger.info(f" 推送 {pushed} 个文件到 01_submissions/{HOSTNAME}/")
else:
logger.info(" 无新提交需要推送")
# ════════════════════════════════════════════════════════════
# 模块 6:加密消息接收 (encrypted_channel)
# ════════════════════════════════════════════════════════════
# 读取 encrypted/pending/ 中 target_host 为自己的文件
# 用本地私钥解密 → 存入 agent-local/identity/secrets/received/
# ────────────────────────────────────────────────────────────
def module_encrypted_channel():
"""解密发往本机的加密消息 → 解密后提示用户"""
if not DIR_ENCRYPTED_PENDING.exists():
return
private_key_path = DIR_SECRETS / "private_key.pem"
if not private_key_path.exists():
logger.info(" 无私钥文件,跳过加密频道")
return
found = 0
for f in sorted(DIR_ENCRYPTED_PENDING.iterdir()):
if not f.name.endswith(".json"):
continue
msg = _read_json(f)
if not msg:
_safe_move(f, DIR_ENCRYPTED_PROCESSED)
continue
target = msg.get("target_host", "")
if target and target != HOSTNAME:
continue
found += 1
msg_id = msg.get("id", f.stem)
msg_type = msg.get("type", "unknown")
sender = msg.get("sender", "unknown")
logger.info(f" 发现加密消息 [{msg_id}] from={sender} type={msg_type}")
# 创建用户通知文件(可用于前端/外部提示)
notice = {
"msg_id": msg_id, "sender": sender, "type": msg_type,
"description": msg.get("description", ""),
"received_at": datetime.now(timezone.utc).isoformat(),
"needs_action": True,
}
notice_dir = DIR_GUARDD_LOG / "notifications"
notice_dir.mkdir(parents=True, exist_ok=True)
(notice_dir / f"{msg_id}.json").write_text(
json.dumps(notice, indent=2, ensure_ascii=False), encoding="utf-8")
# 如果是文件传输请求,特别标注
if msg_type == "file_transfer":
logger.info(f" 🔄 文件传输请求: {msg.get('description', '')}")
notice["action_type"] = "approve_transfer"
# 尝试解密
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
encrypted_data = msg.get("payload", "")
if encrypted_data:
with open(private_key_path, "rb") as key_file:
private_key = serialization.load_pem_private_key(key_file.read(), password=None)
decrypted = private_key.decrypt(
bytes.fromhex(encrypted_data),
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(), label=None),
)
received_dir = DIR_SECRETS / "received"
received_dir.mkdir(parents=True, exist_ok=True)
out_path = received_dir / f"{msg_id}.json"
out_path.write_text(decrypted.decode("utf-8"), encoding="utf-8")
logger.info(f" ✅ 解密成功 → {out_path}")
notice["decrypted"] = True
notice["output"] = str(out_path)
_write_event("message_decrypted", {"msg_id": msg_id})
else:
# 无 payload 的消息(如请求类),确认收到即可
logger.info(f" ℹ️ 无加密载荷,确认收到")
notice["decrypted"] = True
except Exception as e:
logger.error(f" ⚠️ 解密失败: {e}")
notice["decrypted"] = False
notice["error"] = str(e)
_write_event("message_decrypt_failed", {"msg_id": msg_id, "error": str(e)})
# 更新通知状态
notice["needs_action"] = False
(notice_dir / f"{msg_id}.json").write_text(
json.dumps(notice, indent=2, ensure_ascii=False), encoding="utf-8")
_safe_move(f, DIR_ENCRYPTED_PROCESSED)
if found == 0:
logger.info(" 无待处理加密消息")
# ════════════════════════════════════════════════════════════
# 模块 7:清理 (cleanup)
# ════════════════════════════════════════════════════════════
# 清理超过 30 天的旧事件文件和已完成的任务
# ────────────────────────────────────────────────────────────
def module_cleanup():
"""清理过期数据"""
cleaned = 0
# 清理过期事件文件 (>30天)
if DIR_EVENTS.exists():
for date_dir in DIR_EVENTS.iterdir():
if not date_dir.is_dir() or date_dir.name.startswith("."):
continue
# 目录名格式: YYYY-MM-DD
try:
dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d")
if (datetime.now() - dir_date).days > 30:
shutil.rmtree(str(date_dir))
cleaned += 1
logger.info(f" 清理事件目录: {date_dir.name}")
except ValueError:
continue
# 清理过期已完成任务 (>30天)
if DIR_TASKS_COMPLETED.exists():
for f in DIR_TASKS_COMPLETED.iterdir():
if f.is_file() and _is_file_older_than(f, 30):
f.unlink()
cleaned += 1
logger.info(f" 清理完成任务: {f.name}")
# 清理过期已处理加密消息 (>30天)
if DIR_ENCRYPTED_PROCESSED.exists():
for f in DIR_ENCRYPTED_PROCESSED.iterdir():
if f.is_file() and _is_file_older_than(f, 30):
f.unlink()
cleaned += 1
logger.info(f" 清理已处理加密消息: {f.name}")
if cleaned > 0:
logger.info(f" 清理了 {cleaned} 个过期条目")
else:
logger.info(" 无过期数据需要清理")
# ════════════════════════════════════════════════════════════
# 模块 8:同步检查 (sync_checker)
# ════════════════════════════════════════════════════════════
# 自动 git pull → 检测变更 → 触发协同更新
# ────────────────────────────────────────────────────────────
def module_sync_checker():
"""自动拉取远程变更,检测是否有新的协同任务"""
if not (AGENT_SYNC / ".git").exists():
logger.info(" 非 git 仓库,跳过同步检查")
return
try:
# 1. git pull 获取远程变更
result = subprocess.run(
["git", "pull", "--autostash"],
capture_output=True, text=True, timeout=30,
cwd=str(AGENT_SYNC),
)
output = result.stdout.strip()
stderr = result.stderr.strip()
if "Already up to date" in output:
logger.info(" 远程仓库已最新")
return
if result.returncode == 0:
# 有更新!
changed = "has new commits" in output or "Fast-forward" in output
if changed or "files changed" in output:
# 提取变更统计
stats = ""
for line in output.split("\n"):
if "files changed" in line:
stats = line.strip()
break
logger.info(f" 🔄 检测到远程更新: {stats or output[:80]}")
_write_event("sync_updated", {
"summary": output[:500],
"hostname": HOSTNAME,
})
else:
logger.info(f" git pull 输出: {output[:200]}")
else:
logger.warning(f" git pull 失败: {stderr[:200]}")
except subprocess.TimeoutExpired:
logger.warning(" git pull 超时 (30s)")
except FileNotFoundError:
logger.info(" git 未安装,跳过同步检查")
except Exception as e:
logger.warning(f" git pull 异常: {e}")
# ════════════════════════════════════════════════════════════
# 主循环
# ════════════════════════════════════════════════════════════
def module_dashboard_sync():
"""将本机Dashboard插件数据写入 cross_machine/data/{plugin}/{uid}.json
确保所有机器的 Dashboard 可以看到本机的完整状态。
格式统一由 DashboardPlugin.write_shared() 保证。
"""
import importlib, pkgutil
dashboard_plugins_dir = AGENT_SYNC / "05_tools" / "10_dashboard" / "plugins"
if not dashboard_plugins_dir.exists():
logger.debug(" Dashboard插件目录不存在,跳过")
return
sys.path.insert(0, str(dashboard_plugins_dir.parent))
try:
from plugins.base import MACHINE_UID as DASH_UID, HOSTNAME as DASH_HOST
from plugins import discover_plugins
discovered = discover_plugins()
for name, inst in discovered.items():
try:
if hasattr(inst, 'write_shared'):
inst.write_shared()
logger.info(f" ✓ dashboard/{name} 已同步")
except Exception as e:
logger.debug(f" dashboard/{name} 同步失败: {e}")
except Exception as e:
logger.debug(f" Dashboard插件加载失败: {e}")
finally:
sys.path.pop(0)
def _run_heartbeat_cycle():
"""执行一轮健康检查(9 模块)"""
t0 = time.time()
modules = [
("heartbeat", module_heartbeat),
("dashboard_sync", module_dashboard_sync),
("task_worker", module_task_worker),
("upgrade_checker", module_upgrade_checker),
("memory_triage", module_memory_triage),
("knowledge_sync", module_knowledge_sync),
("encrypted_channel", module_encrypted_channel),
("sync_checker", module_sync_checker),
("cleanup", module_cleanup),
]
results = {}
for name, func in modules:
try:
func()
results[name] = "ok"
logger.info(f" ✓ {name}")
except Exception as e:
results[name] = f"error: {e}"
logger.error(f" ✗ {name}: {e}")
with open(ERROR_LOG_FILE, "a") as f:
f.write(f"{datetime.now()} {name}: {e}\n")
elapsed = time.time() - t0
last_run = {
"hostname": HOSTNAME,
"version": version,
"started_at": datetime.now(timezone.utc).isoformat(),
"elapsed_sec": round(elapsed, 2),
"results": results,
"next_run": (datetime.now(timezone.utc) + timedelta(seconds=300)).isoformat(),
"running_tasks": len(_get_tasks()),
}
with open(LAST_RUN_FILE, "w") as f:
json.dump(last_run, f, indent=2, ensure_ascii=False)
logger.info(f"guardd 心跳完成 — {elapsed:.2f}s, results={results}")
# ── 全局实例 ──
_task_store = None
_slot_manager = None
_scheduler = None
_heartbeat_reporter = None
_account_monitor = None
_oracle_sync = None
# 防止 _init_scheduler() 并发初始化(HTTP 线程和主线程竞态)
_init_lock = threading.Lock()
def _init_scheduler():
"""初始化调度引擎(线程安全,可重复调)"""
global _task_store, _slot_manager, _scheduler, _heartbeat_reporter, _oracle_sync, _account_monitor, _schedule_bridge
if _scheduler is not None:
return # 已初始化
if not _init_lock.acquire(blocking=False):
logger.warning(" ⏳ 另一线程正在初始化调度引擎,等待...")
_init_lock.acquire(blocking=True) # 等初始化完成
_init_lock.release()
return
try:
logger.info(" ⚙️ 初始化调度引擎 (v4.3.0)...")
# TaskStore
_task_store = TaskStore()
logger.info(f" 📦 TaskStore 就绪 ({_task_store.count()})")
# PriorityQueue
queue = PriorityQueue()
# SlotManager
_slot_manager = BrowserSlotManager(max_slots=3)
_slot_manager.cleanup_orphans()
logger.info(f" 🖥️ SlotManager 就绪 ({_slot_manager.get_usage()['used']}/{_slot_manager.max_slots})")
# Executor
executor = Executor(_task_store, _slot_manager)
# Scheduler(传入 on_task_event 回调用于状态推送)
def _on_task_event(event_type, task):
"""任务状态变化时推送事件到 Dashboard"""
try:
payload = {
"event": event_type,
"machine": HOSTNAME,
"task_id": task.get("task_id", ""),
"cmd_type": task.get("cmd_type", ""),
"accounts": task.get("accounts", []),
"slot_id": task.get("slot_id"),
"status": task.get("status", ""),
"time": datetime.now(timezone.utc).isoformat(),
}
# 推送给本机 Dashboard
import urllib.request as _urq
import json as _js
req = _urq.Request(
"http://127.0.0.1:9988/api/push/task-event",
data=_js.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
_urq.urlopen(req, timeout=3)
except Exception:
pass # Dashboard 不在线不影响执行
_scheduler = Scheduler(_task_store, queue, _slot_manager, executor, on_task_event=_on_task_event)
# HeartbeatReporter
from modules.account_monitor import AccountMonitor
from modules.schedule_bridge import ScheduleBridge
_account_monitor = AccountMonitor()
_schedule_bridge = ScheduleBridge(_task_store, _scheduler, HOSTNAME)
_heartbeat_reporter = HeartbeatReporter(
_task_store, _slot_manager, _scheduler,
HOSTNAME, MACHINE_UID,
dashboard_url="http://127.0.0.1:9988",
account_monitor=_account_monitor
)
# OracleSync
_oracle_sync = OracleSync(_task_store)
_oracle_sync.sync()
# 恢复未完成任务(始终恢复 ALL queued 任务,不局限于 recovered>0)
recovered = _task_store.reset_unfinished()
queued_tasks = _task_store.get_by_status("queued")
if queued_tasks:
logger.info(f" 🔄 恢复 {len(queued_tasks)} 个排队任务到队列 (含 {recovered} 个从 running 重置)")
for task in queued_tasks:
_scheduler.submit_task(task)
logger.info(" ✅ 调度引擎初始化完成")
# 发一轮心跳
_heartbeat_reporter.send_to_dashboard()
_heartbeat_reporter.write_local()
except Exception as e:
logger.error(f" ❌ 调度引擎初始化失败: {e}")
finally:
_init_lock.release()
def _run_scheduler_loop():
"""在独立线程中运行调度循环"""
_init_scheduler()
logger.info(" 🔁 调度循环启动 (15s间隔)")
_scheduler.run_cycle()
def _run_enhanced_heartbeat():
"""增强版心跳 — 替换原有 module_heartbeat"""
if _heartbeat_reporter is None:
_init_scheduler()
try:
hb = _heartbeat_reporter.collect()
_heartbeat_reporter.write_local(hb)
_heartbeat_reporter.send_to_dashboard(hb)
logger.info(f" ✓ enhanced_heartbeat (slots={hb['slots']['used']}/{hb['slots']['max']})")
except Exception as e:
logger.error(f" ✗ enhanced_heartbeat: {e}")
def api_scheduler_submit(task_json: dict) -> dict:
"""HTTP API 入口:提交任务到调度器"""
_init_scheduler()
_scheduler.submit_task(task_json)
return {"status": "accepted", "task_id": task_json.get("task_id", "")}
def api_scheduler_status() -> dict:
"""HTTP API 入口:查询调度器状态
每次调用先实时检查所有浏览器进程是否活着(os.kill pid 空信号),
确保看板看到的是当前真实状态,不依赖 15 秒周期。
"""
_init_scheduler()
# 实时进程检测 — 每次 API 调用都查,0 延时
if _slot_manager:
try:
for s_info in _slot_manager.get_usage().get("slots", []):
pid = s_info.get("pid")
if pid:
try:
os.kill(pid, 0) # 空信号,仅测存活
s_info["health"] = "healthy"
except OSError:
s_info["health"] = "crashed"
s_info["pid"] = None # 已死,清掉 PID
except Exception:
pass
# 同步清理 scheduler 中已死的活跃任务
if _scheduler and _scheduler.active_tasks:
for slot_id, task in list(_scheduler.active_tasks.items()):
slot_pid = None
if _slot_manager:
slots_info = _slot_manager.get_usage().get("slots", [])
s_info = next((s for s in slots_info if s.get("slot_id") == slot_id), None)
if s_info:
slot_pid = s_info.get("pid")
# 如果 slot 有 PID 但进程已死,或 slot 已释放但 active_tasks 还有 → 强制释放
if slot_pid is None and task.get("status") in ("running",):
# PID 不存在 → 进程已崩溃 → 标记失败
logger.warning(f" ⚰️ [{task['task_id'][:30]}] 进程已死,强制释放 slot {slot_id}")
task["status"] = "failed"
task["error"] = "进程已崩溃"
task["completed_at"] = time.time()
_scheduler._release_slot(slot_id)
return {
"active": list(_scheduler.active_tasks.values()) if _scheduler and _scheduler.active_tasks else [],
"queue": _scheduler.get_all_queued() if _scheduler else [],
"queue_sizes": _scheduler.queue_sizes() if _scheduler else {},
"slots": _slot_manager.get_usage() if _slot_manager else {},
"counts": _task_store.count() if _task_store else {},
}
def main():
logger.info(f"guardd v{version} 启动 — hostname={HOSTNAME} (持久模式)")
# 先初始化调度引擎(确保全局变量就绪)
try:
_init_scheduler()
scheduler_thread = threading.Thread(target=_run_scheduler_loop, daemon=True)
scheduler_thread.start()
logger.info(" 🔁 调度引擎后台线程已启动")
# 5 秒进程追踪线程(独立于调度 cycle,更灵敏)
if _slot_manager:
track_thread = threading.Thread(target=_slot_manager.track_loop, args=(5.0,), daemon=True)
track_thread.start()
logger.info(" 🔁 进程追踪线程已启动(每 5 秒检测 PID 存活)")
except Exception as e:
logger.warning(f" 调度引擎启动失败: {e} (不影响原有功能)")
# 初始化完成后才启动 HTTP 服务器(避免竞态)
http_thread = threading.Thread(target=_run_http_server, daemon=True)
http_thread.start()
# 先跑一轮心跳(含增强版)
_run_heartbeat_cycle()
try:
_run_enhanced_heartbeat()
except Exception as e:
logger.debug(f" 增强心跳首轮失败: {e}")
# 持续心跳循环(每 300 秒)
while True:
time.sleep(300)
_run_heartbeat_cycle()
try:
_run_enhanced_heartbeat()
except Exception as e:
logger.debug(f" 增强心跳失败: {e}")
if __name__ == "__main__":
main()