graph-seed-service.js 16.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429
/**
 * Graph Seed Service — populates `data/graph.sqlite` from existing on-disk
 * agent/template/company sources (P0, read-only w.r.t. the legacy sources).
 *
 * seedGlobalAgents() — system/agents/* → system_agents (source_kind='global_agent')
 * seedTemplates() — system/templates/* → system_agents (source_kind='template')
 * seedCompanyAgents() — data/companies/{id}/company.json.agents[] → system_agents (source_kind='company_hired') + org_graph_nodes
 * (T4 adds verifyCounts() here later.)
 *
 * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。
 */

const fs = require('fs');
const path = require('path');
const { getDb } = require('./graph-db');

const ROOT = path.resolve(__dirname, '../..');
const AGENTS_DIR = path.join(ROOT, 'system', 'agents');
const TEMPLATES_DIR = path.join(ROOT, 'system', 'templates');
const TEMPLATES_REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json');
const COMPANIES_DIR = path.join(ROOT, 'data', 'companies');

/**
 * 扫描 system/agents/*,过滤掉没有 agent.md 的目录(排除 templates/ 等杂物),
 * 逐个 upsert 到 system_agents,scope='public',source_kind='global_agent'。
 *
 * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。
 * @returns {{ scanned: number, seeded: number, ids: string[] }}
 */
function seedGlobalAgents() {
  const db = getDb();

  const entries = fs.existsSync(AGENTS_DIR)
    ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
    : [];

  const agentDirs = entries.filter(d =>
    fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md'))
  );

  const now = new Date().toISOString();

  const upsert = 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, 'public', NULL, 'global_agent', @legacy_path, @status, NULL, @created_at, @updated_at)
    ON CONFLICT(id) DO UPDATE SET
      name = excluded.name,
      legacy_path = excluded.legacy_path,
      status = excluded.status,
      updated_at = excluded.updated_at
  `);

  const ids = [];
  const seedOne = db.transaction((dirEntries) => {
    for (const entry of dirEntries) {
      const id = entry.name;
      const dir = path.join(AGENTS_DIR, id);
      const configPath = path.join(dir, 'config.json');

      let config = null;
      if (fs.existsSync(configPath)) {
        try {
          config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
        } catch {
          config = null; // malformed config.json → fall back to defaults
        }
      }

      const name = (config && config.name) || id;
      const status = (config && config.status) || 'active';

      upsert.run({
        id,
        name,
        legacy_path: `system/agents/${id}`,
        status,
        created_at: now,
        updated_at: now,
      });
      ids.push(id);
    }
  });

  seedOne(agentDirs);

  return { scanned: entries.length, seeded: ids.length, ids };
}

/**
 * 扫描 system/templates/*(目录扫描才是数据源头),逐个 upsert 到 system_agents,
 * scope='public', source_kind='template'。
 *
 * `_registry.json` 只用来补充可选的 benchmark_score:按 id 匹配上的条目用其
 * `benchmarkScore` 字段(registry 里该字段本身可能是 null),匹配不上的一律留 NULL。
 * 容忍模板目录完全没有 config.json(如 ceo/ 只有 plan.md+todo.md):
 * 缺失时 name 默认目录名、status 默认 'active'。
 *
 * Idempotent: 用 INSERT ... ON CONFLICT(id) DO UPDATE,重复执行不产生重复行。
 *
 * id 命名空间跨源共享(`system/agents/` 与 `system/templates/` 有同名目录,如
 * `evolver`/`hunter`/`agent-creator`):当扫到的模板 id 已经被别的 source_kind
 * 占用时(例如 T1 seedGlobalAgents 先写入的 global_agent 行),**跳过写入**,
 * 不覆盖那一行——否则会把 global_agent 行的 legacy_path/name/status 静默篡改成
 * 模板目录的数据,同一行却仍标着 source_kind='global_agent',数据会被污染。
 * 只有当已存在的行本身就是 source_kind='template' 时才走 UPDATE(保证本函数自身可重复执行)。
 *
 * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], skipped: {id: string, existingSourceKind: string}[] }}
 */
function seedTemplates() {
  const db = getDb();

  const entries = fs.existsSync(TEMPLATES_DIR)
    ? fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
    : [];

  let registryById = new Map();
  if (fs.existsSync(TEMPLATES_REGISTRY_PATH)) {
    try {
      const registry = JSON.parse(fs.readFileSync(TEMPLATES_REGISTRY_PATH, 'utf-8'));
      if (Array.isArray(registry)) {
        registryById = new Map(registry.map(entry => [entry.id, entry]));
      }
    } catch {
      registryById = new Map(); // malformed _registry.json → no enrichment, still seed dirs
    }
  }

  const now = new Date().toISOString();

  const findExisting = db.prepare(`SELECT source_kind FROM system_agents WHERE id = ?`);

  const upsert = 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, 'public', NULL, 'template', @legacy_path, @status, @benchmark_score, @created_at, @updated_at)
    ON CONFLICT(id) DO UPDATE SET
      name = excluded.name,
      legacy_path = excluded.legacy_path,
      status = excluded.status,
      benchmark_score = excluded.benchmark_score,
      updated_at = excluded.updated_at
  `);

  const ids = [];
  const registryMatched = [];
  const skipped = [];
  const seedAll = db.transaction((dirEntries) => {
    for (const entry of dirEntries) {
      const id = entry.name;

      const existing = findExisting.get(id);
      if (existing && existing.source_kind !== 'template') {
        skipped.push({ id, existingSourceKind: existing.source_kind });
        continue; // id already owned by another source (e.g. global_agent) — don't clobber it
      }

      const dir = path.join(TEMPLATES_DIR, id);
      const configPath = path.join(dir, 'config.json');

      let config = null;
      if (fs.existsSync(configPath)) {
        try {
          config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
        } catch {
          config = null; // malformed config.json → fall back to defaults
        }
      }

      const name = (config && config.name) || id;
      const status = (config && config.status) || 'active';

      const registryEntry = registryById.get(id);
      const benchmark_score = registryEntry ? (registryEntry.benchmarkScore ?? null) : null;
      if (registryEntry) registryMatched.push(id);

      upsert.run({
        id,
        name,
        legacy_path: `system/templates/${id}`,
        status,
        benchmark_score,
        created_at: now,
        updated_at: now,
      });
      ids.push(id);
    }
  });

  seedAll(entries);

  return { scanned: entries.length, seeded: ids.length, ids, registryMatched, skipped };
}

