/** * 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[] }} */ /** 这个公司角色是不是基础设施 agent(不参与执行流图)。读不到 config.json 一律按普通角色处理。 */ function isMetaBootstrap(companyId, roleName) { try { const cfgPath = path.join(COMPANIES_DIR, companyId, 'agents', roleName, 'config.json'); return JSON.parse(fs.readFileSync(cfgPath, 'utf-8')).type === 'meta-bootstrap'; } catch { return false; } } 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) { // 基础设施角色(type='meta-bootstrap',如 orggraph-planning)不入 org graph—— // 与 company-service.hire() 的同名 guard 保持一致,否则这个补种脚本会把 // hire() 特意排除掉的节点再塞回图里(27-p2-org-graph.md §4.1)。 if (isMetaBootstrap(companyId, roleName)) continue; 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 }, * actual: { global_agent: number, template: number, company_hired: number, nodesPerCompany: Object } }} */ 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, };