graph-service.js 14.9 KB
/**
 * 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` 已于 2026-08-23 随手工
 * 模板管理页一起下线)—— 那条链路上**没有**「写完了」这个
 * 回调点可挂。所以判据只认磁盘:谁来问上岗资格,就现场数一次文件。
 * 幂等且便宜(三次 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,
};