/** * Org Graph Service — 公司级 workflow 图的读写与合法性校验(P2 / T2) * * 设计见 docs/todo/system-design/27-p2-org-graph.md §3.3(四项校验)+ §4.3(API)。 * * 模型要点(v0.4):org_graph 是**执行流图**,不是汇报结构。 * - 节点 = 公司对某个 system_agent 的引用(role_name 在同一公司内唯一,仅约束 active 行) * - 边 = condition ∈ {success, fail} 的执行流转,edge_kind ∈ {forward, backward} * - 删除一律软删除(status='removed'),从不物理删行,保留审计轨迹 * * 对外的 roleName / id 约定(容易踩坑,先看这里): * - writeGraph / patchGraph / validateGraph 的入参边用 **roleName** 表达 from/to * (规划 agent 手里只有角色名,拿不到数据库自增 id) * - getGraph 返回的边用 **节点数字 id** 表达 from/to(前端画布/执行引擎按 id 索引) */ const { getDb } = require('./graph-db'); const { upsertOrgGraphNode } = require('./graph-service'); const CONDITIONS = ['success', 'fail']; const EDGE_KINDS = ['forward', 'backward']; /** * R-19 §4.6:边上可声明的数据契约 kind 取值域(**契约不是文件名** —— 规划时节点还没跑, * 谁也不知道文件叫什么,只能声明「这条边传一份 brd」)。 * * 与 graph-engine.js 的 KNOWN_FILE_KINDS(brd/prd/sdd/acceptance/contract)是**包含**关系: * 那份是「按文件名能自动认出来的 kind」,这份多出 artifact / log 两个兜底值 —— * 声明得出、但认不出文件名时引擎会落到 'artifact',所以取值域必须容得下它。 */ const ARTIFACT_KINDS = ['brd', 'prd', 'sdd', 'acceptance', 'contract', 'artifact', 'log']; /** * 边上的数据契约声明,归一成 [{kind, required, role}]。 * * 两个键,分工是**非对称**的 —— 这层非对称正是 R-19.4 那条校验的依据 * (合成一个键的话,同一行既是「上游产出」又是「下游要吃」, * 「声明要吃 X 但没人产 X」永远自洽,校验就成了空跑): * * produces 这条边的**上游**节点会产出这些 kind,交给下游(绝大多数情况只用这个) * needs 这条边的**下游**节点要吃这些 kind,但产出方**不是**紧邻的上游 * (跨节点中继:引擎按 task + kind 去 graph_files 查实例,不看是谁产的) * * 三种写法都收:`"brd"` / `["brd","prd"]` / `[{"kind":"brd","required":false}]`。 * kind 一律小写化(`"PRD"` 这种大小写差异不该让整张图被打回)。 * 已归一过的边(contracts 数组)再过一次也保持不变 —— work-graph-db 先归一再交给 * validateGraph 二次归一,这里不接住就会把契约在第二次归一时静默丢掉(四环漏一环)。 */ function normalizeContracts(edge) { if (!edge || typeof edge !== 'object') return []; if (Array.isArray(edge.contracts)) { return edge.contracts.map((c) => ({ kind: c && c.kind, required: !(c && c.required === false), role: c && c.role === 'needs' ? 'needs' : 'produces', })); } const out = []; for (const role of ['produces', 'needs']) { const raw = edge[role]; if (raw === undefined || raw === null || raw === '') continue; for (const item of Array.isArray(raw) ? raw : [raw]) { if (item === undefined || item === null || item === '') continue; const rawKind = typeof item === 'string' ? item : item && item.kind; out.push({ kind: typeof rawKind === 'string' ? rawKind.trim().toLowerCase() : rawKind, required: !(typeof item === 'object' && item && item.required === false), role, }); } } return out; } // 用于在事务里主动回滚(better-sqlite3 的 transaction() 靠抛异常回滚) class GraphRollback extends Error { constructor(errors) { super('org graph validation failed'); this.errors = errors; } } // ──────────────────────────────────────────────────────────────── // 入参归一化 // ──────────────────────────────────────────────────────────────── /** * 节点入参既接受 'code-coder' 字符串,也接受 {roleName, systemAgentId?}。 * * systemAgentId 缺省留 null 而**不是**就地回退成 roleName:insertNode 要靠「有没有显式指定」 * 来决定是沿用该角色已有的 agent 绑定,还是按入参改绑(见 insertNode 注释)。 */ function normalizeNodes(nodes) { return (nodes || []).map((n) => { const roleName = typeof n === 'string' ? n : n && n.roleName; const systemAgentId = (typeof n === 'object' && n && n.systemAgentId) || null; return { roleName, systemAgentId }; }); } /** * 边入参:condition 缺省按 'success',edgeKind 缺省按 'forward'(也认下划线写法 edge_kind)。 * failReason(也认 fail_reason)是 condition='fail' 时的细分路由键(P3 §四.D.2.2), * 缺省 / 空串一律归一成 null —— null 就是「兜底边」语义;success 边不允许携带它。 */ function normalizeEdges(edges) { return (edges || []).map((e) => { const condition = e.condition || 'success'; const raw = e.failReason !== undefined ? e.failReason : e.fail_reason; return { from: e.from, to: e.to, condition, failReason: condition === 'fail' && raw !== undefined && raw !== null && raw !== '' ? raw : null, edgeKind: e.edgeKind || e.edge_kind || 'forward', // R-19:边上声明的数据契约(落库时写 graph_edge_contracts,见 writeGraph / createWorkGraph) contracts: normalizeContracts(e), }; }); } // ──────────────────────────────────────────────────────────────── // §3.3 四项校验 + P3 §三.7 第五项 // ──────────────────────────────────────────────────────────────── /** * 校验一张图是否结构合法。 * * 五条规则(rule 字段取值): * 'acyclic' 仅看 forward 边,三色 DFS 判环 * 'single-entry' forward 边意义下入度为 0 的节点必须恰好一个 * 'reachable' 所有节点都要从入口沿 forward 边可达 * 'backward-labeled' 真正的回退边必须标 edge_kind='backward',标注由本函数独立复核 * 'fail-fallback-edge' 有 fail 出边的节点必须留一条 fail_reason 为空的兜底边(P3 §三.7 / T14) * 另有两条前置的形态检查:'schema'(缺字段/重名/枚举越界)、'reference'(边引用了不存在的节点)。 * * @param {Array} nodes [{roleName, systemAgentId?}] 或 ['roleName', ...] * @param {Array} edges [{from, to, condition, failReason?, edgeKind?}],from/to 是 roleName * @returns {{ok: boolean, errors: Array<{rule: string, detail: string}>}} */ function validateGraph(nodes, edges) { const errors = []; const ns = normalizeNodes(nodes); const es = normalizeEdges(edges); // ── 前置:形态与引用完整性(不通过就没法做后面的图论校验,直接返回) const names = []; const nameSet = new Set(); for (const n of ns) { if (!n.roleName || typeof n.roleName !== 'string') { errors.push({ rule: 'schema', detail: `节点缺少 roleName: ${JSON.stringify(n)}` }); continue; } if (nameSet.has(n.roleName)) { errors.push({ rule: 'schema', detail: `节点 roleName 重复: ${n.roleName}` }); continue; } nameSet.add(n.roleName); names.push(n.roleName); } for (const e of es) { const label = `${e.from} -> ${e.to}`; if (!nameSet.has(e.from) || !nameSet.has(e.to)) { errors.push({ rule: 'reference', detail: `边 ${label} 引用了图中不存在的节点` }); } if (!CONDITIONS.includes(e.condition)) { errors.push({ rule: 'schema', detail: `边 ${label} 的 condition 非法: ${e.condition}` }); } if (!EDGE_KINDS.includes(e.edgeKind)) { errors.push({ rule: 'schema', detail: `边 ${label} 的 edgeKind 非法: ${e.edgeKind}` }); } // R-19:数据契约的形态检查。两条都是「写了但静默失效」的坑,必须在写入前拦住: for (const c of e.contracts) { if (!c.kind || !ARTIFACT_KINDS.includes(c.kind)) { errors.push({ rule: 'schema', detail: `边 ${label} 的 ${c.role} 声明了非法 kind: ${JSON.stringify(c.kind)},` + `取值域是 ${ARTIFACT_KINDS.join(' / ')}`, }); } if (e.edgeKind === 'backward') { errors.push({ rule: 'schema', detail: `边 ${label} 是 backward 回退边,不能声明数据契约(${c.role}: ${c.kind})——` + '引擎解析输入只看 forward 入边,写在回退边上等于没写', }); } } } if (errors.length > 0) return { ok: false, errors }; // 空图合法:允许 patchGraph 把一张图清空(否则"删掉最后一个节点"永远做不到) if (names.length === 0) return { ok: true, errors: [] }; const idx = new Map(names.map((name, i) => [name, i])); const forward = es.filter((e) => e.edgeKind === 'forward'); const fwdAdj = names.map(() => []); for (const e of forward) fwdAdj[idx.get(e.from)].push(idx.get(e.to)); // ── 规则 1:无环(三色 DFS,思路同 devloop/doc-store.js 的 hasCycle) const color = new Array(names.length).fill(0); // 0=white 1=gray 2=black let cycleEdge = null; const visit = (i) => { if (cycleEdge || color[i] === 2) return; color[i] = 1; for (const j of fwdAdj[i]) { if (color[j] === 1) { cycleEdge = `${names[i]} -> ${names[j]}`; return; } if (color[j] === 0) visit(j); if (cycleEdge) return; } color[i] = 2; }; for (let i = 0; i < names.length && !cycleEdge; i++) if (color[i] === 0) visit(i); if (cycleEdge) { errors.push({ rule: 'acyclic', detail: `forward 边构成环,闭合于 ${cycleEdge}` }); } // ── 规则 2:单一入口(forward 边意义下入度为 0 的节点恰好一个) const indeg = new Array(names.length).fill(0); for (const e of forward) indeg[idx.get(e.to)] += 1; const entries = names.filter((_, i) => indeg[i] === 0); if (entries.length !== 1) { errors.push({ rule: 'single-entry', detail: entries.length === 0 ? '没有入度为 0 的节点(forward 边构成环时会出现),任务无法确定从哪进入' : `入度为 0 的节点有 ${entries.length} 个:${entries.join(', ')},必须恰好一个`, }); } // ── 规则 3:可达性(从入口沿 forward 边 BFS;多入口时取声明序第一个,保证报错可复现) if (entries.length > 0) { const seen = new Set([idx.get(entries[0])]); const queue = [idx.get(entries[0])]; while (queue.length > 0) { for (const j of fwdAdj[queue.shift()]) { if (!seen.has(j)) { seen.add(j); queue.push(j); } } } const orphans = names.filter((_, i) => !seen.has(i)); if (orphans.length > 0) { errors.push({ rule: 'reachable', detail: `节点从入口 ${entries[0]} 不可达:${orphans.join(', ')}`, }); } } // ── 规则 4:回退边必须显式标注(不信任调用方的 edgeKind,按图结构独立复核) // 判据:边 u→v 是回退边 ⟺ u===v(自环),或去掉这条边之后 v 仍能沿 forward 边回到 u // (即这条边闭合了一个循环)。用可达性而不是某一个具体拓扑序,是因为拓扑序不唯一, // 用它判"更早"会让同一张图的校验结果依赖节点声明顺序。 // ponytail: 每条边一次 DFS,O(E·(V+E))。org graph 是十几个节点的量级,不值得建支配树/SCC。 const reachesWithout = (startIdx, targetIdx, skipEdge) => { const seen = new Set([startIdx]); const stack = [startIdx]; while (stack.length > 0) { const cur = stack.pop(); if (cur === targetIdx && cur !== startIdx) return true; for (const e of forward) { if (e === skipEdge) continue; if (idx.get(e.from) !== cur) continue; const nxt = idx.get(e.to); if (nxt === targetIdx) return true; if (!seen.has(nxt)) { seen.add(nxt); stack.push(nxt); } } } return false; }; for (const e of es) { const isLoopClosing = e.from === e.to || reachesWithout(idx.get(e.to), idx.get(e.from), e); if (isLoopClosing && e.edgeKind !== 'backward') { errors.push({ rule: 'backward-labeled', detail: `边 ${e.from} -> ${e.to} 指向了拓扑序更早的节点(构成回路),edge_kind 必须是 'backward'`, }); } else if (!isLoopClosing && e.edgeKind === 'backward') { errors.push({ rule: 'backward-labeled', detail: `边 ${e.from} -> ${e.to} 并不构成回路,不应标注为 'backward'`, }); } } // ── 规则 5:fail 兜底边(P3 §三.7 / §四.D.2.2 末段) // 归因产出的 fail_reason 先找精确匹配的边,无匹配则退到 fail_reason 为空的兜底边; // 一条兜底边都没有的话,图上没覆盖到的归因取值会直接走进死路(表现为莫名其妙 blocked)。 // work 层同一条规则由 work-graph-db 复用本函数继承,不再单独实现。 const failOut = new Map(); // from -> 是否已有兜底边 for (const e of es) { if (e.condition !== 'fail') continue; failOut.set(e.from, failOut.get(e.from) || e.failReason === null); } for (const [from, hasFallback] of failOut) { if (!hasFallback) { errors.push({ rule: 'fail-fallback-edge', detail: `节点 ${from} 有 fail 出边,但没有一条 fail_reason 为空的兜底边,归因结果不匹配任何边时会直接死路`, }); } } // ── 规则 6:声明要吃的 kind 必须有产出方(R-19.4) // 拦的是第四次真 CLI 冒烟那类断点的**结构版**:图上某个节点声明要吃 X, // 而全图没有任何节点的出边声明产出 X —— 现在这种错只能等跑到那一步、 // 由 agent 说「我拿不到输入」才暴露(原话:「不是上游没产出,是它的产出结构上到不了我手上」)。 // 只查 required 的 needs:required=false 是显式的「可选输入,缺了不算错」逃生口 // (产出方可能在上一层 org graph 或 artifacts/ 里,本图管不着)。 const producedKinds = new Set(); for (const e of es) { for (const c of e.contracts) if (c.role === 'produces') producedKinds.add(c.kind); } for (const e of es) { for (const c of e.contracts) { if (c.role !== 'needs' || !c.required || producedKinds.has(c.kind)) continue; errors.push({ rule: 'data-contract', detail: `边 ${e.from} -> ${e.to} 声明 ${e.to} 要吃 kind=${c.kind},` + '但图上没有任何节点的出边声明产出它:' + `给产出方那条出边补 "produces":["${c.kind}"],` + `或(产出方不在本图时)把这里改成 "needs":[{"kind":"${c.kind}","required":false}]`, }); } } return { ok: errors.length === 0, errors }; } // ──────────────────────────────────────────────────────────────── // 落库 // ──────────────────────────────────────────────────────────────── /** 该公司当前 active 节点:roleName -> id */ function activeNodeIds(db, companyId) { const rows = db .prepare(`SELECT id, role_name FROM org_graph_nodes WHERE company_id = ? AND status = 'active'`) .all(companyId); return new Map(rows.map((r) => [r.role_name, r.id])); } /** * 插入一个 active 节点并返回它的 id(复用 P1 的 upsert,顺带补 system_agents 缺失行)。 * * systemAgentId 的取值优先级(第三处「静默重置」,见文件末的 R3-F2 说明): * 1. 入参显式给了就用入参(并在节点已存在时补一次 UPDATE —— upsert 是 ON CONFLICT DO NOTHING, * 对已有 active 行不会改 system_agent_id,改绑请求会被静默吞掉) * 2. 否则沿用该 (company, role) 最近一条记录的绑定(含刚被 writeGraph 置 removed 的那条) * 3. 都没有才回退成 roleName * 少了第 2 步的话,规划 agent 只带角色名重写一次图,就会把 hire 建立的 agent 绑定重置成 * roleName,并顺手在 system_agents 里造一行同名假 agent。 */ function insertNode(db, companyId, node) { const known = db .prepare( `SELECT system_agent_id FROM org_graph_nodes WHERE company_id = ? AND role_name = ? ORDER BY id DESC LIMIT 1` ) .get(companyId, node.roleName); const systemAgentId = node.systemAgentId || (known && known.system_agent_id) || node.roleName; // 标 'planner':这一行是规划 agent 提交整张图写下的,不是 hire 顺手记的。 // decideOrgNodeRecording 靠它判「这家公司的 org graph 成型了没有」—— // 早先用边数代理,被一张零条边的单节点图打穿(P3B W7-fix)。 upsertOrgGraphNode(companyId, systemAgentId, node.roleName, 'planner'); const row = db .prepare( `SELECT id, system_agent_id FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'` ) .get(companyId, node.roleName); if (row && row.system_agent_id !== systemAgentId) { db.prepare(`UPDATE org_graph_nodes SET system_agent_id = ? WHERE id = ?`).run(systemAgentId, row.id); } return row ? row.id : null; } /** * 插入一条 active 边,返回 `{changes, id}`:changes 是实际落库行数(0 = 撞上去重键被 * DO NOTHING 吞掉),id 是这条边在 `org_graph_edges` 里的行 id —— * 被吞掉时回查已有的那一行,好让 R-19 的契约照样挂到**同一条边**上(不然重复边上的 * 声明会静默蒸发,而声明蒸发的表现是「下游提示词里没有上游产物路径」,极难排查)。 * * conflict target 必须与 graph-db.js 的 idx_org_graph_edges_dedupe_active 逐字一致 * (含 COALESCE(fail_reason,'') 与 WHERE status='active'),否则 SQLite 找不到对应的 * 部分索引会直接报 "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint"。 * 键里带 fail_reason 之后,「精确 fail 边 + 兜底边」是两条不同的边,可以并存(R3-F2)。 */ function insertEdge(db, companyId, fromId, toId, edge, now) { const params = { company_id: companyId, from_node_id: fromId, to_node_id: toId, condition: edge.condition, fail_reason: edge.failReason === undefined ? null : edge.failReason, edge_kind: edge.edgeKind, created_at: now, }; const res = db.prepare(` INSERT INTO org_graph_edges (company_id, from_node_id, to_node_id, condition, fail_reason, edge_kind, status, created_at) VALUES (@company_id, @from_node_id, @to_node_id, @condition, @fail_reason, @edge_kind, 'active', @created_at) ON CONFLICT(company_id, from_node_id, to_node_id, condition, COALESCE(fail_reason, '')) WHERE status = 'active' DO NOTHING `).run(params); if (res.changes > 0) return { changes: res.changes, id: res.lastInsertRowid }; const existing = db.prepare(` SELECT id FROM org_graph_edges WHERE company_id = @company_id AND from_node_id = @from_node_id AND to_node_id = @to_node_id AND condition = @condition AND COALESCE(fail_reason, '') = COALESCE(@fail_reason, '') AND status = 'active' `).get(params); return { changes: 0, id: existing ? existing.id : null }; } /** * R-19 落库侧(org 层):把边上声明的契约写进 `graph_edge_contracts`。 * * 这是 `declareEdgeContract` 在 org 层的**唯一调用方** —— 没有它,`inboundContracts` * 恒返回空,「沿边传递」这一整类数据流实际不生效(地基铺好了但没人踩上去)。 * * 懒 require:work-graph-db 在模块顶层 require 本文件(复用 validateGraph), * 顶层反向 require 会成环并拿到半初始化的 exports。 */ function declareContracts(edgeId, contracts) { if (edgeId == null || !contracts || !contracts.length) return; const { declareEdgeContract } = require('./work-graph-db'); for (const c of contracts) { declareEdgeContract({ edgeLayer: 'org', edgeId, kind: c.kind, required: c.required, role: c.role, }); } } /** * 整体写入:先过 validateGraph(§3.3 四项 + P3 第五项)校验,通过后在一个事务里把该公司已有的 active 图整体作废, * 再写入新的 nodes / edges。校验不通过一行都不落库。 * * @param {string} companyId * @param {{nodes: Array, edges: Array}} graph 边的 from/to 是 roleName * @returns {{ok: boolean, errors?: Array, nodeCount?: number, edgeCount?: number}} * nodeCount / edgeCount 是**实际落库行数**,重复边被去重键吃掉时会小于入参长度 */ function writeGraph(companyId, { nodes = [], edges = [] } = {}) { const ns = normalizeNodes(nodes); const es = normalizeEdges(edges); const verdict = validateGraph(ns, es); if (!verdict.ok) return { ok: false, errors: verdict.errors }; const db = getDb(); const now = new Date().toISOString(); // 计数一律来自「实际落库行数」,不是入参长度:去重键会吃掉重复边(DO NOTHING), // 报入参长度就是在对调用方撒谎(R3-F2 的后果 b)。 let nodeCount = 0; let edgeCount = 0; db.transaction(() => { db.prepare( `UPDATE org_graph_edges SET status = 'removed' WHERE company_id = ? AND status = 'active'` ).run(companyId); db.prepare( `UPDATE org_graph_nodes SET status = 'removed' WHERE company_id = ? AND status = 'active'` ).run(companyId); const ids = new Map(); for (const n of ns) { const id = insertNode(db, companyId, n); ids.set(n.roleName, id); if (id != null) nodeCount += 1; } for (const e of es) { const ins = insertEdge(db, companyId, ids.get(e.from), ids.get(e.to), e, now); edgeCount += ins.changes; declareContracts(ins.id, e.contracts); } })(); return { ok: true, nodeCount, edgeCount }; } /** * 增量修改。所有改动落在一个事务里,改完之后整张图重新过一遍 §3.3 校验, * 不合法则整体回滚并返回 errors(调用方看到的库还是改动前的样子)。 * * @param {string} companyId * @param {{addNodes?: Array, removeNodes?: string[], addEdges?: Array, removeEdges?: Array}} patch * addNodes [{roleName, systemAgentId?}] * removeNodes ['roleName', ...](连带级联失效相关边) * addEdges [{from, to, condition, edgeKind?}],from/to 是 roleName * removeEdges [{from, to, condition?, failReason?}],condition 缺省时删掉这对节点之间所有 active 边; * failReason 缺省时不按路由键过滤,显式给 null 只删兜底边、给具体值只删那条精确边 * @returns {{ok: boolean, errors?: Array}} */ function patchGraph(companyId, patch = {}) { const db = getDb(); const now = new Date().toISOString(); const addNodes = normalizeNodes(patch.addNodes); const addEdges = normalizeEdges(patch.addEdges); const removeNodes = (patch.removeNodes || []).map((n) => (typeof n === 'string' ? n : n.roleName)); const removeEdges = patch.removeEdges || []; try { db.transaction(() => { // 1. 加节点(幂等:同名 active 节点已存在时 upsert 不产生第二行) for (const n of addNodes) insertNode(db, companyId, n); // 2. 删边 let ids = activeNodeIds(db, companyId); for (const e of removeEdges) { const fromId = ids.get(e.from); const toId = ids.get(e.to); if (fromId == null || toId == null) continue; // 同一对 from/to 上可以并存「精确 fail 边 + 兜底边」(R3-F2 后),所以删边也要能按 // 路由键定位:不给 failReason = 不按它过滤(老行为);显式给 null/'' = 只删兜底边; // 给具体值 = 只删那条精确边。COALESCE 与去重索引的键表达式保持一致。 const rawReason = e.failReason !== undefined ? e.failReason : e.fail_reason; const reasonKey = rawReason === undefined ? null : rawReason == null || rawReason === '' ? '' : rawReason; db.prepare(` UPDATE org_graph_edges SET status = 'removed' WHERE company_id = ? AND from_node_id = ? AND to_node_id = ? AND status = 'active' AND (? IS NULL OR condition = ?) AND (? IS NULL OR COALESCE(fail_reason, '') = ?) `).run(companyId, fromId, toId, e.condition || null, e.condition || null, reasonKey, reasonKey); } // 3. 删节点(级联失效它的进出边,避免留下悬边) for (const roleName of removeNodes) removeNodeIn(db, companyId, roleName); // 4. 加边 ids = activeNodeIds(db, companyId); for (const e of addEdges) { const fromId = ids.get(e.from); const toId = ids.get(e.to); if (fromId == null || toId == null) { throw new GraphRollback([ { rule: 'reference', detail: `边 ${e.from} -> ${e.to} 引用了不存在的 active 节点` }, ]); } // 增量加边也要带上契约,否则「PATCH 加的边不传数据」是一条隐形的不对称。 // (R-19.A2 起,第 5 步的重新校验读回契约 + role,规则 6 在本通道上真的生效了: // 删掉某个产出方节点后,仍 needs 它的那条边会让整个 PATCH 回滚。) declareContracts(insertEdge(db, companyId, fromId, toId, e, now).id, e.contracts); } // 5. 改完的整张图必须仍然合法 const graph = readGraph(db, companyId); const verdict = validateGraph( graph.nodes.map((n) => ({ roleName: n.roleName, systemAgentId: n.systemAgentId })), graph.edges.map((e) => ({ from: graph.nameById.get(e.from), to: graph.nameById.get(e.to), condition: e.condition, failReason: e.failReason, edgeKind: e.edgeKind, // 契约必须一起递进去:validateGraph 只看它收到的这份数组, // 漏掉这一行规则 6 就恒真通过(readGraph 那侧的注释说明为什么这条通道最需要它) contracts: e.contracts, })) ); if (!verdict.ok) throw new GraphRollback(verdict.errors); })(); } catch (err) { if (err instanceof GraphRollback) return { ok: false, errors: err.errors }; throw err; } return { ok: true }; } /** 内部读:多返回一个 nameById,供 patchGraph 把 id 边翻回 roleName 边 */ function readGraph(db, companyId) { const nodeRows = db .prepare(` SELECT id, role_name, system_agent_id, status FROM org_graph_nodes WHERE company_id = ? AND status = 'active' ORDER BY id `) .all(companyId); const nameById = new Map(nodeRows.map((r) => [r.id, r.role_name])); const edgeRows = db .prepare(` SELECT id, from_node_id, to_node_id, condition, fail_reason, edge_kind FROM org_graph_edges WHERE company_id = ? AND status = 'active' ORDER BY id `) .all(companyId) // 防御:节点被物理清理/异常状态时不返回悬边 .filter((r) => nameById.has(r.from_node_id) && nameById.has(r.to_node_id)); // R-19.A2:把边上声明的契约一起读回来。少了它,patchGraph 第 5 步那次 // 「改完的整张图必须仍然合法」拿到的是一张没有契约的图,规则 6 恒真通过 —— // 而增量 PATCH(删节点 / 删边)正是「此前满足的 needs 失去产出方」的唯一发生地, // 校验偏偏在最该生效的通道上失效。懒 require 兼作建表保证:契约表由 work-graph-db // 的 ensureSchema 建,只加载本模块时它可能还不存在。 // ponytail: 一条边一次查询。org 图是个位数边,够用;上百条边时改成一次 IN 查询后按 edge_id 分组。 const { edgeContracts } = require('./work-graph-db'); const contractsByEdge = new Map( edgeRows.map((r) => [ r.id, edgeContracts('org', r.id).map((c) => ({ kind: c.kind, required: !!c.required, // 迁移过来的旧行 role 为 NULL(见 work-graph-db 的回填注释),当 produces 读 role: c.role === 'needs' ? 'needs' : 'produces', })), ]) ); return { nodes: nodeRows.map((r) => ({ id: r.id, roleName: r.role_name, systemAgentId: r.system_agent_id, status: r.status, })), edges: edgeRows.map((r) => ({ id: r.id, from: r.from_node_id, to: r.to_node_id, condition: r.condition, failReason: r.fail_reason === undefined ? null : r.fail_reason, edgeKind: r.edge_kind, contracts: contractsByEdge.get(r.id) || [], })), nameById, }; } /** * 只读查询。边的 from/to 是节点数字 id(§4.3 的 GET 返回形状)。 * 公司不存在 / 没有图时返回空图,不抛错。 * * @param {string} companyId * @returns {{nodes: Array<{id, roleName, systemAgentId, status}>, edges: Array<{from, to, condition, edgeKind}>}} */ function getGraph(companyId) { const { nodes, edges } = readGraph(getDb(), companyId); // failReason 只在有值时出现:null 就是「兜底边 / 无路由键」,省掉它可让 GET 的响应形状 // 与 P2 完全兼容(既有消费方不会突然多出一个恒 null 的字段)。 // contracts 同 failReason 一样「只在有值时出现」:绝大多数存量边一条声明都没有, // 恒空数组会让既有消费方凭空多出一个字段。id 是 patchGraph 的内部需要(按边挂契约), // 不进对外形状。 return { nodes, edges: edges.map(({ failReason, id, contracts, ...rest }) => ({ ...rest, ...(failReason == null ? {} : { failReason }), ...(contracts && contracts.length ? { contracts } : {}), })), }; } /** * org graph 的末节点(没有 forward+success 出边)= 一次任务的**实际交付者**, * 对外汇报时当 `from` 用。返回 `{companyId}:{systemAgentId}`,图是空的就返回 null。 * * 为什么住在这里而不是 `routes/company.js`(它原先的家):读侧搬到了 * `services/task-settle.js` 的 goal 级汇报上。让 service 反向 require 一个 express router * 会拖进整条路由链、还埋一个循环 require;而各抄一份就是「改一处漏一处」的老病 * (§九.1 立过案)。末节点的判据只留一份,放在图的持有者身上。 * * `graph` 可以由调用方传进来复用(路由里通常刚 getGraph 过),省一次查库。 */ function terminalAgentId(companyId, graph) { const g = graph || getGraph(companyId); const hasForwardOut = new Set( (g.edges || []) .filter((e) => e.condition === 'success' && e.edgeKind !== 'backward') .map((e) => e.from) ); const nodes = g.nodes || []; const tail = nodes.filter((n) => !hasForwardOut.has(n.id)); const pick = (tail[0] || nodes[0] || {}).systemAgentId; return pick ? `${companyId}:${pick}` : null; } /** removeNode 的事务内实现(patchGraph 复用),返回失效掉的边数;节点不存在返回 null */ function removeNodeIn(db, companyId, roleName) { const row = db .prepare( `SELECT id FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'` ) .get(companyId, roleName); if (!row) return null; const res = db .prepare(` UPDATE org_graph_edges SET status = 'removed' WHERE company_id = ? AND status = 'active' AND (from_node_id = ? OR to_node_id = ?) `) .run(companyId, row.id, row.id); db.prepare(`UPDATE org_graph_nodes SET status = 'removed' WHERE id = ?`).run(row.id); return res.changes; } /** * 软删除一个节点,并级联把它的进出 active 边一并置 'removed'(否则 getGraph 会返回悬边)。 * * 刻意**不**做 §3.3 校验:fire 是既成事实,不能因为"删完图不合法"而拒绝解雇。 * 删完的图可能是多入口/有孤立节点的,由后续 patchGraph 或重跑 orggraph-planning 修复。 * * @returns {{ok: boolean, nodeId?: number, removedEdges?: number, errors?: Array}} */ function removeNode(companyId, roleName) { const db = getDb(); let nodeId = null; let removedEdges = null; db.transaction(() => { const before = db .prepare( `SELECT id FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'` ) .get(companyId, roleName); if (!before) return; nodeId = before.id; removedEdges = removeNodeIn(db, companyId, roleName); })(); if (nodeId == null) { return { ok: false, errors: [{ rule: 'reference', detail: `公司 ${companyId} 没有 active 节点 ${roleName}` }], }; } return { ok: true, nodeId, removedEdges }; } module.exports = { ARTIFACT_KINDS, normalizeContracts, validateGraph, writeGraph, patchGraph, getGraph, terminalAgentId, removeNode, };