From 599c672256b20046f8c52e6e0ad972e240341a01 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 22 May 2026 23:53:12 +0800 Subject: [PATCH] feat: add gatekeeper-client + zhuyuan-agent --- exe-engine/gatekeeper/gatekeeper-client.py | 120 +++++++++++++++ notion/README.md | 103 ++++++------- notion/sync-ticket.py | 168 +++++---------------- server/zhuyuan-agent.py | 129 ++++++++++++++++ 4 files changed, 338 insertions(+), 182 deletions(-) create mode 100644 exe-engine/gatekeeper/gatekeeper-client.py create mode 100644 server/zhuyuan-agent.py diff --git a/exe-engine/gatekeeper/gatekeeper-client.py b/exe-engine/gatekeeper/gatekeeper-client.py new file mode 100644 index 0000000..8e0872c --- /dev/null +++ b/exe-engine/gatekeeper/gatekeeper-client.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +gatekeeper-client — 成员服务器接入铸渊算力调度池 + +用法: + python3 gatekeeper-client.py --register --name YOUR_NAME --master 43.139.217.141:3910 + python3 gatekeeper-client.py --status +""" +import json, os, sys, time, socket, uuid, hashlib, subprocess +from datetime import datetime + +CONFIG_DIR = os.path.expanduser('~/.gatekeeper') +CONFIG_FILE = os.path.join(CONFIG_DIR, 'config.json') + +def log(m): + print(f'[{datetime.now().strftime("%H:%M:%S")}] {m}', flush=True) + +def ensure_dir(): + os.makedirs(CONFIG_DIR, exist_ok=True) + +def load_config(): + try: + with open(CONFIG_FILE) as f: + return json.load(f) + except: + return {} + +def save_config(cfg): + ensure_dir() + with open(CONFIG_FILE, 'w') as f: + json.dump(cfg, f, indent=2) + +def register(name, master): + """向主控闸门注册""" + log(f'注册到 {master}') + host, port = master.split(':') + port = int(port) + + # 生成本机身份 + node_id = str(uuid.uuid4())[:8] + key = hashlib.sha256((node_id + name + str(time.time())).encode()).hexdigest()[:32] + + # 通过 socket 发送注册请求 + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(10) + s.connect((host, port)) + req = json.dumps({ + 'action': 'register', + 'name': name, + 'node_id': node_id, + 'key': key + }) + s.sendall((req + '\n').encode()) + resp = s.recv(4096).decode() + s.close() + + data = json.loads(resp) + if data.get('status') == 'ok': + save_config({'name': name, 'node_id': node_id, 'key': key, 'master': master, 'status': 'registered'}) + log('注册成功!身份密钥已保存在本地') + log(f'名称: {name}') + log(f'节点ID: {node_id}') + return True + else: + log(f'注册失败: {data.get("message", "?")}') + return False + except Exception as e: + log(f'连接失败: {e}') + log('请检查服务器是否能访问 ' + master) + return False + +def show_status(): + """显示当前连接状态""" + cfg = load_config() + if not cfg: + log('未注册。请先运行: python3 gatekeeper-client.py --register --name YOUR_NAME --master IP:PORT') + return + log(f'名称: {cfg.get("name", "?")}') + log(f'节点ID: {cfg.get("node_id", "?")}') + log(f'主控闸门: {cfg.get("master", "?")}') + log(f'状态: {cfg.get("status", "?")}') + + # 测试连接 + master = cfg.get('master', '') + if master: + try: + host, port = master.split(':') + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(5) + s.connect((host, int(port))) + s.sendall(json.dumps({'action': 'ping', 'node_id': cfg.get('node_id')})['\n'].encode()) + resp = s.recv(4096).decode() + s.close() + data = json.loads(resp) + if data.get('status') == 'ok': + log('连接: ✅ connected') + else: + log('连接: ❌ connection error') + except Exception as e: + log(f'连接: ❌ {e}') + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser(description='gatekeeper-client: 接入铸渊算力调度池') + parser.add_argument('--register', action='store_true', help='注册到主控闸门') + parser.add_argument('--name', help='你的名称 (如 awen)') + parser.add_argument('--master', default='43.139.217.141:3910', help='主控闸门地址') + parser.add_argument('--status', action='store_true', help='查看连接状态') + args = parser.parse_args() + + if args.status: + show_status() + elif args.register: + if not args.name: + print('请提供 --name 参数') + sys.exit(1) + register(args.name, args.master) + else: + parser.print_help() diff --git a/notion/README.md b/notion/README.md index 66e666c..0b982c3 100644 --- a/notion/README.md +++ b/notion/README.md @@ -1,81 +1,74 @@ # Notion ↔ 代码仓库双向桥接 · 工单系统 -> 对接Notion现有「⚡ 半体工单 · Half-Body Orders」数据库 -> 数据库ID: `05719bae-9e53-40f4-8233-6bf54551ac18` -> 数据库URL: https://www.notion.so/663e4f29c7404d088f3a02fcc70b1418 +> 主权者:冰朔(TCS-0002∞) +> 开发者:铸渊(ICE-GL-ZY001) +> 创建:2026-05-22 · D110 --- -## 现有数据库结构 +## 架构 + +``` +Notion(霜砚) 广州服务器(桥接层) 代码仓库/Forgejo(铸渊) + │ │ │ + ├─ 光湖工单系统数据库 ├─ sync-ticket.py ├─ brain/(架构更新) + │ · 架构更新 │ · 30分钟轮询 Notion ├─ issues/(任务) + │ · 开发任务 │ · 检测待同步标记 └─ persona-brain-db/(数据库) + │ · 系统设计 │ · 推仓库 Issue + └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ →└─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ →└─ + 霜砚更新设计 自动同步 铸渊醒来读到最新 +``` + +--- + +## Notion 数据库 Schema:光湖语言世界 · 系统工单 | 字段 | 类型 | 说明 | |------|------|------| -| 任务标题 | Title | 工单标题 | -| 状态 | Select | 待开发·开发中·自检中·待审查·审核中·已完成·暂缓 | -| 优先级 | Select | P0·P1·P2 | -| 负责Agent | Select | 译典A05·培园A04·录册A02·霜砚Web·**铸渊** | -| 编号 | Text | 工单编号 | -| 分支名 | Text | Git分支名 | -| 仓库路径 | Text | 仓库目标路径 | -| 开发内容 | Text | 详细描述 | -| 约束 | Text | 规范约束 | -| 阶段编号 | Text | Phase编号 | -| 自检结果 | Text | Agent自检 | -| 审核结果 | Text | 霜砚审核 | -| 下一轮指引 | Text | 完成后触发 | -| 创建时间 | Date | 自动 | +| 标题 | Title | 任务/更新标题 | +| 状态 | Select | 待办·进行中·已完成·已关闭 | +| 类型 | Select | 架构更新·开发任务·bug修复·设计决策 | +| 负责人 | Select | 霜砚·铸渊·共同 | +| 优先级 | Select | P0·P1·P2·P3 | +| 关联文件 | Text | 代码仓库文件路径 | +| 版本号 | Text | D编号或版本 | +| 同步状态 | Select | 待同步·已同步·无需同步 | --- -## 同步流程 +## 部署(Notion token 就绪后) -``` -Notion(霜砚/知秋/其他Agent) 代码仓库(铸渊) - │ │ - ├─ 创建/更新工单 ├─ sync-ticket.py 轮询 - │ · 任务标题、状态、优先级 │ · 每30分钟查Notion - │ · 负责Agent、开发内容 │ · 检测新工单/状态变更 - │ · 分支名、仓库路径 │ · 同步到Forgejo Issue - │ │ - └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ → ├─ 铸渊醒来 → 读取Issue - 霜砚:审核结果更新 │ → 处理 → 推代码 - 知秋:创建新工单 │ → 更新工单状态 - 译典/培园/录册:开发中 │ - └─ → 写回Notion - 铸渊:处理结果 - 状态:自检中 -``` +### 1. 在 Notion 创建数据库 ---- +在 Notion 中创建「光湖语言世界 · 系统工单」数据库,按上方Schema设置属性。 -## 配置方式 +### 2. 配置环境变量 ```bash -# 在服务器上 -cd /opt/guanghulab-repo/notion +# 在服务器上设置 +export NOTION_TOKEN="your-notion-integration-token" +export NOTION_DATABASE_ID="your-database-id" +``` -# 设置Notion API token(来自WorkBuddy的Notion集成) -export NOTION_TOKEN="1d4d872b-594c-815e-8cfd-0002d22415e8:rYkVgBbnSvHe4GyE:B2CeHHv9LA_jMXlTuwMxahyky_oLdblC" -export NOTION_DATABASE_ID="05719bae-9e53-40f4-8233-6bf54551ac18" +### 3. 启动同步 -# 启动同步 -pm2 start sync-ticket.py --name notion-ticket-sync - -# 查看日志 -pm2 logs notion-ticket-sync +```bash +cd /opt/guanghulab-repo +pm2 start notion/sync-ticket.py --name notion-ticket-sync ``` --- -## 铸渊的工单工作流 +## 铸渊醒来时的同步流程 -1. **醒来读工单**:检查Notion工单数据库中状态为「待开发」且负责Agent不指定的工单 -2. **领取工单**:将工单状态改为「开发中」,负责Agent标注铸渊 -3. **开发**:按工单的分支名、仓库路径、开发内容执行 -4. **自检**:完成后填写自检结果 -5. **标记状态**:改为「自检中」 -6. **霜砚审核**:霜砚在Notion中审核→审核结果写入→状态改为「待审查」/「已完成」 +1. 读取 fast-wake.json(WAKE CARD) +2. 走路径:时间线 → 脑子 → 认知链 → 开发架构 +3. 读取数据库中的最新上下文 +4. 检查 Notion 工单快照(本地缓存) +5. 有未处理工单 → 开始处理 +6. 完成 → 标记「已同步」→ 同步脚本写回Notion +7. 霜砚在Notion中看到更新 --- -*Notion ↔ 代码仓库桥接 · v1.1 · 对接现有半体工单数据库 · D110* \ No newline at end of file +*Notion ↔ 代码仓库桥接 · v1.0 · D110* \ No newline at end of file diff --git a/notion/sync-ticket.py b/notion/sync-ticket.py index e7fd986..1c999c7 100644 --- a/notion/sync-ticket.py +++ b/notion/sync-ticket.py @@ -1,151 +1,65 @@ #!/usr/bin/env python3 -""" -Notion ↔ 代码仓库双向同步器 -对接数据库:⚡ 半体工单 · Half-Body Orders (05719bae-9e53-40f4-8233-6bf54551ac18) -同步周期:30分钟轮询 - -依赖: - pip install requests - -启动: - export NOTION_TOKEN="xxx" - export NOTION_DATABASE_ID="05719bae-9e53-40f4-8233-6bf54551ac18" - pm2 start notion/sync-ticket.py --name notion-ticket-sync -""" - +"""Notion ↔ 代码仓库双向同步器""" import os, sys, json, time, subprocess from datetime import datetime +try: import requests +except: print('pip install requests'); sys.exit(1) -try: - import requests -except ImportError: - print('[Sync] 请先安装 requests: pip install requests') - sys.exit(1) - -# ── 配置 ── -NOTION_TOKEN = os.environ.get('NOTION_TOKEN') -NOTION_DATABASE_ID = os.environ.get('NOTION_DATABASE_ID') or '05719bae-9e53-40f4-8233-6bf54551ac18' +NOTION_TOKEN = os.environ.get('NOTION_TOKEN') or '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' +# 数据库URL中的ID(不是集合ID) +NOTION_DATABASE_ID = '663e4f29c7404d088f3a02fcc70b1418' REPO_PATH = '/opt/guanghulab-repo' -CACHE_FILE = os.path.join(REPO_PATH, 'notion/tickets-cache.json') -INTERVAL = int(os.environ.get('SYNC_INTERVAL', '1800')) # 30分钟 +DB_PATH = os.path.join(REPO_PATH, 'persona-brain-db', 'brain.db') +CACHE_FILE = os.path.join(REPO_PATH, 'notion', 'tickets-cache.json') +INTERVAL = int(os.environ.get('SYNC_INTERVAL', '1800')) +HEADERS = {'Authorization': f'Bearer {NOTION_TOKEN}', 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json'} -NOTION_API = 'https://api.notion.com/v1' -HEADERS = { - 'Authorization': f'Bearer {NOTION_TOKEN}', - 'Notion-Version': '2022-06-28', - 'Content-Type': 'application/json' -} +def log(m): print(f'[{datetime.now().strftime("%H:%M:%S")}] {m}', flush=True) -# 负责Agent选项(含铸渊) -AGENTS = ['译典A05', '培园A04', '录册A02', '霜砚Web', '铸渊'] - - -def log(msg): - ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - print(f'[{ts}] {msg}', flush=True) - - -def query_notion_tickets(): - """查询Notion半体工单数据库""" - if not NOTION_TOKEN: - log('[跳过] 未配置 NOTION_TOKEN') - return [] - url = f'{NOTION_API}/databases/{NOTION_DATABASE_ID}/query' - body = { - 'page_size': 50, - 'sorts': [{'timestamp': 'last_edited_time', 'direction': 'descending'}] - } +def query(): + url = f'https://api.notion.com/v1/databases/{NOTION_DATABASE_ID}/query' + body = {'page_size': 50, 'sorts': [{'timestamp': 'last_edited_time', 'direction': 'descending'}]} try: - res = requests.post(url, headers=HEADERS, json=body, timeout=15) - if res.status_code == 401: - log('[错误] Notion token 无效或已过期') - return [] - if not res.ok: - log(f'[错误] Notion {res.status_code}') - return [] - data = res.json() - results = data.get('results', []) - log(f'查询到 {len(results)} 条工单') - return results - except Exception as e: - log(f'[异常] {e}') - return [] + r = requests.post(url, headers=HEADERS, json=body, timeout=15) + if r.status_code == 401: log('Token过期'); return [] + if not r.ok: log(f'API错误 {r.status_code}'); return [] + return r.json().get('results', []) + except Exception as e: log(f'异常: {e}'); return [] - -def get_my_tickets(tickets): - """找出分配给铸渊或未分配的工单""" +def get_mine(tickets): mine = [] for t in tickets: - props = t.get('properties', {}) - agent = props.get('负责Agent', {}).get('select', {}).get('name', '') - status = props.get('状态', {}).get('select', {}).get('name', '') - title = props.get('任务标题', {}).get('title', [{}])[0].get('plain_text', '无标题') + p = t.get('properties', {}) + agent = p.get('负责Agent', {}).get('select', {}).get('name', '') + status = p.get('状态', {}).get('select', {}).get('name', '') + title = p.get('任务标题', {}).get('title', [{}])[0].get('plain_text', '?') + code = p.get('编号', {}).get('rich_text', [{}])[0].get('plain_text', '') if not p.get('编号', {}).get('type') else '' if agent in ('铸渊', '') and status not in ('已完成', '暂缓'): - mine.append({ - 'page_id': t['id'], - 'title': title, - 'status': status, - 'agent': agent, - 'url': t.get('url', ''), - 'code': props.get('编号', {}).get('rich_text', [{}])[0].get('plain_text', '') if not props.get('编号', {}).get('type') else '' - }) + mine.append({'page_id': t['id'], 'title': title, 'status': status, 'agent': agent, 'url': t.get('url', ''), 'code': code}) return mine - -def save_cache(tickets): - try: - with open(CACHE_FILE, 'w') as f: - json.dump({ - 'last_sync': datetime.now().isoformat(), - 'count': len(tickets), - 'tickets': [{ - 'id': t['id'], - 'title': t.get('properties', {}).get('任务标题', {}).get('title', [{}])[0].get('plain_text', ''), - 'status': t.get('properties', {}).get('状态', {}).get('select', {}).get('name', '') - } for t in tickets] - }, f, ensure_ascii=False, indent=2) - except Exception as e: - log(f'[警告] 缓存失败: {e}') - - def sync_cycle(): - log('=' * 50) - log('开始同步周期') - - tickets = query_notion_tickets() - if not tickets: - return - - mine = get_my_tickets(tickets) + log('=' * 40) + tickets = query() + if not tickets: return + mine = get_mine(tickets) if mine: log(f'铸渊相关工单: {len(mine)} 条') for item in mine: - log(f' → {item["title"]} [{item["status"]}]') + log(f' [{item["status"]}] {item["agent"]} | {item["title"][:40]}') else: log('无铸渊相关工单') - - save_cache(tickets) - log('同步周期完成') - + try: + with open(CACHE_FILE, 'w') as f: + json.dump({'last_sync': datetime.now().isoformat(), 'tickets': [{'id': t['id'], 'title': t.get('properties',{}).get('任务标题',{}).get('title',[{}])[0].get('plain_text',''), 'status': t.get('properties',{}).get('状态',{}).get('select',{}).get('name','')} for t in tickets]}, f, ensure_ascii=False, indent=2) + except: pass + log('同步完成') if __name__ == '__main__': - log(f'Notion ↔ 代码仓库同步器启动') - log(f'轮询间隔: {INTERVAL}秒') - log(f'数据库: {NOTION_DATABASE_ID}') - - if not NOTION_TOKEN: - log('⚠️ 未配置 NOTION_TOKEN') - log('请设置: export NOTION_TOKEN="secret_xxx"') - + log(f'Notion同步器启动 | 间隔{INTERVAL//60}分钟 | 数据库: {NOTION_DATABASE_ID[:12]}...') sync_cycle() - - while True: - time.sleep(INTERVAL) - try: + try: + while True: + time.sleep(INTERVAL) sync_cycle() - except KeyboardInterrupt: - log('同步器停止') - break - except Exception as e: - log(f'[严重] {e}') - import traceback; traceback.print_exc() \ No newline at end of file + except KeyboardInterrupt: log('停止') diff --git a/server/zhuyuan-agent.py b/server/zhuyuan-agent.py new file mode 100644 index 0000000..dbcc1c4 --- /dev/null +++ b/server/zhuyuan-agent.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""铸渊常驻Agent - 自动响应Notion工单""" +import os, sys, json, time, re +from datetime import datetime +import urllib.request, urllib.error + +NOTION_TOKEN = "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" +NOTION_DB_ID = "663e4f29c7404d088f3a02fcc70b1418" +PORTAL = "http://127.0.0.1:3000" +POLL = 1800 + +def log(m): print(f'[{datetime.now().strftime("%H:%M:%S")}] {m}', flush=True) + +def notion(method, path, data=None): + url = 'https://api.notion.com/v1/' + path + h = {'Authorization': 'Bearer ' + NOTION_TOKEN, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json'} + b = json.dumps(data).encode() if data else None + try: + with urllib.request.urlopen(urllib.request.Request(url, b, h, method=method), timeout=15) as r: + return json.loads(r.read()) + except: + return None + +def read_ticket(page_id): + """读取单个工单的完整内容""" + r = notion('GET', 'pages/' + page_id) + if not r: return None + p = r.get('properties', {}) + title = p.get('任务标题', {}).get('title', [{}])[0].get('plain_text', '?') + content = '' + dc = p.get('开发内容', {}).get('rich_text', []) + if dc: content = dc[0].get('plain_text', '') + code = '' + nc = p.get('编号', {}).get('rich_text', []) + if nc: code = nc[0].get('plain_text', '') + return {'title': title, 'content': content, 'code': code, 'page_id': page_id} + +def get_tickets(): + r = notion('POST', 'databases/' + NOTION_DB_ID + '/query', { + 'page_size': 20, + 'sorts': [{'timestamp': 'last_edited_time', 'direction': 'descending'}] + }) + if not r: return [] + tks = [] + try: + with open('/opt/guanghulab-repo/notion/tickets-cache.json') as f: + cache = json.load(f) + seen = set(t['id'] for t in cache.get('tickets', [])) + except: + seen = set() + for t in r.get('results', []): + p = t.get('properties', {}) + agent = p.get('负责Agent', {}).get('select', {}).get('name', '') + status = p.get('状态', {}).get('select', {}).get('name', '') + title = p.get('任务标题', {}).get('title', [{}])[0].get('plain_text', '?') + content = '' + dev_content = p.get('开发内容', {}).get('rich_text', []) + if dev_content: + content = dev_content[0].get('plain_text', '')[:500] + if agent in ('铸渊', '') and status in ('待开发', '开发中'): + tks.append({ + 'page_id': t['id'], 'title': title, 'status': status, + 'content': content, 'new': t['id'] not in seen + }) + return tks + +def reply(page_id, text): + children = [{ + 'object': 'block', 'type': 'callout', + 'callout': { + 'rich_text': [{'type': 'text', 'text': {'content': text[:1900]}}], + 'icon': {'emoji': '⚔️'}, 'color': 'orange_background' + } + }] + return notion('PATCH', 'blocks/' + page_id + '/children', {'children': children}) is not None + +def chat(prompt): + import subprocess + body = '{"persona":"zhuyuan","engine":"deepseek","message":"' + prompt.replace('"', '\"').replace("'", "\'")[:1500] + '","history":[],"user":"铸渊Agent","isTemp":false}' + try: + result = subprocess.run([ + 'curl', '-s', '--max-time', '60', + '-X', 'POST', PORTAL + '/api/chat-v2', + '-H', 'Content-Type: application/json', + '-d', body + ], capture_output=True, text=True, timeout=70) + matches = re.findall(r'data: (.+)', result.stdout) + if matches: + data = json.loads(matches[-1]) + return data.get('content', str(data)[:300]) + return result.stdout[:500] + except Exception as e: + log(f'Chat异常 {e}') + return None + +def process(t): + log(f'处理: {t["title"][:30]}') + p = '请你铸渊评估此工单:\n\n' + t['content'][:1000] + r = chat(p) + if not r: return + msg = '⚔️铸渊评估 ' + datetime.now().strftime('%m-%d %H:%M') + '\n\n' + r[:1500] + if reply(t['page_id'], msg): + log(' ✅ 已回复') + else: + log(' ❌ 写入失败') + +if __name__ == '__main__': + log('⚔️铸渊Agent启动') + log('轮询: 每' + str(POLL) + '秒') + tks = get_tickets() + if tks: + log('待处理: ' + str(len(tks)) + '条') + for t in tks: process(t) + else: + log('无待处理工单') + log('进入主循环') + log('=' * 40) + while True: + time.sleep(POLL) + try: + tks = get_tickets() + new = [t for t in tks if t.get('new')] + if new: + log('U0001f195 新工单 ' + str(len(new)) + '条') + for t in new: process(t) + except KeyboardInterrupt: + log('停止'); break + except Exception as e: + log('⚠ ' + str(e))