/**
 * 扫描 data/companies/{id}/company.json,对每个 company 的 `agents[]`(角色名字符串
 * 扁平数组)逐个:
 *   - 若 system_agents 里已存在同 id 的行(来自 T1/T2 的 global_agent/template)→
 *     只建一条 org_graph_nodes 指过去。
 *   - 若不存在 → 先插入一条新的 system_agents 行(scope='company',
 *     owner_company_id=companyId, source_kind='company_hired',
 *     legacy_path='data/companies/{companyId}/agents/{name}'),再建 org_graph_nodes。
 *
 * `org_graph_edges` 本阶段留空——company.json 目前没有汇报关系数据,是预期状态,不是 bug。
 *
 * Idempotent:
 *   - system_agents 用 INSERT ... ON CONFLICT(id) DO NOTHING(company_hired 行一旦建立
 *     不应被后续扫描覆盖,行为上等同"首次发现即定型")。
 *   - org_graph_nodes 没有自然唯一键,靠先查后插(company_id + system_agent_id + role_name
 *     三元组)去重,避免重复跑脚本时行数上涨。
 *
 * @returns {{ companies: string[], nodesCreated: number, nodesExisting: number,
 *             newSystemAgents: string[], reusedSystemAgents: string[] }}
 */
function seedCompanyAgents() {
  const db = getDb();

  const companyDirs = fs.existsSync(COMPANIES_DIR)
    ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
    : [];

  const now = new Date().toISOString();

  const findAgent = db.prepare(`SELECT id FROM system_agents WHERE id = ?`);
  const insertAgent = 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)
    ON CONFLICT(id) DO NOTHING
  `);
  const findNode = db.prepare(`
    SELECT id FROM org_graph_nodes WHERE company_id = ? AND system_agent_id = ? AND role_name = ?
  `);
  const insertNode = 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)
  `);

  const companies = [];
  let nodesCreated = 0;
  let nodesExisting = 0;
  const newSystemAgents = [];
  const reusedSystemAgents = [];

  const seedAll = db.transaction((dirEntries) => {
    for (const entry of dirEntries) {
      const companyId = entry.name;
      const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json');
      if (!fs.existsSync(companyJsonPath)) continue; // no company.json → not a real company yet

      let companyJson;
      try {
        companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8'));
      } catch {
        continue; // malformed company.json → skip, don't seed partial data
      }

      const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : [];
      companies.push(companyId);

      for (const roleName of roleNames) {
        const existing = findAgent.get(roleName);
        if (existing) {
          reusedSystemAgents.push(roleName);
        } else {
          insertAgent.run({
            id: roleName,
            name: roleName,
            owner_company_id: companyId,
            legacy_path: `data/companies/${companyId}/agents/${roleName}`,
            created_at: now,
            updated_at: now,
          });
          newSystemAgents.push(roleName);
        }

        // role_name 目前恒等于 system_agent_id:company.json.agents[] 现在只是纯角色名字符串数组,
        // 没有独立的"角色名 vs 引用哪个 agent"区分。P2 引入 org_graph 编辑能力后两者会分开。
        const existingNode = findNode.get(companyId, roleName, roleName);
        if (existingNode) {
          nodesExisting += 1;
        } else {
          insertNode.run({
            company_id: companyId,
            system_agent_id: roleName,
            role_name: roleName,
            created_at: now,
          });
          nodesCreated += 1;
        }
      }
    }
  });

  seedAll(companyDirs);

  return { companies, nodesCreated, nodesExisting, newSystemAgents, reusedSystemAgents };
}

