io-model.js
9.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
/**
* 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,
};
}