diff --git a/docs/todo/system-design/27-p1-virtual-reference.md b/docs/todo/system-design/27-p1-virtual-reference.md new file mode 100644 index 0000000..ba70675 --- /dev/null +++ b/docs/todo/system-design/27-p1-virtual-reference.md @@ -0,0 +1,257 @@ +# 27-P1. 虚拟引用寻址 — 需求与设计文档 + +> **所属**: [27 Graph Agent 架构](./27-graph-agent-architecture.md) 的 Phase P1("虚拟引用") +> **版本**: v0.2(设计阶段,未实现;因 P0 完工同步更新) +> **日期**: 2026-08-15 +> **前置**: P0 地基已**全部完工**并提交(commit `8421f55`)——`seedGlobalAgents()`/`seedTemplates()`/`seedCompanyAgents()`/`verifyCounts()` 四步全部实现,`system_agents.legacy_path` 可用,`org_graph_nodes` 已经把**现有全部公司雇佣关系**回填完毕(不再是空表)。P0 实施过程中发现一个直接影响 P1 设计的问题:**`system/agents/` 与 `system/templates/` 存在 6 个同名但内容可能不同步的 id**,详见 [27 号文档 §7](./27-graph-agent-architecture.md#七p0-实施发现2026-08-15) 与下文 §4.6——本版本已针对这个问题修正了查表逻辑。 + +--- + +## 一、目标与非目标 + +### 目标(本阶段要交付什么) + +按 27 号文档 P1 行的原始定义: + +> 停止文件拷贝,改为引用。改造 `AgentManager.getAgentConfig()` + `PromptBuilder.build()` 支持 `(agentId, companyId)` 寻址;`company-service.hire()` 改为创建 org_graph 引用节点而非 `_copyTemplate()`。 + +经过对现有代码的走查(见第三节),这句话在真正落地时需要收窄——不是"完全停止拷贝",而是**只让 `agent.md` 这一份文件摆脱物理拷贝**,理由见第四节的关键约束分析。 + +### 非目标(本阶段明确不做) + +- **不做** `soul.md` / `identity.md` / `tools.md` / `.claude/skills/` 的去重——原因是运行时 CLI 进程按 `cwd` 相对路径读取它们(证据见 §3.3),贸然去重会在 runtime 直接读不到文件。这些文件的去重留给后续阶段(等这些模板文件内部的相对引用被统一改写为绝对共享路径之后再做)。 +- **不做** Org Graph 前端展示、`company.json.agents[]` 的废弃——`org_graph_nodes` 在 P1 只是**追加写入**,不替换任何现有读取路径。 +- **不做** `memory-service.js` / `heartbeat-service.js` / `task-service.js` / `agent-chat.js` 里重复的 `id.includes(':')` 寻址逻辑的统一收编——P1 刻意不碰这些文件,把 blast radius 限制在 `agent-manager.js` + `prompt-builder.js` + `company-service.js` 三个文件内(见第八节)。 +- **不做**存量公司数据迁移——旧公司(P1 上线前已 `hire()` 过的 agent)永远走原有的物理拷贝路径,不强制转换。 +- **不做** `system/agents/` 与 `system/templates/` 之间 6 个同名 id 的内容合并决策——这是 27 号文档 §7 抛出的产品问题(哪份文件才是权威版本),P1 只做到"绕开风险、不读错文件",不做"解决冲突"。见 §4.6。 + +--- + +## 二、背景:为什么只能先做 agent.md + +27 号文档 §1 列的四个问题里,P1 直接对应的是第 1 条"状态碎片化": + +> 同一角色在 10 家公司名下就有 10 份互不相干的 `agent.md`/`memory.md`。模板更新后,已雇佣的实例不会同步。 + +但 `memory.md` 是运行时持续写入的**私有**状态(每家公司的 Agent 记忆本来就该不同),不在去重范围内——27 号文档自己在 §3.2 也是这么定义 `scope=public` 的 `memory.md`(跨公司共享的是"能力"记忆,不是任意私有工作记忆)。真正符合"应该共享但目前被拷贝成 N 份"定义的,是**角色定义文件**:`agent.md`/`soul.md`/`identity.md`/`tools.md`。 + +P1 要解决的具体问题:Innovator 把某个角色的 `agent.md` evolve 到 v1.4,但已经雇佣过 v1.1 版本的 5 家公司,实例文件永远停在 v1.1,除非手动逐个覆盖。 + +--- + +## 三、现状代码走查(决定设计边界的关键证据) + +### 3.1 寻址入口:`AgentManager.getAgentConfig()` + +`server/src/core/agent-manager.js:124-202`。对 `"companyId:role"` 格式的地址,走的是"动态兜底"分支(第 131-191 行),不是 `this.config.agents` 静态配置——company-hired 的 agent **从来没有**进入过静态配置,每次都靠这个分支现场拼出 `agentConfig`: + +```js +agentConfig = { + type: 'orchestrator', protocol: 'claude-cli', ..., + prompt: path.join(agentDir, 'agent.md'), // ← P1 要改的就是这一行的来源 + workDir: agentDir, // ← 这一行必须保持不变(见 3.3) + ... +}; +``` + +拼出来的结果会缓存进 `this.config.agents[agentName]`(第 187-190 行),下一步 `PromptBuilder.build()` 直接读这个缓存,**不会**重新解析地址。这意味着 P1 的核心改动点只有一个:**这个分支怎么决定 `prompt` 字段指向哪。** + +### 3.2 `PromptBuilder.build()` 不需要感知寻址 + +`server/src/core/prompt-builder.js:130-177`。它读的是 `this.config.agents[agentName].prompt`,对这个路径来自哪里完全无感。也就是说:**只要 agent-manager.js 把 `prompt` 字段指对地方,PromptBuilder 本身理论上不用改——除非要把"公共 agent.md + 私有 company-context"拼成两段(见 3.4)。** + +### 3.3 关键约束:`workDir` 决定了哪些文件必须留在私有目录 + +`system/templates/qa-judge/agent.md:483-489`:"参考文档"一节用的是相对路径: + +``` +- `soul.md` - 判官人格与思维方式 +- `identity.md` - 身份信息与核心原则 +- `../../workspace/innovator/qa_judge_ideal_model.md` - 理想能力模型 +``` + +Claude CLI 子进程以 `workDir`(= `agentDir`,私有目录)为 `cwd` 启动,之后自己用文件工具按相对路径读取 `soul.md`。**如果这些文件不物理存在于私有目录,agent 运行时直接读不到。** 这就排除了"把整个 agent 目录指向共享位置"这个最简单的方案——那样会导致同一个共享目录被多家公司的 CLI 进程并发写入/读取,且 Claude CLI 自身的项目级状态(存在 `~/.claude/projects/<按路径 hash>/`)也会被多家公司共用同一路径而串数据。**结论:`workDir` 必须继续指向私有目录,不能改。** + +### 3.4 隐藏耦合:`agent.md` 今天不是纯共享文件,会被写脏 + +`server/services/company-service.js:687-704`(`_injectCompanyContext` 第 2 步):`hire()` 复制模板后,**直接把公司归属信息追加进复制出来的 `agent.md`**: + +```js +const companyContext = ['', '---', '', '## 所属公司', `- **公司ID**: ${companyId}`, ...].join('\n'); +fs.writeFileSync(agentMdPath, content + companyContext, 'utf-8'); +``` + +这意味着如果简单粗暴地让 `prompt` 直接指向共享 `legacy_path/agent.md`,会丢失这段"我是哪家公司的员工"的上下文——而这段上下文对 Agent 的行为是必需的(决定它向谁汇报、服务于什么使命)。**P1 必须把这段追加内容从"写死进文件"改成"运行时拼接"**,这正好对应 27 号文档 §3.6 说的"PromptBuilder 装配公共定义 + 私有语境"。 + +`user.md` 不受影响:它是**整份重写**(`company-service.js:707-729`),不是追加共享内容,本来就是纯私有文件,P1 不用动它。 + +--- + +## 四、设计方案 + +### 4.1 数据流总览 + +``` +hire(companyId, role) + │ + ├─ 查 system_agents 表: SELECT * WHERE id = role AND source_kind = 'template' + │ (注意:只匹配 'template',不匹配 'global_agent'——原因见 §4.6) + │ + ├─ 命中 + │ → 引用模式: + │ · 私有目录仍创建(config.json / soul.md / identity.md / tools.md / + │ heartbeat.md / plan.md / todo.md / cron.json / .claude/ / skills/ + │ 照今天原样拷贝 —— 不变) + │ · 不拷贝 agent.md + │ · config.json 新增字段 systemAgentRef = role + │ · 新写 company-context.md(取代原本追加进 agent.md 的那段内容) + │ · INSERT org_graph_nodes(company_id, system_agent_id, role_name) + │ (幂等,见 4.4 的唯一索引) + │ + └─ 未命中(system_agents 里没有 source_kind='template' 的这一行—— + 可能是新模板还没跑 seed,也可能是 §4.6 那 6 个命名冲突 id 之一) + → 回退到今天的 _copyTemplate() 全量拷贝(含 agent.md),行为零变化 + → console.warn 提示"运行 npm run graph:seed 以启用引用模式,或该 id 存在命名空间冲突见 27 号文档 §7" +``` + +### 4.2 `AgentManager.getAgentConfig()` 改动(agent-manager.js:131-191) + +在现有动态兜底分支里,`agentDir` 存在之后,新增一步:读 `agentDir/config.json` 的 `systemAgentRef` 字段。 + +```js +if (fs.existsSync(agentDir)) { + const config = readConfigIfExists(agentDir); // 已有的 _readAgentConfig 等价逻辑 + const ref = config?.systemAgentRef; + const sharedAgentMdPath = ref ? lookupSharedAgentMd(ref) : null; // 见 4.3 + + agentConfig = { + type: 'orchestrator', protocol: 'claude-cli', ..., + prompt: sharedAgentMdPath || path.join(agentDir, 'agent.md'), // 引用模式 vs legacy 二选一 + companyContextPath: ref ? path.join(agentDir, 'company-context.md') : null, // 新增,供 PromptBuilder 用 + workDir: agentDir, // 不变 + ... + }; +} +``` + +`lookupSharedAgentMd(systemAgentId)`: + +```sql +SELECT legacy_path FROM system_agents WHERE id = ? AND source_kind = 'template' +``` + +**`source_kind` 过滤是硬性要求,不是可选优化**——见 §4.6。查到行后拼 `path.join(ROOT, legacy_path, 'agent.md')`;查不到则整体走 legacy 全量拷贝分支。这个查询函数放在**新文件** `server/services/graph-service.js`(P1 自己的模块,不改动 P0 owner 的 `graph-db.js`/`graph-seed-service.js`,避免并行开发冲突),内部调用 `graph-db.js` 导出的 `getDb()`。 + +### 4.3 `PromptBuilder.build()` 改动(prompt-builder.js:130-177) + +`agentConfig.companyContextPath` 非空时,在 `getAgentPrompt()` 读完共享 `agent.md` 之后,再读一份 `company-context.md` 并作为独立 part 拼进去,位置对应今天"agent.md 正文 + 追加的所属公司段"的顺序: + +```js +if (isSkillBased) { + ... +} else { + ... + const agentPrompt = this.getAgentPrompt(agentName); // 现在可能来自共享路径 + if (agentPrompt) parts.push(agentPrompt); + if (agent.companyContextPath && fs.existsSync(agent.companyContextPath)) { + parts.push(this.loadFile(agent.companyContextPath)); // 新增 + } +} +``` + +对 legacy 拷贝模式的 agent(`companyContextPath` 为 `null`),这个分支不生效,行为完全不变。 + +### 4.4 `company-service.hire()` 改动(company-service.js:189-230, 621-648, 667-739) + +- `_copyTemplate()` 新增一个引用模式变体(暂命名 `_provisionReferenceAgent()`):复制 `TEMPLATE_FILES` 时跳过 `agent.md`,其余不变。 +- `_injectCompanyContext()` 引用模式下:第 2 步(追加进 agent.md)改成写一个新文件 `company-context.md`(内容就是原来那段 `## 所属公司` 文本 + `opts.customAgent`),不再碰 `agent.md`(因为它现在压根不在私有目录里)。第 1、3、4 步(config.json / user.md / soul.md 自定义)完全不变。 +- `hire()` 成功后,除了今天已有的 `company.agents.push(roleName)` + `_saveCompany()`(**继续保留,不替换**——很多地方还在读 `company.json.agents[]`),额外调用 `graph-service.upsertOrgGraphNode(companyId, roleName, roleName)`。这一步失败不应该让 `hire()` 整体失败(try/catch + warn),因为 P1 阶段 org_graph_nodes 只是审计记录,不是唯一真相源。 + +### 4.5 schema 微调:`org_graph_nodes` 唯一索引 + +现有 `graph-db.js`(P0 产物)的 `org_graph_nodes` 没有约束同一 `(company_id, role_name)` 不能重复插入。P1 需要在 `hire()` 幂等重跑时不产生重复行。这是**加法**,不改 P0 已有的表结构定义,用独立语句追加: + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS idx_org_graph_nodes_company_role + ON org_graph_nodes(company_id, role_name); +``` + +放在 `graph-service.js` 里首次调用时执行(`CREATE ... IF NOT EXISTS` 幂等),不需要动 `graph-db.js`。加之前建议先跑一次 `SELECT company_id, role_name, COUNT(*) FROM org_graph_nodes GROUP BY 1,2 HAVING COUNT(*) > 1` 确认 `seedCompanyAgents()` 回填的历史数据里没有已存在的重复行(按其去重逻辑推断不应该有,但加唯一索引前验证一次成本很低)。 + +### 4.6 关键风险:`agent-id` 命名空间冲突(真实存在,不是假设) + +27 号文档 §7 记录了一个 P0 实施时才暴露的事实:`agent-creator` / `digester` / `evolver` / `evolver-youtube-crawler` / `hunter` / `innovator` 这 6 个 id,在 `system/agents/` 和 `system/templates/` 下**同时存在**,物理上是两份可能已经不同步的文件。`seedTemplates()` 遇到冲突时让 `global_agent` 行赢(跳过 template 行的写入),保证 `system_agents.id` 唯一性不被破坏——但这只是保护了数据库,没有回答"该读哪份文件"。 + +这不是纸上谈兵:`data/companies/idearun/agents/` 里已经真实雇佣了 `evolver-youtube-crawler`,正是冲突名单里的一个。而 `company-service.hire()` 的模板查找**永远只看 `TEMPLATES_DIR`**(`company-service.js:199`,`path.join(TEMPLATES_DIR, roleName)`),从来不看 `system/agents/`。 + +**如果 P1 的查表逻辑写成 `WHERE id = role`(不加 `source_kind` 过滤)**,对 `evolver-youtube-crawler` 这类 id 会查到 `global_agent` 行,`legacy_path` 指向 `system/agents/evolver-youtube-crawler/`——但 `idearun` 当初实际雇佣时拷贝的是 `system/templates/evolver-youtube-crawler/agent.md`。这两份文件如果内容不同,P1 上线后 `idearun` 的 `evolver-youtube-crawler` 在下一次调用时会突然读到一份**它从未被雇佣时使用过的 agent.md**——对用户来说是一次无法预期的、未经确认的角色定义漂移。 + +**修正**:§4.1/§4.2 已经把查表条件改成 `WHERE id = role AND source_kind = 'template'`。对这 6 个冲突 id,这个条件永远查不到行(因为 `seedTemplates()` 跳过了它们),于是自动落进"未命中 → legacy 全量拷贝"分支——即这 6 个 id 在 P1 阶段**天然被排除在引用模式之外**,不会有虚拟引用行为,直到 27 号文档 §7 提出的"该以哪份文件为准"这个产品问题被人工决定之后,才应该考虑把它们纳入。 + +--- + +## 五、与 P0(已完工)的衔接点 + +P0 已经把 `seedCompanyAgents()` 也做完了:`org_graph_nodes` 现在不是空表,已经为**所有现存公司雇佣关系**(含 `idearun` 的 6 个角色)各建了一行。这对 P1 意味着: + +- P1 的 `hire()` 改动(§4.4)在写入新的 `org_graph_nodes` 行时,不能假设自己是"第一个写入者"——历史数据已经在那,§4.5 的唯一索引就是为了让新写入和历史数据共存时不产生重复行。 +- P1 不需要(也不应该)对 `seedCompanyAgents()` 已经创建的 `source_kind='company_hired'` 行做任何处理——那些是"公司自己培养、没有对应模板"的角色,天然不满足 §4.1 的 `source_kind='template'` 命中条件,会正确地继续走 legacy 路径。 +- 唯一需要在 P1 动工前处理的衔接项就是 §4.6 的命名冲突排除逻辑——这一点已经通过查表条件的过滤自动满足,**不需要额外的迁移或人工介入**。 + +--- + +## 六、验收标准 + +沿用 27 号文档给出的验收标准,细化为可直接验证的检查项: + +1. 对一个已在 `system_agents` 表中的模板角色(如 `qa-judge`)执行 `hire(companyId, 'qa-judge')`: + - 私有目录下**没有** `agent.md` 文件 + - 私有目录下**有** `company-context.md`,内容包含公司 ID/名称/使命 + - `config.json` 含 `systemAgentRef: "qa-judge"` + - `org_graph_nodes` 表新增一行 `(companyId, 'qa-judge', 'qa-judge')` +2. 对该 agent 调用 `callAgent()`:`buildFullPrompt()` 产出的最终 prompt 中,agent.md 正文来自 `system/templates/qa-judge/agent.md`(与共享文件内容一致,改共享文件后无需重新 hire 即可生效),且包含 company-context.md 的所属公司段落。 +3. 对一个**不在** `system_agents` 表中的模板角色执行 `hire()`:退回到今天的行为(完整拷贝含 `agent.md`),并在日志里看到 reseed 提示。 +4. 找一家 P1 上线前已经 `hire()` 过的旧公司(纯目录拷贝,无 `systemAgentRef`):`callAgent()` 行为不变,`getAgentConfig()`/`PromptBuilder.build()` 都不进入新分支。 +5. 重复对同一 `(companyId, role)` 调用 `hire()`:第二次直接命中 `already_exists` 短路(这一步逻辑不变),`org_graph_nodes` 不产生重复行。 +6. **命名冲突回归用例**(对应 §4.6):用一家**新**测试公司(不是 `idearun`——它是 P1 上线前的存量雇佣,天然走 legacy 分支,测不出这个问题)对角色名 `evolver-youtube-crawler` 执行一次全新的 `hire()`。断言:`hire()` 内部的 `system_agents` 查表**查不到**行(因为唯一的 template 行被 seed 时跳过了),因此整个走 legacy 全量拷贝分支(含 `agent.md`),而不是误把 `global_agent` 行的 `legacy_path`(`system/agents/evolver-youtube-crawler/`)当成引用来源。这条用例失败就意味着 §4.6 的 `source_kind='template'` 过滤条件被写错了。 + +--- + +## 七、影响面 / Blast Radius + +P1 改动限定在三个文件 + 一个新文件: + +| 文件 | 改动类型 | +|------|----------| +| `server/src/core/agent-manager.js` | 修改:动态兜底分支的 `prompt` 来源判断 | +| `server/src/core/prompt-builder.js` | 修改:`build()` 增加 `companyContextPath` 拼接分支 | +| `server/services/company-service.js` | 修改:`hire()` / `_copyTemplate()` / `_injectCompanyContext()` 分叉出引用模式路径 | +| `server/services/graph-service.js` | 新增:`lookupSharedAgentMd()` / `upsertOrgGraphNode()` | + +**明确不改**,但依赖同一批私有目录路径的下游——之所以不改是因为引用模式下私有目录的物理位置、`config.json`、`memory/`、`.claude/` 全部保持原样,这些文件仍然照常存在: + +| 文件 | 为什么不受影响 | +|------|----------------| +| `server/services/memory-service.js` | 只读 `data/companies/{id}/agents/{role}/memory/`,P1 不改这个目录的存在 | +| `server/services/heartbeat-service.js` | 同上,读 `config.json`/`heartbeat.md`,两者仍照常私有存在 | +| `server/services/task-service.js` | 同上 | +| `server/routes/agent-chat.js` | 同上,且 `agentName.includes(':')` 的解析方式完全不变 | + +如果后续阶段要把 `soul.md`/`identity.md`/`tools.md` 也去重,才需要重新评估这四个文件。 + +--- + +## 八、测试计划 + +- 单元测试:`graph-service.js` 的 `upsertOrgGraphNode` 幂等性、`lookupSharedAgentMd` 对存在/不存在两种情况的返回。 +- 集成测试(复用 `server/scripts/test-company-e2e.js` 的模式):`hire()` 引用模式全流程 → `callAgent()` → 断言最终 prompt 内容分段正确。 +- 回归测试:跑一遍现有 `server/tests/**` 中涉及 `company-service.js` / `agent-manager.js` 的用例,确认 legacy 拷贝模式路径零回归。 + +--- + +## 九、Open Questions(留给实现阶段核实) + +1. **SKILL.md 模式的 agent**:`PromptBuilder.build()` 里 `isSkillBased`(`promptPath.endsWith('SKILL.md')`)分支目前直接把 SKILL.md 当唯一 prompt 来源,不叠加 protocol。当前梳理的模板样本(`qa-judge` 等)都是 `agent.md` 模式,没有验证 SKILL.md 模式模板是否也存在同样的"追加公司上下文"问题。实现前需要抽查 `system/templates/*` 里是不是有 `prompt` 指向 `.claude/skills/*/SKILL.md` 的模板。 +2. **`opts.customAgent` 的语义**:今天允许调用方在 `hire()` 时传一段自定义文本追加进 `agent.md`。P1 把它挪进 `company-context.md` 后,语义上从"追加进角色定义"变成"追加进公司专属上下文",需要确认现有调用方(如果有)不依赖它出现在 `agent.md` 本身的物理路径里。 +3. **共享 `agent.md` 被 Evolver 重写时的一致性**:P1 只解决"读"的问题(新 hire 读到最新版)。如果 Evolver 正在原地重写 `system/templates/{role}/agent.md` 的同时有 agent 正在被调用读取,会不会读到半写状态?这个问题在 P0 之前就存在(模板文件本来就会被 Evolver 直接改写),P1 没有让它变得更差,但既然现在读路径变得"更热"(被更多公司共享读取),值得在实现时评估要不要加一层读写锁或原子替换(写临时文件 + rename)。 +4. **6 个命名冲突 id 何时纳入引用模式**:§4.6 的过滤条件让 `agent-creator`/`digester`/`evolver`/`evolver-youtube-crawler`/`hunter`/`innovator` 这 6 个 id 在 P1 阶段永远走 legacy 分支。这是刻意的临时状态,不是长期方案——一旦 27 号文档 §7 提出的"哪份文件权威"问题有了产品决策,需要回来把这 6 个 id 的处理方式补上(可能是人工合并文件、也可能是确认两份本来就该是不同东西、重新命名去冲突)。这一项应该在排 P2(Org Graph)之前找时间处理掉,因为 P2 要让 CEO/HR agent 直接在 org graph 上招聘,到时候这 6 个 id 的歧义会更早暴露给用户。 diff --git a/server/services/company-service.js b/server/services/company-service.js index 5b8530d..e4117c3 100644 --- a/server/services/company-service.js +++ b/server/services/company-service.js @@ -12,6 +12,7 @@ const fs = require('fs'); const path = require('path'); +const { lookupSharedAgentMd, upsertOrgGraphNode } = require('./graph-service'); const ROOT_DATA_DIR = path.resolve(__dirname, '../../data'); const DATA_DIR = path.join(ROOT_DATA_DIR, 'companies'); @@ -198,9 +199,18 @@ class CompanyService { // 阶段 A: 在模板库中查找 const templateDir = path.join(TEMPLATES_DIR, roleName); if (fs.existsSync(templateDir)) { - // ✅ 模板存在 → 复制骨架到公司 - this._copyTemplate(templateDir, targetDir); - this._injectCompanyContext(companyId, roleName, opts); + // system_agents 里是否有这个角色的共享 agent.md(source_kind='template')? + // 有 → 引用模式(不拷贝 agent.md);没有(含 6 个命名冲突 id)→ legacy 全量拷贝,行为零变化 + const sharedAgentMdPath = lookupSharedAgentMd(roleName); + + if (sharedAgentMdPath) { + this._provisionReferenceAgent(templateDir, targetDir); + this._injectCompanyContext(companyId, roleName, opts, { referenceMode: true }); + } else { + // ✅ 模板存在 → 复制骨架到公司(legacy 全量拷贝,含 agent.md) + this._copyTemplate(templateDir, targetDir); + this._injectCompanyContext(companyId, roleName, opts); + } this._updateTemplateUsedCount(roleName); // 更新公司的 agents 列表 @@ -215,7 +225,14 @@ class CompanyService { company.updatedAt = new Date().toISOString(); this._saveCompany(company); - console.log(`[Company] ✅ ${companyId} 招聘 ${roleName} (从模板)`); + // Org Graph 引用节点记录(审计用途,失败不影响雇佣结果本身) + try { + upsertOrgGraphNode(companyId, roleName, roleName); + } catch (err) { + console.warn(`[Company] ⚠️ org_graph_nodes 写入失败(不影响雇佣结果): ${err.message}`); + } + + console.log(`[Company] ✅ ${companyId} 招聘 ${roleName} (从模板${sharedAgentMdPath ? ',引用模式' : ''})`); return { status: 'hired_from_template', agentId: roleName, agentDir: targetDir }; } @@ -647,6 +664,43 @@ class CompanyService { fs.writeFileSync(path.join(memoryDir, 'memory.md'), '# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n## 已验证的模式\n\n## 失败教训\n', 'utf-8'); } + /** + * 复制模板骨架到目标目录 — 引用模式变体 + * + * 和 _copyTemplate() 几乎一样,唯一区别是跳过 'agent.md'(这份文件改为运行时 + * 引用共享的 legacy_path/agent.md,见 graph-service.lookupSharedAgentMd)。 + * 其余骨架文件 / .claude/ / skills/ / memory 初始化完全一致。 + */ + _provisionReferenceAgent(srcDir, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + + // 复制白名单中的骨架文件,跳过 agent.md + for (const file of TEMPLATE_FILES) { + if (file === 'agent.md') continue; + const srcFile = path.join(srcDir, file); + if (fs.existsSync(srcFile)) { + fs.copyFileSync(srcFile, path.join(destDir, file)); + } + } + + // 复制 .claude/ 目录 (包含 skills/main_skill/SKILL.md 等) + const srcClaude = path.join(srcDir, '.claude'); + if (fs.existsSync(srcClaude)) { + this._copyDirRecursive(srcClaude, path.join(destDir, '.claude')); + } + + // 复制 skills/ 目录 (旧结构兼容) + const srcSkills = path.join(srcDir, 'skills'); + if (fs.existsSync(srcSkills)) { + this._copyDirRecursive(srcSkills, path.join(destDir, 'skills')); + } + + // 初始化空的 memory 目录 + const memoryDir = path.join(destDir, 'memory'); + fs.mkdirSync(memoryDir, { recursive: true }); + fs.writeFileSync(path.join(memoryDir, 'memory.md'), '# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n## 已验证的模式\n\n## 失败教训\n', 'utf-8'); + } + _copyDirRecursive(src, dest) { fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src, { withFileTypes: true }); @@ -663,8 +717,16 @@ class CompanyService { /** * 注入公司上下文到新复制的 Agent + * + * @param {string} companyId + * @param {string} agentId + * @param {object} opts + * @param {object} [modeOpts] + * @param {boolean} [modeOpts.referenceMode=false] - 引用模式:私有目录里没有 agent.md + * (见 hire() 的 _provisionReferenceAgent 分支),所属公司信息不再追加进 agent.md, + * 而是全新写入 company-context.md。默认 false,legacy 行为完全不变。 */ - _injectCompanyContext(companyId, agentId, opts = {}) { + _injectCompanyContext(companyId, agentId, opts = {}, { referenceMode = false } = {}) { const company = this.get(companyId); if (!company) return; @@ -681,26 +743,35 @@ class CompanyService { } else { config.parent_id = company.ceo || 'ceo'; } + if (referenceMode) { + config.systemAgentRef = agentId; + } fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); } - // 2. 追加公司信息到 agent.md - const agentMdPath = path.join(agentDir, 'agent.md'); - if (fs.existsSync(agentMdPath)) { - const content = fs.readFileSync(agentMdPath, 'utf-8'); - const companyContext = [ - '', - '---', - '', - '## 所属公司', - `- **公司ID**: ${companyId}`, - `- **公司名称**: ${company.name}`, - `- **公司使命**: ${company.description || '待定义'}`, - '', - `> 你是 **${company.name}** 的员工。你的所有工作都应围绕公司的使命和目标展开。`, - opts.customAgent || '', - ].join('\n'); - fs.writeFileSync(agentMdPath, content + companyContext, 'utf-8'); + // 2. 追加公司信息到 agent.md(legacy)/ 全新写入 company-context.md(引用模式,agent.md 不在私有目录里) + const companyContext = [ + '', + '---', + '', + '## 所属公司', + `- **公司ID**: ${companyId}`, + `- **公司名称**: ${company.name}`, + `- **公司使命**: ${company.description || '待定义'}`, + '', + `> 你是 **${company.name}** 的员工。你的所有工作都应围绕公司的使命和目标展开。`, + opts.customAgent || '', + ].join('\n'); + + if (referenceMode) { + const contextPath = path.join(agentDir, 'company-context.md'); + fs.writeFileSync(contextPath, companyContext.replace(/^\n+/, ''), 'utf-8'); + } else { + const agentMdPath = path.join(agentDir, 'agent.md'); + if (fs.existsSync(agentMdPath)) { + const content = fs.readFileSync(agentMdPath, 'utf-8'); + fs.writeFileSync(agentMdPath, content + companyContext, 'utf-8'); + } } // 3. 重写 user.md: 指明上级关系 diff --git a/server/services/graph-service.js b/server/services/graph-service.js new file mode 100644 index 0000000..fcdfba0 --- /dev/null +++ b/server/services/graph-service.js @@ -0,0 +1,107 @@ +/** + * Graph Service — P1 虚拟引用寻址支撑函数(新文件,P1 专属) + * + * 不修改 P0 已有的 graph-db.js / graph-seed-service.js(那是并行工作线), + * 只通过 graph-db.js 导出的 getDb() 读写同一个 SQLite 连接。 + * + * 详细需求与设计见 docs/todo/system-design/27-p1-virtual-reference.md。 + */ + +const path = require('path'); +const { getDb } = require('./graph-db'); + +const ROOT = path.resolve(__dirname, '../..'); + +let indexEnsured = false; + +/** + * 查询某个 system_agent id 对应的共享 agent.md 绝对路径。 + * + * 只匹配 source_kind = 'template'(不匹配 'global_agent')——这是本次设计修复的一个 + * 真实 bug:system/agents/ 和 system/templates/ 下有 6 个同名 id + * (agent-creator/digester/evolver/evolver-youtube-crawler/hunter/innovator) + * 内容可能不同步,hire() 只从 system/templates/ 拷贝过文件,如果查表命中了 + * global_agent 那一行会读错文件(详见 27-graph-agent-architecture.md §7、 + * 27-p1-virtual-reference.md §4.6)。 + * + * @param {string} systemAgentId + * @returns {string|null} 共享 agent.md 的绝对路径;查不到对应 template 行时返回 null + */ +function lookupSharedAgentMd(systemAgentId) { + const db = getDb(); + const row = db + .prepare(`SELECT legacy_path FROM system_agents WHERE id = ? AND source_kind = 'template'`) + .get(systemAgentId); + + if (!row) return null; + + return path.resolve(ROOT, row.legacy_path, 'agent.md'); +} + +function ensureOrgGraphNodeIndex(db) { + if (indexEnsured) return; + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_org_graph_nodes_company_role + ON org_graph_nodes(company_id, role_name) + `); + indexEnsured = true; +} + +/** + * 为 (companyId, roleName) 建立/复用一条 org_graph_nodes 引用记录。 + * + * hire() 每次成功后都会调用(不论走引用模式还是 legacy 全量拷贝模式),因此不能假设 + * system_agents 里已经有 systemAgentId 对应的行——没有时按 seedCompanyAgents() + * (graph-seed-service.js 约 213-295 行)对同样场景的字段约定 fallback 插入一行 + * source_kind='company_hired' 的 system_agents 记录。 + * + * 幂等:org_graph_nodes(company_id, role_name) 唯一索引 + INSERT ... ON CONFLICT DO NOTHING, + * 重复调用不产生重复行。 + * + * @param {string} companyId + * @param {string} systemAgentId + * @param {string} roleName + * @returns {void} + */ +function upsertOrgGraphNode(companyId, systemAgentId, roleName) { + const db = getDb(); + ensureOrgGraphNodeIndex(db); + + const now = new Date().toISOString(); + + const tx = db.transaction(() => { + const existingAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId); + + if (!existingAgent) { + 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) + `).run({ + id: systemAgentId, + name: systemAgentId, + owner_company_id: companyId, + legacy_path: `data/companies/${companyId}/agents/${roleName}`, + created_at: now, + updated_at: now, + }); + } + + 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) + ON CONFLICT(company_id, role_name) DO NOTHING + `).run({ + company_id: companyId, + system_agent_id: systemAgentId, + role_name: roleName, + created_at: now, + }); + }); + + tx(); +} + +module.exports = { + lookupSharedAgentMd, + upsertOrgGraphNode, +}; diff --git a/server/src/core/agent-manager.js b/server/src/core/agent-manager.js index bc77f63..27cf711 100644 --- a/server/src/core/agent-manager.js +++ b/server/src/core/agent-manager.js @@ -143,13 +143,41 @@ class AgentManager extends EventEmitter { // 1. Check if it's an independent Agent if (fs.existsSync(agentDir)) { + // P1 虚拟引用寻址:company-hired 的 agent 可能在 config.json 里带 + // systemAgentRef(company-service.hire() 走引用模式雇佣时写入), + // 此时 agent.md 不再物理拷贝进私有目录,改为指向共享的 + // system_agents.legacy_path/agent.md(见 docs/todo/system-design/27-p1-virtual-reference.md §4.2)。 + let promptPath = path.join(agentDir, 'agent.md'); + let companyContextPath = null; + const configJsonPath = path.join(agentDir, 'config.json'); + if (fs.existsSync(configJsonPath)) { + let ref; + try { + const dirConfig = JSON.parse(fs.readFileSync(configJsonPath, 'utf-8')); + ref = dirConfig?.systemAgentRef; + } catch (e) { + // config.json 损坏/不可解析:退回 legacy 行为,不让寻址崩溃 + ref = undefined; + } + if (ref) { + const sharedAgentMdPath = require('../../services/graph-service').lookupSharedAgentMd(ref); + if (sharedAgentMdPath) { + promptPath = sharedAgentMdPath; + companyContextPath = path.join(agentDir, 'company-context.md'); + } + // sharedAgentMdPath 为 null(理论上不应发生,防御性处理): + // promptPath/companyContextPath 保持上面初始化的 legacy 默认值。 + } + } + agentConfig = { type: 'orchestrator', protocol: 'claude-cli', transport: 'pipe', launcher: 'claude-local', client: 'claude-local', - prompt: path.join(agentDir, 'agent.md'), + prompt: promptPath, + companyContextPath, workDir: agentDir, // CLI 进程在 Agent 自己的目录下运行,自动读取 .claude/skills/ description: `Dynamic Agent ${agentName}` }; diff --git a/server/src/core/prompt-builder.js b/server/src/core/prompt-builder.js index 7fcf028..287c9e3 100644 --- a/server/src/core/prompt-builder.js +++ b/server/src/core/prompt-builder.js @@ -156,6 +156,9 @@ class PromptBuilder { } const agentPrompt = this.getAgentPrompt(agentName); if (agentPrompt) parts.push(agentPrompt); + if (agent.companyContextPath && fs.existsSync(agent.companyContextPath)) { + parts.push(this.loadFile(agent.companyContextPath)); + } } // User Task diff --git a/server/src/tests/unit/prompt-builder.test.js b/server/src/tests/unit/prompt-builder.test.js index d303bf7..094bc14 100644 --- a/server/src/tests/unit/prompt-builder.test.js +++ b/server/src/tests/unit/prompt-builder.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; const path = require('path'); const fs = require('fs'); const { PromptBuilder } = require('../../core/prompt-builder'); @@ -105,6 +105,52 @@ describe('PromptBuilder', () => { }); }); + describe('build - companyContextPath (P1 virtual reference)', () => { + const os = require('os'); + let tmpFile; + + afterEach(() => { + if (tmpFile && fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + tmpFile = undefined; + }); + + it('appends company-context.md content when companyContextPath exists', () => { + tmpFile = path.join(os.tmpdir(), `company-context-${Date.now()}.md`); + fs.writeFileSync(tmpFile, '## 所属公司\n- **公司ID**: test-co', 'utf-8'); + + config.agents.coder.companyContextPath = tmpFile; + const result = builder.build('coder'); + + expect(result).toContain('## 所属公司'); + expect(result).toContain('test-co'); + }); + + // in getSystemStateXML() is wall-clock and can legitimately + // differ by a millisecond between two build() calls, so strip it before + // comparing outputs for structural equality. + const stripTimestamp = (s) => s.replace(/.*?<\/timestamp>/, ''); + + it('does not change output when companyContextPath is null (legacy mode)', () => { + const baseline = stripTimestamp(builder.build('coder')); + + config.agents.coder.companyContextPath = null; + const withNull = stripTimestamp(builder.build('coder')); + + expect(withNull).toBe(baseline); + }); + + it('does not change output when companyContextPath points to a nonexistent file', () => { + const baseline = stripTimestamp(builder.build('coder')); + + config.agents.coder.companyContextPath = path.join(os.tmpdir(), 'does-not-exist-xyz.md'); + const withMissing = stripTimestamp(builder.build('coder')); + + expect(withMissing).toBe(baseline); + }); + }); + describe('caching', () => { it('should cache loaded files', () => { // 第一次加载 diff --git a/server/tests/integration/graph-agent-p1-acceptance.test.js b/server/tests/integration/graph-agent-p1-acceptance.test.js new file mode 100644 index 0000000..20d45f9 --- /dev/null +++ b/server/tests/integration/graph-agent-p1-acceptance.test.js @@ -0,0 +1,293 @@ +/** + * End-to-end acceptance test — P1 虚拟引用寻址 + * + * Verifies all 6 acceptance criteria from + * docs/todo/system-design/27-p1-virtual-reference.md §6, against the real + * hire() -> getAgentConfig() -> promptBuilder.build() pipeline (not mocks), + * including the §4.6 / §7 naming-collision regression (criterion 6) using + * the real `evolver-youtube-crawler` id that genuinely collides between + * system/agents/ and system/templates/ in this repo. + * + * Isolation: + * - GRAPH_DB_PATH points at a throwaway SQLite file for the whole file + * (same pattern as server/tests/unit/graph-db.test.js) — the real + * data/graph.sqlite is opened once, read-only, only to assert that the + * production seed data for the collision id actually has the shape the + * design doc claims (no `source_kind='template'` row) before mirroring + * that exact shape into the isolated test DB. No writes ever hit the + * real data/graph.sqlite. + * - All hired agents live under temporary company ids + * (`tmp-p1-acceptance-*`) removed in afterAll. + * - The one legacy (pre-P1) company used for criterion 4 is the real + * `idearun` company, accessed read-only only (getAgentConfig / build), + * never hired into or written to. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import Database from 'better-sqlite3'; + +describe('P1 virtual reference addressing — full acceptance suite (27-p1-virtual-reference.md §6)', () => { + const REPO_ROOT = path.resolve(__dirname, '../../..'); + const TEST_DB_PATH = '/tmp/test-graph-agent-p1-acceptance.sqlite'; + const REAL_GRAPH_DB_PATH = path.join(REPO_ROOT, 'data/graph.sqlite'); + const DATA_DIR = path.join(REPO_ROOT, 'data/companies'); + const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates'); + + const RUN_ID = Date.now(); + const CO_QA = `tmp-p1-acceptance-qa-${RUN_ID}`; + const CO_UNKNOWN = `tmp-p1-acceptance-unknown-${RUN_ID}`; + const CO_COLLISION = `tmp-p1-acceptance-collision-${RUN_ID}`; + const UNKNOWN_ROLE = `tmp-p1-nonexistent-role-${RUN_ID}-${Math.random().toString(36).slice(2)}`; + const COLLISION_ROLE = 'evolver-youtube-crawler'; // real 27§4.6/§7 collision id + const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); + + let graphDb; + let CompanyService; + let companyService; + let AgentManager; + let registrySnapshot; // hire() bumps _registry.json usedCount for real templates as a side + // effect (company-service.js _updateTemplateUsedCount) — snapshot/restore so hiring the + // real qa-judge/evolver-youtube-crawler templates in this test never leaves a permanent + // diff on a tracked file. + + beforeAll(() => { + registrySnapshot = fs.readFileSync(REGISTRY_PATH, 'utf-8'); + // --- Sanity check against the REAL (already-seeded, committed 8421f55) graph.sqlite, + // read-only: confirm the production seed data genuinely has the shape the design doc + // claims for the collision id (no source_kind='template' row). This is what makes + // criterion 6 a real regression test instead of a test of hand-picked fixture data. + expect(fs.existsSync(REAL_GRAPH_DB_PATH)).toBe(true); + const realDb = new Database(REAL_GRAPH_DB_PATH, { readonly: true }); + try { + const rows = realDb.prepare('SELECT source_kind, legacy_path FROM system_agents WHERE id = ?').all(COLLISION_ROLE); + expect(rows.length).toBeGreaterThan(0); + expect(rows.some(r => r.source_kind === 'template')).toBe(false); + expect(rows.some(r => r.source_kind === 'global_agent')).toBe(true); + } finally { + realDb.close(); + } + // Also confirm the physical files this test depends on actually exist in the repo, + // so a passing test means something (not vacuously true because fixtures were missing). + expect(fs.existsSync(path.join(TEMPLATES_DIR, 'qa-judge', 'agent.md'))).toBe(true); + expect(fs.existsSync(path.join(TEMPLATES_DIR, COLLISION_ROLE, 'agent.md'))).toBe(true); + expect(fs.existsSync(path.join(REPO_ROOT, 'system/agents', COLLISION_ROLE, 'agent.md'))).toBe(true); + expect(fs.existsSync(path.join(DATA_DIR, 'idearun', 'agents', COLLISION_ROLE, 'agent.md'))).toBe(true); + + // --- Now switch to the isolated test DB for everything the test actually mutates. + 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-service')]; + delete require.cache[require.resolve('../../services/company-service')]; + delete require.cache[require.resolve('../../src/core/agent-manager')]; + delete require.cache[require.resolve('../../src/core/prompt-builder')]; + graphDb = require('../../services/graph-db'); + ({ CompanyService } = require('../../services/company-service')); + AgentManager = require('../../src/core/agent-manager'); + companyService = new CompanyService(); + + const db = graphDb.getDb(); + const now = new Date().toISOString(); + + // Mirror the real production seed for qa-judge (source_kind='template'). + db.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?) + `).run('qa-judge', 'qa-judge', 'system/templates/qa-judge', now, now); + + // Mirror the real production seed for the collision id: only the global_agent row + // exists (seedTemplates() skipped the template row because seedGlobalAgents() claimed + // the id first) — verified against the real DB above. + db.prepare(` + INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) + VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', NULL, ?, ?) + `).run(COLLISION_ROLE, COLLISION_ROLE, `system/agents/${COLLISION_ROLE}`, now, now); + + companyService.create({ id: CO_QA, name: 'P1 Acceptance QA Co', description: 'Verifies QA-judge lives here.' }); + companyService.create({ id: CO_UNKNOWN, name: 'P1 Acceptance Unknown-role Co', description: 'Untrained role fallback.' }); + companyService.create({ id: CO_COLLISION, name: 'P1 Acceptance Collision Co', description: 'Naming collision regression.' }); + }); + + afterAll(() => { + for (const id of [CO_QA, CO_UNKNOWN, CO_COLLISION]) { + try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {} + } + 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; + if (registrySnapshot !== undefined) fs.writeFileSync(REGISTRY_PATH, registrySnapshot, 'utf-8'); + }); + + // ───────────────────────────────────────────────────────────────── + // Criterion 1: hire() of a system_agents template role -> reference mode + // ───────────────────────────────────────────────────────────────── + it('criterion 1: hire(companyId, "qa-judge") uses reference mode (no agent.md, company-context.md, systemAgentRef, org_graph_nodes row)', () => { + const result = companyService.hire(CO_QA, 'qa-judge', {}); + expect(result.status).toBe('hired_from_template'); + + const agentDir = result.agentDir; + expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(false); + + const contextPath = path.join(agentDir, 'company-context.md'); + expect(fs.existsSync(contextPath)).toBe(true); + const context = fs.readFileSync(contextPath, 'utf-8'); + expect(context).toContain(CO_QA); + expect(context).toContain('P1 Acceptance QA Co'); + expect(context).toContain('Verifies QA-judge lives here.'); + + const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8')); + expect(config.systemAgentRef).toBe('qa-judge'); + + const db = graphDb.getDb(); + const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(CO_QA, 'qa-judge'); + expect(node).toBeTruthy(); + expect(node.system_agent_id).toBe('qa-judge'); + }); + + // ───────────────────────────────────────────────────────────────── + // Criterion 2: final prompt = real shared agent.md content + company-context.md + // ───────────────────────────────────────────────────────────────── + it('criterion 2: buildFullPrompt() assembles the real shared system/templates/qa-judge/agent.md content plus the company-context.md section', () => { + const manager = new AgentManager({}, REPO_ROOT); + const agentName = `${CO_QA}:qa-judge`; + const { agent } = manager.getAgentConfig(agentName); + + const expectedSharedPath = path.resolve(REPO_ROOT, 'system/templates/qa-judge', 'agent.md'); + expect(agent.prompt).toBe(expectedSharedPath); + + const realSharedContent = fs.readFileSync(expectedSharedPath, 'utf-8'); + const fullPrompt = manager.buildFullPrompt(agentName, 'test task'); + + // The full assembled prompt carries the exact real content of the shared template file. + expect(fullPrompt).toContain(realSharedContent); + // ... and the private company-context.md section (proves the two-part assembly from §4.3). + expect(fullPrompt).toContain('## 所属公司'); + expect(fullPrompt).toContain(CO_QA); + expect(fullPrompt).toContain('P1 Acceptance QA Co'); + + // Regression guard for the "改共享文件后无需重新 hire 即可生效" claim in criterion 2: + // prompt-builder must not have baked a stale copy into the private dir — there is no + // agent.md in the private dir to have gone stale in the first place. + const agentDir = path.join(DATA_DIR, CO_QA, 'agents/qa-judge'); + expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(false); + }); + + // ───────────────────────────────────────────────────────────────── + // Criterion 3: role absent from system_agents -> legacy full copy, no error + // ───────────────────────────────────────────────────────────────── + it('criterion 3: hiring a role not in system_agents falls back to legacy full copy (including agent.md) without throwing', () => { + // UNKNOWN_ROLE has no system_agents row (never seeded) AND no template directory at + // all, so hire() must return needs_training rather than throw. To exercise the + // "template exists on disk but has no system_agents row" branch specifically (the one + // §4.1/§4.2 describe as the legacy-fallback trigger), also create a throwaway template + // dir that deliberately is NOT registered in system_agents. + const looseTemplateRole = `tmp-p1-loose-template-${RUN_ID}`; + const looseTemplateDir = path.join(TEMPLATES_DIR, looseTemplateRole); + fs.mkdirSync(looseTemplateDir, { recursive: true }); + fs.writeFileSync(path.join(looseTemplateDir, 'agent.md'), '# unregistered fixture agent\n', 'utf-8'); + fs.writeFileSync(path.join(looseTemplateDir, 'config.json'), JSON.stringify({ type: 'skill', name: looseTemplateRole }), 'utf-8'); + + try { + let result; + expect(() => { result = companyService.hire(CO_UNKNOWN, looseTemplateRole, {}); }).not.toThrow(); + expect(result.status).toBe('hired_from_template'); + + const agentDir = result.agentDir; + expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(true); + const content = fs.readFileSync(path.join(agentDir, 'agent.md'), 'utf-8'); + expect(content).toContain('unregistered fixture agent'); + expect(fs.existsSync(path.join(agentDir, 'company-context.md'))).toBe(false); + + const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8')); + expect(config.systemAgentRef).toBeUndefined(); + } finally { + try { fs.rmSync(looseTemplateDir, { recursive: true, force: true }); } catch {} + } + + // And confirm hire() also degrades gracefully (no throw, needs_training) for a role that + // has neither a system_agents row nor a template directory at all. + let unknownResult; + expect(() => { unknownResult = companyService.hire(CO_UNKNOWN, UNKNOWN_ROLE, {}); }).not.toThrow(); + expect(unknownResult.status).toBe('needs_training'); + expect(unknownResult.needsTraining).toBe(true); + }); + + // ───────────────────────────────────────────────────────────────── + // Criterion 6 (design doc numbering) — naming collision regression + // ───────────────────────────────────────────────────────────────── + it('criterion 6 [KEY REGRESSION]: hire("evolver-youtube-crawler") on a brand-new company falls back to legacy full copy, never redirecting to system/agents/', () => { + const result = companyService.hire(CO_COLLISION, COLLISION_ROLE, {}); + expect(result.status).toBe('hired_from_template'); + + const agentDir = result.agentDir; + const agentMdPath = path.join(agentDir, 'agent.md'); + expect(fs.existsSync(agentMdPath)).toBe(true); // legacy path -> agent.md IS physically copied + + const copiedContent = fs.readFileSync(agentMdPath, 'utf-8'); + const templateContent = fs.readFileSync(path.join(TEMPLATES_DIR, COLLISION_ROLE, 'agent.md'), 'utf-8'); + const globalAgentContent = fs.readFileSync(path.join(REPO_ROOT, 'system/agents', COLLISION_ROLE, 'agent.md'), 'utf-8'); + + // These two source files are confirmed to genuinely differ in this repo (27§7). + expect(templateContent).not.toBe(globalAgentContent); + + // The copied file must match system/templates/ (what _copyTemplate() actually reads), + // and must NOT be — or even coincidentally equal — the system/agents/ (global_agent) version. + // (companyContext gets appended in legacy mode, so compare via startsWith/contains rather + // than strict equality.) + expect(copiedContent.startsWith(templateContent)).toBe(true); + expect(copiedContent).not.toContain(globalAgentContent.split('\n')[0]); // global_agent's distinct H1 heading + + // Reference-mode artifacts must be absent — this id must NOT have taken the reference branch. + expect(fs.existsSync(path.join(agentDir, 'company-context.md'))).toBe(false); + const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8')); + expect(config.systemAgentRef).toBeUndefined(); + + // graph-service.lookupSharedAgentMd itself must report "no reference" for this id, + // proving the source_kind='template' filter — not just hire()'s outer branching — is correct. + const graphService = require('../../services/graph-service'); + expect(graphService.lookupSharedAgentMd(COLLISION_ROLE)).toBeNull(); + + // Still gets an audit org_graph_nodes row even on the legacy path. + const db = graphDb.getDb(); + const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(CO_COLLISION, COLLISION_ROLE); + expect(node).toBeTruthy(); + }); + + // ───────────────────────────────────────────────────────────────── + // Criterion 5 (design doc numbering) — idempotency + // ───────────────────────────────────────────────────────────────── + it('criterion 5: hiring the same (companyId, role) twice short-circuits to already_exists and does not duplicate org_graph_nodes', () => { + const first = companyService.hire(CO_QA, 'qa-judge', {}); // already hired in criterion-1 test above + expect(first.status).toBe('already_exists'); + + const second = companyService.hire(CO_QA, 'qa-judge', {}); + expect(second.status).toBe('already_exists'); + + const db = graphDb.getDb(); + const count = db.prepare('SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(CO_QA, 'qa-judge').c; + expect(count).toBe(1); + }); + + // ───────────────────────────────────────────────────────────────── + // Criterion 4 (design doc numbering) — pre-P1 legacy company unaffected + // ───────────────────────────────────────────────────────────────── + it('criterion 4: a pre-P1 legacy company (real "idearun", never touched by this test) still resolves through the old private-agent.md path, not the new reference branch', () => { + // Read-only against real, already-hired production data — proves old instances are + // untouched by the new code path rather than merely "not tested". + const legacyAgentDir = path.join(DATA_DIR, 'idearun', 'agents', COLLISION_ROLE); + expect(fs.existsSync(path.join(legacyAgentDir, 'agent.md'))).toBe(true); + const legacyConfig = JSON.parse(fs.readFileSync(path.join(legacyAgentDir, 'config.json'), 'utf-8')); + expect(legacyConfig.systemAgentRef).toBeUndefined(); + + const manager = new AgentManager({}, REPO_ROOT); + const agentName = `idearun:${COLLISION_ROLE}`; + const { agent } = manager.getAgentConfig(agentName); + + // Must resolve to the agent's own private agent.md, not any shared/legacy_path location. + expect(agent.prompt).toBe(path.join(legacyAgentDir, 'agent.md')); + expect(agent.companyContextPath).toBeFalsy(); + expect(agent.workDir).toBe(legacyAgentDir); + }); +}); diff --git a/server/tests/integration/graph-agent-p1-hire.test.js b/server/tests/integration/graph-agent-p1-hire.test.js new file mode 100644 index 0000000..f092df0 --- /dev/null +++ b/server/tests/integration/graph-agent-p1-hire.test.js @@ -0,0 +1,134 @@ +/** + * Integration Test — company-service.hire() P1 引用模式 + * + * 覆盖 docs/todo/system-design/27-p1-virtual-reference.md §6 验收标准 1/3/5: + * 1. system_agents 里有 source_kind='template' 行的角色(qa-judge) → 引用模式: + * 私有目录没有 agent.md,有 company-context.md(含公司 ID),config.json 有 + * systemAgentRef,org_graph_nodes 新增一行。 + * 2. 不在 system_agents 里的角色 → 完全 legacy 行为(含 agent.md 被拷贝)。 + * 3. 幂等性:同一 (companyId, role) 调用 hire() 两次,第二次 already_exists, + * org_graph_nodes 不重复。 + * + * 用 GRAPH_DB_PATH 覆盖到临时 SQLite 文件(同 graph-service.test.js / graph-db.test.js + * 的模式),永不触碰真实 data/graph.sqlite;用临时公司 id,测试完清理 data/companies/ + * 下对应目录,不污染真实数据。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +describe('company-service.hire() — P1 virtual reference mode', () => { + const TEST_DB_PATH = '/tmp/test-graph-agent-p1-hire.sqlite'; + const REPO_ROOT = path.resolve(__dirname, '../../..'); + const DATA_DIR = path.join(REPO_ROOT, 'data/companies'); + const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates'); + + const TEST_COMPANY_ID = `tmp-p1-hire-test-co-${Date.now()}`; + const LEGACY_TEMPLATE_ROLE = `tmp-p1-legacy-role-${Date.now()}`; + const legacyTemplateDir = path.join(TEMPLATES_DIR, LEGACY_TEMPLATE_ROLE); + + let graphDb; + let CompanyService; + let companyService; + + 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-service')]; + delete require.cache[require.resolve('../../services/company-service')]; + graphDb = require('../../services/graph-db'); + ({ CompanyService } = require('../../services/company-service')); + companyService = new CompanyService(); + + // Seed a 'template' row for qa-judge pointing at the real template dir (already exists + // in the repo with a real agent.md) — this is exactly what npm run graph:seed produced + // in commit 8421f55, just re-created here against the isolated test DB. + 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 (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?) + `).run('qa-judge', 'qa-judge', 'system/templates/qa-judge', now, now); + + // Fixture template dir for the "role not in system_agents" legacy-fallback case. + // Deliberately NOT inserted into system_agents, so lookupSharedAgentMd() returns null + // and hire() must fall back to the full legacy copy (including agent.md). + fs.mkdirSync(legacyTemplateDir, { recursive: true }); + fs.writeFileSync(path.join(legacyTemplateDir, 'agent.md'), '# legacy fixture agent\n', 'utf-8'); + fs.writeFileSync(path.join(legacyTemplateDir, 'config.json'), JSON.stringify({ type: 'skill', name: LEGACY_TEMPLATE_ROLE }), 'utf-8'); + + // Company fixture (CompanyService.hire() requires an existing company). + companyService.create({ id: TEST_COMPANY_ID, name: 'P1 Hire Test Co', description: 'test fixture' }); + }); + + afterAll(() => { + try { fs.rmSync(path.join(DATA_DIR, TEST_COMPANY_ID), { recursive: true, force: true }); } catch {} + try { fs.rmSync(legacyTemplateDir, { recursive: true, force: true }); } catch {} + 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('reference mode: hiring a system_agents template role does not copy agent.md, writes company-context.md, sets systemAgentRef, records org_graph_nodes', () => { + const result = companyService.hire(TEST_COMPANY_ID, 'qa-judge', {}); + + expect(result.status).toBe('hired_from_template'); + const agentDir = result.agentDir; + + expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(false); + + const contextPath = path.join(agentDir, 'company-context.md'); + expect(fs.existsSync(contextPath)).toBe(true); + const contextContent = fs.readFileSync(contextPath, 'utf-8'); + expect(contextContent).toContain(TEST_COMPANY_ID); + expect(contextContent).toContain('## 所属公司'); + + const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8')); + expect(config.systemAgentRef).toBe('qa-judge'); + + // Non-shared files must still be provisioned privately (workDir constraint, §3.3 of the design doc). + expect(fs.existsSync(path.join(agentDir, 'soul.md'))).toBe(true); + expect(fs.existsSync(path.join(agentDir, 'identity.md'))).toBe(true); + expect(fs.existsSync(path.join(agentDir, 'memory/memory.md'))).toBe(true); + + const db = graphDb.getDb(); + const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(TEST_COMPANY_ID, 'qa-judge'); + expect(node).toBeTruthy(); + expect(node.system_agent_id).toBe('qa-judge'); + }); + + it('legacy fallback: hiring a role absent from system_agents copies agent.md and writes no company-context.md / systemAgentRef', () => { + const result = companyService.hire(TEST_COMPANY_ID, LEGACY_TEMPLATE_ROLE, {}); + + expect(result.status).toBe('hired_from_template'); + const agentDir = result.agentDir; + + expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(true); + const agentMdContent = fs.readFileSync(path.join(agentDir, 'agent.md'), 'utf-8'); + expect(agentMdContent).toContain('legacy fixture agent'); + expect(agentMdContent).toContain('## 所属公司'); // still appended in-place, legacy behavior + + expect(fs.existsSync(path.join(agentDir, 'company-context.md'))).toBe(false); + + const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8')); + expect(config.systemAgentRef).toBeUndefined(); + + // Still gets an org_graph_nodes audit row even though it went through the legacy path. + const db = graphDb.getDb(); + const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(TEST_COMPANY_ID, LEGACY_TEMPLATE_ROLE); + expect(node).toBeTruthy(); + }); + + it('idempotency: hiring the same (companyId, role) twice returns already_exists the second time and does not duplicate org_graph_nodes', () => { + const role = 'qa-judge'; // already hired by the first test above + + const second = companyService.hire(TEST_COMPANY_ID, role, {}); + expect(second.status).toBe('already_exists'); + + const db = graphDb.getDb(); + const count = db.prepare('SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(TEST_COMPANY_ID, role).c; + expect(count).toBe(1); + }); +}); diff --git a/server/tests/unit/agent-manager-p1.test.js b/server/tests/unit/agent-manager-p1.test.js new file mode 100644 index 0000000..a7fed93 --- /dev/null +++ b/server/tests/unit/agent-manager-p1.test.js @@ -0,0 +1,116 @@ +/** + * Unit Tests — AgentManager.getAgentConfig() P1 虚拟引用寻址 + * + * 覆盖 docs/todo/system-design/27-p1-virtual-reference.md §4.2 / §6-1,2,4: + * - config.json 带 systemAgentRef 且 system_agents 表命中 template 行 → prompt 指向共享 + * agent.md,companyContextPath 指向私有 company-context.md + * - config.json 没有 systemAgentRef(legacy agent) → prompt 仍指向私有 agent.md,行为不变 + * + * Uses GRAPH_DB_PATH env override (same pattern as graph-service.test.js) so the real + * data/graph.sqlite is never touched. Also creates a temporary company directory under + * data/companies/ that is removed in afterAll. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +describe('AgentManager.getAgentConfig() — P1 virtual reference addressing', () => { + const TEST_DB_PATH = '/tmp/test-agent-manager-p1-graph.sqlite'; + const REPO_ROOT = path.resolve(__dirname, '../../..'); + const TMP_COMPANY_ID = `tmp-p1-test-co-${Date.now()}`; + const AGENTS_ROOT = path.join(REPO_ROOT, 'data/companies', TMP_COMPANY_ID, 'agents'); + + let graphDb; + let AgentManager; + + 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-service')]; + delete require.cache[require.resolve('../../src/core/agent-manager')]; + graphDb = require('../../services/graph-db'); + AgentManager = require('../../src/core/agent-manager'); + + // Seed a template row for "qa-judge-p1-test" pointing at a fixture-free legacy_path + // (the shared agent.md just needs to exist so lookupSharedAgentMd's result is a real file). + 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 (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?) + `).run('qa-judge', 'qa-judge', 'system/templates/qa-judge', now, now); + + fs.mkdirSync(AGENTS_ROOT, { recursive: true }); + }); + + afterAll(() => { + try { fs.rmSync(path.join(REPO_ROOT, 'data/companies', TMP_COMPANY_ID), { recursive: true, force: true }); } catch {} + 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; + }); + + function makeAgentDir(role, configJson) { + const dir = path.join(AGENTS_ROOT, role); + fs.mkdirSync(dir, { recursive: true }); + if (configJson !== undefined) { + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(configJson), 'utf-8'); + } + // Legacy agent.md always present in the private dir (P1 doesn't delete it if it exists + // for the reference-mode case — company-service.hire() simply never writes it there). + fs.writeFileSync(path.join(dir, 'agent.md'), '# legacy private agent.md\n', 'utf-8'); + return dir; + } + + it('reference mode: systemAgentRef present + template row found -> prompt points at shared agent.md, companyContextPath set', () => { + const role = 'qa-judge-referenced'; + const agentDir = makeAgentDir(role, { systemAgentRef: 'qa-judge' }); + + const manager = new AgentManager({}, REPO_ROOT); + const { agent } = manager.getAgentConfig(`${TMP_COMPANY_ID}:${role}`); + + const expectedSharedPath = path.resolve(REPO_ROOT, 'system/templates/qa-judge', 'agent.md'); + expect(agent.prompt).toBe(expectedSharedPath); + expect(agent.companyContextPath).toBe(path.join(agentDir, 'company-context.md')); + // Hard constraint: workDir must remain the private directory (CLI cwd for soul.md/identity.md/.claude/skills/). + expect(agent.workDir).toBe(agentDir); + }); + + it('legacy mode: no systemAgentRef -> prompt points at private agent.md, companyContextPath falsy', () => { + const role = 'legacy-role'; + const agentDir = makeAgentDir(role, { someOtherField: true }); + + const manager = new AgentManager({}, REPO_ROOT); + const { agent } = manager.getAgentConfig(`${TMP_COMPANY_ID}:${role}`); + + expect(agent.prompt).toBe(path.join(agentDir, 'agent.md')); + expect(agent.companyContextPath).toBeFalsy(); + expect(agent.workDir).toBe(agentDir); + }); + + it('legacy mode: no config.json at all -> prompt points at private agent.md, companyContextPath falsy', () => { + const role = 'no-config-role'; + const agentDir = makeAgentDir(role, undefined); + + const manager = new AgentManager({}, REPO_ROOT); + const { agent } = manager.getAgentConfig(`${TMP_COMPANY_ID}:${role}`); + + expect(agent.prompt).toBe(path.join(agentDir, 'agent.md')); + expect(agent.companyContextPath).toBeFalsy(); + expect(agent.workDir).toBe(agentDir); + }); + + it('defensive fallback: systemAgentRef present but no matching template row -> falls back to private agent.md', () => { + const role = 'dangling-ref-role'; + const agentDir = makeAgentDir(role, { systemAgentRef: 'does-not-exist-in-system-agents' }); + + const manager = new AgentManager({}, REPO_ROOT); + const { agent } = manager.getAgentConfig(`${TMP_COMPANY_ID}:${role}`); + + expect(agent.prompt).toBe(path.join(agentDir, 'agent.md')); + expect(agent.companyContextPath).toBeNull(); + expect(agent.workDir).toBe(agentDir); + }); +}); diff --git a/server/tests/unit/graph-service.test.js b/server/tests/unit/graph-service.test.js new file mode 100644 index 0000000..73954f8 --- /dev/null +++ b/server/tests/unit/graph-service.test.js @@ -0,0 +1,125 @@ +/** + * Unit Tests — graph-service.js (P1) + * + * Uses GRAPH_DB_PATH env override (same pattern as graph-db.test.js / + * graph-seed-service.test.js) so the real data/graph.sqlite is never touched. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +describe('graph-service', () => { + let graphDb; + let graphService; + const TEST_DB_PATH = '/tmp/test-graph-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-service')]; + graphDb = require('../../services/graph-db'); + graphService = require('../../services/graph-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; + }); + + beforeEach(() => { + // Fresh tables between tests so assertions don't depend on ordering. + const db = graphDb.getDb(); + db.exec('DELETE FROM org_graph_nodes; DELETE FROM system_agents;'); + }); + + function insertSystemAgent({ id, source_kind, legacy_path, scope = 'public', owner_company_id = null }) { + 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 (?, ?, ?, ?, ?, ?, 'active', NULL, ?, ?) + `).run(id, id, scope, owner_company_id, source_kind, legacy_path, now, now); + } + + describe('lookupSharedAgentMd', () => { + it('returns the resolved agent.md path when a template row exists', () => { + insertSystemAgent({ id: 'qa-judge', source_kind: 'template', legacy_path: 'system/templates/qa-judge' }); + + const result = graphService.lookupSharedAgentMd('qa-judge'); + + expect(result).toBe(path.resolve(__dirname, '../../..', 'system/templates/qa-judge', 'agent.md')); + }); + + it('returns null when the only matching row is source_kind=global_agent (naming-collision regression)', () => { + // Regression case: evolver-youtube-crawler exists under BOTH system/agents/ and + // system/templates/ but the template row got skipped by seedTemplates() because + // seedGlobalAgents() claimed the id first. Only a global_agent row exists here — + // lookupSharedAgentMd must NOT fall back to it, or idearun's already-hired copy + // (which was physically copied from system/templates/) would silently start + // reading a different file it was never hired with. + insertSystemAgent({ + id: 'evolver-youtube-crawler', + source_kind: 'global_agent', + legacy_path: 'system/agents/evolver-youtube-crawler', + }); + + const result = graphService.lookupSharedAgentMd('evolver-youtube-crawler'); + + expect(result).toBeNull(); + }); + + it('returns null when the id does not exist at all', () => { + const result = graphService.lookupSharedAgentMd('does-not-exist'); + expect(result).toBeNull(); + }); + }); + + describe('upsertOrgGraphNode', () => { + it('creates an org_graph_nodes row when system_agents already has the id', () => { + insertSystemAgent({ id: 'qa-judge', source_kind: 'template', legacy_path: 'system/templates/qa-judge' }); + + graphService.upsertOrgGraphNode('acme-co', 'qa-judge', 'qa-judge'); + + const db = graphDb.getDb(); + const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get('acme-co', 'qa-judge'); + expect(node).toBeTruthy(); + expect(node.system_agent_id).toBe('qa-judge'); + + // Must not have touched/duplicated the existing system_agents row. + const agentCount = db.prepare('SELECT COUNT(*) AS c FROM system_agents WHERE id = ?').get('qa-judge').c; + expect(agentCount).toBe(1); + }); + + it('inserts a fallback company_hired system_agents row when the id is missing', () => { + graphService.upsertOrgGraphNode('acme-co', 'custom-role', 'custom-role'); + + const db = graphDb.getDb(); + const agent = db.prepare('SELECT * FROM system_agents WHERE id = ?').get('custom-role'); + expect(agent).toBeTruthy(); + expect(agent.scope).toBe('company'); + expect(agent.owner_company_id).toBe('acme-co'); + expect(agent.source_kind).toBe('company_hired'); + expect(agent.legacy_path).toBe('data/companies/acme-co/agents/custom-role'); + expect(agent.status).toBe('active'); + expect(agent.benchmark_score).toBeNull(); + + const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get('acme-co', 'custom-role'); + expect(node).toBeTruthy(); + expect(node.system_agent_id).toBe('custom-role'); + }); + + it('is idempotent: calling twice for the same (companyId, roleName) does not duplicate org_graph_nodes rows', () => { + insertSystemAgent({ id: 'qa-judge', source_kind: 'template', legacy_path: 'system/templates/qa-judge' }); + + graphService.upsertOrgGraphNode('acme-co', 'qa-judge', 'qa-judge'); + graphService.upsertOrgGraphNode('acme-co', 'qa-judge', 'qa-judge'); + + const db = graphDb.getDb(); + const count = db.prepare('SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get('acme-co', 'qa-judge').c; + expect(count).toBe(1); + }); + }); +});