/** * 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 * verifyCounts() — 独立重扫目录做行数交叉核对(T4) * reconcileDanglingRows() — 回收指向已删目录的 company_hired 行 / 已删公司的 org 节点(P3B W1) * runStartupSeed() — 服务启动时跑的那一组(reconcile + global + templates),见 server/index.js * * P0 阶段:只加不改,不涉及任何现有执行路径(company-service.js / agent-manager.js 等)。 * P3B W1 起本模块被接进服务启动流程(runStartupSeed),不再只是手动脚本。 */ const fs = require('fs'); const path = require('path'); const { getDb } = require('./graph-db'); // GRAPH_SEED_ROOT 只是测试用的落地缝(与 graph-db.js 的 GRAPH_DB_PATH 同一套路): // 单测把它指向临时目录,就能在完全受控的 system/ + data/companies/ 上跑种子与对账, // 不再依赖真实仓库里有多少公司。生产不设这个变量,走仓库根。 const ROOT = path.resolve(process.env.GRAPH_SEED_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'); // ───────────────────────── 悬空行回收(P3B W1)───────────────────────── // // 背景:`data/companies/{cid}/` 被手工删掉之后,seedCompanyAgents()/hire() 当初写进 // system_agents 的 `company_hired` 行留在库里,legacy_path 指着一个早已不存在的目录。 // 这些行占着 id 命名空间:`seedTemplates()` 扫到同名模板目录时会因为「id 已被别的 // source_kind 占用」而跳过,于是 `lookupSharedAgentMd()`(只认 source_kind='template') // 对这些 id 恒返回 null,company-service.hire() 静默从「引用模式」退回「legacy 全量拷贝」—— // 结果就是模板改动对任何已雇公司都不生效。所以这不是洁癖,是一条真实的失效链。 /** * 扫描 data/companies/*\/company.json,收集**所有还活着的公司**声明用到的角色名。 * * 这是判断「一行 company_hired 是不是还有人认领」的唯一依据,比只看 owner_company_id 宽: * 同一个 agent id 可能被多家公司复用(seedCompanyAgents 对已存在的 id 只建节点、不重复建行), * 只要还有任何一家活着的公司在 agents[] 里点名它,就不能回收。 * * @returns {{ claimedRoles: Set, liveCompanyIds: Set, companiesDirPresent: boolean }} */ function collectLiveCompanyClaims() { const companiesDirPresent = fs.existsSync(COMPANIES_DIR); const claimedRoles = new Set(); const liveCompanyIds = new Set(); if (!companiesDirPresent) return { claimedRoles, liveCompanyIds, companiesDirPresent }; for (const entry of fs.readdirSync(COMPANIES_DIR, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const companyJsonPath = path.join(COMPANIES_DIR, entry.name, 'company.json'); if (!fs.existsSync(companyJsonPath)) continue; // 没有 company.json → 还不是一家公司 liveCompanyIds.add(entry.name); try { const companyJson = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); if (Array.isArray(companyJson.agents)) { for (const roleName of companyJson.agents) claimedRoles.add(roleName); } } catch { // company.json 坏了 → 公司仍然算活着(目录在),但读不出 agents[]。 // 这时宁可少回收:把整个 claim 判断放宽是危险的,只能退而求其次靠 fs 存在性兜底。 } } return { claimedRoles, liveCompanyIds, companiesDirPresent }; } /** * 一行 system_agents 是不是「悬空的 company_hired」。三个条件必须同时成立,缺一不回收: * 1. source_kind === 'company_hired'(`global_agent` / `template` 行永不参与回收, * 否则 system/agents 与 system/templates 同名 id 的冲突保护会被绕过); * 2. legacy_path 指向的目录**不存在**(真实存在的公司私有 agent 一律不动 —— 这是防误伤的主闸); * 3. 没有任何活着的公司在 company.json.agents[] 里点名这个 id * (目录一时缺失但公司还声明着它 → 视为安装损坏,保留原行,交给人处理,不擅自改语义)。 * * @param {{id: string, source_kind: string, legacy_path: string}} row * @param {Set} claimedRoles collectLiveCompanyClaims().claimedRoles */ function isStaleCompanyHired(row, claimedRoles) { if (!row || row.source_kind !== 'company_hired') return false; if (claimedRoles.has(row.id)) return false; if (!row.legacy_path) return true; return !fs.existsSync(path.resolve(ROOT, row.legacy_path)); } /** * 幂等的维护步骤:清掉两类悬空数据。跑第二遍时两个计数都是 0。 * * A. **已删公司的 org_graph_nodes / org_graph_edges**:company_id 在磁盘上已经没有 * `data/companies/{cid}/company.json`。先清这一类,否则 B 里要删的 system_agents 行 * 还被这些幽灵节点外键引着,删不掉。 * B. **悬空的 company_hired 行**(判定见 isStaleCompanyHired)。 * * 安全阀: * - `data/companies/` 整个目录都不存在时**跳过 A**。分不清「一家公司都没有」和 * 「目录没挂上」,宁可不删。 * - 每行删除单独 try/catch:真被别处外键引着(work_graph_nodes.system_agent_id、 * work_graphs.owner_org_node_id 等)就保留并记进 retained,不让一行删不掉拖垮整轮启动。 * 这样以后新增引用表也不需要回来改这里。 * * @returns {{ orgNodesRemoved: number, orgEdgesRemoved: number, ghostCompanyIds: string[], * agentsRemoved: string[], retained: {id: string, reason: string}[] }} */ function reconcileDanglingRows() { const db = getDb(); const { claimedRoles, liveCompanyIds, companiesDirPresent } = collectLiveCompanyClaims(); const ghostCompanyIds = []; let orgNodesRemoved = 0; let orgEdgesRemoved = 0; const agentsRemoved = []; const retained = []; // ── A. 已删公司的 org 节点/边 ── if (companiesDirPresent) { const nodeCompanyIds = db .prepare(`SELECT DISTINCT company_id FROM org_graph_nodes`) .all() .map(r => r.company_id); for (const companyId of nodeCompanyIds) { if (liveCompanyIds.has(companyId)) continue; ghostCompanyIds.push(companyId); try { db.transaction(() => { orgEdgesRemoved += db.prepare(`DELETE FROM org_graph_edges WHERE company_id = ?`).run(companyId).changes; // reports_to_node_id 是同表自引用,整批删之前先解开,免得删除顺序踩到外键 db.prepare(`UPDATE org_graph_nodes SET reports_to_node_id = NULL WHERE company_id = ?`).run(companyId); orgNodesRemoved += db.prepare(`DELETE FROM org_graph_nodes WHERE company_id = ?`).run(companyId).changes; })(); } catch (err) { retained.push({ id: `org_graph_nodes[${companyId}]`, reason: err.message }); } } } // ── B. 悬空 company_hired 行 ── const hiredRows = db .prepare(`SELECT id, source_kind, legacy_path FROM system_agents WHERE source_kind = 'company_hired'`) .all(); for (const row of hiredRows) { if (!isStaleCompanyHired(row, claimedRoles)) continue; try { db.prepare(`DELETE FROM system_agents WHERE id = ? AND source_kind = 'company_hired'`).run(row.id); agentsRemoved.push(row.id); } catch (err) { // 还被别的表外键引着 → 留着。seedTemplates() 的接管分支能把其中的模板 id 就地转正, // 不需要先删成功。 retained.push({ id: row.id, reason: err.message }); } } return { orgNodesRemoved, orgEdgesRemoved, ghostCompanyIds, agentsRemoved, retained }; } /** * 扫描 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(保证本函数自身可重复执行)。 * * **P3B W1 的例外(接管)**:占位的那行如果是一条**悬空的 company_hired** * (isStaleCompanyHired:目录没了 + 没有任何活着的公司认领),就地转正成 template 行 —— * scope/owner_company_id/source_kind/legacy_path 全部改写。原来的「一律 skip」让污染不可逆: * 公司目录删了、行还占着模板 id,模板永远补不回来(现状核实第 3-5 条那条失效链)。 * 转正而不是删了重建,是为了让还引着这行的 org_graph_nodes 外键继续有效。 * `global_agent` 占位仍然照旧 skip —— 那是同名不同源,不是污染。 * * @returns {{ scanned: number, seeded: number, ids: string[], registryMatched: string[], * skipped: {id: string, existingSourceKind: string}[], reclaimed: 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 id, source_kind, legacy_path FROM system_agents WHERE id = ?`); // ON CONFLICT 分支必须连 scope/owner_company_id/source_kind 一起改写,接管才算完成: // 只改 legacy_path 会留下一行「legacy_path 指向 system/templates/ 却仍标着 company_hired」 // 的半吊子记录,lookupSharedAgentMd 照旧查不到。对本来就是 template 的行这三列是原值写原值。 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, scope = excluded.scope, owner_company_id = excluded.owner_company_id, source_kind = excluded.source_kind, legacy_path = excluded.legacy_path, status = excluded.status, benchmark_score = excluded.benchmark_score, updated_at = excluded.updated_at `); const { claimedRoles } = collectLiveCompanyClaims(); const ids = []; const registryMatched = []; const skipped = []; const reclaimed = []; 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') { if (isStaleCompanyHired(existing, claimedRoles)) { reclaimed.push(id); // 悬空的 company_hired 占位 → 下面的 upsert 就地转正成 template } else { skipped.push({ id, existingSourceKind: existing.source_kind }); continue; // id already owned by a live 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, reclaimed }; } /** * 扫描 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, }, }; } /** * 服务启动时跑的那一组(P3B W1)。调用方:server/index.js、server/agent-server.js。 * * 跑 reconcileDanglingRows() → seedGlobalAgents() → seedTemplates(),**不跑 * seedCompanyAgents()**。取舍理由:前三个的数据源是仓库自带的 `system/` 目录, * 属于「代码的一部分」,每次启动按最新目录对齐才是对的;而 seedCompanyAgents() 的数据源是 * `data/companies/*\/company.json`,那是运行时状态,归 company-service 的 hire()/fire() 管, * 启动时从 company.json 反推 org_graph_nodes 会跟这两条写入路径抢所有权。它继续留给 * `node scripts/graph-seed.js` 手动跑。 * * 顺序不能换:reconcile 必须在 seedTemplates 之前,否则悬空行还占着模板 id。 * 全同步、全幂等,51 个模板目录 + 20 家公司量级在毫秒级,放在 listen() 之前不影响启动。 * * @returns {{ reconciled: object, globalAgents: object, templates: object }} */ function runStartupSeed() { const reconciled = reconcileDanglingRows(); const globalAgents = seedGlobalAgents(); const templates = seedTemplates(); return { reconciled, globalAgents, templates }; } /** * runStartupSeed() 的「失败不许挡住服务启动」包装 —— 两个 server 入口直接调这个。 * 种子失败只影响 agent 元数据的新鲜度,不该让整个进程起不来;打日志降级即可。 * * @returns {object|null} 成功时是 runStartupSeed() 的汇总,失败时 null */ function runStartupSeedSafely() { try { const summary = runStartupSeed(); const r = summary.reconciled; const parts = [ `global_agent ${summary.globalAgents.seeded}`, `template ${summary.templates.seeded}`, ]; if (summary.templates.reclaimed.length) parts.push(`回收占位 id ${summary.templates.reclaimed.join('/')}`); if (r.agentsRemoved.length) parts.push(`清悬空 company_hired ${r.agentsRemoved.length} 行`); if (r.orgNodesRemoved) parts.push(`清已删公司 org 节点 ${r.orgNodesRemoved} 行`); if (r.retained.length) parts.push(`⚠️ ${r.retained.length} 行被外键引用暂留`); console.log(`[graph-seed] ${parts.join(',')}`); return summary; } catch (err) { console.error(`[graph-seed] 启动种子失败,已降级跳过(不影响服务启动): ${err.message}`); return null; } } module.exports = { seedGlobalAgents, seedTemplates, seedCompanyAgents, verifyCounts, reconcileDanglingRows, runStartupSeed, runStartupSeedSafely, };