/** * R-19 数据契约的前端读法 —— 把「输入从哪来 / 产出给了谁 / 这条边传的是什么」接成实体。 * * R-4 原话要「**边的输入输出**」。27-P3/P4 §四.B.3 此前把它落成了「渲染 output_ref 指向的 * 文件」——**数据流是推断的**,所以那个 tab 只看得见自己的产出,看不见输入从哪来。 * R-19 之后这两件事有实体了: * graph_edge_contracts 规划时写的**契约**(这条边传一份 brd),只有 kind 没有实例 * graph_files 运行时由引擎写的**实例**(rel_path + kind + 谁产的) * 「解析」就是把二者按 kind 对上 —— 这里做的正是引擎 describeInputs 做的那次 join, * 所以下面的取值规则必须与 graph-engine.describeInputs 保持一致,逐条注明。 * * 需要后端在 GET /api/graph-runs/:id 上补三个字段(见 MISSING_HINT);缺了就整块降级, * **不猜**:推断出来的数据流正是第四次真跑栽的那个坑。 */ /** 任务级共享产物的 kind —— 与 graph-engine.TASK_SHARED_KINDS 同一份取值。 * 这一类不受拓扑相邻性限制(R-9.3:acceptance 从 planner 流到 validator,中间隔着 coder, * 两个节点之间没有边),所以它不在任何边上,只能按 task + kind 检索。 */ export const TASK_SHARED_KINDS = ['acceptance', 'contract']; export const IO_BACKEND_FIELDS = ['companyRoot', 'taskFiles', 'edges[].contracts']; /** sqlite 行(snake_case)与手拼对象(camelCase)都能进来,统一成一种形状 */ export function normFile(f) { if (!f) return null; return { id: f.id || null, relPath: f.relPath || f.rel_path || '', kind: f.kind || 'artifact', taskId: f.taskId || f.task_id || null, producedByRunId: f.producedByRunId || f.produced_by_run_id || null, producedByNodeRunId: f.producedByNodeRunId || f.produced_by_node_run_id || null, createdAt: f.createdAt || f.created_at || null, supersededBy: f.supersededBy || f.superseded_by || null, }; } /** * required 在库里是 INTEGER(0/1),路由若直接吐行就是数字;两种都当真值读。 * * role 是**规划侧**的键(org-graph-service.normalizeContracts 的 produces / needs): * produces 紧邻上游会产出它 * needs 下游要吃它,但产出方不必是紧邻上游(跨节点中继,按 task + kind 检索) * 引擎解析输入时两者一视同仁(inboundContracts 不看 role),所以这里只用它做展示区分; * `graph_edge_contracts` 目前没有 role 列,字段缺失时按 produces 读。 */ export function normContract(c) { return { kind: c.kind, required: c.required === undefined ? true : Boolean(Number(c.required)), role: c.role === 'needs' ? 'needs' : 'produces', }; } /** 一条边上声明的契约(后端没补 contracts 字段时返回空数组 → 画布上什么都不多画) */ export function contractsOf(edge) { return ((edge && edge.contracts) || []).map(normContract); } /** 绝对路径 = 公司根 + 相对路径。库里只存相对路径(R-19.3:绝对路径入库会烂), * 根由后端按 companyRootDir 推导后随响应带下来;没带就只显示相对路径,不自己编一个前缀。 */ export function absPath(companyRoot, relPath) { if (!relPath) return null; if (!companyRoot) return relPath; return `${String(companyRoot).replace(/\/+$/, '')}/${relPath}`; } function inboundEdges(edges, defNodeId) { return (edges || []).filter( (e) => String(e.to) === String(defNodeId) && (e.edgeKind || 'forward') !== 'backward' ); } function outboundEdges(edges, defNodeId) { return (edges || []).filter( (e) => String(e.from) === String(defNodeId) && (e.edgeKind || 'forward') !== 'backward' ); } /** * 这个节点的输入 / 输出 / 出边契约。 * * @param {object} a * @param {object} a.node getRunState 的合并节点(要 id 与 defNodeId 两个键) * @param {Array} a.nodes 同图全部节点(拿名字用) * @param {Array} a.edges 同图全部边(带 contracts 才有输入可解析) * @param {Array?} a.files 本 task 的产物实例(后端 taskFiles);null = 后端没给 * @param {string?} a.companyRoot 公司根绝对路径 */ export function resolveNodeIo({ node, nodes = [], edges = [], files = null, companyRoot = null }) { const hasIndex = Array.isArray(files); const rows = (files || []).map(normFile); const nameOf = (defId) => { const n = (nodes || []).find((x) => String(x.defNodeId) === String(defId)); return (n && (n.name || n.defNodeId)) || String(defId); }; // 按 kind 取最新一份实例。engine 是 `for (const f of found) byKind.set(f.kind, f)`, // 而 findFiles 按 created_at 升序返回 —— 所以**后来者胜**,这里必须同向遍历。 const byKind = new Map(); for (const f of rows) byKind.set(f.kind, f); // ── 输入①:沿边传递 ────────────────────────────────────────── // 同一个 kind 可能被多条入边同时声明(fan-in),engine 里 declared 是去重集合, // required 取「任一条声明为必需」。这里同样按 kind 归并,但把声明来源都留下 —— // 「我的输入从哪来」是这个 tab 的第一个问题,来源丢了这块就白做。 const byKindDecl = new Map(); for (const e of inboundEdges(edges, node.defNodeId)) { for (const c of contractsOf(e)) { const cur = byKindDecl.get(c.kind) || { kind: c.kind, required: false, froms: [], relay: true }; cur.required = cur.required || c.required; // produces = 紧邻上游产的;needs = 别处产的、只是从这条边上宣告要吃。 // 全部声明都是 needs 时这份输入并不由上游节点产出,UI 上不能写成「来自 X」 cur.relay = cur.relay && c.role === 'needs'; cur.froms.push({ edgeId: e.id, from: e.from, fromName: nameOf(e.from), condition: e.condition, role: c.role, }); byKindDecl.set(c.kind, cur); } } const inputs = [...byKindDecl.values()].map((d) => { const file = byKind.get(d.kind) || null; return { ...d, source: 'edge', file, path: file ? absPath(companyRoot, file.relPath) : null, resolved: Boolean(file), // 引擎 describeInputs 的取舍:解析到就给路径;没解析到且必需 → 拼一行 ⚠️; // 没解析到且可选 → **整行省略**。所以「agent 到底看见了这一行吗」= resolved || required。 inPrompt: Boolean(file) || d.required, }; }); // ── 输入②:任务级共享(不受相邻性限制)─────────────────────── const shared = rows .filter((f) => TASK_SHARED_KINDS.includes(f.kind) && !byKindDecl.has(f.kind)) .map((f) => ({ kind: f.kind, required: false, froms: [], source: 'task', file: f, path: absPath(companyRoot, f.relPath), resolved: true, inPrompt: true, })); // ── 输出:本节点产出、且已登记进索引的实例 ───────────────────── const outputs = rows .filter((f) => f.producedByNodeRunId && f.producedByNodeRunId === node.id) .map((f) => ({ ...f, path: absPath(companyRoot, f.relPath) })); const producedKinds = new Set(outputs.map((f) => f.kind)); // ── 出边契约:我的产出给了谁 ───────────────────────────────── // 三种状态,别把中间那种也标红: // mine 我产的,就是这条边要传的那份 // elsewhere 索引里有这个 kind 的实例,但不是我产的 —— 合法:引擎解析输入 // 只按 task + kind 查 findFiles,不看是谁产的(role='needs' 的中继就是这种) // missing 索引里根本没有这个 kind → 下游到那一步只会拿到「⚠️ 查不到实例」, // 这正是 R-19.4 说的「跑之前就能看出来的图结构错误」 const producerName = (kind) => { const f = byKind.get(kind); if (!f || !f.producedByNodeRunId) return null; const owner = (nodes || []).find((x) => x.id === f.producedByNodeRunId); return (owner && (owner.name || owner.defNodeId)) || f.producedByNodeRunId; }; const outbound = outboundEdges(edges, node.defNodeId) .map((e) => ({ edgeId: e.id, to: e.to, toName: nameOf(e.to), condition: e.condition, contracts: contractsOf(e).map((c) => { // eslint-disable-next-line no-nested-ternary const state = producedKinds.has(c.kind) ? 'mine' : (byKind.has(c.kind) ? 'elsewhere' : 'missing'); return { ...c, state, by: state === 'elsewhere' ? producerName(c.kind) : null }; }), })) .filter((e) => e.contracts.length); return { hasIndex, inputs: [...inputs, ...shared], outputs, outbound, missingRequired: inputs.filter((i) => i.required && !i.resolved).length, declaredNone: byKindDecl.size === 0, }; } /** * 画布上的边标:克制到一枚小药丸 —— 只显示**一个** kind,其余折成 `+N`,全量进