Commit 8421f55f6aea5d91edac0c40ba786657944b8fc7
1 parent
3c14a4dd
feat(graph-agent): P0 foundation — system_agents/org_graph_* tables + seed migration
Adds server/services/graph-db.js (new data/graph.sqlite: system_agents, org_graph_nodes, org_graph_edges) and graph-seed-service.js, which scans system/agents/, system/templates/, and data/companies/*/company.json into seed rows without touching company-service.js's existing hire/copy path. Real repo data diverged from the design doc's original estimates (8 not 10 global agents; 39 not 34 template dirs; system/agents/ and system/templates/ turn out to share 6 ids describing the "same" agent from two different files) — documented in docs/todo/system-design/27-graph-agent-architecture.md section 7 for P1/P2 to resolve. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
10 changed files
with
1034 additions
and
1 deletions
.gitignore
| @@ -20,7 +20,7 @@ system/agents/*/logs/ | @@ -20,7 +20,7 @@ system/agents/*/logs/ | ||
| 20 | system/agents/*/workspace/ | 20 | system/agents/*/workspace/ |
| 21 | system/agents/*/uploads/ | 21 | system/agents/*/uploads/ |
| 22 | system/agents/*/memory/ | 22 | system/agents/*/memory/ |
| 23 | -system/agents/.tasks/ | 23 | +system/tasklog/ |
| 24 | 24 | ||
| 25 | # System templates runtime data | 25 | # System templates runtime data |
| 26 | system/templates/*/conversations/ | 26 | system/templates/*/conversations/ |
| @@ -88,6 +88,8 @@ system/memory/ | @@ -88,6 +88,8 @@ system/memory/ | ||
| 88 | 88 | ||
| 89 | # SQLite databases (runtime data) | 89 | # SQLite databases (runtime data) |
| 90 | *.sqlite | 90 | *.sqlite |
| 91 | +*.sqlite-wal | ||
| 92 | +*.sqlite-shm | ||
| 91 | 93 | ||
| 92 | # System company runtime data | 94 | # System company runtime data |
| 93 | system/data/companies/ | 95 | system/data/companies/ |
docs/todo/system-design/27-graph-agent-architecture.md
0 → 100644
| 1 | +# 27. Graph Agent 架构 — Org Graph / Work Graph 与 System Agent 统一寻址 | ||
| 2 | + | ||
| 3 | +> **版本**: v0.2(P0 已实现) | ||
| 4 | +> **日期**: 2026-08-15 | ||
| 5 | +> **状态**: P0 ✅ 已实现(`server/services/graph-db.js` + `graph-seed-service.js`,见第七节);P1 起 📝 设计中/未实现 | ||
| 6 | +> **前置**: [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 实现) | ||
| 7 | + | ||
| 8 | +--- | ||
| 9 | + | ||
| 10 | +## 一、核心问题 | ||
| 11 | + | ||
| 12 | +### 现有模型(24 号文档)的局限 | ||
| 13 | + | ||
| 14 | +24 号文档确立的模型是「System Agent(全局单例)+ Agent Template(可复用蓝图)+ Company Agent(雇佣时从模板 **文件拷贝** 出来的实例)」。这套模型跑通了 Multi-Tenant 公司隔离,但暴露出四个结构性问题: | ||
| 15 | + | ||
| 16 | +1. **状态碎片化**:每次 `hire()` 都是一次 `fs.copyFileSync`,同一角色在 10 家公司名下就有 10 份互不相干的 `agent.md`/`memory.md`。模板更新后,已雇佣的实例不会同步,也无法双向学习——A 公司的 QA 遇到的坑,B 公司的 QA 完全学不到。 | ||
| 17 | +2. **无法自我进化**:Agent 的"智能"被锁在一次性拷贝的静态文件里,没有一个机制让它在被反复调用后越来越准。唯一的进化路径是 `depositTemplate()`——训练出一个新 agent,跑 benchmark,≥95 分才存回模板库,这是"生个孩子"而不是"自己变强"。 | ||
| 18 | +3. **协作关系是扁平列表**:`company.json.agents[]` 只是一个角色名数组,没有边、没有汇报关系,更没有"谁的输出喂给谁"的显式结构。 | ||
| 19 | +4. **失败恢复局限在单一场景**:目前唯一的循环恢复机制是 DevLoop 的 `runUnit`(Coder ↔ Tester 两节点、固定方向、`retry_budget` 硬顶 5 次),无法泛化到"任意节点失败后,带着上下文跳回图中任意历史节点重跑"这种通用场景。 | ||
| 20 | + | ||
| 21 | +### 设计目标 | ||
| 22 | + | ||
| 23 | +把「company 拥有并复制 agent」改造为「company 引用全局 system agent,并在其上定义**静态协作结构(Org Graph)**与**动态任务执行路径(Work Graph)**」: | ||
| 24 | + | ||
| 25 | +1. Agent 是跨公司复用、持续自我进化的一等公民,不是一次性文件拷贝。 | ||
| 26 | +2. 公司的组织结构(Org Graph)与具体任务怎么跑(Work Graph)解耦:前者是company 层面相对稳定的静态定义,后者由 planning agent 针对每个目标动态规划、执行完即归档。 | ||
| 27 | +3. 失败恢复不再局限于"重跑同一个节点":任意节点失败可以携带上下文回退到图中任意历史节点,同时有引擎级别的硬性防死循环机制。 | ||
| 28 | + | ||
| 29 | +--- | ||
| 30 | + | ||
| 31 | +## 二、术语定义 | ||
| 32 | + | ||
| 33 | +| 术语 | 定义 | | ||
| 34 | +|------|------| | ||
| 35 | +| **System Agent** | 全局唯一的可执行 Agent 实体。自带 prompt 定义、公共记忆(`memory.md`)、按公司分区的私有历史。是 24 号文档里"System Agent"与"Agent Template"两个概念的合并——不再区分"模板"和"实例",只有"这个 agent,被谁调用过"。 | | ||
| 36 | +| **scope** | System Agent 的可见性:`public`(跨公司共享,记忆公共累积)或 `company`(私有,只属于 `owner_company_id` 指定的一家公司)。 | | ||
| 37 | +| **Org Graph** | 公司级别的**静态**协作结构。节点 = 对某个 system agent 的引用;边 = 汇报/协作关系。取代现有 `company.json.agents[]` 扁平列表。 | | ||
| 38 | +| **Work Graph** | 某个 org 节点收到一个 goal/task 后,现场调用 **work-graph-planning agent** 针对这一具体任务规划出的**动态**执行图。节点 = 对某个 system agent 的一次调用;边带 `success`/`fail` 条件与目标节点。执行完归档,不复用到下一个任务。 | | ||
| 39 | +| **Loop-Validator** | 一个专职的 system agent,内置带硬编码熔断逻辑的 skill。所有"回退到历史节点"的边必须先经过它:备案 → 审计上次修复是否生效 → 判断修复方案是否被正确传递 → 循环数 > 3 强制终止。 | | ||
| 40 | +| **Node 内容 / Edge 内容** | 图结构本身(节点、边、状态、指针)存 SQLite;节点的输入输出内容、日志、上下文,依旧是 Markdown 文件,物理落在对应 system agent 的目录下。 | | ||
| 41 | + | ||
| 42 | +--- | ||
| 43 | + | ||
| 44 | +## 三、架构设计 | ||
| 45 | + | ||
| 46 | +### 3.1 三层模型总览 | ||
| 47 | + | ||
| 48 | +``` | ||
| 49 | + ┌─────────────────────────────────────┐ | ||
| 50 | + │ System Agent 池 │ | ||
| 51 | + │ (全局唯一,可跨公司复用,自我进化) │ | ||
| 52 | + │ │ | ||
| 53 | + │ [ceo] [coder] [qa-judge] [...] │ | ||
| 54 | + │ ▲ ▲ ▲ │ | ||
| 55 | + └─────┼───────┼─────────┼────────────────┘ | ||
| 56 | + │ │ │ (引用,非复制) | ||
| 57 | + ┌────────────┴───────┴─────────┴──────────────┐ | ||
| 58 | + │ Company: {companyId} │ | ||
| 59 | + │ │ | ||
| 60 | + │ Org Graph(静态,company 生命周期内相对稳定) │ | ||
| 61 | + │ [CEO 节点] ──reports──> [CTO 节点] ──> ... │ | ||
| 62 | + │ │ | ||
| 63 | + │ Work Graph(动态,per-goal,执行完归档) │ | ||
| 64 | + │ [node1:coder] --success--> [node2:tester] │ | ||
| 65 | + │ --fail-----> [loop-validator]│ | ||
| 66 | + │ │ │ | ||
| 67 | + │ ▼ │ | ||
| 68 | + │ [回退到 node1] │ | ||
| 69 | + └────────────────────────────────────────────────┘ | ||
| 70 | +``` | ||
| 71 | + | ||
| 72 | +### 3.2 System Agent:唯一执行体与数据隔离 | ||
| 73 | + | ||
| 74 | +目录结构(取代现有 `system/agents/` 与 `system/templates/` 的分离): | ||
| 75 | + | ||
| 76 | +``` | ||
| 77 | +system/agents/{agentId}/ | ||
| 78 | + agent.md / soul.md / identity.md / tools.md # 定义本体,全局唯一 | ||
| 79 | + memory.md # 公共记忆:自我迭代蒸馏出的能力/经验,跨公司共享持续增长 | ||
| 80 | + config.json # { scope: public|company, owner_company_id, status, benchmark_score } | ||
| 81 | + companies/ | ||
| 82 | + {companyId}/ | ||
| 83 | + memory.md # 该公司私有工作记忆(目标、上下文、汇报关系) | ||
| 84 | + history/ | ||
| 85 | + {workGraphId}-{nodeId}-{attempt}.md # 该公司触发的每次执行原始记录 | ||
| 86 | + logs/ | ||
| 87 | +``` | ||
| 88 | + | ||
| 89 | +- **company-scoped(私有)**:`companies/` 下永远只有 owner 一个子目录,代码路径与 public 完全一致,不做特殊分支。 | ||
| 90 | +- **public(公共)**:`companies/` 下有多个公司各自的隔离子目录。 | ||
| 91 | + | ||
| 92 | +**读取隔离原则**:PromptBuilder 组装某次调用的上下文时,只按 `(agentId, companyId)` 构造路径——公共部分(`agent.md`/`memory.md`)+ 这一家公司自己的 `companies/{companyId}/`。别的公司的目录**从物理路径上就不会被触达**,跟现有 DevLoop CONVENTIONS.md §12「跨 unit 只能读 `../{otherUnit}/contract.json`」是同一种"靠构造方式天然隔离"的思路,不是运行时权限过滤。 | ||
| 93 | + | ||
| 94 | +**写入分级**:私有 `companies/{id}/memory.md` 每次调用后可自由写,风险局限在一家公司内。公共 `memory.md` 的更新(自我进化)风险更高——写错会污染所有后续调用方——因此需要走一次自省校验(复用 loop-validator 或类似机制),并且像 DevLoop 项目一样纳入 git 版本管理,坏的自我迭代可以回滚。 | ||
| 95 | + | ||
| 96 | +**新建与升级治理**:Work-graph 执行中如果需要的 system agent 不存在,直接触发创建,默认 `scope=company`(私有)。是否升级为 `public`,复用现有 `hireWithTraining()` → `agent-creator`/`innovator` 训练 → benchmark ≥95 → 升级的门槛链路,只是终点从"写文件到 `system/templates/`"改为"更新 DB 里的 `scope` 字段"。**不新增治理机制,只改落点。** | ||
| 97 | + | ||
| 98 | +### 3.3 Org Graph:公司静态结构 | ||
| 99 | + | ||
| 100 | +- 节点:对某个 system agent 的引用(`org_graph_nodes`),代替 `company.json.agents[]`。 | ||
| 101 | +- 边:汇报/协作关系(`org_graph_edges`),公司创建/招聘时由 CEO 或 HR agent 维护。 | ||
| 102 | +- "招聘"语义变化:不再是"拷贝一份文件",而是"在 org graph 里加一个指向某 system agent 的引用节点"。 | ||
| 103 | + | ||
| 104 | +### 3.4 Work Graph:任务动态执行图 | ||
| 105 | + | ||
| 106 | +1. 某 org 节点收到一个 goal/task。 | ||
| 107 | +2. 该节点调用 **work-graph-planning agent**,针对这个具体任务规划出一张图:节点指向要执行的 system agent,边带 `success`/`fail` 条件。 | ||
| 108 | +3. **目标节点范围不受 org graph 约束,只受 system agent 池约束**——planning agent 可以调用任意 public agent、company 自己私有的 agent,或判断需要新建一个(走 3.2 的创建+治理链路)。 | ||
| 109 | +4. **回退边必须显式经过 loop-validator 节点**——即"节点A --fail--> loop-validator --> 节点B",而不是"节点A --fail(隐藏回退属性)--> 节点B"。这是画图时的**结构要求**,不是引擎运行时才附加的隐藏逻辑。 | ||
| 110 | +5. **执行前结构校验**:图规划完成后、进入执行前,引擎做一次静态校验——任何边如果目标节点在图中已存在其他指向它的边(即会构成"回到历史节点"),但没有经过 loop-validator 节点,直接拒绝这张图,不进入执行。类比现有 `npm run spec:check` 的"结构先校验,再允许跑"模式。这是对"planning agent 应该自觉画上 validator"这条规则的**引擎级兜底**,防止规划疏漏导致死循环绕过审计。 | ||
| 111 | + | ||
| 112 | +### 3.5 Loop-Validator:死循环兜底 | ||
| 113 | + | ||
| 114 | +- 本身是一个 system agent(可以是 public,因为"判断循环是否收敛"这个能力本质上与具体业务无关,值得跨公司共享进化)。 | ||
| 115 | +- 自带 skill,skill 内部包含**硬编码的计数器/熔断逻辑**,不是纯靠一次 LLM 推理决定"要不要停": | ||
| 116 | + 1. **备案**:任意节点即将进入回退循环前,先向 loop-validator 登记这次尝试。 | ||
| 117 | + 2. **审计**:目标节点重跑完成后,loop-validator 比对这次结果与上次失败原因,判断是否真的修复。 | ||
| 118 | + 3. **修复方案传递校验**:如果没修复,检查这次回退时是否把"应该如何修复"的方案正确带给了下一轮,而不是盲目重跑。 | ||
| 119 | + 4. **硬顶**:循环次数 > 3,无论 LLM 判断如何,skill 内的计数器强制终止,不进入第 4 次回退。 | ||
| 120 | +- **引擎级不变量**:任何回退边必须路由经过 loop-validator,这一点不能被 planning agent 在规划阶段跳过——由 3.4 第 5 点的执行前校验保证。 | ||
| 121 | + | ||
| 122 | +### 3.6 与执行链路的关系 | ||
| 123 | + | ||
| 124 | +节点执行本质上仍是现有 `AgentManager.callAgent()` 的调用,只是 `getAgentConfig()` 的输入从"`companyId:roleName` 路径拼接"变成"`(system_agent_id, companyId)` 的引用查表",`PromptBuilder.build()` 在装配 prompt 时按 3.2 的隔离规则拼装公共定义 + 私有语境。**执行引擎(session-pool、runtime-resolver、stream-executor)不需要改**,改的是"config 从哪来"这一层。 | ||
| 125 | + | ||
| 126 | +--- | ||
| 127 | + | ||
| 128 | +## 四、数据模型(Schema 草稿) | ||
| 129 | + | ||
| 130 | +DB 只存**关系与状态指针**,节点/边的具体内容(任务描述、输出、日志)始终是 Markdown 文件,按 3.2 的目录约定物理落在对应 system agent 目录下(**不在 DB 里存文件路径字符串,路径由 `(system_agent_id, company_id, work_graph_id, node_id, attempt)` 确定性推导**,避免路径漂移)。 | ||
| 131 | + | ||
| 132 | +```sql | ||
| 133 | +-- 全局 system agent 注册表 | ||
| 134 | +CREATE TABLE system_agents ( | ||
| 135 | + id TEXT PRIMARY KEY, | ||
| 136 | + name TEXT NOT NULL, | ||
| 137 | + scope TEXT NOT NULL CHECK (scope IN ('public', 'company')), | ||
| 138 | + owner_company_id TEXT, -- scope='company' 时必填 | ||
| 139 | + status TEXT NOT NULL DEFAULT 'active', -- active | training | deprecated | ||
| 140 | + benchmark_score REAL, | ||
| 141 | + created_at INTEGER NOT NULL, | ||
| 142 | + updated_at INTEGER NOT NULL | ||
| 143 | +); | ||
| 144 | + | ||
| 145 | +-- 公司静态协作结构 | ||
| 146 | +CREATE TABLE org_graph_nodes ( | ||
| 147 | + id TEXT PRIMARY KEY, | ||
| 148 | + company_id TEXT NOT NULL, | ||
| 149 | + system_agent_id TEXT NOT NULL REFERENCES system_agents(id), | ||
| 150 | + role_name TEXT NOT NULL, | ||
| 151 | + created_at INTEGER NOT NULL | ||
| 152 | +); | ||
| 153 | + | ||
| 154 | +CREATE TABLE org_graph_edges ( | ||
| 155 | + id TEXT PRIMARY KEY, | ||
| 156 | + company_id TEXT NOT NULL, | ||
| 157 | + from_node_id TEXT NOT NULL REFERENCES org_graph_nodes(id), | ||
| 158 | + to_node_id TEXT NOT NULL REFERENCES org_graph_nodes(id), | ||
| 159 | + relation_type TEXT NOT NULL -- reports_to | collaborates_with | ||
| 160 | +); | ||
| 161 | + | ||
| 162 | +-- 单次任务的动态执行图 | ||
| 163 | +CREATE TABLE work_graphs ( | ||
| 164 | + id TEXT PRIMARY KEY, | ||
| 165 | + company_id TEXT NOT NULL, | ||
| 166 | + triggered_by_org_node_id TEXT REFERENCES org_graph_nodes(id), | ||
| 167 | + goal_task_id TEXT, | ||
| 168 | + status TEXT NOT NULL DEFAULT 'planning', -- planning | running | passed | failed | blocked | ||
| 169 | + created_at INTEGER NOT NULL, | ||
| 170 | + updated_at INTEGER NOT NULL | ||
| 171 | +); | ||
| 172 | + | ||
| 173 | +CREATE TABLE work_graph_nodes ( | ||
| 174 | + id TEXT PRIMARY KEY, | ||
| 175 | + work_graph_id TEXT NOT NULL REFERENCES work_graphs(id), | ||
| 176 | + system_agent_id TEXT NOT NULL REFERENCES system_agents(id), | ||
| 177 | + node_kind TEXT NOT NULL DEFAULT 'task', -- task | loop_validator | ||
| 178 | + status TEXT NOT NULL DEFAULT 'pending', -- pending | running | success | fail | blocked | ||
| 179 | + attempt INTEGER NOT NULL DEFAULT 1 | ||
| 180 | +); | ||
| 181 | + | ||
| 182 | +CREATE TABLE work_graph_edges ( | ||
| 183 | + id TEXT PRIMARY KEY, | ||
| 184 | + work_graph_id TEXT NOT NULL REFERENCES work_graphs(id), | ||
| 185 | + from_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id), | ||
| 186 | + to_node_id TEXT NOT NULL REFERENCES work_graph_nodes(id), | ||
| 187 | + condition TEXT NOT NULL CHECK (condition IN ('success', 'fail')), | ||
| 188 | + edge_kind TEXT NOT NULL DEFAULT 'forward' -- forward | backward(backward 必须 to_node 是 loop_validator 或来自 loop_validator) | ||
| 189 | +); | ||
| 190 | +``` | ||
| 191 | + | ||
| 192 | +`edge_kind='backward'` 由 planning agent 在画图时显式标注;执行前校验(3.4 第 5 点)会独立复核——不信任标注本身,而是遍历图结构确认任何指向"已在图中更早出现过"的节点的边,必须满足"经过 `node_kind='loop_validator'`"这一条件,双重保险。 | ||
| 193 | + | ||
| 194 | +--- | ||
| 195 | + | ||
| 196 | +## 五、与现有系统的关系 / 迁移策略 | ||
| 197 | + | ||
| 198 | +| 现有组件 | 变化 | | ||
| 199 | +|----------|------| | ||
| 200 | +| `server/services/company-service.js` 的 `hire()` / `_copyTemplate()` | 废弃文件拷贝,替换为"创建/复用 system agent + 在 org_graph 加引用节点" | | ||
| 201 | +| `system/templates/` | 逐步废弃,内容并入 `system_agents`(`scope=public`),`_registry.json` 被 DB 表取代 | | ||
| 202 | +| `AgentManager.getAgentConfig()` | 输入从路径拼接改为 `(system_agent_id, company_id)` 查表 + 引用 3.2 目录约定 | | ||
| 203 | +| `PromptBuilder.build()` | 增加"公共定义 + 私有语境"两段拼装逻辑 | | ||
| 204 | +| `hireWithTraining()` / `agent-creator` / `innovator` / `depositTemplate()` | 保留,只改落点(写 DB scope 字段而非写模板目录) | | ||
| 205 | +| DevLoop `loop-controller.js` 的 `runUnit` | 可视为 Work Graph 的一个特例(固定两节点、单方向重试)。本次不强制收编,留作后续 Phase 6 的可选项 | | ||
| 206 | + | ||
| 207 | +--- | ||
| 208 | + | ||
| 209 | +## 六、开发计划(Phased Roadmap) | ||
| 210 | + | ||
| 211 | +| Phase | 目标 | 交付物 | 验收标准 | | ||
| 212 | +|-------|------|--------|----------| | ||
| 213 | +| **P0 地基** ✅ | 建表 + 现有数据迁移,不改变任何现有执行路径 | `system_agents`/`org_graph_*` 表;把现有 `system/agents/` + `system/templates/` 目录扫描导入为种子数据(`system_agents` 行,`scope` 按当前所属推断) | 迁移脚本跑完,`system_agents` 表行数与现有 agent+模板目录数一致(**实际行数与本文档最初估计不同,见第七节**);现有 `company-service.js` 行为不变(并行只读验证,已确认零改动) | | ||
| 214 | +| **P1 虚拟引用** | 停止文件拷贝,改为引用 | 改造 `AgentManager.getAgentConfig()` + `PromptBuilder.build()` 支持 `(agentId, companyId)` 寻址;`company-service.hire()` 改为创建 org_graph 引用节点而非 `_copyTemplate()`。细化需求与设计见 [27-P1 虚拟引用寻址](./27-p1-virtual-reference.md) | 新雇佣的 agent 不再产生文件拷贝,调用时能正确拼装公共+私有上下文;旧公司(仍是拷贝模式)继续可跑,不强制迁移存量数据 | | ||
| 215 | +| **P2 Org Graph** | `company.json.agents[]` 迁移为图结构 | `org_graph_nodes`/`edges` 落地;HR/CEO 招聘走新表 | 现有 Company Dashboard 前端能正确读出 org graph 并渲染(哪怕暂时按列表展示) | | ||
| 216 | +| **P3 Work Graph 最小闭环** | 单向执行,先不做回退 | work-graph-planning agent(system agent,新建);`work_graphs`/`nodes`/`edges` 表;顺序执行器(success → next,fail → 停止/标记 blocked,暂不回退) | 一个 goal 能被规划成 2-3 节点的图并顺序跑完,状态正确落库 | | ||
| 217 | +| **P4 回退 + Loop-Validator** | 引入死循环防护 | loop-validator system agent + 内置熔断 skill;`edge_kind=backward` 支持;3.4 第 5 点的执行前结构校验 | 人为构造一个"总是失败"的节点,验证第 4 次回退被 loop-validator 强制终止,且校验能拒绝"回退边未经过 validator"的非法图 | | ||
| 218 | +| **P5 自我进化** | 公共记忆蒸馏机制 | 公共 `memory.md` 写入走自省校验 + git 版本化;company-scoped → public 的升级门槛复用现有 benchmark ≥95 链路 | 同一 public agent 被两家公司调用后,`memory.md` 有可观察的增量更新,且能 git revert 到升级前版本 | | ||
| 219 | +| **P6(可选)DevLoop 收编** | 统一循环逻辑 | 把 `loop-controller.js`/`unit-registry.js` 重构为 Work Graph 引擎上的一个具体图模板 | DevLoop 现有测试套件(`server/tests/devloop/**`)在新引擎上全部通过,无需单独维护两套循环代码 | | ||
| 220 | + | ||
| 221 | +**依赖顺序是强顺序的**:P1 依赖 P0 的数据先迁移进 DB;P3 的执行器依赖 P1 的引用寻址跑通;P4 的回退依赖 P3 的顺序执行先稳定。P5、P6 相对独立,可以在 P4 稳定后并行推进或往后延。 | ||
| 222 | + | ||
| 223 | +每个 Phase 建议保留旧路径作为 fallback(不删除 `_copyTemplate()`/`system/templates/` 扫描逻辑),直到下一 Phase 验收通过,避免中途出问题时无法回退。 | ||
| 224 | + | ||
| 225 | +--- | ||
| 226 | + | ||
| 227 | +## 七、P0 实施发现(2026-08-15) | ||
| 228 | + | ||
| 229 | +P0 已实现(`server/services/graph-db.js` + `server/services/graph-seed-service.js` + `scripts/graph-seed.js`,`npm run graph:seed` 可跑),经 code review 通过。实施过程中对真实仓库数据做了独立核实,几个数字和本文档最初的估计(来自第一轮调研)不一致,记录如下,供 P1/P2 设计时参考: | ||
| 230 | + | ||
| 231 | +| 项 | 本文档最初估计 | 真实数据 | | ||
| 232 | +|---|---|---| | ||
| 233 | +| `system/agents/` 里真正的 agent(含 `agent.md`) | 10 | **8** —— `screenwriter/`、`youtube-crawler/` 两个目录没有 `agent.md`,不满足"是一个可执行 agent"的最低条件,被迁移脚本正确排除 | | ||
| 234 | +| `system/templates/` 目录数 | 34 | **39** | | ||
| 235 | +| 最终落库的 `source_kind='template'` 行数 | 34 | **33**(39 减去下面的命名空间重叠问题里被 `global_agent` 占用的 6 个) | | ||
| 236 | +| `_registry.json` 里 `benchmark_score` 非空的条目 | 9 | **3**(`ceo`=97/`qa-judge`=95/`base`=90)——另外 6 个 `devloop-*` 虽然在 registry 里能匹配上 id,但 registry 原始数据里 `benchmarkScore` 本来就是 `null`,不是脚本漏读 | | ||
| 237 | +| `idearun` 公司 6 个角色里"公司专属、无对应模板"的数量 | 4 | **0** —— 6 个角色现在全部能在 `system/templates/*` 里找到同名目录,全部复用已有行,没有触发新建 `scope='company'` 记录 | | ||
| 238 | + | ||
| 239 | +### 关键发现:`system/agents/` 与 `system/templates/` 命名空间不是互斥的 | ||
| 240 | + | ||
| 241 | +有 6 个 id(`agent-creator`/`digester`/`evolver`/`evolver-youtube-crawler`/`hunter`/`innovator`)**在两个目录下都存在**,描述的是概念上"同一个" agent,但物理上是两份独立的文件(内容很可能已经不同步)。这一点在最初两轮只读调研(用于写这份文档正文)里没有被发现,是 P0 实际扫描真实目录时才暴露出来的。 | ||
| 242 | + | ||
| 243 | +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`。 | ||
| 244 | + | ||
| 245 | +### 关联文档 | ||
| 246 | + | ||
| 247 | +P1(虚拟引用寻址)的详细需求与设计已有独立草稿:[27-P1 虚拟引用寻址](./27-p1-virtual-reference.md)。 |
package.json
| @@ -31,6 +31,7 @@ | @@ -31,6 +31,7 @@ | ||
| 31 | "analyze": "node server/src/utils/analyze-log.js", | 31 | "analyze": "node server/src/utils/analyze-log.js", |
| 32 | "parse": "node server/src/utils/parse-output.js", | 32 | "parse": "node server/src/utils/parse-output.js", |
| 33 | "skills:sync": "node scripts/sync-skills.js", | 33 | "skills:sync": "node scripts/sync-skills.js", |
| 34 | + "graph:seed": "node scripts/graph-seed.js", | ||
| 34 | "spec:extract": "node scripts/extract-spec.js", | 35 | "spec:extract": "node scripts/extract-spec.js", |
| 35 | "spec:check": "node scripts/extract-spec.js --check", | 36 | "spec:check": "node scripts/extract-spec.js --check", |
| 36 | "whatsapp:auth": "node skills/whatsapp-bot/auth.js", | 37 | "whatsapp:auth": "node skills/whatsapp-bot/auth.js", |
scripts/graph-seed.js
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | + | ||
| 3 | +/** | ||
| 4 | + * graph-seed.js — seeds data/graph.sqlite from the on-disk agent/template/company sources. | ||
| 5 | + * | ||
| 6 | + * 依次跑 seedGlobalAgents()(T1)→ seedTemplates()(T2)→ seedCompanyAgents()(T3) | ||
| 7 | + * → verifyCounts()(T4,独立重新扫描目录/文件做行数交叉核对)。 | ||
| 8 | + * 打印按 source_kind 分组的汇总报告;核对不一致时以非零状态码退出。 | ||
| 9 | + * | ||
| 10 | + * 用法: | ||
| 11 | + * node scripts/graph-seed.js | ||
| 12 | + */ | ||
| 13 | + | ||
| 14 | +const { seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts } = require('../server/services/graph-seed-service'); | ||
| 15 | + | ||
| 16 | +function main() { | ||
| 17 | + console.log('[graph-seed] 开始种子导入\n'); | ||
| 18 | + | ||
| 19 | + const globalAgents = seedGlobalAgents(); | ||
| 20 | + console.log(`[global_agent] 扫描 system/agents/* 目录: ${globalAgents.scanned} 个,含 agent.md 且已导入: ${globalAgents.seeded} 个`); | ||
| 21 | + console.log(` ids: ${globalAgents.ids.join(', ')}`); | ||
| 22 | + | ||
| 23 | + const templates = seedTemplates(); | ||
| 24 | + console.log(`\n[template] 扫描 system/templates/* 目录: ${templates.scanned} 个,已导入: ${templates.seeded} 个`); | ||
| 25 | + console.log(` registry 匹配到 benchmark_score 的 ids (${templates.registryMatched.length}): ${templates.registryMatched.join(', ')}`); | ||
| 26 | + if (templates.skipped.length > 0) { | ||
| 27 | + console.log(` ⚠️ id 与其他 source_kind 冲突,已跳过写入 (${templates.skipped.length}): ${templates.skipped.map(s => `${s.id}(已属于 ${s.existingSourceKind})`).join(', ')}`); | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + const companyAgents = seedCompanyAgents(); | ||
| 31 | + console.log(`\n[company_hired] 扫描到 ${companyAgents.companies.length} 家有 company.json 的公司: ${companyAgents.companies.join(', ')}`); | ||
| 32 | + console.log(` org_graph_nodes 本次新建: ${companyAgents.nodesCreated} 行,已存在跳过: ${companyAgents.nodesExisting} 行`); | ||
| 33 | + console.log(` 引用已有 system_agents 行 (${companyAgents.reusedSystemAgents.length}): ${companyAgents.reusedSystemAgents.join(', ')}`); | ||
| 34 | + console.log(` 新建 scope='company' 行 (${companyAgents.newSystemAgents.length}): ${companyAgents.newSystemAgents.join(', ')}`); | ||
| 35 | + | ||
| 36 | + console.log('\n[verify] 行数对账(独立重新扫描目录/文件,不复用上面的计数器)'); | ||
| 37 | + const v = verifyCounts(); | ||
| 38 | + console.log(` system_agents[global_agent]: 期望 ${v.expected.global_agent},实际 ${v.actual.global_agent}`); | ||
| 39 | + console.log(` system_agents[template]: 期望 ${v.expected.template},实际 ${v.actual.template}`); | ||
| 40 | + console.log(` system_agents[company_hired]:期望 ${v.expected.company_hired},实际 ${v.actual.company_hired}`); | ||
| 41 | + const companyIds = Object.keys({ ...v.expected.nodesPerCompany, ...v.actual.nodesPerCompany }); | ||
| 42 | + for (const companyId of companyIds) { | ||
| 43 | + const expected = v.expected.nodesPerCompany[companyId] || 0; | ||
| 44 | + const actual = v.actual.nodesPerCompany[companyId] || 0; | ||
| 45 | + console.log(` org_graph_nodes[${companyId}]: 期望 ${expected},实际 ${actual}`); | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + if (!v.ok) { | ||
| 49 | + console.error('\n❌ 行数对账失败,发现以下不一致:'); | ||
| 50 | + for (const m of v.mismatches) { | ||
| 51 | + console.error(` - ${m.check}: 期望 ${m.expected},实际 ${m.actual}`); | ||
| 52 | + } | ||
| 53 | + process.exitCode = 1; | ||
| 54 | + return; | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + console.log('\n✅ 种子导入完成,行数对账通过'); | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +main(); |
server/services/graph-db.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Graph Database — SQLite for the Graph Agent architecture (P0) | ||
| 3 | + * | ||
| 4 | + * 表结构: | ||
| 5 | + * system_agents 全局 / 模板 / 公司专属 agent 元数据(TEXT PRIMARY KEY = 目录名) | ||
| 6 | + * org_graph_nodes 公司组织图节点(company 对某个 system_agent 的引用 + 角色名) | ||
| 7 | + * org_graph_edges 公司组织图边(节点间的关系) | ||
| 8 | + * | ||
| 9 | + * P0 阶段:只加不改,不涉及任何现有执行路径。 | ||
| 10 | + */ | ||
| 11 | + | ||
| 12 | +const Database = require('better-sqlite3'); | ||
| 13 | +const path = require('path'); | ||
| 14 | +const fs = require('fs'); | ||
| 15 | + | ||
| 16 | +const DATA_DIR = path.resolve(__dirname, '../../data'); | ||
| 17 | +const DB_PATH = process.env.GRAPH_DB_PATH || path.join(DATA_DIR, 'graph.sqlite'); | ||
| 18 | + | ||
| 19 | +let db = null; | ||
| 20 | + | ||
| 21 | +function getDb() { | ||
| 22 | + if (db) return db; | ||
| 23 | + | ||
| 24 | + if (!fs.existsSync(DATA_DIR)) { | ||
| 25 | + fs.mkdirSync(DATA_DIR, { recursive: true }); | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + db = new Database(DB_PATH); | ||
| 29 | + db.pragma('journal_mode = WAL'); | ||
| 30 | + db.pragma('foreign_keys = ON'); | ||
| 31 | + | ||
| 32 | + db.exec(` | ||
| 33 | + CREATE TABLE IF NOT EXISTS system_agents ( | ||
| 34 | + id TEXT PRIMARY KEY, | ||
| 35 | + name TEXT NOT NULL, | ||
| 36 | + scope TEXT NOT NULL CHECK (scope IN ('public','company')), | ||
| 37 | + owner_company_id TEXT, | ||
| 38 | + source_kind TEXT NOT NULL CHECK (source_kind IN ('global_agent','template','company_hired')), | ||
| 39 | + legacy_path TEXT NOT NULL, | ||
| 40 | + status TEXT NOT NULL DEFAULT 'active', | ||
| 41 | + benchmark_score REAL, | ||
| 42 | + created_at TEXT NOT NULL, | ||
| 43 | + updated_at TEXT NOT NULL | ||
| 44 | + ); | ||
| 45 | + | ||
| 46 | + CREATE TABLE IF NOT EXISTS org_graph_nodes ( | ||
| 47 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 48 | + company_id TEXT NOT NULL, | ||
| 49 | + system_agent_id TEXT NOT NULL REFERENCES system_agents(id), | ||
| 50 | + role_name TEXT NOT NULL, | ||
| 51 | + created_at TEXT NOT NULL | ||
| 52 | + ); | ||
| 53 | + | ||
| 54 | + CREATE TABLE IF NOT EXISTS org_graph_edges ( | ||
| 55 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 56 | + company_id TEXT NOT NULL, | ||
| 57 | + from_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id), | ||
| 58 | + to_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id), | ||
| 59 | + relation_type TEXT NOT NULL | ||
| 60 | + ); | ||
| 61 | + `); | ||
| 62 | + | ||
| 63 | + return db; | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +module.exports = { | ||
| 67 | + getDb, | ||
| 68 | +}; |
server/services/graph-seed-service.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Graph Seed Service — populates `data/graph.sqlite` from existing on-disk | ||
| 3 | + * agent/template/company sources (P0, read-only w.r.t. the legacy sources). | ||
| 4 | + * | ||
| 5 | + * seedGlobalAgents() — system/agents/* → system_agents (source_kind='global_agent') | ||
| 6 | + * seedTemplates() — system/templates/* → system_agents (source_kind='template') | ||
| 7 | + * seedCompanyAgents() — data/companies/{id}/company.json.agents[] → system_agents (source_kind='company_hired') + org_graph_nodes | ||
| 8 | + * (T4 adds verifyCounts() here later.) | ||
| 9 | + * | ||
| 10 | + * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。 | ||
| 11 | + */ | ||
| 12 | + | ||
| 13 | +const fs = require('fs'); | ||
| 14 | +const path = require('path'); | ||
| 15 | +const { getDb } = require('./graph-db'); | ||
| 16 | + | ||
| 17 | +const ROOT = path.resolve(__dirname, '../..'); | ||
| 18 | +const AGENTS_DIR = path.join(ROOT, 'system', 'agents'); | ||
| 19 | +const TEMPLATES_DIR = path.join(ROOT, 'system', 'templates'); | ||
| 20 | +const TEMPLATES_REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); | ||
| 21 | +const COMPANIES_DIR = path.join(ROOT, 'data', 'companies'); | ||
| 22 | + | ||
| 23 | +/** | ||
| 24 | + * 扫描 system/agents/*,过滤掉没有 agent.md 的目录(排除 templates/ 等杂物), | ||
| 25 | + * 逐个 upsert 到 system_agents,scope='public',source_kind='global_agent'。 | ||
| 26 | + * | ||
| 27 | + * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。 | ||
| 28 | + * @returns {{ scanned: number, seeded: number, ids: string[] }} | ||
| 29 | + */ | ||
| 30 | +function seedGlobalAgents() { | ||
| 31 | + const db = getDb(); | ||
| 32 | + | ||
| 33 | + const entries = fs.existsSync(AGENTS_DIR) | ||
| 34 | + ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) | ||
| 35 | + : []; | ||
| 36 | + | ||
| 37 | + const agentDirs = entries.filter(d => | ||
| 38 | + fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md')) | ||
| 39 | + ); | ||
| 40 | + | ||
| 41 | + const now = new Date().toISOString(); | ||
| 42 | + | ||
| 43 | + const upsert = db.prepare(` | ||
| 44 | + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) | ||
| 45 | + VALUES (@id, @name, 'public', NULL, 'global_agent', @legacy_path, @status, NULL, @created_at, @updated_at) | ||
| 46 | + ON CONFLICT(id) DO UPDATE SET | ||
| 47 | + name = excluded.name, | ||
| 48 | + legacy_path = excluded.legacy_path, | ||
| 49 | + status = excluded.status, | ||
| 50 | + updated_at = excluded.updated_at | ||
| 51 | + `); | ||
| 52 | + | ||
| 53 | + const ids = []; | ||
| 54 | + const seedOne = db.transaction((dirEntries) => { | ||
| 55 | + for (const entry of dirEntries) { | ||
| 56 | + const id = entry.name; | ||
| 57 | + const dir = path.join(AGENTS_DIR, id); | ||
| 58 | + const configPath = path.join(dir, 'config.json'); | ||
| 59 | + | ||
| 60 | + let config = null; | ||
| 61 | + if (fs.existsSync(configPath)) { | ||
| 62 | + try { | ||
| 63 | + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); | ||
| 64 | + } catch { | ||
| 65 | + config = null; // malformed config.json → fall back to defaults | ||
| 66 | + } | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + const name = (config && config.name) || id; | ||
| 70 | + const status = (config && config.status) || 'active'; | ||
| 71 | + | ||
| 72 | + upsert.run({ | ||
| 73 | + id, | ||
| 74 | + name, | ||
| 75 | + legacy_path: `system/agents/${id}`, | ||
| 76 | + status, | ||
| 77 | + created_at: now, | ||
| 78 | + updated_at: now, | ||
| 79 | + }); | ||
| 80 | + ids.push(id); | ||
| 81 | + } | ||
| 82 | + }); | ||
| 83 | + | ||
| 84 | + seedOne(agentDirs); | ||
| 85 | + | ||
| 86 | + return { scanned: entries.length, seeded: ids.length, ids }; | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/** | ||
| 90 | + * 扫描 system/templates/*(目录扫描才是数据源头),逐个 upsert 到 system_agents, | ||
| 91 | + * scope='public', source_kind='template'。 | ||
| 92 | + * | ||
| 93 | + * `_registry.json` 只用来补充可选的 benchmark_score:按 id 匹配上的条目用其 | ||
| 94 | + * `benchmarkScore` 字段(registry 里该字段本身可能是 null),匹配不上的一律留 NULL。 | ||
| 95 | + * 容忍模板目录完全没有 config.json(如 ceo/ 只有 plan.md+todo.md): | ||
| 96 | + * 缺失时 name 默认目录名、status 默认 'active'。 | ||
| 97 | + * | ||
| 98 | + * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。 | ||
| 99 | + * | ||
| 100 | + * id 命名空间跨源共享(`system/agents/` 与 `system/templates/` 有同名目录,如 | ||
| 101 | + * `evolver`/`hunter`/`agent-creator`):当扫到的模板 id 已经被别的 source_kind | ||
| 102 | + * 占用时(例如 T1 seedGlobalAgents 先写入的 global_agent 行),**跳过写入**, | ||
| 103 | + * 不覆盖那一行——否则会把 global_agent 行的 legacy_path/name/status 静默篡改成 | ||
| 104 | + * 模板目录的数据,同一行却仍标着 source_kind='global_agent',数据会被污染。 | ||
| 105 | + * 只有当已存在的行本身就是 source_kind='template' 时才走 UPDATE(保证本函数自身可重复执行)。 | ||
| 106 | + * | ||
| 107 | + * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], skipped: {id: string, existingSourceKind: string}[] }} | ||
| 108 | + */ | ||
| 109 | +function seedTemplates() { | ||
| 110 | + const db = getDb(); | ||
| 111 | + | ||
| 112 | + const entries = fs.existsSync(TEMPLATES_DIR) | ||
| 113 | + ? fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) | ||
| 114 | + : []; | ||
| 115 | + | ||
| 116 | + let registryById = new Map(); | ||
| 117 | + if (fs.existsSync(TEMPLATES_REGISTRY_PATH)) { | ||
| 118 | + try { | ||
| 119 | + const registry = JSON.parse(fs.readFileSync(TEMPLATES_REGISTRY_PATH, 'utf-8')); | ||
| 120 | + if (Array.isArray(registry)) { | ||
| 121 | + registryById = new Map(registry.map(entry => [entry.id, entry])); | ||
| 122 | + } | ||
| 123 | + } catch { | ||
| 124 | + registryById = new Map(); // malformed _registry.json → no enrichment, still seed dirs | ||
| 125 | + } | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + const now = new Date().toISOString(); | ||
| 129 | + | ||
| 130 | + const findExisting = db.prepare(`SELECT source_kind FROM system_agents WHERE id = ?`); | ||
| 131 | + | ||
| 132 | + const upsert = db.prepare(` | ||
| 133 | + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) | ||
| 134 | + VALUES (@id, @name, 'public', NULL, 'template', @legacy_path, @status, @benchmark_score, @created_at, @updated_at) | ||
| 135 | + ON CONFLICT(id) DO UPDATE SET | ||
| 136 | + name = excluded.name, | ||
| 137 | + legacy_path = excluded.legacy_path, | ||
| 138 | + status = excluded.status, | ||
| 139 | + benchmark_score = excluded.benchmark_score, | ||
| 140 | + updated_at = excluded.updated_at | ||
| 141 | + `); | ||
| 142 | + | ||
| 143 | + const ids = []; | ||
| 144 | + const registryMatched = []; | ||
| 145 | + const skipped = []; | ||
| 146 | + const seedAll = db.transaction((dirEntries) => { | ||
| 147 | + for (const entry of dirEntries) { | ||
| 148 | + const id = entry.name; | ||
| 149 | + | ||
| 150 | + const existing = findExisting.get(id); | ||
| 151 | + if (existing && existing.source_kind !== 'template') { | ||
| 152 | + skipped.push({ id, existingSourceKind: existing.source_kind }); | ||
| 153 | + continue; // id already owned by another source (e.g. global_agent) — don't clobber it | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + const dir = path.join(TEMPLATES_DIR, id); | ||
| 157 | + const configPath = path.join(dir, 'config.json'); | ||
| 158 | + | ||
| 159 | + let config = null; | ||
| 160 | + if (fs.existsSync(configPath)) { | ||
| 161 | + try { | ||
| 162 | + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); | ||
| 163 | + } catch { | ||
| 164 | + config = null; // malformed config.json → fall back to defaults | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + const name = (config && config.name) || id; | ||
| 169 | + const status = (config && config.status) || 'active'; | ||
| 170 | + | ||
| 171 | + const registryEntry = registryById.get(id); | ||
| 172 | + const benchmark_score = registryEntry ? (registryEntry.benchmarkScore ?? null) : null; | ||
| 173 | + if (registryEntry) registryMatched.push(id); | ||
| 174 | + | ||
| 175 | + upsert.run({ | ||
| 176 | + id, | ||
| 177 | + name, | ||
| 178 | + legacy_path: `system/templates/${id}`, | ||
| 179 | + status, | ||
| 180 | + benchmark_score, | ||
| 181 | + created_at: now, | ||
| 182 | + updated_at: now, | ||
| 183 | + }); | ||
| 184 | + ids.push(id); | ||
| 185 | + } | ||
| 186 | + }); | ||
| 187 | + | ||
| 188 | + seedAll(entries); | ||
| 189 | + | ||
| 190 | + return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped }; | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +/** | ||
| 194 | + * 扫描 data/companies/{id}/company.json,对每个 company 的 `agents[]`(角色名字符串 | ||
| 195 | + * 扁平数组)逐个: | ||
| 196 | + * - 若 system_agents 里已存在同 id 的行(来自 T1/T2 的 global_agent/template)→ | ||
| 197 | + * 只建一条 org_graph_nodes 指过去。 | ||
| 198 | + * - 若不存在 → 先插入一条新的 system_agents 行(scope='company', | ||
| 199 | + * owner_company_id=companyId, source_kind='company_hired', | ||
| 200 | + * legacy_path='data/companies/{companyId}/agents/{name}'),再建 org_graph_nodes。 | ||
| 201 | + * | ||
| 202 | + * `org_graph_edges` 本阶段留空——company.json 目前没有汇报关系数据,是预期状态,不是 bug。 | ||
| 203 | + * | ||
| 204 | + * Idempotent: | ||
| 205 | + * - system_agents 用 INSERT ... ON CONFLICT(id) DO NOTHING(company_hired 行一旦建立 | ||
| 206 | + * 不应被后续扫描覆盖,行为上等同"首次发现即定型")。 | ||
| 207 | + * - org_graph_nodes 没有自然唯一键,靠先查后插(company_id + system_agent_id + role_name | ||
| 208 | + * 三元组)去重,避免重复跑脚本时行数上涨。 | ||
| 209 | + * | ||
| 210 | + * @returns {{ companies: string[], nodesCreated: number, nodesExisting: number, | ||
| 211 | + * newSystemAgents: string[], reusedSystemAgents: string[] }} | ||
| 212 | + */ | ||
| 213 | +function seedCompanyAgents() { | ||
| 214 | + const db = getDb(); | ||
| 215 | + | ||
| 216 | + const companyDirs = fs.existsSync(COMPANIES_DIR) | ||
| 217 | + ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) | ||
| 218 | + : []; | ||
| 219 | + | ||
| 220 | + const now = new Date().toISOString(); | ||
| 221 | + | ||
| 222 | + const findAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`); | ||
| 223 | + const insertAgent = db.prepare(` | ||
| 224 | + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) | ||
| 225 | + VALUES (@id, @name, 'company', @owner_company_id, 'company_hired', @legacy_path, 'active', NULL, @created_at, @updated_at) | ||
| 226 | + ON CONFLICT(id) DO NOTHING | ||
| 227 | + `); | ||
| 228 | + const findNode = db.prepare(` | ||
| 229 | + SELECT id FROM org_graph_nodes WHERE company_id = ? AND system_agent_id = ? AND role_name = ? | ||
| 230 | + `); | ||
| 231 | + const insertNode = db.prepare(` | ||
| 232 | + INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at) | ||
| 233 | + VALUES (@company_id, @system_agent_id, @role_name, @created_at) | ||
| 234 | + `); | ||
| 235 | + | ||
| 236 | + const companies = []; | ||
| 237 | + let nodesCreated = 0; | ||
| 238 | + let nodesExisting = 0; | ||
| 239 | + const newSystemAgents = []; | ||
| 240 | + const reusedSystemAgents = []; | ||
| 241 | + | ||
| 242 | + const seedAll = db.transaction((dirEntries) => { | ||
| 243 | + for (const entry of dirEntries) { | ||
| 244 | + const companyId = entry.name; | ||
| 245 | + const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json'); | ||
| 246 | + if (!fs.existsSync(companyJsonPath)) continue; // no company.json → not a real company yet | ||
| 247 | + | ||
| 248 | + let companyJson; | ||
| 249 | + try { | ||
| 250 | + companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); | ||
| 251 | + } catch { | ||
| 252 | + continue; // malformed company.json → skip, don't seed partial data | ||
| 253 | + } | ||
| 254 | + | ||
| 255 | + const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : []; | ||
| 256 | + companies.push(companyId); | ||
| 257 | + | ||
| 258 | + for (const roleName of roleNames) { | ||
| 259 | + const existing = findAgent.get(roleName); | ||
| 260 | + if (existing) { | ||
| 261 | + reusedSystemAgents.push(roleName); | ||
| 262 | + } else { | ||
| 263 | + insertAgent.run({ | ||
| 264 | + id: roleName, | ||
| 265 | + name: roleName, | ||
| 266 | + owner_company_id: companyId, | ||
| 267 | + legacy_path: `data/companies/${companyId}/agents/${roleName}`, | ||
| 268 | + created_at: now, | ||
| 269 | + updated_at: now, | ||
| 270 | + }); | ||
| 271 | + newSystemAgents.push(roleName); | ||
| 272 | + } | ||
| 273 | + | ||
| 274 | + // role_name 目前恒等于 system_agent_id:company.json.agents[] 现在只是纯角色名字符串数组, | ||
| 275 | + // 没有独立的"角色名 vs 引用哪个 agent"区分。P2 引入 org_graph 编辑能力后两者会分开。 | ||
| 276 | + const existingNode = findNode.get(companyId, roleName, roleName); | ||
| 277 | + if (existingNode) { | ||
| 278 | + nodesExisting += 1; | ||
| 279 | + } else { | ||
| 280 | + insertNode.run({ | ||
| 281 | + company_id: companyId, | ||
| 282 | + system_agent_id: roleName, | ||
| 283 | + role_name: roleName, | ||
| 284 | + created_at: now, | ||
| 285 | + }); | ||
| 286 | + nodesCreated += 1; | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + } | ||
| 290 | + }); | ||
| 291 | + | ||
| 292 | + seedAll(companyDirs); | ||
| 293 | + | ||
| 294 | + return { companies, nodesCreated, nodesExisting, newSystemAgents, reusedSystemAgents }; | ||
| 295 | +} | ||
| 296 | + | ||
| 297 | +/** | ||
| 298 | + * 独立行数交叉核对(T4)——不调用上面三个 seed 函数、不复用它们的返回计数器, | ||
| 299 | + * 而是从零重新扫描 system/agents/、system/templates/、data/companies/*\/company.json, | ||
| 300 | + * 按种子逻辑描述的同一套优先级规则(global_agent 优先于 template 优先于 company_hired) | ||
| 301 | + * 独立推导出"应该"落在 system_agents/org_graph_nodes 里的行数, | ||
| 302 | + * 再拿去和 DB 里 `SELECT source_kind, COUNT(*) ... GROUP BY source_kind` 以及 | ||
| 303 | + * `SELECT company_id, COUNT(*) FROM org_graph_nodes GROUP BY company_id` 的真实结果比对。 | ||
| 304 | + * | ||
| 305 | + * 这样一来,如果 seedGlobalAgents/seedTemplates/seedCompanyAgents 内部逻辑跑偏 | ||
| 306 | + * (例如 agent.md 过滤条件写错、冲突跳过逻辑失效、company 角色去重出错), | ||
| 307 | + * 这里独立算出来的期望值不会跟着一起错,能捕捉到。 | ||
| 308 | + * | ||
| 309 | + * @returns {{ ok: boolean, mismatches: Array<{check: string, expected: number, actual: number}>, | ||
| 310 | + * expected: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object<string, number> }, | ||
| 311 | + * actual: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object<string, number> } }} | ||
| 312 | + */ | ||
| 313 | +function verifyCounts() { | ||
| 314 | + const db = getDb(); | ||
| 315 | + | ||
| 316 | + // ── 独立重新扫描 system/agents/(含 agent.md 才算数,与 T1 判定规则一致,但独立实现)── | ||
| 317 | + const agentEntries = fs.existsSync(AGENTS_DIR) | ||
| 318 | + ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) | ||
| 319 | + : []; | ||
| 320 | + const globalIds = new Set( | ||
| 321 | + agentEntries | ||
| 322 | + .filter(d => fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md'))) | ||
| 323 | + .map(d => d.name) | ||
| 324 | + ); | ||
| 325 | + | ||
| 326 | + // ── 独立重新扫描 system/templates/ ── | ||
| 327 | + const templateEntries = fs.existsSync(TEMPLATES_DIR) | ||
| 328 | + ? fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) | ||
| 329 | + : []; | ||
| 330 | + const templateDirIds = templateEntries.map(d => d.name); | ||
| 331 | + // id 命名空间跨源共享:与 global_agent 同名的模板目录会被 seedTemplates 冲突保护跳过, | ||
| 332 | + // 不落地为 template 行——独立复算这条精确规则,而不是直接拿模板目录总数当期望值。 | ||
| 333 | + const expectedTemplateIds = new Set(templateDirIds.filter(id => !globalIds.has(id))); | ||
| 334 | + | ||
| 335 | + // ── 独立重新扫描 data/companies/*/company.json ── | ||
| 336 | + const companyDirs = fs.existsSync(COMPANIES_DIR) | ||
| 337 | + ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()) | ||
| 338 | + : []; | ||
| 339 | + | ||
| 340 | + const knownIds = new Set([...globalIds, ...expectedTemplateIds]); | ||
| 341 | + const expectedNewCompanyHiredIds = new Set(); | ||
| 342 | + const expectedNodesPerCompany = {}; | ||
| 343 | + | ||
| 344 | + for (const entry of companyDirs) { | ||
| 345 | + const companyId = entry.name; | ||
| 346 | + const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json'); | ||
| 347 | + if (!fs.existsSync(companyJsonPath)) continue; | ||
| 348 | + | ||
| 349 | + let companyJson; | ||
| 350 | + try { | ||
| 351 | + companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); | ||
| 352 | + } catch { | ||
| 353 | + continue; // 与 seedCompanyAgents 一致:损坏的 company.json 不参与核对 | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : []; | ||
| 357 | + const distinctRoles = new Set(roleNames); // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重 | ||
| 358 | + expectedNodesPerCompany[companyId] = distinctRoles.size; | ||
| 359 | + | ||
| 360 | + for (const roleName of distinctRoles) { | ||
| 361 | + if (!knownIds.has(roleName)) { | ||
| 362 | + expectedNewCompanyHiredIds.add(roleName); | ||
| 363 | + } | ||
| 364 | + } | ||
| 365 | + } | ||
| 366 | + | ||
| 367 | + // ── DB 里的真实计数 ── | ||
| 368 | + const bySourceKind = db.prepare(` | ||
| 369 | + SELECT source_kind, COUNT(*) AS cnt FROM system_agents GROUP BY source_kind | ||
| 370 | + `).all(); | ||
| 371 | + const actualCounts = { global_agent: 0, template: 0, company_hired: 0 }; | ||
| 372 | + for (const row of bySourceKind) { | ||
| 373 | + actualCounts[row.source_kind] = row.cnt; | ||
| 374 | + } | ||
| 375 | + | ||
| 376 | + const nodesByCompany = db.prepare(` | ||
| 377 | + SELECT company_id, COUNT(*) AS cnt FROM org_graph_nodes GROUP BY company_id | ||
| 378 | + `).all(); | ||
| 379 | + const actualNodesPerCompany = {}; | ||
| 380 | + for (const row of nodesByCompany) { | ||
| 381 | + actualNodesPerCompany[row.company_id] = row.cnt; | ||
| 382 | + } | ||
| 383 | + | ||
| 384 | + // ── 比对 ── | ||
| 385 | + const mismatches = []; | ||
| 386 | + | ||
| 387 | + if (actualCounts.global_agent !== globalIds.size) { | ||
| 388 | + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='global_agent'", expected: globalIds.size, actual: actualCounts.global_agent }); | ||
| 389 | + } | ||
| 390 | + if (actualCounts.template !== expectedTemplateIds.size) { | ||
| 391 | + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='template'", expected: expectedTemplateIds.size, actual: actualCounts.template }); | ||
| 392 | + } | ||
| 393 | + if (actualCounts.company_hired !== expectedNewCompanyHiredIds.size) { | ||
| 394 | + mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='company_hired'", expected: expectedNewCompanyHiredIds.size, actual: actualCounts.company_hired }); | ||
| 395 | + } | ||
| 396 | + | ||
| 397 | + const allCompanyIds = new Set([...Object.keys(expectedNodesPerCompany), ...Object.keys(actualNodesPerCompany)]); | ||
| 398 | + for (const companyId of allCompanyIds) { | ||
| 399 | + const expected = expectedNodesPerCompany[companyId] || 0; | ||
| 400 | + const actual = actualNodesPerCompany[companyId] || 0; | ||
| 401 | + if (expected !== actual) { | ||
| 402 | + mismatches.push({ check: `org_graph_nodes COUNT(*) WHERE company_id='${companyId}'`, expected, actual }); | ||
| 403 | + } | ||
| 404 | + } | ||
| 405 | + | ||
| 406 | + return { | ||
| 407 | + ok: mismatches.length === 0, | ||
| 408 | + mismatches, | ||
| 409 | + expected: { | ||
| 410 | + global_agent: globalIds.size, | ||
| 411 | + template: expectedTemplateIds.size, | ||
| 412 | + company_hired: expectedNewCompanyHiredIds.size, | ||
| 413 | + nodesPerCompany: expectedNodesPerCompany, | ||
| 414 | + }, | ||
| 415 | + actual: { | ||
| 416 | + global_agent: actualCounts.global_agent, | ||
| 417 | + template: actualCounts.template, | ||
| 418 | + company_hired: actualCounts.company_hired, | ||
| 419 | + nodesPerCompany: actualNodesPerCompany, | ||
| 420 | + }, | ||
| 421 | + }; | ||
| 422 | +} | ||
| 423 | + | ||
| 424 | +module.exports = { | ||
| 425 | + seedGlobalAgents, | ||
| 426 | + seedTemplates, | ||
| 427 | + seedCompanyAgents, | ||
| 428 | + verifyCounts, | ||
| 429 | +}; |
server/tests/unit/graph-db.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Unit Tests — graph-db.js | ||
| 3 | + * Asserts the three P0 tables exist and that GRAPH_DB_PATH env override | ||
| 4 | + * actually redirects the underlying sqlite file (regression guard against | ||
| 5 | + * the benchmark-db.js "declared but never read" bug). | ||
| 6 | + */ | ||
| 7 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | ||
| 8 | +import fs from 'fs'; | ||
| 9 | +import path from 'path'; | ||
| 10 | +import Database from 'better-sqlite3'; | ||
| 11 | + | ||
| 12 | +describe('graph-db', () => { | ||
| 13 | + let graphDb; | ||
| 14 | + const TEST_DB_PATH = '/tmp/test-graph-db.sqlite'; | ||
| 15 | + const DEFAULT_DB_PATH = path.resolve(__dirname, '../../../data/graph.sqlite'); | ||
| 16 | + | ||
| 17 | + beforeAll(() => { | ||
| 18 | + if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); | ||
| 19 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | ||
| 20 | + delete require.cache[require.resolve('../../services/graph-db')]; | ||
| 21 | + graphDb = require('../../services/graph-db'); | ||
| 22 | + }); | ||
| 23 | + | ||
| 24 | + afterAll(() => { | ||
| 25 | + try { fs.unlinkSync(TEST_DB_PATH); } catch {} | ||
| 26 | + try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {} | ||
| 27 | + try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {} | ||
| 28 | + delete process.env.GRAPH_DB_PATH; | ||
| 29 | + }); | ||
| 30 | + | ||
| 31 | + it('should create all three tables', () => { | ||
| 32 | + const d = graphDb.getDb(); | ||
| 33 | + const tables = d.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name); | ||
| 34 | + expect(tables).toContain('system_agents'); | ||
| 35 | + expect(tables).toContain('org_graph_nodes'); | ||
| 36 | + expect(tables).toContain('org_graph_edges'); | ||
| 37 | + }); | ||
| 38 | + | ||
| 39 | + it('should honor GRAPH_DB_PATH env override for reads/writes', () => { | ||
| 40 | + const d = graphDb.getDb(); | ||
| 41 | + const now = new Date().toISOString(); | ||
| 42 | + d.prepare(` | ||
| 43 | + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) | ||
| 44 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| 45 | + `).run('test-agent-env-check', 'Test Agent', 'public', null, 'global_agent', 'system/agents/test-agent-env-check', 'active', null, now, now); | ||
| 46 | + | ||
| 47 | + // The row must have landed in the overridden file, not the default one. | ||
| 48 | + expect(fs.existsSync(TEST_DB_PATH)).toBe(true); | ||
| 49 | + | ||
| 50 | + // Prove the override actually redirected the file: the default-path DB | ||
| 51 | + // (opened independently, never touched by graph-db in this test run) | ||
| 52 | + // must NOT contain the row we just wrote. | ||
| 53 | + if (fs.existsSync(DEFAULT_DB_PATH)) { | ||
| 54 | + const defaultDb = new Database(DEFAULT_DB_PATH, { readonly: true }); | ||
| 55 | + try { | ||
| 56 | + const row = defaultDb.prepare('SELECT 1 FROM system_agents WHERE id = ?').get('test-agent-env-check'); | ||
| 57 | + expect(row).toBeUndefined(); | ||
| 58 | + } finally { | ||
| 59 | + defaultDb.close(); | ||
| 60 | + } | ||
| 61 | + } else { | ||
| 62 | + // Default DB file was never created at all -- also proves the write | ||
| 63 | + // went to the overridden path instead of the default one. | ||
| 64 | + expect(fs.existsSync(DEFAULT_DB_PATH)).toBe(false); | ||
| 65 | + } | ||
| 66 | + }); | ||
| 67 | +}); |
server/tests/unit/graph-seed-service.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * Unit Tests — graph-seed-service.js verifyCounts() (T4) | ||
| 3 | + * Runs the real seed functions against the actual repo fs (system/agents/, | ||
| 4 | + * system/templates/, data/companies/) into a throwaway GRAPH_DB_PATH, then | ||
| 5 | + * asserts verifyCounts() reports ok:true against that freshly-seeded data, | ||
| 6 | + * and that it actually catches a mismatch when the DB drifts from the fs. | ||
| 7 | + */ | ||
| 8 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | ||
| 9 | +import fs from 'fs'; | ||
| 10 | + | ||
| 11 | +describe('graph-seed-service verifyCounts', () => { | ||
| 12 | + let seedService; | ||
| 13 | + let graphDb; | ||
| 14 | + const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite'; | ||
| 15 | + | ||
| 16 | + beforeAll(() => { | ||
| 17 | + if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); | ||
| 18 | + process.env.GRAPH_DB_PATH = TEST_DB_PATH; | ||
| 19 | + delete require.cache[require.resolve('../../services/graph-db')]; | ||
| 20 | + delete require.cache[require.resolve('../../services/graph-seed-service')]; | ||
| 21 | + graphDb = require('../../services/graph-db'); | ||
| 22 | + seedService = require('../../services/graph-seed-service'); | ||
| 23 | + }); | ||
| 24 | + | ||
| 25 | + afterAll(() => { | ||
| 26 | + try { fs.unlinkSync(TEST_DB_PATH); } catch {} | ||
| 27 | + try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {} | ||
| 28 | + try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {} | ||
| 29 | + delete process.env.GRAPH_DB_PATH; | ||
| 30 | + }); | ||
| 31 | + | ||
| 32 | + it('reports ok:true against a freshly-seeded DB (no drift)', () => { | ||
| 33 | + seedService.seedGlobalAgents(); | ||
| 34 | + seedService.seedTemplates(); | ||
| 35 | + seedService.seedCompanyAgents(); | ||
| 36 | + | ||
| 37 | + const v = seedService.verifyCounts(); | ||
| 38 | + expect(v.mismatches).toEqual([]); | ||
| 39 | + expect(v.ok).toBe(true); | ||
| 40 | + }); | ||
| 41 | + | ||
| 42 | + it('catches a mismatch when the DB has a row the filesystem does not back', () => { | ||
| 43 | + const db = graphDb.getDb(); | ||
| 44 | + const now = new Date().toISOString(); | ||
| 45 | + db.prepare(` | ||
| 46 | + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) | ||
| 47 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| 48 | + `).run('bogus-agent-no-fs-backing', 'Bogus', 'public', null, 'global_agent', 'system/agents/bogus-agent-no-fs-backing', 'active', null, now, now); | ||
| 49 | + | ||
| 50 | + const v = seedService.verifyCounts(); | ||
| 51 | + expect(v.ok).toBe(false); | ||
| 52 | + expect(v.mismatches.some(m => m.check.includes("source_kind='global_agent'"))).toBe(true); | ||
| 53 | + | ||
| 54 | + db.prepare('DELETE FROM system_agents WHERE id = ?').run('bogus-agent-no-fs-backing'); | ||
| 55 | + }); | ||
| 56 | +}); |
tasks/plan.md
0 → 100644
| 1 | +# P0 地基 — Graph Agent 架构任务拆分 | ||
| 2 | + | ||
| 3 | +> 对应 `docs/system-design/27-graph-agent-architecture.md` 的 Phase P0。目标:只加不改——新建三张表,把现有数据原样导入作为种子数据并校验行数,完全不动 `company-service.js` 现有的 hire/copy 执行路径。这是 P1(虚拟引用改造)能安全推进的前提:先有一份可信、经过校验的镜像数据在 DB 里,再去改代码。 | ||
| 4 | + | ||
| 5 | +## Context | ||
| 6 | + | ||
| 7 | +两轮只读调研确认了关键事实: | ||
| 8 | + | ||
| 9 | +- 现有三个 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,新代码要避免重蹈。 | ||
| 10 | +- `system/agents/` 有 11 个目录,其中 `system/agents/templates/` 是遗留杂物、不是真 agent,必须过滤掉(真实 agent 数 = 10)。 | ||
| 11 | +- `system/templates/` 有 34 个目录,但 `_registry.json`(数组)只覆盖 9 个(`ceo`/`qa-judge`/`base`/6 个 devloop-*)——目录扫描才是数据源头,registry 只用来补充可选的 benchmark 元数据,其余 25 个模板要容忍没有 `config.json`(如 `ceo/` 只有 `plan.md`+`todo.md`)。 | ||
| 12 | +- `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"这个概念在真实数据里的第一批样本。 | ||
| 13 | +- `company-service.js` 的 `hire`(189)/`scaffoldNewAgent`(241)/`depositTemplate`(294)/`hireWithTraining`(381)/`_copyTemplate`(621)/`_injectCompanyContext`(667) 六个函数行号已核实无漂移,P0 全程不动这个文件。 | ||
| 14 | + | ||
| 15 | +## 新增文件 | ||
| 16 | + | ||
| 17 | +- `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 覆盖,且要确保代码真的读了这个变量**。 | ||
| 18 | +- `server/services/graph-seed-service.js` —— 导出 `seedGlobalAgents()` / `seedTemplates()` / `seedCompanyAgents()` / `verifyCounts()`,供脚本和测试复用。 | ||
| 19 | +- `scripts/graph-seed.js` —— 薄 CLI 入口,依次调用上面四个函数并打印汇总报告,风格参照现有 `scripts/sync-skills.js`。 | ||
| 20 | +- `package.json` 加一条 `"graph:seed": "node scripts/graph-seed.js"`。 | ||
| 21 | +- `server/tests/unit/graph-db.test.js` —— 照抄 `skill-db.test.js` 的临时文件 + env 覆盖 + 清 require 缓存范式。 | ||
| 22 | + | ||
| 23 | +## Schema | ||
| 24 | + | ||
| 25 | +```sql | ||
| 26 | +CREATE TABLE IF NOT EXISTS system_agents ( | ||
| 27 | + id TEXT PRIMARY KEY, -- 就是目录名,如 'master'/'ceo'/'claude-youtube-skills' | ||
| 28 | + name TEXT NOT NULL, | ||
| 29 | + scope TEXT NOT NULL CHECK (scope IN ('public','company')), | ||
| 30 | + owner_company_id TEXT, | ||
| 31 | + source_kind TEXT NOT NULL CHECK (source_kind IN ('global_agent','template','company_hired')), | ||
| 32 | + legacy_path TEXT NOT NULL, -- 当前真实所在目录,如 'system/agents/master' | ||
| 33 | + status TEXT NOT NULL DEFAULT 'active', | ||
| 34 | + benchmark_score REAL, | ||
| 35 | + created_at TEXT NOT NULL, | ||
| 36 | + updated_at TEXT NOT NULL | ||
| 37 | +); | ||
| 38 | + | ||
| 39 | +CREATE TABLE IF NOT EXISTS org_graph_nodes ( | ||
| 40 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 41 | + company_id TEXT NOT NULL, | ||
| 42 | + system_agent_id TEXT NOT NULL REFERENCES system_agents(id), | ||
| 43 | + role_name TEXT NOT NULL, | ||
| 44 | + created_at TEXT NOT NULL | ||
| 45 | +); | ||
| 46 | + | ||
| 47 | +CREATE TABLE IF NOT EXISTS org_graph_edges ( | ||
| 48 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 49 | + company_id TEXT NOT NULL, | ||
| 50 | + from_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id), | ||
| 51 | + to_node_id INTEGER NOT NULL REFERENCES org_graph_nodes(id), | ||
| 52 | + relation_type TEXT NOT NULL | ||
| 53 | +); | ||
| 54 | +``` | ||
| 55 | + | ||
| 56 | +相对设计文档(27 号)草稿的两处调整: | ||
| 57 | + | ||
| 58 | +1. `system_agents.id` 用 **TEXT PRIMARY KEY = 目录名**,不用自增代理键——后续阶段要靠这个 id 反查文件系统路径,代理键只会多一层无意义的间接。 | ||
| 59 | +2. 新增 `source_kind` + `legacy_path` 两列——P0 明确不做目录搬迁(三种来源现在物理上分散在三个不同地方),没有这两列种子数据就"没有指针",没法在 P1 真正切换执行路径时定位到原文件。 | ||
| 60 | + | ||
| 61 | +`org_graph_nodes`/`org_graph_edges` 沿用仓库现有的自增整数主键惯例,暂不需要对外暴露的字符串 ID(要到 P2/P3 才会被外部引用)。 | ||
| 62 | + | ||
| 63 | +## 验证方式 | ||
| 64 | + | ||
| 65 | +1. `npm run test:unit` —— 新增的 `graph-db.test.js` 通过,现有测试无回归 | ||
| 66 | +2. `npm run graph:seed` —— 在真实仓库数据上跑,人工核对输出的行数汇总(10 / 34 / 6,见下方任务列表) | ||
| 67 | +3. `sqlite3 data/graph.sqlite ".schema"` —— 人工确认三张表结构与上面 SQL 一致 | ||
| 68 | +4. `git diff --stat` —— 确认这一阶段的改动只新增文件,没有改动 `company-service.js`/`agent-manager.js`/`prompt-builder.js` 任何一行 | ||
| 69 | + | ||
| 70 | +任务清单见 [todo.md](./todo.md)。 |
tasks/todo.md
0 → 100644
| 1 | +# P0 任务清单 | ||
| 2 | + | ||
| 3 | +依赖顺序:T0 阻塞全部 → T1/T2 → checkpoint → T3 → T4 → T5 | ||
| 4 | + | ||
| 5 | +- [x] **T0 — 建表 + DB 模块** | ||
| 6 | + - 产出:`server/services/graph-db.js` + `server/tests/unit/graph-db.test.js` | ||
| 7 | + - 验收:测试断言三张表通过 `sqlite_master` 查询存在;断言设置 `GRAPH_DB_PATH` 后确实写到了别的文件(回归防 benchmark-db.js 那种"声明了但没读"的 bug) | ||
| 8 | + | ||
| 9 | +- [x] **T1 — 导入 `system/agents/` → `system_agents`** | ||
| 10 | + - 扫描 `system/agents/*`,过滤掉没有 `agent.md` 的目录(排除 `templates/` 杂物),逐个插入 `scope='public', owner_company_id=NULL, source_kind='global_agent'` | ||
| 11 | + - 验收:`SELECT COUNT(*) WHERE source_kind='global_agent'` = 10;脚本可重复执行不产生重复行(`INSERT OR IGNORE`/upsert on id) | ||
| 12 | + | ||
| 13 | +- [x] **T2 — 导入 `system/templates/` → `system_agents`** | ||
| 14 | + - 扫描 34 个模板目录,用 `_registry.json` 按 id 匹配补充 `benchmark_score`(只有 9 个能匹配上,其余留 NULL),容忍缺失 `config.json` | ||
| 15 | + - 验收:`COUNT(*) WHERE source_kind='template'` = 34;抽查 9 个 registry 覆盖到的条目 `benchmark_score` 非空,其余 25 个为 NULL | ||
| 16 | + - **实际结果(见执行报告)**:真实目录是 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 | ||
| 17 | + | ||
| 18 | +- [ ] **checkpoint** — T0-T2 落地后手动跑一遍 `graph:seed`,人工看一眼种子数据是否符合预期,再继续 T3 | ||
| 19 | + | ||
| 20 | +- [x] **T3 — 导入 `data/companies/*/company.json.agents[]` → `system_agents` + `org_graph_nodes`** | ||
| 21 | + - 已有同名 `system_agents` 行(来自 T1/T2)→ 只建 `org_graph_nodes` 指过去;没有 → 新建 `scope='company', owner_company_id=<companyId>, source_kind='company_hired'` 行,再建 `org_graph_nodes`。`org_graph_edges` 本阶段留空(`company.json` 目前没有汇报关系数据,是预期状态) | ||
| 22 | + - 验收:对真实的 `idearun`,`org_graph_nodes` 恰好 6 行;其中 2 行引用已存在的行(`youtube-crawler`/`evolver-youtube-crawler`),4 行触发新建 `scope='company'` 记录 | ||
| 23 | + - **实际结果(见执行报告)**:`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。 | ||
| 24 | + | ||
| 25 | +- [x] **T4 — 行数对账 + npm 脚本接线** | ||
| 26 | + - `npm run graph:seed` 串联 T1→T2→T3;跑完打印按 `source_kind` 分组的行数汇总,独立于种子脚本内部计数器、重新扫描一次目录/文件做交叉核对 | ||
| 27 | + - 验收:连续跑两次 `npm run graph:seed` 行数不变(幂等);汇总报告三类 `source_kind` 数字都对得上 T1-T3 的真实数字 | ||
| 28 | + - **实际结果**:`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,验证了对账/退出码逻辑本身有效,不是摆设。 | ||
| 29 | + | ||
| 30 | +- [x] **T5 — 确认零行为变更** | ||
| 31 | + - 非编码任务:跑 `npm run test:unit` 全绿;`git diff --stat server/services/company-service.js` 应该没有任何改动 | ||
| 32 | + - 对应设计文档里 P0 的验收标准"现有 company-service.js 行为不变" | ||
| 33 | + - **实际结果**:`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 阶段这三个文件零改动(含已提交和未提交两种情况)。 |