io-model.js 9.88 KB
/**
 * 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`,全量进 <title>。
 * 占那唯一一个位置的是**必需**的那个(可选输入缺了不算错,不值得占位)。
 * kind 与 `+N` 分开返回:调用方按画布宽度截断 kind 时不能把 `+N` 一起切掉,
 * 否则「这条边还传着别的东西」这个信号就没了。
 */
export function edgeContractLabel(edge) {
  const cs = contractsOf(edge);
  if (!cs.length) return null;
  const head = cs.find((c) => c.required) || cs[0];
  return {
    kind: head.kind,
    extra: cs.length - 1,
    text: cs.length > 1 ? `${head.kind} +${cs.length - 1}` : head.kind,
    title: cs.map((c) => (c.required ? c.kind : `${c.kind}?`)).join(' · '),
    optionalOnly: cs.every((c) => !c.required),
    count: cs.length,
  };
}