/** * 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 fs = require('fs'); const path = require('path'); const { getDb } = require('./graph-db'); const { missingBodyDefinition, BASE_SKELETON_SENTINEL } = require('./agent-locator'); const ROOT = path.resolve(__dirname, '../..'); let indexEnsured = false; /** * 查询某个 system_agent id 对应的共享 agent.md 绝对路径。 * * P1 时这里带着 `AND source_kind = 'template'` 的过滤,因为 `system/agents/` 和 * `system/templates/` 下有 7 个同名 id、内容不同步,命中错的那一行会读错文件。 * 28 批次 1 把两个目录合并成唯一的本体注册表之后,同名冲突不复存在,过滤也就成了 * 纯粹的伤害:那 7 个 id 恒返回 null → hire() 每次都退回全量拷贝。所以过滤去掉, * 路径也不再读 legacy_path,而是直接由 id 推导(本体地址恒为 system/agents/{id})。 * * 仍然返回 null 的两种情况,都是防御性的: * - 库里没有这行(还没 seed / 是别的命名空间的东西); * - 目录在库里、`agent.md` 却不在磁盘上。`ceo` 就当过这样的残缺本体 * (只有 plan.md + todo.md),P1 时它让 hire() 指向一个不存在的文件, * `mycompany/company.json` 的 `"ceo": null` 就是这么来的。 * * @param {string} systemAgentId * @returns {string|null} 共享 agent.md 的绝对路径;查不到行或文件不存在时返回 null */ function lookupSharedAgentMd(systemAgentId) { const db = getDb(); const row = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId); if (!row) return null; const agentMdPath = path.resolve(ROOT, 'system', 'agents', systemAgentId, 'agent.md'); return fs.existsSync(agentMdPath) ? agentMdPath : null; } /** * P2(27-p2-org-graph.md §3.2)起改为**部分**唯一索引:只约束 status='active' 的行, * 这样某个角色被 fire(status='removed')之后,同一个 role_name 能被重新 hire 进来。 * 索引本身 graph-db.js 的 migrate() 已经建过了,这里保留是为了让本模块不依赖调用顺序; * 名字/条件必须与 graph-db.js 完全一致,否则会互相把对方的索引建回来。 */ function ensureOrgGraphNodeIndex(db) { if (indexEnsured) return; db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_org_graph_nodes_company_role_active ON org_graph_nodes(company_id, role_name) WHERE status = 'active' `); indexEnsured = true; } /** * 保证 system_agents 里有 systemAgentId 这一行(缺则按 seedCompanyAgents() 对同样场景的 * 字段约定 fallback 插入 origin='created' 的记录)。已存在的行一个字段都不改—— * 绝不把 origin='builtin' 的公共本体降级成公司私有。 * * P3B/W7 起从 upsertOrgGraphNode() 里拆出来单独导出:work 层本体被雇时**不写** * org_graph_nodes(判据见 company-service.decideOrgNodeRecording),但 system_agents * 这一行仍然必须补——`work_graph_nodes.system_agent_id` 是指向该表的外键, * 缺行的话 workgraph-planning 雇完人再提交图会直接外键挂(27-P3B §四 W1)。 * * 幂等,可重复调用。调用方自己起事务时也安全(better-sqlite3 支持嵌套 transaction)。 * * @param {string} companyId * @param {string} systemAgentId * @param {string} roleName - 保留形参(调用方都在传),28 批次 4 起不再参与路径推导: * 本体只有一份、恒在 `system/agents/{systemAgentId}`,角色名不进路径 * @param {object} [opts] - `{ status }`:新造的本体传 'draft'(28 批次 5-22 的闸门), * 缺省 'active' 保持既有语义(补一行审计用的引用记录,不改上岗资格) * @returns {boolean} true = 本次真的插了一行;false = 早就有了 */ function ensureSystemAgentRow(companyId, systemAgentId, roleName, opts = {}) { const db = getDb(); const existingAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`).get(systemAgentId); if (existingAgent) return false; const now = new Date().toISOString(); db.prepare(` INSERT INTO system_agents (id, name, scope, owner_company_id, origin, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (@id, @name, 'company', @owner_company_id, 'created', @legacy_path, @status, NULL, @created_at, @updated_at) `).run({ id: systemAgentId, name: systemAgentId, owner_company_id: companyId, legacy_path: `system/agents/${systemAgentId}`, status: opts.status || 'active', created_at: now, updated_at: now, }); return true; } /** * draft 本体的转正闸门(28 批次 5-23)。 * * 为什么是**按需校验**而不是「agent-creator 写完就升」:填 agent.md / soul.md / SKILL.md 的 * 是 agent-creator 自己的 Claude CLI 子进程,触发它的是一次 fire-and-forget 的 HTTP 自调用 * (`company.js` 的 /hire 与 /templates/:id/generate)—— 那条链路上**没有**「写完了」这个 * 回调点可挂。所以判据只认磁盘:谁来问上岗资格,就现场数一次文件。 * 幂等且便宜(三次 readFileSync),可以放在 hire() 的热路径上。 * * 只升不降:已经 active 的行一个字段都不碰 —— 本体在跑的时候被 agent 自己改空了 * (evolve 中途、或者只是暂时写坏),不该把它从岗位上撤下来。 * * `training` 与 `draft` 同属「定义还没填满」:前者是这个状态在 `config.json` 里的拼法 * (hireFromBaseMetaAgent / scaffoldNewAgent 写的),而 `graph-seed-service.seedAgents()` * 会把 config.json 的 status 原样 upsert 进库。只认 'draft' 的话,一份被 seed 回收过的 * 新本体会永远卡在 training 上 —— 闸门抬不起来,且不报错。 * * @param {string} agentId * @returns {{ status: string, missing: string[], promoted: boolean }} * status 是**校验之后**的库内状态;库里没有这行时返回 status='unknown' */ const UNGRADUATED_STATUS = new Set(['draft', 'training']); /** * 这个库内状态算不算「定义还没填满、不许上岗」。 * * 必须由本模块单点回答,调用方不能自己写 `status !== 'active'`:`system_agents.status` * 不是一个封闭取值域 —— `seedAgents()` 把 `config.json.status` 原样 upsert 进库, * 生产库里因此有 `enabled` / `idle` / `ready` / `trained` 共 36 行合法本体。 * 拿 `!== 'active'` 当闸门 = 把这 36 个(含 workgraph-planning / master / agent-creator / * qa-judge)全判成「定义没填满」,hire() 返回 body_not_ready 且 missing 为空数组, * 报的理由还是错的。闸门要挡的只有 draft / training 这两个「刚建出来还没填」的状态。 */ function isUngraduatedStatus(status) { return UNGRADUATED_STATUS.has(status); } /** * 「图基础设施本体」的取值域 —— **永不作为 org 节点**的 config.json `type`。 * * 与 isUngraduatedStatus 同一个道理:取值域必须单点持有,抄一份就迟早漂。 * 这一条已经真漂过:`company-service.decideOrgNodeRecording` 判据①在 P3B/W7 从 * `{'meta-bootstrap'}` 放宽到 `{'meta-bootstrap','meta-planning'}`(要盖住按需雇的 * workgraph-planning 以及它自己雇的 prd/sdd/brd-planning、goal-benchmark), * 而 `graph-seed-service` 里那份手抄的 `isMetaBootstrap()` 停在只认 meta-bootstrap。 * 后果不是「补种脚本画歪一张图」:`seedCompanyAgents()` 会把 hire() 特意排除掉的 * 规划本体重新塞回 `org_graph_nodes`(`authored_by=null` 的孤儿节点),而 * `work-graph-db.js:473` 起 org run 读的就是这张表的 active 行 —— 多出来的是一个 * **真的会被执行**、却谁也点不亮的 org 节点(正是 graph-engine.resolvePlanningAgentName * 注释里点名要防的形态)。 * * 更糟的是 `verifyCounts()` 用**同一个**手抄函数算期望值:两边一致地错 = * `npm run graph:seed` exit 0、零告警,这条漂移永远发现不了。所以判据只能有一处。 * * 放在 graph-service 而不是 agent-locator:这是**图语义**(谁算组织图节点), * 不是目录寻址。company-service 眼下仍持有一份同名字面量(那是别人的文件,本轮不动), * 它已经 require 了本模块,改成引用这里是一行的事;在那之前由 * `server/tests/unit/seed-org-node-guard.test.js` 的漂移守卫盯着两边一致。 */ const NON_ORG_AGENT_TYPES = new Set(['meta-bootstrap', 'meta-planning']); /** 这个 config.json `type` 算不算图基础设施本体(永不入 org graph) */ function isNonOrgAgentType(type) { return NON_ORG_AGENT_TYPES.has(type); } function promoteDraftIfComplete(agentId) { const db = getDb(); const row = db.prepare(`SELECT status FROM system_agents WHERE id = ?`).get(agentId); if (!row) return { status: 'unknown', missing: [], promoted: false }; if (!UNGRADUATED_STATUS.has(row.status)) return { status: row.status, missing: [], promoted: false }; const missing = missingBodyDefinition(agentId); if (missing.length > 0) { // 把「为什么没转正」说出来:绝大多数情况是本体刚从 `_base` 拷出来、agent-creator // 那次 fire-and-forget 还没落盘(或者压根没跑成),文件都在、只是内容还是通用骨架。 // 不打这条日志的话,现场只能看到 hire() 返回 body_not_ready,而目录、库行、文件一应俱全, // 没有任何一处指向「哨兵还在」这个真实原因。 console.log(`[Graph] ⏸️ 本体 ${agentId} 仍在 ${row.status}:缺 ${missing.join(' / ')}(空白,或仍带 ${BASE_SKELETON_SENTINEL} 未被填充)`); return { status: row.status, missing, promoted: false }; } db.prepare(`UPDATE system_agents SET status = 'active', updated_at = ? WHERE id = ? AND status = ?`) .run(new Date().toISOString(), agentId, row.status); console.log(`[Graph] 🎓 本体 ${agentId} 定义已齐全,status: ${row.status} → active`); return { status: 'active', missing: [], promoted: true }; } /** * 这家公司的 org graph 有没有成型,以及某个角色在不在图上(P3B §四 W7 的判据之一)。 * * `hasWorkflow` 判的是「orggraph-planning 提交过一张图没有」,而**不是**「有没有节点」—— * 节点 hire() 自己就会造(P1/P2 语义),拿节点数当信号会把「同一家公司连雇两个人, * 第二个不入图」这条既有语义废掉。 * * 主判据是 `authored_by='planner'` 的节点(writeGraph 是唯一写它的地方)。 * 早先用的是**边数**代理,而真跑里 orggraph-planning 裁出过一张**零条边的单节点图**, * 代理当场失效:三条判据全落空 → work 层本体被写进 org_graph_nodes, * 下一次下达任务时被 advanceOne 当 org 节点执行(P3B W7-fix)。 * 边数保留为存量行(authored_by IS NULL)的回落判据。 * * 此后这张图的作者就是 orggraph-planning, * hire() 不再往上追加节点:`work-graph-db.js:473` 起 org run 时读的正是 org_graph_nodes * 的 active 行,混进去的 work 层本体不是画歪一张图,而是真的多出一个会被执行的 org 节点。 * * @param {string} companyId * @param {string} roleName * @returns {{hasWorkflow: boolean, isNode: boolean}} */ function orgGraphMembership(companyId, roleName) { const db = getDb(); // 主判据:有没有 planner 写下的节点。这是「orggraph-planning 提交过一张图」的**直接**证据。 const planned = db.prepare( `SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND status = 'active' AND authored_by = 'planner'` ).get(companyId); // 回落判据:本列之前的存量行 authored_by 是 NULL,对它们仍用原来的边数代理,行为不变。 const edges = db.prepare( `SELECT COUNT(*) AS c FROM org_graph_edges WHERE company_id = ? AND status = 'active'` ).get(companyId); const node = db.prepare( `SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'` ).get(companyId, roleName); const hasWorkflow = ((planned && planned.c) || 0) > 0 || ((edges && edges.c) || 0) > 0; return { hasWorkflow, isNode: ((node && node.c) || 0) > 0 }; } /** * 为 (companyId, roleName) 建立/复用一条 org_graph_nodes 引用记录。 * * 语义是「把这个 agent 记成本公司 org graph 的一个节点」——**显式动作**, * 调用方有两类:org-graph-service.insertNode(规划 agent 提交 org graph)与 * company-service.hire 判定为 org 层招聘时。work 层招聘不走这里(P3B §四 W7)。 * * system_agents 缺行时先补(ensureSystemAgentRow),因为 org_graph_nodes.system_agent_id * 也是外键。 * * 幂等:org_graph_nodes(company_id, role_name) WHERE status='active' 部分唯一索引 * + INSERT ... ON CONFLICT(...) WHERE status='active' DO NOTHING,重复调用不产生重复行。 * (SQLite 的 conflict target 要匹配部分索引,必须原样带上索引的 WHERE 条件。) * * @param {string} companyId * @param {string} systemAgentId * @param {string} roleName * @returns {void} */ function upsertOrgGraphNode(companyId, systemAgentId, roleName, authoredBy = 'hire') { const db = getDb(); ensureOrgGraphNodeIndex(db); const now = new Date().toISOString(); const tx = db.transaction(() => { ensureSystemAgentRow(companyId, systemAgentId, roleName); db.prepare(` INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at, authored_by) VALUES (@company_id, @system_agent_id, @role_name, 'active', @created_at, @authored_by) ON CONFLICT(company_id, role_name) WHERE status = 'active' DO NOTHING `).run({ company_id: companyId, system_agent_id: systemAgentId, role_name: roleName, created_at: now, authored_by: authoredBy, }); // 冲突被 DO NOTHING 吞掉时行还在,但来源可能需要升格: // hire 先记了一行(authored_by='hire'),随后 orggraph-planning 提交整张图把它纳入, // 这行就该算 planner 写的。反向**不升格** —— planner 写过的图不会因为后续 hire 降级。 if (authoredBy === 'planner') { db.prepare(` UPDATE org_graph_nodes SET authored_by = 'planner' WHERE company_id = ? AND role_name = ? AND status = 'active' `).run(companyId, roleName); } }); tx(); } module.exports = { lookupSharedAgentMd, ensureSystemAgentRow, promoteDraftIfComplete, isUngraduatedStatus, NON_ORG_AGENT_TYPES, isNonOrgAgentType, orgGraphMembership, upsertOrgGraphNode, };