From 8421f55f6aea5d91edac0c40ba786657944b8fc7 Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 15 Aug 2026 15:23:16 +0800 Subject: [PATCH] feat(graph-agent): P0 foundation — system_agents/org_graph_* tables + seed migration --- .gitignore | 4 +++- docs/todo/system-design/27-graph-agent-architecture.md | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + scripts/graph-seed.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/services/graph-db.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/services/graph-seed-service.js | 429 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/graph-db.test.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/graph-seed-service.test.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tasks/plan.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tasks/todo.md | 33 +++++++++++++++++++++++++++++++++ 10 files changed, 1034 insertions(+), 1 deletion(-) create mode 100644 docs/todo/system-design/27-graph-agent-architecture.md create mode 100644 scripts/graph-seed.js create mode 100644 server/services/graph-db.js create mode 100644 server/services/graph-seed-service.js create mode 100644 server/tests/unit/graph-db.test.js create mode 100644 server/tests/unit/graph-seed-service.test.js create mode 100644 tasks/plan.md create mode 100644 tasks/todo.md diff --git a/.gitignore b/.gitignore index 162cb2e..58c54c3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ system/agents/*/logs/ system/agents/*/workspace/ system/agents/*/uploads/ system/agents/*/memory/ -system/agents/.tasks/ +system/tasklog/ # System templates runtime data system/templates/*/conversations/ @@ -88,6 +88,8 @@ system/memory/ # SQLite databases (runtime data) *.sqlite +*.sqlite-wal +*.sqlite-shm # System company runtime data system/data/companies/ diff --git a/docs/todo/system-design/27-graph-agent-architecture.md b/docs/todo/system-design/27-graph-agent-architecture.md new file mode 100644 index 0000000..9c57657 --- /dev/null +++ b/docs/todo/system-design/27-graph-agent-architecture.md @@ -0,0 +1,247 @@ +# 27. Graph Agent 架构 — Org Graph / Work Graph 与 System Agent 统一寻址 + +> **版本**: v0.2(P0 已实现) +> **日期**: 2026-08-15 +> **状态**: P0 ✅ 已实现(`server/services/graph-db.js` + `graph-seed-service.js`,见第七节);P1 起 📝 设计中/未实现 +> **前置**: [02 四层 Agent 架构](../../done/system-design/02-agent-architecture.md)、[24 公司层架构](../../done/system-design/24-company-layer-architecture.md)、[22 Agent 记忆系统](../../done/system-design/22-agent-memory-system.md)、`server/src/core/devloop/loop-controller.js`(现有 retry-with-reflection 实现) + +--- + +## 一、核心问题 + +### 现有模型(24 号文档)的局限 + +24 号文档确立的模型是「System Agent(全局单例)+ Agent Template(可复用蓝图)+ Company Agent(雇佣时从模板 **文件拷贝** 出来的实例)」。这套模型跑通了 Multi-Tenant 公司隔离,但暴露出四个结构性问题: + +1. **状态碎片化**:每次 `hire()` 都是一次 `fs.copyFileSync`,同一角色在 10 家公司名下就有 10 份互不相干的 `agent.md`/`memory.md`。模板更新后,已雇佣的实例不会同步,也无法双向学习——A 公司的 QA 遇到的坑,B 公司的 QA 完全学不到。 +2. **无法自我进化**:Agent 的"智能"被锁在一次性拷贝的静态文件里,没有一个机制让它在被反复调用后越来越准。唯一的进化路径是 `depositTemplate()`——训练出一个新 agent,跑 benchmark,≥95 分才存回模板库,这是"生个孩子"而不是"自己变强"。 +3. **协作关系是扁平列表**:`company.json.agents[]` 只是一个角色名数组,没有边、没有汇报关系,更没有"谁的输出喂给谁"的显式结构。 +4. **失败恢复局限在单一场景**:目前唯一的循环恢复机制是 DevLoop 的 `runUnit`(Coder ↔ Tester 两节点、固定方向、`retry_budget` 硬顶 5 次),无法泛化到"任意节点失败后,带着上下文跳回图中任意历史节点重跑"这种通用场景。 + +### 设计目标 + +把「company 拥有并复制 agent」改造为「company 引用全局 system agent,并在其上定义**静态协作结构(Org Graph)**与**动态任务执行路径(Work Graph)**」: + +1. Agent 是跨公司复用、持续自我进化的一等公民,不是一次性文件拷贝。 +2. 公司的组织结构(Org Graph)与具体任务怎么跑(Work Graph)解耦:前者是company 层面相对稳定的静态定义,后者由 planning agent 针对每个目标动态规划、执行完即归档。 +3. 失败恢复不再局限于"重跑同一个节点":任意节点失败可以携带上下文回退到图中任意历史节点,同时有引擎级别的硬性防死循环机制。 + +--- + +## 二、术语定义 + +| 术语 | 定义 | +|------|------| +| **System Agent** | 全局唯一的可执行 Agent 实体。自带 prompt 定义、公共记忆(`memory.md`)、按公司分区的私有历史。是 24 号文档里"System Agent"与"Agent Template"两个概念的合并——不再区分"模板"和"实例",只有"这个 agent,被谁调用过"。 | +| **scope** | System Agent 的可见性:`public`(跨公司共享,记忆公共累积)或 `company`(私有,只属于 `owner_company_id` 指定的一家公司)。 | +| **Org Graph** | 公司级别的**静态**协作结构。节点 = 对某个 system agent 的引用;边 = 汇报/协作关系。取代现有 `company.json.agents[]` 扁平列表。 | +| **Work Graph** | 某个 org 节点收到一个 goal/task 后,现场调用 **work-graph-planning agent** 针对这一具体任务规划出的**动态**执行图。节点 = 对某个 system agent 的一次调用;边带 `success`/`fail` 条件与目标节点。执行完归档,不复用到下一个任务。 | +| **Loop-Validator** | 一个专职的 system agent,内置带硬编码熔断逻辑的 skill。所有"回退到历史节点"的边必须先经过它:备案 → 审计上次修复是否生效 → 判断修复方案是否被正确传递 → 循环数 > 3 强制终止。 | +| **Node 内容 / Edge 内容** | 图结构本身(节点、边、状态、指针)存 SQLite;节点的输入输出内容、日志、上下文,依旧是 Markdown 文件,物理落在对应 system agent 的目录下。 | + +--- + +## 三、架构设计 + +### 3.1 三层模型总览 + +``` + ┌─────────────────────────────────────┐ + │ System Agent 池 │ + │ (全局唯一,可跨公司复用,自我进化) │ + │ │ + │ [ceo] [coder] [qa-judge] [...] │ + │ ▲ ▲ ▲ │ + └─────┼───────┼─────────┼────────────────┘ + │ │ │ (引用,非复制) + ┌────────────┴───────┴─────────┴──────────────┐ + │ Company: {companyId} │ + │ │ + │ Org Graph(静态,company 生命周期内相对稳定) │ + │ [CEO 节点] ──reports──> [CTO 节点] ──> ... │ + │ │ + │ Work Graph(动态,per-goal,执行完归档) │ + │ [node1:coder] --success--> [node2:tester] │ + │ --fail-----> [loop-validator]│ + │ │ │ + │ ▼ │ + │ [回退到 node1] │ + └────────────────────────────────────────────────┘ +``` + +### 3.2 System Agent:唯一执行体与数据隔离 + +目录结构(取代现有 `system/agents/` 与 `system/templates/` 的分离): + +``` +system/agents/{agentId}/ + agent.md / soul.md / identity.md / tools.md # 定义本体,全局唯一 + memory.md # 公共记忆:自我迭代蒸馏出的能力/经验,跨公司共享持续增长 + config.json # { scope: public|company, owner_company_id, status, benchmark_score } + companies/ + {companyId}/ + memory.md # 该公司私有工作记忆(目标、上下文、汇报关系) + history/ + {workGraphId}-{nodeId}-{attempt}.md # 该公司触发的每次执行原始记录 + logs/ +``` + +- **company-scoped(私有)**:`companies/` 下永远只有 owner 一个子目录,代码路径与 public 完全一致,不做特殊分支。 +- **public(公共)**:`companies/` 下有多个公司各自的隔离子目录。 + +**读取隔离原则**:PromptBuilder 组装某次调用的上下文时,只按 `(agentId, companyId)` 构造路径——公共部分(`agent.md`/`memory.md`)+ 这一家公司自己的 `companies/{companyId}/`。别的公司的目录**从物理路径上就不会被触达**,跟现有 DevLoop CONVENTIONS.md §12「跨 unit 只能读 `../{otherUnit}/contract.json`」是同一种"靠构造方式天然隔离"的思路,不是运行时权限过滤。 + +**写入分级**:私有 `companies/{id}/memory.md` 每次调用后可自由写,风险局限在一家公司内。公共 `memory.md` 的更新(自我进化)风险更高——写错会污染所有后续调用方——因此需要走一次自省校验(复用 loop-validator 或类似机制),并且像 DevLoop 项目一样纳入 git 版本管理,坏的自我迭代可以回滚。 + +**新建与升级治理**:Work-graph 执行中如果需要的 system agent 不存在,直接触发创建,默认 `scope=company`(私有)。是否升级为 `public`,复用现有 `hireWithTraining()` → `agent-creator`/`innovator` 训练 → benchmark ≥95 → 升级的门槛链路,只是终点从"写文件到 `system/templates/`"改为"更新 DB 里的 `scope` 字段"。**不新增治理机制,只改落点。** + +### 3.3 Org Graph:公司静态结构 + +- 节点:对某个 system agent 的引用(`org_graph_nodes`),代替 `company.json.agents[]`。 +- 边:汇报/协作关系(`org_graph_edges`),公司创建/招聘时由 CEO 或 HR agent 维护。 +- "招聘"语义变化:不再是"拷贝一份文件",而是"在 org graph 里加一个指向某 system agent 的引用节点"。 + +### 3.4 Work Graph:任务动态执行图 + +1. 某 org 节点收到一个 goal/task。 +2. 该节点调用 **work-graph-planning agent**,针对这个具体任务规划出一张图:节点指向要执行的 system agent,边带 `success`/`fail` 条件。 +3. **目标节点范围不受 org graph 约束,只受 system agent 池约束**——planning agent 可以调用任意 public agent、company 自己私有的 agent,或判断需要新建一个(走 3.2 的创建+治理链路)。 +4. **回退边必须显式经过 loop-validator 节点**——即"节点A --fail--> loop-validator --> 节点B",而不是"节点A --fail(隐藏回退属性)--> 节点B"。这是画图时的**结构要求**,不是引擎运行时才附加的隐藏逻辑。 +5. **执行前结构校验**:图规划完成后、进入执行前,引擎做一次静态校验——任何边如果目标节点在图中已存在其他指向它的边(即会构成"回到历史节点"),但没有经过 loop-validator 节点,直接拒绝这张图,不进入执行。类比现有 `npm run spec:check` 的"结构先校验,再允许跑"模式。这是对"planning agent 应该自觉画上 validator"这条规则的**引擎级兜底**,防止规划疏漏导致死循环绕过审计。 + +### 3.5 Loop-Validator:死循环兜底 + +- 本身是一个 system agent(可以是 public,因为"判断循环是否收敛"这个能力本质上与具体业务无关,值得跨公司共享进化)。 +- 自带 skill,skill 内部包含**硬编码的计数器/熔断逻辑**,不是纯靠一次 LLM 推理决定"要不要停": + 1. **备案**:任意节点即将进入回退循环前,先向 loop-validator 登记这次尝试。 + 2. **审计**:目标节点重跑完成后,loop-validator 比对这次结果与上次失败原因,判断是否真的修复。 + 3. **修复方案传递校验**:如果没修复,检查这次回退时是否把"应该如何修复"的方案正确带给了下一轮,而不是盲目重跑。 + 4. **硬顶**:循环次数 > 3,无论 LLM 判断如何,skill 内的计数器强制终止,不进入第 4 次回退。 +- **引擎级不变量**:任何回退边必须路由经过 loop-validator,这一点不能被 planning agent 在规划阶段跳过——由 3.4 第 5 点的执行前校验保证。 + +### 3.6 与执行链路的关系 + +节点执行本质上仍是现有 `AgentManager.callAgent()` 的调用,只是 `getAgentConfig()` 的输入从"`companyId:roleName` 路径拼接"变成"`(system_agent_id, companyId)` 的引用查表",`PromptBuilder.build()` 在装配 prompt 时按 3.2 的隔离规则拼装公共定义 + 私有语境。**执行引擎(session-pool、runtime-resolver、stream-executor)不需要改**,改的是"config 从哪来"这一层。 + +--- + +## 四、数据模型(Schema 草稿) + +DB 只存**关系与状态指针**,节点/边的具体内容(任务描述、输出、日志)始终是 Markdown 文件,按 3.2 的目录约定物理落在对应 system agent 目录下(**不在 DB 里存文件路径字符串,路径由 `(system_agent_id, company_id, work_graph_id, node_id, attempt)` 确定性推导**,避免路径漂移)。 + +```sql +-- 全局 system agent 注册表 +CREATE TABLE system_agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('public', 'company')), + owner_company_id TEXT, -- scope='company' 时必填 + status TEXT NOT NULL DEFAULT 'active', -- active | training | deprecated + benchmark_score REAL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- 公司静态协作结构 +CREATE TABLE org_graph_nodes ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + system_agent_id TEXT NOT NULL REFERENCES system_agents(id), + role_name TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE org_graph_edges ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + from_node_id TEXT NOT NULL REFERENCES org_graph_nodes(id), + to_node_id TEXT NOT NULL REFERENCES org_graph_nodes(id), + relation_type TEXT NOT NULL -- reports_to | collaborates_with +); + +-- 单次任务的动态执行图 +CREATE TABLE work_graphs ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + triggered_by_org_node_id TEXT REFERENCES org_graph_nodes(id), + goal_task_id TEXT, + status TEXT NOT NULL DEFAULT 'planning', -- planning | running | passed | failed | blocked + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE work_graph_nodes ( + id TEXT PRIMARY KEY, + work_graph_id TEXT NOT NULL REFERENCES work_graphs(id), + system_agent_id TEXT NOT NULL REFERENCES system_agents(id), + node_kind TEXT NOT NULL DEFAULT 'task', -- task | loop_validator + status TEXT NOT NULL DEFAULT 'pending', -- pending | running | success | fail | blocked + attempt INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE work_graph_edges ( + id TEXT PRIMARY KEY, + work_graph_id TEXT NOT NULL REFERENCES work_graphs(id), + from_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id), + to_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id), + condition TEXT NOT NULL CHECK (condition IN ('success', 'fail')), + edge_kind TEXT NOT NULL DEFAULT 'forward' -- forward | backward(backward 必须 to_node 是 loop_validator 或来自 loop_validator) +); +``` + +`edge_kind='backward'` 由 planning agent 在画图时显式标注;执行前校验(3.4 第 5 点)会独立复核——不信任标注本身,而是遍历图结构确认任何指向"已在图中更早出现过"的节点的边,必须满足"经过 `node_kind='loop_validator'`"这一条件,双重保险。 + +--- + +## 五、与现有系统的关系 / 迁移策略 + +| 现有组件 | 变化 | +|----------|------| +| `server/services/company-service.js` 的 `hire()` / `_copyTemplate()` | 废弃文件拷贝,替换为"创建/复用 system agent + 在 org_graph 加引用节点" | +| `system/templates/` | 逐步废弃,内容并入 `system_agents`(`scope=public`),`_registry.json` 被 DB 表取代 | +| `AgentManager.getAgentConfig()` | 输入从路径拼接改为 `(system_agent_id, company_id)` 查表 + 引用 3.2 目录约定 | +| `PromptBuilder.build()` | 增加"公共定义 + 私有语境"两段拼装逻辑 | +| `hireWithTraining()` / `agent-creator` / `innovator` / `depositTemplate()` | 保留,只改落点(写 DB scope 字段而非写模板目录) | +| DevLoop `loop-controller.js` 的 `runUnit` | 可视为 Work Graph 的一个特例(固定两节点、单方向重试)。本次不强制收编,留作后续 Phase 6 的可选项 | + +--- + +## 六、开发计划(Phased Roadmap) + +| Phase | 目标 | 交付物 | 验收标准 | +|-------|------|--------|----------| +| **P0 地基** ✅ | 建表 + 现有数据迁移,不改变任何现有执行路径 | `system_agents`/`org_graph_*` 表;把现有 `system/agents/` + `system/templates/` 目录扫描导入为种子数据(`system_agents` 行,`scope` 按当前所属推断) | 迁移脚本跑完,`system_agents` 表行数与现有 agent+模板目录数一致(**实际行数与本文档最初估计不同,见第七节**);现有 `company-service.js` 行为不变(并行只读验证,已确认零改动) | +| **P1 虚拟引用** | 停止文件拷贝,改为引用 | 改造 `AgentManager.getAgentConfig()` + `PromptBuilder.build()` 支持 `(agentId, companyId)` 寻址;`company-service.hire()` 改为创建 org_graph 引用节点而非 `_copyTemplate()`。细化需求与设计见 [27-P1 虚拟引用寻址](./27-p1-virtual-reference.md) | 新雇佣的 agent 不再产生文件拷贝,调用时能正确拼装公共+私有上下文;旧公司(仍是拷贝模式)继续可跑,不强制迁移存量数据 | +| **P2 Org Graph** | `company.json.agents[]` 迁移为图结构 | `org_graph_nodes`/`edges` 落地;HR/CEO 招聘走新表 | 现有 Company Dashboard 前端能正确读出 org graph 并渲染(哪怕暂时按列表展示) | +| **P3 Work Graph 最小闭环** | 单向执行,先不做回退 | work-graph-planning agent(system agent,新建);`work_graphs`/`nodes`/`edges` 表;顺序执行器(success → next,fail → 停止/标记 blocked,暂不回退) | 一个 goal 能被规划成 2-3 节点的图并顺序跑完,状态正确落库 | +| **P4 回退 + Loop-Validator** | 引入死循环防护 | loop-validator system agent + 内置熔断 skill;`edge_kind=backward` 支持;3.4 第 5 点的执行前结构校验 | 人为构造一个"总是失败"的节点,验证第 4 次回退被 loop-validator 强制终止,且校验能拒绝"回退边未经过 validator"的非法图 | +| **P5 自我进化** | 公共记忆蒸馏机制 | 公共 `memory.md` 写入走自省校验 + git 版本化;company-scoped → public 的升级门槛复用现有 benchmark ≥95 链路 | 同一 public agent 被两家公司调用后,`memory.md` 有可观察的增量更新,且能 git revert 到升级前版本 | +| **P6(可选)DevLoop 收编** | 统一循环逻辑 | 把 `loop-controller.js`/`unit-registry.js` 重构为 Work Graph 引擎上的一个具体图模板 | DevLoop 现有测试套件(`server/tests/devloop/**`)在新引擎上全部通过,无需单独维护两套循环代码 | + +**依赖顺序是强顺序的**:P1 依赖 P0 的数据先迁移进 DB;P3 的执行器依赖 P1 的引用寻址跑通;P4 的回退依赖 P3 的顺序执行先稳定。P5、P6 相对独立,可以在 P4 稳定后并行推进或往后延。 + +每个 Phase 建议保留旧路径作为 fallback(不删除 `_copyTemplate()`/`system/templates/` 扫描逻辑),直到下一 Phase 验收通过,避免中途出问题时无法回退。 + +--- + +## 七、P0 实施发现(2026-08-15) + +P0 已实现(`server/services/graph-db.js` + `server/services/graph-seed-service.js` + `scripts/graph-seed.js`,`npm run graph:seed` 可跑),经 code review 通过。实施过程中对真实仓库数据做了独立核实,几个数字和本文档最初的估计(来自第一轮调研)不一致,记录如下,供 P1/P2 设计时参考: + +| 项 | 本文档最初估计 | 真实数据 | +|---|---|---| +| `system/agents/` 里真正的 agent(含 `agent.md`) | 10 | **8** —— `screenwriter/`、`youtube-crawler/` 两个目录没有 `agent.md`,不满足"是一个可执行 agent"的最低条件,被迁移脚本正确排除 | +| `system/templates/` 目录数 | 34 | **39** | +| 最终落库的 `source_kind='template'` 行数 | 34 | **33**(39 减去下面的命名空间重叠问题里被 `global_agent` 占用的 6 个) | +| `_registry.json` 里 `benchmark_score` 非空的条目 | 9 | **3**(`ceo`=97/`qa-judge`=95/`base`=90)——另外 6 个 `devloop-*` 虽然在 registry 里能匹配上 id,但 registry 原始数据里 `benchmarkScore` 本来就是 `null`,不是脚本漏读 | +| `idearun` 公司 6 个角色里"公司专属、无对应模板"的数量 | 4 | **0** —— 6 个角色现在全部能在 `system/templates/*` 里找到同名目录,全部复用已有行,没有触发新建 `scope='company'` 记录 | + +### 关键发现:`system/agents/` 与 `system/templates/` 命名空间不是互斥的 + +有 6 个 id(`agent-creator`/`digester`/`evolver`/`evolver-youtube-crawler`/`hunter`/`innovator`)**在两个目录下都存在**,描述的是概念上"同一个" agent,但物理上是两份独立的文件(内容很可能已经不同步)。这一点在最初两轮只读调研(用于写这份文档正文)里没有被发现,是 P0 实际扫描真实目录时才暴露出来的。 + +P0 阶段的处理方式是**迁移脚本层面的临时保护**:`system_agents.id` 是跨 `source_kind` 共享的单一 TEXT 主键,`seedTemplates()` 遇到一个已经被 `seedGlobalAgents()` 占用的 id 时会跳过写入(不覆盖),保证 `global_agent` 优先、幂等可重跑。这只是避免了"数据被静默覆盖"的坏结果,**没有回答"这 6 个 agent 到底该以哪份文件为准"这个产品问题**——`system/agents/{id}/agent.md` 和 `system/templates/{id}/agent.md` 目前很可能内容不同,P1 做虚拟引用寻址、P2 做 org graph 招聘的时候都会撞到这批 id,需要在那之前决定:是人工合并成一份、保留 `global_agent` 版本视为权威并废弃 template 版本,还是干脆认为这 6 个本来就是两个不同的东西(比如一个是"基础设施 agent"、一个是"可招聘的角色模板")、id 冲突只是命名巧合。这个决定应该在 P1 动手改 `AgentManager`/`PromptBuilder` 之前做,否则虚拟引用会读到不确定是哪一份的 `agent.md`。 + +### 关联文档 + +P1(虚拟引用寻址)的详细需求与设计已有独立草稿:[27-P1 虚拟引用寻址](./27-p1-virtual-reference.md)。 diff --git a/package.json b/package.json index 6382966..7495041 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "analyze": "node server/src/utils/analyze-log.js", "parse": "node server/src/utils/parse-output.js", "skills:sync": "node scripts/sync-skills.js", + "graph:seed": "node scripts/graph-seed.js", "spec:extract": "node scripts/extract-spec.js", "spec:check": "node scripts/extract-spec.js --check", "whatsapp:auth": "node skills/whatsapp-bot/auth.js", diff --git a/scripts/graph-seed.js b/scripts/graph-seed.js new file mode 100644 index 0000000..939a798 --- /dev/null +++ b/scripts/graph-seed.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +/** + * graph-seed.js — seeds data/graph.sqlite from the on-disk agent/template/company sources. + * + * 依次跑 seedGlobalAgents()(T1)→ seedTemplates()(T2)→ seedCompanyAgents()(T3) + * → verifyCounts()(T4,独立重新扫描目录/文件做行数交叉核对)。 + * 打印按 source_kind 分组的汇总报告;核对不一致时以非零状态码退出。 + * + * 用法: + * node scripts/graph-seed.js + */ + +const { seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts } = require('../server/services/graph-seed-service'); + +function main() { + console.log('[graph-seed] 开始种子导入\n'); + + const globalAgents = seedGlobalAgents(); + console.log(`[global_agent] 扫描 system/agents/* 目录: ${globalAgents.scanned} 个,含 agent.md 且已导入: ${globalAgents.seeded} 个`); + console.log(` ids: ${globalAgents.ids.join(', ')}`); + + const templates = seedTemplates(); + console.log(`\n[template] 扫描 system/templates/* 目录: ${templates.scanned} 个,已导入: ${templates.seeded} 个`); + console.log(` registry 匹配到 benchmark_score 的 ids (${templates.registryMatched.length}): ${templates.registryMatched.join(', ')}`); + if (templates.skipped.length > 0) { + console.log(` ⚠️ id 与其他 source_kind 冲突,已跳过写入 (${templates.skipped.length}): ${templates.skipped.map(s => `${s.id}(已属于 ${s.existingSourceKind})`).join(', ')}`); + } + + const companyAgents = seedCompanyAgents(); + console.log(`\n[company_hired] 扫描到 ${companyAgents.companies.length} 家有 company.json 的公司: ${companyAgents.companies.join(', ')}`); + console.log(` org_graph_nodes 本次新建: ${companyAgents.nodesCreated} 行,已存在跳过: ${companyAgents.nodesExisting} 行`); + console.log(` 引用已有 system_agents 行 (${companyAgents.reusedSystemAgents.length}): ${companyAgents.reusedSystemAgents.join(', ')}`); + console.log(` 新建 scope='company' 行 (${companyAgents.newSystemAgents.length}): ${companyAgents.newSystemAgents.join(', ')}`); + + console.log('\n[verify] 行数对账(独立重新扫描目录/文件,不复用上面的计数器)'); + const v = verifyCounts(); + console.log(` system_agents[global_agent]: 期望 ${v.expected.global_agent},实际 ${v.actual.global_agent}`); + console.log(` system_agents[template]: 期望 ${v.expected.template},实际 ${v.actual.template}`); + console.log(` system_agents[company_hired]:期望 ${v.expected.company_hired},实际 ${v.actual.company_hired}`); + const companyIds = Object.keys({ ...v.expected.nodesPerCompany, ...v.actual.nodesPerCompany }); + for (const companyId of companyIds) { + const expected = v.expected.nodesPerCompany[companyId] || 0; + const actual = v.actual.nodesPerCompany[companyId] || 0; + console.log(` org_graph_nodes[${companyId}]: 期望 ${expected},实际 ${actual}`); + } + + if (!v.ok) { + console.error('\n❌ 行数对账失败,发现以下不一致:'); + for (const m of v.mismatches) { + console.error(` - ${m.check}: 期望 ${m.expected},实际 ${m.actual}`); + } + process.exitCode = 1; + return; + } + + console.log('\n✅ 种子导入完成,行数对账通过'); +} + +main(); diff --git a/server/services/graph-db.js b/server/services/graph-db.js new file mode 100644 index 0000000..469fee3 --- /dev/null +++ b/server/services/graph-db.js @@ -0,0 +1,68 @@ +/** + * Graph Database — SQLite for the Graph Agent architecture (P0) + * + * 表结构: + * system_agents 全局 / 模板 / 公司专属 agent 元数据(TEXT PRIMARY KEY = 目录名) + * org_graph_nodes 公司组织图节点(company 对某个 system_agent 的引用 + 角色名) + * org_graph_edges 公司组织图边(节点间的关系) + * + * P0 阶段:只加不改,不涉及任何现有执行路径。 + */ + +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const DATA_DIR = path.resolve(__dirname, '../../data'); +const DB_PATH = process.env.GRAPH_DB_PATH || path.join(DATA_DIR, 'graph.sqlite'); + +let db = null; + +function getDb() { + if (db) return db; + + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + db.exec(` + CREATE TABLE IF NOT EXISTS system_agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('public','company')), + owner_company_id TEXT, + source_kind TEXT NOT NULL CHECK (source_kind IN ('global_agent','template','company_hired')), + legacy_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + benchmark_score REAL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS org_graph_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id TEXT NOT NULL, + system_agent_id TEXT NOT NULL REFERENCES system_agents(id), + role_name TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS org_graph_edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id TEXT NOT NULL, + from_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id), + to_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id), + relation_type TEXT NOT NULL + ); + `); + + return db; +} + +module.exports = { + getDb, +}; diff --git a/server/services/graph-seed-service.js b/server/services/graph-seed-service.js new file mode 100644 index 0000000..149b095 --- /dev/null +++ b/server/services/graph-seed-service.js @@ -0,0 +1,429 @@ +/** + * Graph Seed Service — populates `data/graph.sqlite` from existing on-disk + * agent/template/company sources (P0, read-only w.r.t. the legacy sources). + * + * seedGlobalAgents() — system/agents/* → system_agents (source_kind='global_agent') + * seedTemplates() — system/templates/* → system_agents (source_kind='template') + * seedCompanyAgents() — data/companies/{id}/company.json.agents[] → system_agents (source_kind='company_hired') + org_graph_nodes + * (T4 adds verifyCounts() here later.) + * + * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。 + */ + +const fs = require('fs'); +const path = require('path'); +const { getDb } = require('./graph-db'); + +const ROOT = path.resolve(__dirname, '../..'); +const AGENTS_DIR = path.join(ROOT, 'system', 'agents'); +const TEMPLATES_DIR = path.join(ROOT, 'system', 'templates'); +const TEMPLATES_REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); +const COMPANIES_DIR = path.join(ROOT, 'data', 'companies'); + +/** + * 扫描 system/agents/*,过滤掉没有 agent.md 的目录(排除 templates/ 等杂物), + * 逐个 upsert 到 system_agents,scope='public',source_kind='global_agent'。 + * + * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。 + * @returns {{ scanned: number, seeded: number, ids: string[] }} + */ +function seedGlobalAgents() { + const db = getDb(); + + const entries = fs.existsSync(AGENTS_DIR) + ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) + : []; + + const agentDirs = entries.filter(d => + fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md')) + ); + + const now = new Date().toISOString(); + + const upsert = db.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (@id, @name, 'public', NULL, 'global_agent', @legacy_path, @status, NULL, @created_at, @updated_at) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + legacy_path = excluded.legacy_path, + status = excluded.status, + updated_at = excluded.updated_at + `); + + const ids = []; + const seedOne = db.transaction((dirEntries) => { + for (const entry of dirEntries) { + const id = entry.name; + const dir = path.join(AGENTS_DIR, id); + const configPath = path.join(dir, 'config.json'); + + let config = null; + if (fs.existsSync(configPath)) { + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + config = null; // malformed config.json → fall back to defaults + } + } + + const name = (config && config.name) || id; + const status = (config && config.status) || 'active'; + + upsert.run({ + id, + name, + legacy_path: `system/agents/${id}`, + status, + created_at: now, + updated_at: now, + }); + ids.push(id); + } + }); + + seedOne(agentDirs); + + return { scanned: entries.length, seeded: ids.length, ids }; +} + +/** + * 扫描 system/templates/*(目录扫描才是数据源头),逐个 upsert 到 system_agents, + * scope='public', source_kind='template'。 + * + * `_registry.json` 只用来补充可选的 benchmark_score:按 id 匹配上的条目用其 + * `benchmarkScore` 字段(registry 里该字段本身可能是 null),匹配不上的一律留 NULL。 + * 容忍模板目录完全没有 config.json(如 ceo/ 只有 plan.md+todo.md): + * 缺失时 name 默认目录名、status 默认 'active'。 + * + * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。 + * + * id 命名空间跨源共享(`system/agents/` 与 `system/templates/` 有同名目录,如 + * `evolver`/`hunter`/`agent-creator`):当扫到的模板 id 已经被别的 source_kind + * 占用时(例如 T1 seedGlobalAgents 先写入的 global_agent 行),**跳过写入**, + * 不覆盖那一行——否则会把 global_agent 行的 legacy_path/name/status 静默篡改成 + * 模板目录的数据,同一行却仍标着 source_kind='global_agent',数据会被污染。 + * 只有当已存在的行本身就是 source_kind='template' 时才走 UPDATE(保证本函数自身可重复执行)。 + * + * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], skipped: {id: string, existingSourceKind: string}[] }} + */ +function seedTemplates() { + const db = getDb(); + + const entries = fs.existsSync(TEMPLATES_DIR) + ? fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) + : []; + + let registryById = new Map(); + if (fs.existsSync(TEMPLATES_REGISTRY_PATH)) { + try { + const registry = JSON.parse(fs.readFileSync(TEMPLATES_REGISTRY_PATH, 'utf-8')); + if (Array.isArray(registry)) { + registryById = new Map(registry.map(entry => [entry.id, entry])); + } + } catch { + registryById = new Map(); // malformed _registry.json → no enrichment, still seed dirs + } + } + + const now = new Date().toISOString(); + + const findExisting = db.prepare(`SELECT source_kind FROM system_agents WHERE id = ?`); + + const upsert = db.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (@id, @name, 'public', NULL, 'template', @legacy_path, @status, @benchmark_score, @created_at, @updated_at) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + legacy_path = excluded.legacy_path, + status = excluded.status, + benchmark_score = excluded.benchmark_score, + updated_at = excluded.updated_at + `); + + const ids = []; + const registryMatched = []; + const skipped = []; + const seedAll = db.transaction((dirEntries) => { + for (const entry of dirEntries) { + const id = entry.name; + + const existing = findExisting.get(id); + if (existing && existing.source_kind !== 'template') { + skipped.push({ id, existingSourceKind: existing.source_kind }); + continue; // id already owned by another source (e.g. global_agent) — don't clobber it + } + + const dir = path.join(TEMPLATES_DIR, id); + const configPath = path.join(dir, 'config.json'); + + let config = null; + if (fs.existsSync(configPath)) { + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + config = null; // malformed config.json → fall back to defaults + } + } + + const name = (config && config.name) || id; + const status = (config && config.status) || 'active'; + + const registryEntry = registryById.get(id); + const benchmark_score = registryEntry ? (registryEntry.benchmarkScore ?? null) : null; + if (registryEntry) registryMatched.push(id); + + upsert.run({ + id, + name, + legacy_path: `system/templates/${id}`, + status, + benchmark_score, + created_at: now, + updated_at: now, + }); + ids.push(id); + } + }); + + seedAll(entries); + + return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped }; +} + +/** + * 扫描 data/companies/{id}/company.json,对每个 company 的 `agents[]`(角色名字符串 + * 扁平数组)逐个: + * - 若 system_agents 里已存在同 id 的行(来自 T1/T2 的 global_agent/template)→ + * 只建一条 org_graph_nodes 指过去。 + * - 若不存在 → 先插入一条新的 system_agents 行(scope='company', + * owner_company_id=companyId, source_kind='company_hired', + * legacy_path='data/companies/{companyId}/agents/{name}'),再建 org_graph_nodes。 + * + * `org_graph_edges` 本阶段留空——company.json 目前没有汇报关系数据,是预期状态,不是 bug。 + * + * Idempotent: + * - system_agents 用 INSERT ... ON CONFLICT(id) DO NOTHING(company_hired 行一旦建立 + * 不应被后续扫描覆盖,行为上等同"首次发现即定型")。 + * - org_graph_nodes 没有自然唯一键,靠先查后插(company_id + system_agent_id + role_name + * 三元组)去重,避免重复跑脚本时行数上涨。 + * + * @returns {{ companies: string[], nodesCreated: number, nodesExisting: number, + * newSystemAgents: string[], reusedSystemAgents: string[] }} + */ +function seedCompanyAgents() { + const db = getDb(); + + const companyDirs = fs.existsSync(COMPANIES_DIR) + ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) + : []; + + const now = new Date().toISOString(); + + const findAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`); + const insertAgent = db.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (@id, @name, 'company', @owner_company_id, 'company_hired', @legacy_path, 'active', NULL, @created_at, @updated_at) + ON CONFLICT(id) DO NOTHING + `); + const findNode = db.prepare(` + SELECT id FROM org_graph_nodes WHERE company_id = ? AND system_agent_id = ? AND role_name = ? + `); + const insertNode = db.prepare(` + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at) + VALUES (@company_id, @system_agent_id, @role_name, @created_at) + `); + + const companies = []; + let nodesCreated = 0; + let nodesExisting = 0; + const newSystemAgents = []; + const reusedSystemAgents = []; + + const seedAll = db.transaction((dirEntries) => { + for (const entry of dirEntries) { + const companyId = entry.name; + const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json'); + if (!fs.existsSync(companyJsonPath)) continue; // no company.json → not a real company yet + + let companyJson; + try { + companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); + } catch { + continue; // malformed company.json → skip, don't seed partial data + } + + const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : []; + companies.push(companyId); + + for (const roleName of roleNames) { + const existing = findAgent.get(roleName); + if (existing) { + reusedSystemAgents.push(roleName); + } else { + insertAgent.run({ + id: roleName, + name: roleName, + owner_company_id: companyId, + legacy_path: `data/companies/${companyId}/agents/${roleName}`, + created_at: now, + updated_at: now, + }); + newSystemAgents.push(roleName); + } + + // role_name 目前恒等于 system_agent_id:company.json.agents[] 现在只是纯角色名字符串数组, + // 没有独立的"角色名 vs 引用哪个 agent"区分。P2 引入 org_graph 编辑能力后两者会分开。 + const existingNode = findNode.get(companyId, roleName, roleName); + if (existingNode) { + nodesExisting += 1; + } else { + insertNode.run({ + company_id: companyId, + system_agent_id: roleName, + role_name: roleName, + created_at: now, + }); + nodesCreated += 1; + } + } + } + }); + + seedAll(companyDirs); + + return { companies, nodesCreated, nodesExisting, newSystemAgents, reusedSystemAgents }; +} + +/** + * 独立行数交叉核对(T4)——不调用上面三个 seed 函数、不复用它们的返回计数器, + * 而是从零重新扫描 system/agents/、system/templates/、data/companies/*\/company.json, + * 按种子逻辑描述的同一套优先级规则(global_agent 优先于 template 优先于 company_hired) + * 独立推导出"应该"落在 system_agents/org_graph_nodes 里的行数, + * 再拿去和 DB 里 `SELECT source_kind, COUNT(*) ... GROUP BY source_kind` 以及 + * `SELECT company_id, COUNT(*) FROM org_graph_nodes GROUP BY company_id` 的真实结果比对。 + * + * 这样一来,如果 seedGlobalAgents/seedTemplates/seedCompanyAgents 内部逻辑跑偏 + * (例如 agent.md 过滤条件写错、冲突跳过逻辑失效、company 角色去重出错), + * 这里独立算出来的期望值不会跟着一起错,能捕捉到。 + * + * @returns {{ ok: boolean, mismatches: Array<{check: string, expected: number, actual: number}>, + * expected: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object }, + * actual: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object } }} + */ +function verifyCounts() { + const db = getDb(); + + // ── 独立重新扫描 system/agents/(含 agent.md 才算数,与 T1 判定规则一致,但独立实现)── + const agentEntries = fs.existsSync(AGENTS_DIR) + ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) + : []; + const globalIds = new Set( + agentEntries + .filter(d => fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md'))) + .map(d => d.name) + ); + + // ── 独立重新扫描 system/templates/ ── + const templateEntries = fs.existsSync(TEMPLATES_DIR) + ? fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) + : []; + const templateDirIds = templateEntries.map(d => d.name); + // id 命名空间跨源共享:与 global_agent 同名的模板目录会被 seedTemplates 冲突保护跳过, + // 不落地为 template 行——独立复算这条精确规则,而不是直接拿模板目录总数当期望值。 + const expectedTemplateIds = new Set(templateDirIds.filter(id => !globalIds.has(id))); + + // ── 独立重新扫描 data/companies/*/company.json ── + const companyDirs = fs.existsSync(COMPANIES_DIR) + ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) + : []; + + const knownIds = new Set([...globalIds, ...expectedTemplateIds]); + const expectedNewCompanyHiredIds = new Set(); + const expectedNodesPerCompany = {}; + + for (const entry of companyDirs) { + const companyId = entry.name; + const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json'); + if (!fs.existsSync(companyJsonPath)) continue; + + let companyJson; + try { + companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); + } catch { + continue; // 与 seedCompanyAgents 一致:损坏的 company.json 不参与核对 + } + + const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : []; + const distinctRoles = new Set(roleNames); // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重 + expectedNodesPerCompany[companyId] = distinctRoles.size; + + for (const roleName of distinctRoles) { + if (!knownIds.has(roleName)) { + expectedNewCompanyHiredIds.add(roleName); + } + } + } + + // ── DB 里的真实计数 ── + const bySourceKind = db.prepare(` + SELECT source_kind, COUNT(*) AS cnt FROM system_agents GROUP BY source_kind + `).all(); + const actualCounts = { global_agent: 0, template: 0, company_hired: 0 }; + for (const row of bySourceKind) { + actualCounts[row.source_kind] = row.cnt; + } + + const nodesByCompany = db.prepare(` + SELECT company_id, COUNT(*) AS cnt FROM org_graph_nodes GROUP BY company_id + `).all(); + const actualNodesPerCompany = {}; + for (const row of nodesByCompany) { + actualNodesPerCompany[row.company_id] = row.cnt; + } + + // ── 比对 ── + const mismatches = []; + + if (actualCounts.global_agent !== globalIds.size) { + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='global_agent'", expected: globalIds.size, actual: actualCounts.global_agent }); + } + if (actualCounts.template !== expectedTemplateIds.size) { + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='template'", expected: expectedTemplateIds.size, actual: actualCounts.template }); + } + if (actualCounts.company_hired !== expectedNewCompanyHiredIds.size) { + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='company_hired'", expected: expectedNewCompanyHiredIds.size, actual: actualCounts.company_hired }); + } + + const allCompanyIds = new Set([...Object.keys(expectedNodesPerCompany), ...Object.keys(actualNodesPerCompany)]); + for (const companyId of allCompanyIds) { + const expected = expectedNodesPerCompany[companyId] || 0; + const actual = actualNodesPerCompany[companyId] || 0; + if (expected !== actual) { + mismatches.push({ check: `org_graph_nodes COUNT(*) WHERE company_id='${companyId}'`, expected, actual }); + } + } + + return { + ok: mismatches.length === 0, + mismatches, + expected: { + global_agent: globalIds.size, + template: expectedTemplateIds.size, + company_hired: expectedNewCompanyHiredIds.size, + nodesPerCompany: expectedNodesPerCompany, + }, + actual: { + global_agent: actualCounts.global_agent, + template: actualCounts.template, + company_hired: actualCounts.company_hired, + nodesPerCompany: actualNodesPerCompany, + }, + }; +} + +module.exports = { + seedGlobalAgents, + seedTemplates, + seedCompanyAgents, + verifyCounts, +}; diff --git a/server/tests/unit/graph-db.test.js b/server/tests/unit/graph-db.test.js new file mode 100644 index 0000000..ac52f7d --- /dev/null +++ b/server/tests/unit/graph-db.test.js @@ -0,0 +1,67 @@ +/** + * Unit Tests — graph-db.js + * Asserts the three P0 tables exist and that GRAPH_DB_PATH env override + * actually redirects the underlying sqlite file (regression guard against + * the benchmark-db.js "declared but never read" bug). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import Database from 'better-sqlite3'; + +describe('graph-db', () => { + let graphDb; + const TEST_DB_PATH = '/tmp/test-graph-db.sqlite'; + const DEFAULT_DB_PATH = path.resolve(__dirname, '../../../data/graph.sqlite'); + + beforeAll(() => { + if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + delete require.cache[require.resolve('../../services/graph-db')]; + graphDb = require('../../services/graph-db'); + }); + + afterAll(() => { + try { fs.unlinkSync(TEST_DB_PATH); } catch {} + try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {} + try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {} + delete process.env.GRAPH_DB_PATH; + }); + + it('should create all three tables', () => { + const d = graphDb.getDb(); + const tables = d.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name); + expect(tables).toContain('system_agents'); + expect(tables).toContain('org_graph_nodes'); + expect(tables).toContain('org_graph_edges'); + }); + + it('should honor GRAPH_DB_PATH env override for reads/writes', () => { + const d = graphDb.getDb(); + const now = new Date().toISOString(); + d.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('test-agent-env-check', 'Test Agent', 'public', null, 'global_agent', 'system/agents/test-agent-env-check', 'active', null, now, now); + + // The row must have landed in the overridden file, not the default one. + expect(fs.existsSync(TEST_DB_PATH)).toBe(true); + + // Prove the override actually redirected the file: the default-path DB + // (opened independently, never touched by graph-db in this test run) + // must NOT contain the row we just wrote. + if (fs.existsSync(DEFAULT_DB_PATH)) { + const defaultDb = new Database(DEFAULT_DB_PATH, { readonly: true }); + try { + const row = defaultDb.prepare('SELECT 1 FROM system_agents WHERE id = ?').get('test-agent-env-check'); + expect(row).toBeUndefined(); + } finally { + defaultDb.close(); + } + } else { + // Default DB file was never created at all -- also proves the write + // went to the overridden path instead of the default one. + expect(fs.existsSync(DEFAULT_DB_PATH)).toBe(false); + } + }); +}); diff --git a/server/tests/unit/graph-seed-service.test.js b/server/tests/unit/graph-seed-service.test.js new file mode 100644 index 0000000..a96d131 --- /dev/null +++ b/server/tests/unit/graph-seed-service.test.js @@ -0,0 +1,56 @@ +/** + * Unit Tests — graph-seed-service.js verifyCounts() (T4) + * Runs the real seed functions against the actual repo fs (system/agents/, + * system/templates/, data/companies/) into a throwaway GRAPH_DB_PATH, then + * asserts verifyCounts() reports ok:true against that freshly-seeded data, + * and that it actually catches a mismatch when the DB drifts from the fs. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; + +describe('graph-seed-service verifyCounts', () => { + let seedService; + let graphDb; + const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite'; + + beforeAll(() => { + if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + delete require.cache[require.resolve('../../services/graph-db')]; + delete require.cache[require.resolve('../../services/graph-seed-service')]; + graphDb = require('../../services/graph-db'); + seedService = require('../../services/graph-seed-service'); + }); + + afterAll(() => { + try { fs.unlinkSync(TEST_DB_PATH); } catch {} + try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {} + try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {} + delete process.env.GRAPH_DB_PATH; + }); + + it('reports ok:true against a freshly-seeded DB (no drift)', () => { + seedService.seedGlobalAgents(); + seedService.seedTemplates(); + seedService.seedCompanyAgents(); + + const v = seedService.verifyCounts(); + expect(v.mismatches).toEqual([]); + expect(v.ok).toBe(true); + }); + + it('catches a mismatch when the DB has a row the filesystem does not back', () => { + const db = graphDb.getDb(); + const now = new Date().toISOString(); + db.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('bogus-agent-no-fs-backing', 'Bogus', 'public', null, 'global_agent', 'system/agents/bogus-agent-no-fs-backing', 'active', null, now, now); + + const v = seedService.verifyCounts(); + expect(v.ok).toBe(false); + expect(v.mismatches.some(m => m.check.includes("source_kind='global_agent'"))).toBe(true); + + db.prepare('DELETE FROM system_agents WHERE id = ?').run('bogus-agent-no-fs-backing'); + }); +}); diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 0000000..70dab54 --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,70 @@ +# P0 地基 — Graph Agent 架构任务拆分 + +> 对应 `docs/system-design/27-graph-agent-architecture.md` 的 Phase P0。目标:只加不改——新建三张表,把现有数据原样导入作为种子数据并校验行数,完全不动 `company-service.js` 现有的 hire/copy 执行路径。这是 P1(虚拟引用改造)能安全推进的前提:先有一份可信、经过校验的镜像数据在 DB 里,再去改代码。 + +## Context + +两轮只读调研确认了关键事实: + +- 现有三个 sqlite 库(`skills.sqlite`/`benchmark_logs.sqlite`/`users.sqlite`)都遵循同一套手写约定:`server/services/*-db.js` 里 `new Database()` + WAL + `CREATE TABLE IF NOT EXISTS`(无迁移文件、无版本追踪),snake_case 列名。**没有 `:memory:` 测试,测试用临时文件 + env 变量覆盖 DB 路径 + 清 require 缓存**(`server/tests/unit/skill-db.test.js` 是标准范式)。`benchmark-db.js` 声明了 env override 但实际没读,是个已知的潜在 bug,新代码要避免重蹈。 +- `system/agents/` 有 11 个目录,其中 `system/agents/templates/` 是遗留杂物、不是真 agent,必须过滤掉(真实 agent 数 = 10)。 +- `system/templates/` 有 34 个目录,但 `_registry.json`(数组)只覆盖 9 个(`ceo`/`qa-judge`/`base`/6 个 devloop-*)——目录扫描才是数据源头,registry 只用来补充可选的 benchmark 元数据,其余 25 个模板要容忍没有 `config.json`(如 `ceo/` 只有 `plan.md`+`todo.md`)。 +- `data/companies/` 目前只有一家真实公司 `idearun`,`company.json.agents` 是 6 个角色名字符串的扁平数组:`youtube-crawler`/`evolver-youtube-crawler` 能对上 `system/agents/` 里的全局 agent,另外 4 个(`claude-youtube-skills`/`trend-scout-global`/`content-selector`/`idearuncreate`)在 global/template 里都找不到对应目录——这些是公司专属训练出来的实例,是"company-scoped system agent"这个概念在真实数据里的第一批样本。 +- `company-service.js` 的 `hire`(189)/`scaffoldNewAgent`(241)/`depositTemplate`(294)/`hireWithTraining`(381)/`_copyTemplate`(621)/`_injectCompanyContext`(667) 六个函数行号已核实无漂移,P0 全程不动这个文件。 + +## 新增文件 + +- `server/services/graph-db.js` —— 新 sqlite 库 `data/graph.sqlite`,照抄 `skill-db.js`/`user-db.js` 的样板:`fs.mkdirSync` 保证目录存在 → `new Database(DB_PATH)` → `journal_mode=WAL` + `foreign_keys=ON` → lazy singleton `getDb()`。**支持 `GRAPH_DB_PATH` env 覆盖,且要确保代码真的读了这个变量**。 +- `server/services/graph-seed-service.js` —— 导出 `seedGlobalAgents()` / `seedTemplates()` / `seedCompanyAgents()` / `verifyCounts()`,供脚本和测试复用。 +- `scripts/graph-seed.js` —— 薄 CLI 入口,依次调用上面四个函数并打印汇总报告,风格参照现有 `scripts/sync-skills.js`。 +- `package.json` 加一条 `"graph:seed": "node scripts/graph-seed.js"`。 +- `server/tests/unit/graph-db.test.js` —— 照抄 `skill-db.test.js` 的临时文件 + env 覆盖 + 清 require 缓存范式。 + +## Schema + +```sql +CREATE TABLE IF NOT EXISTS system_agents ( + id TEXT PRIMARY KEY, -- 就是目录名,如 'master'/'ceo'/'claude-youtube-skills' + name TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('public','company')), + owner_company_id TEXT, + source_kind TEXT NOT NULL CHECK (source_kind IN ('global_agent','template','company_hired')), + legacy_path TEXT NOT NULL, -- 当前真实所在目录,如 'system/agents/master' + status TEXT NOT NULL DEFAULT 'active', + benchmark_score REAL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS org_graph_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id TEXT NOT NULL, + system_agent_id TEXT NOT NULL REFERENCES system_agents(id), + role_name TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS org_graph_edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id TEXT NOT NULL, + from_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id), + to_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id), + relation_type TEXT NOT NULL +); +``` + +相对设计文档(27 号)草稿的两处调整: + +1. `system_agents.id` 用 **TEXT PRIMARY KEY = 目录名**,不用自增代理键——后续阶段要靠这个 id 反查文件系统路径,代理键只会多一层无意义的间接。 +2. 新增 `source_kind` + `legacy_path` 两列——P0 明确不做目录搬迁(三种来源现在物理上分散在三个不同地方),没有这两列种子数据就"没有指针",没法在 P1 真正切换执行路径时定位到原文件。 + +`org_graph_nodes`/`org_graph_edges` 沿用仓库现有的自增整数主键惯例,暂不需要对外暴露的字符串 ID(要到 P2/P3 才会被外部引用)。 + +## 验证方式 + +1. `npm run test:unit` —— 新增的 `graph-db.test.js` 通过,现有测试无回归 +2. `npm run graph:seed` —— 在真实仓库数据上跑,人工核对输出的行数汇总(10 / 34 / 6,见下方任务列表) +3. `sqlite3 data/graph.sqlite ".schema"` —— 人工确认三张表结构与上面 SQL 一致 +4. `git diff --stat` —— 确认这一阶段的改动只新增文件,没有改动 `company-service.js`/`agent-manager.js`/`prompt-builder.js` 任何一行 + +任务清单见 [todo.md](./todo.md)。 diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..4a73cbe --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,33 @@ +# P0 任务清单 + +依赖顺序:T0 阻塞全部 → T1/T2 → checkpoint → T3 → T4 → T5 + +- [x] **T0 — 建表 + DB 模块** + - 产出:`server/services/graph-db.js` + `server/tests/unit/graph-db.test.js` + - 验收:测试断言三张表通过 `sqlite_master` 查询存在;断言设置 `GRAPH_DB_PATH` 后确实写到了别的文件(回归防 benchmark-db.js 那种"声明了但没读"的 bug) + +- [x] **T1 — 导入 `system/agents/` → `system_agents`** + - 扫描 `system/agents/*`,过滤掉没有 `agent.md` 的目录(排除 `templates/` 杂物),逐个插入 `scope='public', owner_company_id=NULL, source_kind='global_agent'` + - 验收:`SELECT COUNT(*) WHERE source_kind='global_agent'` = 10;脚本可重复执行不产生重复行(`INSERT OR IGNORE`/upsert on id) + +- [x] **T2 — 导入 `system/templates/` → `system_agents`** + - 扫描 34 个模板目录,用 `_registry.json` 按 id 匹配补充 `benchmark_score`(只有 9 个能匹配上,其余留 NULL),容忍缺失 `config.json` + - 验收:`COUNT(*) WHERE source_kind='template'` = 34;抽查 9 个 registry 覆盖到的条目 `benchmark_score` 非空,其余 25 个为 NULL + - **实际结果(见执行报告)**:真实目录是 39 个(非文档所写的 34),其中 6 个与 `system/agents/*` 目录同名(`agent-creator`/`digester`/`evolver`/`evolver-youtube-crawler`/`hunter`/`innovator`)——已加冲突保护跳过写入,避免污染 T1 的 global_agent 行,故 `COUNT(*) WHERE source_kind='template'` = 33;registry 9 个 id 全部匹配上,但其中 6 个 devloop-* 在 registry 里本身 `benchmarkScore` 就是 `null`,故非空的只有 `ceo`(97)/`qa-judge`(95)/`base`(90) 3 个,其余(含 6 个 devloop-* 匹配项)均为 NULL + +- [ ] **checkpoint** — T0-T2 落地后手动跑一遍 `graph:seed`,人工看一眼种子数据是否符合预期,再继续 T3 + +- [x] **T3 — 导入 `data/companies/*/company.json.agents[]` → `system_agents` + `org_graph_nodes`** + - 已有同名 `system_agents` 行(来自 T1/T2)→ 只建 `org_graph_nodes` 指过去;没有 → 新建 `scope='company', owner_company_id=, source_kind='company_hired'` 行,再建 `org_graph_nodes`。`org_graph_edges` 本阶段留空(`company.json` 目前没有汇报关系数据,是预期状态) + - 验收:对真实的 `idearun`,`org_graph_nodes` 恰好 6 行;其中 2 行引用已存在的行(`youtube-crawler`/`evolver-youtube-crawler`),4 行触发新建 `scope='company'` 记录 + - **实际结果(见执行报告)**:`org_graph_nodes` 行数对得上(6 行),但引用/新建的拆分与本条原始预期不一致——亲自核对 `data/companies/idearun/company.json` 与 `system/agents/`、`system/templates/` 目录后发现:`idearun.agents[]` 的 6 个角色名(`claude-youtube-skills`/`youtube-crawler`/`evolver-youtube-crawler`/`trend-scout-global`/`content-selector`/`idearuncreate`)**全部**能在 T1/T2 已导入的 `system_agents` 里找到同名行(1 个 `source_kind='global_agent'`:`evolver-youtube-crawler`;5 个 `source_kind='template'`:`claude-youtube-skills`/`youtube-crawler`/`trend-scout-global`/`content-selector`/`idearuncreate`——这 5 个模板目录在 `system/templates/` 下真实存在,此前文档判断"在 global/template 里都找不到对应目录"是错的)。因此触发新建 `scope='company'` 行数 = **0**,不是预期的 4;`org_graph_nodes` 6 行全部引用已存在行。`youtube-crawler` 本身在 `system/agents/` 下也有同名目录,但该目录下没有 `agent.md`(只有 `.claude/`),T1 过滤规则将其排除,故它落在 `system_agents` 里的行是 T2 写入的 `template` 行,不是 `global_agent`。二次运行 `npm run graph:seed` 验证幂等:`org_graph_nodes` 新建 0 行、跳过 6 行,总数仍是 6。 + +- [x] **T4 — 行数对账 + npm 脚本接线** + - `npm run graph:seed` 串联 T1→T2→T3;跑完打印按 `source_kind` 分组的行数汇总,独立于种子脚本内部计数器、重新扫描一次目录/文件做交叉核对 + - 验收:连续跑两次 `npm run graph:seed` 行数不变(幂等);汇总报告三类 `source_kind` 数字都对得上 T1-T3 的真实数字 + - **实际结果**:`verifyCounts()` 已加到 `server/services/graph-seed-service.js`,独立重新扫描 `system/agents/`(含 `agent.md` 过滤)、`system/templates/`(含跨源冲突规则)、`data/companies/*/company.json`(角色名去重),推导期望行数,再和 `SELECT source_kind, COUNT(*) ... GROUP BY source_kind` / `SELECT company_id, COUNT(*) FROM org_graph_nodes GROUP BY company_id` 的真实结果比对,不一致时返回 `ok:false` + `mismatches[]`。`scripts/graph-seed.js` 依次调用 `seedGlobalAgents → seedTemplates → seedCompanyAgents → verifyCounts`,打印汇总报告,`v.ok===false` 时 `process.exitCode = 1`。`package.json` 已有 `"graph:seed": "node scripts/graph-seed.js"`(此前会话已加)。连续跑两次 `npm run graph:seed`,真实数字:`global_agent=8`(`system/agents/` 现有 11 个目录,含 `agent.md` 的 8 个,文档里旧的"10"是过时数字,与本任务无关,不予改动)、`template=33`(39 个模板目录减去 6 个与 global_agent 同名被冲突保护跳过的)、`company_hired=0`(`idearun.agents[]` 6 个角色全部能对上已有行)、`org_graph_nodes[idearun]=6`。两次运行结果完全一致(幂等),第二次运行 `org_graph_nodes` 新建 0 行、跳过 6 行;直接查 SQLite 复核 `system_agents` 总行数 41(8+33)、`org_graph_nodes` 总行数 6、`org_graph_edges` 总行数 0,无重复行。另外人工注入一条无文件系统依据的假 `system_agents` 行验证 `verifyCounts()` 确实能检测出不一致(`ok:false`),且 `scripts/graph-seed.js` 在该场景下退出码为 1,验证了对账/退出码逻辑本身有效,不是摆设。 + +- [x] **T5 — 确认零行为变更** + - 非编码任务:跑 `npm run test:unit` 全绿;`git diff --stat server/services/company-service.js` 应该没有任何改动 + - 对应设计文档里 P0 的验收标准"现有 company-service.js 行为不变" + - **实际结果**:`npm run test:unit` 全绿(54 test files passed, 571 tests passed, 0 failed)。`git diff --stat server/services/company-service.js server/src/core/agent-manager.js server/src/core/prompt-builder.js`(working tree 和 `--cached` 两个都跑了)输出均为空字符串,退出码 0。额外核对:这三个文件最近一次被改动的提交是 `538d5d0`,早于本次 P0/devloop 全部提交(`83523ed`/`7335930`/`e1f71cf`/`bd1636b`/`3c14a4d`),确认整个 P0 阶段这三个文件零改动(含已提交和未提交两种情况)。 -- libgit2 0.21.4