/**
 * 独立行数交叉核对(T4)——不调用上面三个 seed 函数、不复用它们的返回计数器,
 * 而是从零重新扫描 system/agents/、system/templates/、data/companies/*\/company.json,
 * 按种子逻辑描述的同一套优先级规则(global_agent 优先于 template 优先于 company_hired)
 * 独立推导出"应该"落在 system_agents/org_graph_nodes 里的行数,
 * 再拿去和 DB 里 `SELECT source_kind, COUNT(*) ... GROUP BY source_kind` 以及
 * `SELECT company_id, COUNT(*) FROM org_graph_nodes GROUP BY company_id` 的真实结果比对。
 *
 * 这样一来,如果 seedGlobalAgents/seedTemplates/seedCompanyAgents 内部逻辑跑偏
 * (例如 agent.md 过滤条件写错、冲突跳过逻辑失效、company 角色去重出错),
 * 这里独立算出来的期望值不会跟着一起错,能捕捉到。
 *
 * @returns {{ ok: boolean, mismatches: Array<{check: string, expected: number, actual: number}>,
 *             expected: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object<string, number> },
 *             actual: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object<string, number> } }}
 */
function verifyCounts() {
  const db = getDb();

  // ── 独立重新扫描 system/agents/(含 agent.md 才算数,与 T1 判定规则一致,但独立实现)──
  const agentEntries = fs.existsSync(AGENTS_DIR)
    ? fs.readdirSync(AGENTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
    : [];
  const globalIds = new Set(
    agentEntries
      .filter(d => fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md')))
      .map(d => d.name)
  );

  // ── 独立重新扫描 system/templates/ ──
  const templateEntries = fs.existsSync(TEMPLATES_DIR)
    ? fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
    : [];
  const templateDirIds = templateEntries.map(d => d.name);
  // id 命名空间跨源共享:与 global_agent 同名的模板目录会被 seedTemplates 冲突保护跳过,
  // 不落地为 template 行——独立复算这条精确规则,而不是直接拿模板目录总数当期望值。
  const expectedTemplateIds = new Set(templateDirIds.filter(id => !globalIds.has(id)));

  // ── 独立重新扫描 data/companies/*/company.json ──
  const companyDirs = fs.existsSync(COMPANIES_DIR)
    ? fs.readdirSync(COMPANIES_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
    : [];

  const knownIds = new Set([...globalIds, ...expectedTemplateIds]);
  const expectedNewCompanyHiredIds = new Set();
  const expectedNodesPerCompany = {};

  for (const entry of companyDirs) {
    const companyId = entry.name;
    const companyJsonPath = path.join(COMPANIES_DIR, companyId, 'company.json');
    if (!fs.existsSync(companyJsonPath)) continue;

    let companyJson;
    try {
      companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8'));
    } catch {
      continue; // 与 seedCompanyAgents 一致:损坏的 company.json 不参与核对
    }

    const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : [];
    const distinctRoles = new Set(roleNames); // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重
    expectedNodesPerCompany[companyId] = distinctRoles.size;

    for (const roleName of distinctRoles) {
      if (!knownIds.has(roleName)) {
        expectedNewCompanyHiredIds.add(roleName);
      }
    }
  }

  // ── DB 里的真实计数 ──
  const bySourceKind = db.prepare(`
    SELECT source_kind, COUNT(*) AS cnt FROM system_agents GROUP BY source_kind
  `).all();
  const actualCounts = { global_agent: 0, template: 0, company_hired: 0 };
  for (const row of bySourceKind) {
    actualCounts[row.source_kind] = row.cnt;
  }

  const nodesByCompany = db.prepare(`
    SELECT company_id, COUNT(*) AS cnt FROM org_graph_nodes GROUP BY company_id
  `).all();
  const actualNodesPerCompany = {};
  for (const row of nodesByCompany) {
    actualNodesPerCompany[row.company_id] = row.cnt;
  }

  // ── 比对 ──
  const mismatches = [];

  if (actualCounts.global_agent !== globalIds.size) {
    mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='global_agent'", expected: globalIds.size, actual: actualCounts.global_agent });
  }
  if (actualCounts.template !== expectedTemplateIds.size) {
    mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='template'", expected: expectedTemplateIds.size, actual: actualCounts.template });
  }
  if (actualCounts.company_hired !== expectedNewCompanyHiredIds.size) {
    mismatches.push({ check: "system_agents COUNT(*) WHERE source_kind='company_hired'", expected: expectedNewCompanyHiredIds.size, actual: actualCounts.company_hired });
  }

  const allCompanyIds = new Set([...Object.keys(expectedNodesPerCompany), ...Object.keys(actualNodesPerCompany)]);
  for (const companyId of allCompanyIds) {
    const expected = expectedNodesPerCompany[companyId] || 0;
    const actual = actualNodesPerCompany[companyId] || 0;
    if (expected !== actual) {
      mismatches.push({ check: `org_graph_nodes COUNT(*) WHERE company_id='${companyId}'`, expected, actual });
    }
  }

  return {
    ok: mismatches.length === 0,
    mismatches,
    expected: {
      global_agent: globalIds.size,
      template: expectedTemplateIds.size,
      company_hired: expectedNewCompanyHiredIds.size,
      nodesPerCompany: expectedNodesPerCompany,
    },
    actual: {
      global_agent: actualCounts.global_agent,
      template: actualCounts.template,
      company_hired: actualCounts.company_hired,
      nodesPerCompany: actualNodesPerCompany,
    },
  };
}

module.exports = {
  seedGlobalAgents,
  seedTemplates,
  seedCompanyAgents,
  verifyCounts,
};