graph-service.js 8.42 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 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');
}

/**
 * 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()
 * (graph-seed-service.js 约 213-295 行)对同样场景的字段约定 fallback 插入
 * source_kind='company_hired' 的记录)。已存在的行一个字段都不改——绝不把
 * source_kind='template' / 'global_agent' 的公共 agent 降级成公司私有。
 *
 * 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 - 仅用于 fallback 行的 legacy_path
 * @returns {boolean} true = 本次真的插了一行;false = 早就有了
 */
function ensureSystemAgentRow(companyId, systemAgentId, roleName) {
  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, 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,
  });
  return 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,
  orgGraphMembership,
  upsertOrgGraphNode,
};