@tea-agent/loop-agent 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/executors/dag-pi-executor.js +72 -4
  3. package/dist/executors/pi-sdk-executor.js +61 -0
  4. package/dist/executors/shell-executor.js +136 -79
  5. package/dist/worker/observability/dag-execution-trajectory.js +591 -0
  6. package/dist/worker/observability/read-model.js +258 -31
  7. package/dist/worker/observe/dag-node-execution-output.js +180 -0
  8. package/dist/worker/observe/routes.js +53 -6
  9. package/dist/worker/observe/static/dag-edge-routing.js +368 -0
  10. package/dist/worker/observe/static/dag-history-labels.js +95 -0
  11. package/dist/worker/observe/static/dag-layout.d.ts +12 -7
  12. package/dist/worker/observe/static/dag-layout.js +101 -21
  13. package/dist/worker/observe/static/favicon.svg +37 -0
  14. package/dist/worker/observe/static/format.js +31 -1
  15. package/dist/worker/observe/static/index.html +1 -1
  16. package/dist/worker/observe/static/state.js +102 -0
  17. package/dist/worker/observe/static/styles.css +267 -7
  18. package/dist/worker/observe/static/views/dag-graph.js +414 -154
  19. package/dist/worker/observe/static/views/dag-inspector.js +478 -27
  20. package/dist/worker/observe/static/views/dag-trajectory.js +313 -0
  21. package/dist/worker/observe/static/views/dag.js +20 -3
  22. package/dist/workflows/dag/backend-test-pytest-collection.js +162 -7
  23. package/dist/workflows/dag/backend-test-result-contract.js +105 -67
  24. package/dist/workflows/dag/backend-test-scenario-param.js +92 -30
  25. package/dist/workflows/dag/backend-test-writer-completeness.js +55 -0
  26. package/dist/workflows/dag/init-hybrid.js +46 -49
  27. package/dist/workflows/dag/rerun-task.js +86 -0
  28. package/docs/architecture/README.md +4 -0
  29. package/docs/architecture/dag-execution.md +1 -1
  30. package/docs/architecture/worker-and-feature.md +1 -1
  31. package/docs/governance/README.md +3 -0
  32. package/docs/operations/README.md +1 -0
  33. package/docs/templates/backend-test-dag.json +40 -60
  34. package/docs/templates/frontend-test-dag.json +1 -1
  35. package/harness.json +3 -3
  36. package/package.json +3 -2
  37. package/scripts/kb-bootstrap-init-skeleton.sh +1 -1
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Deterministic edge routing for Observe DAG logical topology (P1 simple).
3
+ * Ordinary dependency edges: same stable port-y → explicit straight M...L...;
4
+ * different port-y → single-segment M...C... cubic. Control-loop edges keep an
5
+ * independent top-lane rounded-orthogonal path. Pure geometry — no DOM.
6
+ *
7
+ * Ordinary ports use a logical-row axis (base nodeHeight / 2 from card top),
8
+ * not the visual card center, so dynamic history height does not tilt edges.
9
+ */
10
+
11
+ const CORNER_RADIUS = 10;
12
+ const CONTROL_LANE_HEIGHT = 36;
13
+ const CONTROL_LANE_TOP = 20;
14
+ const GUTTER_MARGIN = 12;
15
+ /** Port-y equality tolerance for straight ordinary edges (float only; px). */
16
+ const SAME_Y_EPS = 0.5;
17
+
18
+ /**
19
+ * Stable ordinary dependency port y for a laid-out node.
20
+ * Uses baseNodeHeight (logical row axis) when provided; otherwise falls back
21
+ * to the node's own height (equal-height layouts / direct unit calls).
22
+ */
23
+ export function dependencyPortY(node, baseNodeHeight) {
24
+ const base = Number(baseNodeHeight);
25
+ const axisHeight =
26
+ Number.isFinite(base) && base > 0 ? base : Number(node?.height) || 0;
27
+ return (Number(node?.y) || 0) + axisHeight / 2;
28
+ }
29
+
30
+ /**
31
+ * Build a smooth cubic SVG path between two ports, or an explicit straight
32
+ * segment when port-y values match within SAME_Y_EPS.
33
+ * Control-point x stays strictly inside the rank gutter (source right → target left).
34
+ */
35
+ export function pointsToSmoothCubicPath(fromPoint, toPoint, options = {}) {
36
+ if (!fromPoint || !toPoint) return "";
37
+ const x1 = fromPoint.x;
38
+ const y1 = fromPoint.y;
39
+ const x2 = toPoint.x;
40
+ const y2 = toPoint.y;
41
+ // AC-001: same port-y → explicit straight M...L...
42
+ if (Math.abs(y1 - y2) <= SAME_Y_EPS) {
43
+ return `M ${fmt(x1)} ${fmt(y1)} L ${fmt(x2)} ${fmt(y2)}`;
44
+ }
45
+ const spanX = x2 - x1;
46
+ if (!(spanX > 0)) {
47
+ // Degenerate / backward: fall back to a straight segment.
48
+ return `M ${fmt(x1)} ${fmt(y1)} L ${fmt(x2)} ${fmt(y2)}`;
49
+ }
50
+ // Keep both control x values inside the open gutter (x1, x2).
51
+ // channelOffset is intentionally ignored for ordinary P1 edges.
52
+ void options;
53
+ const inset = Math.max(GUTTER_MARGIN, spanX / 3);
54
+ let c1x = x1 + inset;
55
+ let c2x = x2 - inset;
56
+ const lo = x1 + 1;
57
+ const hi = x2 - 1;
58
+ c1x = clamp(c1x, lo, hi);
59
+ c2x = clamp(c2x, lo, hi);
60
+ if (c1x > c2x) {
61
+ const mid = (c1x + c2x) / 2;
62
+ c1x = mid;
63
+ c2x = mid;
64
+ }
65
+ return `M ${fmt(x1)} ${fmt(y1)} C ${fmt(c1x)} ${fmt(y1)}, ${fmt(c2x)} ${fmt(y2)}, ${fmt(x2)} ${fmt(y2)}`;
66
+ }
67
+
68
+ /**
69
+ * Build a rounded-orthogonal SVG path from structured polyline points.
70
+ * Used by control-loop edges only under the P1 ordinary-edge rollback.
71
+ */
72
+ export function pointsToRoundedOrthogonalPath(points = [], radius = CORNER_RADIUS) {
73
+ if (!Array.isArray(points) || points.length === 0) return "";
74
+ if (points.length === 1) {
75
+ const p = points[0];
76
+ return `M ${fmt(p.x)} ${fmt(p.y)}`;
77
+ }
78
+ const cleaned = collapseColinear(points);
79
+ if (cleaned.length === 1) {
80
+ return `M ${fmt(cleaned[0].x)} ${fmt(cleaned[0].y)}`;
81
+ }
82
+ let d = `M ${fmt(cleaned[0].x)} ${fmt(cleaned[0].y)}`;
83
+ for (let i = 1; i < cleaned.length; i++) {
84
+ const prev = cleaned[i - 1];
85
+ const curr = cleaned[i];
86
+ const next = cleaned[i + 1];
87
+ if (!next) {
88
+ d += ` L ${fmt(curr.x)} ${fmt(curr.y)}`;
89
+ break;
90
+ }
91
+ const dx1 = curr.x - prev.x;
92
+ const dy1 = curr.y - prev.y;
93
+ const dx2 = next.x - curr.x;
94
+ const dy2 = next.y - curr.y;
95
+ const len1 = Math.hypot(dx1, dy1);
96
+ const len2 = Math.hypot(dx2, dy2);
97
+ if (len1 === 0 || len2 === 0) continue;
98
+ const r = Math.min(radius, len1 / 2, len2 / 2);
99
+ const x1 = curr.x - (dx1 / len1) * r;
100
+ const y1 = curr.y - (dy1 / len1) * r;
101
+ const x2 = curr.x + (dx2 / len2) * r;
102
+ const y2 = curr.y + (dy2 / len2) * r;
103
+ d += ` L ${fmt(x1)} ${fmt(y1)} Q ${fmt(curr.x)} ${fmt(curr.y)} ${fmt(x2)} ${fmt(y2)}`;
104
+ }
105
+ return d;
106
+ }
107
+
108
+ /**
109
+ * Route an ordinary dependency edge with P1 simple geometry.
110
+ * Stable logical-row ports (baseNodeHeight axis) — no port/channel/bypass hybrid.
111
+ * Arrow endpoint sits on the target outer left edge.
112
+ *
113
+ * - Same port-y: explicit straight M...L... (kind: "line")
114
+ * - Different port-y: single-segment cubic M...C... (kind: "cubic")
115
+ * - All forward spans (including long edges) use the same simple path family.
116
+ * - context.baseNodeHeight: logical row axis; dynamic card height is ignored for ports.
117
+ * - SAME_Y_EPS stays 0.5 (float equality only; never enlarged to hide true misalign).
118
+ */
119
+ export function routeDependencyEdge(from, to, context = {}) {
120
+ const baseNodeHeight = context?.baseNodeHeight;
121
+ const y1 = dependencyPortY(from, baseNodeHeight);
122
+ const y2 = dependencyPortY(to, baseNodeHeight);
123
+ const x1 = from.x + from.width;
124
+ const x2 = to.x;
125
+ const start = { x: x1, y: y1 };
126
+ const end = { x: x2, y: y2 };
127
+ const sameY = Math.abs(y1 - y2) <= SAME_Y_EPS;
128
+ const path = pointsToSmoothCubicPath(start, end);
129
+ return {
130
+ points: [start, end],
131
+ path,
132
+ kind: sameY ? "line" : "cubic",
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Route a control-loop edge on an independent top lane (rounded-orthogonal).
138
+ * Path leaves the source left edge into its local left gutter, rises into the
139
+ * lane, travels horizontally above the node canvas, descends in the target
140
+ * right gutter, and enters the target right edge — never through node rects.
141
+ */
142
+ export function routeControlEdge(from, to, laneIndex = 0, options = {}) {
143
+ const laneHeight = options.controlLaneHeight ?? CONTROL_LANE_HEIGHT;
144
+ const laneTop = options.controlLaneTop ?? CONTROL_LANE_TOP;
145
+ const laneY = laneTop + laneIndex * laneHeight + laneHeight / 2;
146
+ const y1 = from.y + from.height / 2;
147
+ const y2 = to.y + to.height / 2;
148
+ // Per-lane gutter offsets keep multi-edge paths from fully overlapping.
149
+ const leftLift = Math.max(28, GUTTER_MARGIN + 8 + laneIndex * 6);
150
+ const rightLift = Math.max(28, GUTTER_MARGIN + 8 + laneIndex * 6);
151
+ const xLeave = from.x;
152
+ const xEnter = to.x + to.width;
153
+ // Rise only in the source-local left gutter (not across intermediate ranks).
154
+ const xRise = from.x - leftLift;
155
+ // Descend only in the target-local right gutter.
156
+ const xDescend = to.x + to.width + rightLift;
157
+
158
+ const points = collapseColinear([
159
+ { x: xLeave, y: y1 },
160
+ { x: xRise, y: y1 },
161
+ { x: xRise, y: laneY },
162
+ { x: xDescend, y: laneY },
163
+ { x: xDescend, y: y2 },
164
+ { x: xEnter, y: y2 },
165
+ ]);
166
+ // Label anchors on the top horizontal lane segment midpoint (not a corner).
167
+ const topA = points.find((p, i) => i > 0 && nearlyEqual(p.y, laneY));
168
+ const topB = [...points]
169
+ .reverse()
170
+ .find((p) => nearlyEqual(p.y, laneY));
171
+ const labelX =
172
+ topA && topB ? (topA.x + topB.x) / 2 : (from.x + to.x + to.width) / 2;
173
+ const labelY = laneY - 8;
174
+ return {
175
+ points,
176
+ path: pointsToRoundedOrthogonalPath(points),
177
+ laneIndex,
178
+ laneY,
179
+ labelX,
180
+ labelY,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * True if any polyline segment intersects a non-endpoint node rectangle.
186
+ * Endpoint contact at source/target ports is allowed.
187
+ */
188
+ export function edgeCollidesNonEndpointNodes(points, nodes, fromId, toId) {
189
+ if (!Array.isArray(points) || points.length < 2) return false;
190
+ const obstacles = (nodes ?? []).filter(
191
+ (node) => node.nodeId !== fromId && node.nodeId !== toId,
192
+ );
193
+ for (let i = 0; i < points.length - 1; i++) {
194
+ const a = points[i];
195
+ const b = points[i + 1];
196
+ for (const node of obstacles) {
197
+ if (segmentIntersectsRect(a, b, nodeRect(node))) return true;
198
+ }
199
+ }
200
+ return false;
201
+ }
202
+
203
+ export function controlLaneMetrics(laneCount = 0, options = {}) {
204
+ const count = Math.max(0, Number(laneCount) || 0);
205
+ const laneHeight = options.controlLaneHeight ?? CONTROL_LANE_HEIGHT;
206
+ const laneTop = options.controlLaneTop ?? CONTROL_LANE_TOP;
207
+ const controlLaneHeight = count === 0 ? 0 : laneTop + count * laneHeight;
208
+ return {
209
+ controlLaneCount: count,
210
+ controlLaneHeight,
211
+ controlLaneOffset: controlLaneHeight,
212
+ laneHeight,
213
+ laneTop,
214
+ };
215
+ }
216
+
217
+ export function assignControlLanes(controlEdges = [], layoutById = new Map()) {
218
+ const valid = (controlEdges ?? [])
219
+ .filter((edge) => layoutById.has(edge.from) && layoutById.has(edge.to))
220
+ .map((edge) => {
221
+ const from = layoutById.get(edge.from);
222
+ const to = layoutById.get(edge.to);
223
+ const span = Math.abs((from?.x ?? 0) - (to?.x ?? 0));
224
+ return { edge, span, key: `${edge.from}\0${edge.to}` };
225
+ });
226
+ // Stable multi-edge assignment: longer span first, then identity.
227
+ valid.sort(
228
+ (a, b) => b.span - a.span || a.key.localeCompare(b.key),
229
+ );
230
+ return valid.map((entry, laneIndex) => ({
231
+ ...entry.edge,
232
+ laneIndex,
233
+ span: entry.span,
234
+ }));
235
+ }
236
+
237
+ function nodeRect(node) {
238
+ return {
239
+ x: node.x,
240
+ y: node.y,
241
+ width: node.width,
242
+ height: node.height,
243
+ };
244
+ }
245
+
246
+ /** Axis-aligned segment vs rect intersection (closed). Degenerate points ok. */
247
+ function segmentIntersectsRect(a, b, rect) {
248
+ const minX = rect.x;
249
+ const maxX = rect.x + rect.width;
250
+ const minY = rect.y;
251
+ const maxY = rect.y + rect.height;
252
+ // Both endpoints strictly inside → collision.
253
+ if (pointInRect(a, rect) && pointInRect(b, rect)) return true;
254
+ // Liang–Barsky style against four edges.
255
+ const edges = [
256
+ [
257
+ { x: minX, y: minY },
258
+ { x: maxX, y: minY },
259
+ ],
260
+ [
261
+ { x: maxX, y: minY },
262
+ { x: maxX, y: maxY },
263
+ ],
264
+ [
265
+ { x: maxX, y: maxY },
266
+ { x: minX, y: maxY },
267
+ ],
268
+ [
269
+ { x: minX, y: maxY },
270
+ { x: minX, y: minY },
271
+ ],
272
+ ];
273
+ // Interior open-segment probe: if midpoint is inside, it crosses the body.
274
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
275
+ if (pointStrictlyInside(mid, rect)) return true;
276
+ for (const [c, d] of edges) {
277
+ if (segmentsIntersect(a, b, c, d)) {
278
+ // Touching only at a corner/edge that is not interior still counts
279
+ // as crossing the obstacle boundary when the other point is beyond.
280
+ if (pointStrictlyInside(a, rect) || pointStrictlyInside(b, rect)) {
281
+ return true;
282
+ }
283
+ // Segment crosses through the rect (not merely grazing a corner
284
+ // outside the open interior). Check a slight inset sample.
285
+ const samples = 4;
286
+ for (let s = 1; s < samples; s++) {
287
+ const t = s / samples;
288
+ const p = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
289
+ if (pointStrictlyInside(p, rect)) return true;
290
+ }
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+
296
+ function pointInRect(p, rect) {
297
+ return (
298
+ p.x >= rect.x &&
299
+ p.x <= rect.x + rect.width &&
300
+ p.y >= rect.y &&
301
+ p.y <= rect.y + rect.height
302
+ );
303
+ }
304
+
305
+ function pointStrictlyInside(p, rect) {
306
+ const eps = 0.51;
307
+ return (
308
+ p.x > rect.x + eps &&
309
+ p.x < rect.x + rect.width - eps &&
310
+ p.y > rect.y + eps &&
311
+ p.y < rect.y + rect.height - eps
312
+ );
313
+ }
314
+
315
+ function segmentsIntersect(p1, p2, p3, p4) {
316
+ const d1 = direction(p3, p4, p1);
317
+ const d2 = direction(p3, p4, p2);
318
+ const d3 = direction(p1, p2, p3);
319
+ const d4 = direction(p1, p2, p4);
320
+ if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
321
+ ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
322
+ return true;
323
+ }
324
+ return false;
325
+ }
326
+
327
+ function direction(a, b, c) {
328
+ return (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x);
329
+ }
330
+
331
+ function collapseColinear(points) {
332
+ if (points.length <= 2) return points.map(clonePoint);
333
+ const out = [clonePoint(points[0])];
334
+ for (let i = 1; i < points.length - 1; i++) {
335
+ const prev = out[out.length - 1];
336
+ const curr = points[i];
337
+ const next = points[i + 1];
338
+ const colinear =
339
+ (nearlyEqual(prev.x, curr.x) && nearlyEqual(curr.x, next.x)) ||
340
+ (nearlyEqual(prev.y, curr.y) && nearlyEqual(curr.y, next.y));
341
+ if (!colinear && !samePoint(prev, curr)) out.push(clonePoint(curr));
342
+ }
343
+ const last = points[points.length - 1];
344
+ if (!samePoint(out[out.length - 1], last)) out.push(clonePoint(last));
345
+ return out;
346
+ }
347
+
348
+ function clonePoint(p) {
349
+ return { x: p.x, y: p.y };
350
+ }
351
+
352
+ function samePoint(a, b) {
353
+ return nearlyEqual(a.x, b.x) && nearlyEqual(a.y, b.y);
354
+ }
355
+
356
+ function nearlyEqual(a, b) {
357
+ return Math.abs(a - b) < 1e-6;
358
+ }
359
+
360
+ function clamp(value, lo, hi) {
361
+ return Math.min(Math.max(value, lo), hi);
362
+ }
363
+
364
+ function fmt(n) {
365
+ const v = Number(n);
366
+ if (!Number.isFinite(v)) return "0";
367
+ return Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/\.?0+$/, "");
368
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * DAG 执行轨迹业务文案映射(Observe UI,2026-08-08-observe-trajectory-business-labels)。
3
+ *
4
+ * 纯函数表驱动映射:raw 内部 token(attempt failureCategory / occurrence
5
+ * semanticVerdict / lane nextReason)仅保留在 title 等诊断属性中,主文案
6
+ * (textContent 与 aria-label)一律使用中文业务术语。未知或缺失分类
7
+ * fail-closed 为通用中文,不猜测、不新增授权外映射键(AC-001/AC-002/AC-003)。
8
+ */
9
+
10
+ /** 表一:attempt 失败分类 → 中文业务术语(未知/缺失 → 其他失败原因)。 */
11
+ const ATTEMPT_FAILURE_LABELS = {
12
+ "protocol-invalid": "格式校验失败",
13
+ unavailable: "服务暂不可用",
14
+ timeout: "执行超时",
15
+ "nonzero-exit": "命令执行失败",
16
+ "invalid-output": "输出格式无效",
17
+ "empty-output": "输出为空",
18
+ auth: "认证失败",
19
+ quota: "额度或限流",
20
+ "rate-limit": "额度或限流",
21
+ "context-overflow": "上下文超限",
22
+ "tool-policy": "工具权限受限",
23
+ "write-guard": "写入边界阻断",
24
+ };
25
+
26
+ export const ATTEMPT_FAILURE_UNKNOWN_LABEL = "其他失败原因";
27
+
28
+ /**
29
+ * 表一映射:attempt 失败分类 → 中文。未知或缺失分类 fail-closed 为
30
+ * 「其他失败原因」(AC-001)。
31
+ */
32
+ export function attemptFailureLabel(category) {
33
+ if (!category) return ATTEMPT_FAILURE_UNKNOWN_LABEL;
34
+ return ATTEMPT_FAILURE_LABELS[String(category)] ?? ATTEMPT_FAILURE_UNKNOWN_LABEL;
35
+ }
36
+
37
+ /** 表二:语义 verdict → 中文业务术语(未知 → 状态待确认;纯中文原样透传)。 */
38
+ const SEMANTIC_VERDICT_LABELS = {
39
+ "request-revision": "要求修订",
40
+ pass: "审查通过",
41
+ "auto-approve": "自动批准",
42
+ "hard-verify-failed": "验证失败",
43
+ "review-request-revision": "要求修订,进入下一轮",
44
+ "review-pass": "审查通过",
45
+ "hard-verify-pass": "验证通过",
46
+ };
47
+
48
+ export const SEMANTIC_VERDICT_UNKNOWN_LABEL = "状态待确认";
49
+
50
+ /** 投影层半中文混合串(hard-verify 失败)归入 hard-verify-failed → 验证失败。 */
51
+ const HARD_VERIFY_MIXED_FAILURE = /^hard-verify\s*失败$/;
52
+ const CJK_RE = /[\u4e00-\u9fff]/;
53
+
54
+ /**
55
+ * 表二映射:occurrence 语义 verdict → 中文。投影半中文混合串
56
+ * 「hard-verify 失败」归入「验证失败」;既有纯中文取值(如「失败」)
57
+ * 原样透传;未知英文 token fail-closed 为「状态待确认」;空值返回空串
58
+ * 供调用方守卫(AC-002)。
59
+ */
60
+ export function semanticVerdictLabel(verdict) {
61
+ if (!verdict) return "";
62
+ const key = String(verdict);
63
+ if (SEMANTIC_VERDICT_LABELS[key]) return SEMANTIC_VERDICT_LABELS[key];
64
+ if (HARD_VERIFY_MIXED_FAILURE.test(key)) {
65
+ return SEMANTIC_VERDICT_LABELS["hard-verify-failed"];
66
+ }
67
+ // 既有投影产出的纯中文取值原样透传,不做猜测。
68
+ if (CJK_RE.test(key)) return key;
69
+ return SEMANTIC_VERDICT_UNKNOWN_LABEL;
70
+ }
71
+
72
+ /** 表三:lane transition 原因 → 中文业务术语(未知 → 状态待确认)。 */
73
+ const TRANSITION_REASON_LABELS = {
74
+ "review-request-revision": "要求修订,进入下一轮",
75
+ "hard-verify-failed": "验证失败,进入下一轮",
76
+ "review-pass": "审查通过",
77
+ "hard-verify-pass": "验证通过",
78
+ // 既有生产 token 的中文文案(repository fallback,非本任务新增授权键)。
79
+ "non-retry-failure": "不可重试失败",
80
+ "blocked-boundary": "边界阻断",
81
+ "no-progress": "无进展",
82
+ regression: "回归",
83
+ "max-passes": "达到最大轮次",
84
+ };
85
+
86
+ export const TRANSITION_REASON_UNKNOWN_LABEL = "状态待确认";
87
+
88
+ /**
89
+ * 表三映射:lane 间 transition 原因 → 中文。未知 token fail-closed 为
90
+ * 「状态待确认」;空值返回空串(无 nextReason 时不渲染箭头,既有守卫)。
91
+ */
92
+ export function transitionReasonLabel(reason) {
93
+ if (!reason) return "";
94
+ return TRANSITION_REASON_LABELS[String(reason)] ?? TRANSITION_REASON_UNKNOWN_LABEL;
95
+ }
@@ -17,15 +17,20 @@ export type DagLayout = {
17
17
  edges: DagLayoutEdge[];
18
18
  };
19
19
 
20
+ export type DagLayoutOptions = Partial<{
21
+ nodeWidth: number;
22
+ nodeHeight: number;
23
+ rankGap: number;
24
+ nodeGap: number;
25
+ padding: number;
26
+ controlLaneCount: number;
27
+ /** Per-node visual card height overrides; ordinary ports still use nodeHeight axis. */
28
+ nodeHeights: Record<string, number>;
29
+ }>;
30
+
20
31
  export function layoutDag(
21
32
  nodes?: DagLayoutNodeInput[],
22
33
  edges?: DagLayoutEdgeInput[],
23
34
  ranks?: string[][],
24
- options?: Partial<{
25
- nodeWidth: number;
26
- nodeHeight: number;
27
- rankGap: number;
28
- nodeGap: number;
29
- padding: number;
30
- }>,
35
+ options?: DagLayoutOptions,
31
36
  ): DagLayout;
@@ -1,49 +1,124 @@
1
+ import {
2
+ controlLaneMetrics,
3
+ routeDependencyEdge,
4
+ } from "./dag-edge-routing.js";
5
+
1
6
  const DEFAULTS = {
2
7
  nodeWidth: 216,
8
+ // Default card height for 3 text rows (title / executor / status).
9
+ // History row uses a taller per-node height via options.nodeHeights.
3
10
  nodeHeight: 92,
4
11
  rankGap: 104,
5
12
  nodeGap: 28,
6
13
  padding: 32,
14
+ /** Presentation-only: reserved top control-lane count (control edges never enter topology). */
15
+ controlLaneCount: 0,
7
16
  };
8
17
 
18
+ /**
19
+ * Layered DAG layout with P1 simple rank order and simple dependency routing
20
+ * (straight line for same logical-row port-y, single cubic for different-y).
21
+ * Ordinary dependency ports use the base nodeHeight axis so dynamic card
22
+ * heights (history rows) expand downward without tilting same-row edges.
23
+ * Control edges never enter topology; optional controlLaneCount only expands
24
+ * presentation height/meta.
25
+ */
9
26
  export function layoutDag(nodes = [], edges = [], ranks = [], options = {}) {
10
27
  const config = { ...DEFAULTS, ...options };
11
- const nodeIds = [...new Set(nodes.map((node) => node.nodeId).filter(Boolean))].sort();
12
- if (nodeIds.length === 0) return { width: 320, height: 160, nodes: [], edges: [] };
28
+ const nodeIds = [
29
+ ...new Set(nodes.map((node) => node.nodeId).filter(Boolean)),
30
+ ].sort();
31
+ if (nodeIds.length === 0)
32
+ return { width: 320, height: 160, nodes: [], edges: [], meta: emptyMeta() };
13
33
  const known = new Set(nodeIds);
34
+ // Base logical-row axis for ordinary dependency ports (not visual card center).
35
+ const baseNodeHeight = config.nodeHeight;
36
+ const heightFor = (nodeId) => {
37
+ const override = config.nodeHeights?.[nodeId];
38
+ const n = Number(override);
39
+ return Number.isFinite(n) && n > 0 ? n : baseNodeHeight;
40
+ };
14
41
  const validEdges = edges
15
- .filter((edge) => known.has(edge.from) && known.has(edge.to) && edge.from !== edge.to)
42
+ .filter(
43
+ (edge) =>
44
+ known.has(edge.from) && known.has(edge.to) && edge.from !== edge.to,
45
+ )
16
46
  .sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
17
- const layers = normalizeLayers(nodeIds, validEdges, ranks);
47
+
48
+ // ranks remain the authoritative rank assignment (AC-001).
49
+ // Within-rank order is stable identity sort only (no barycenter sweeps).
50
+ const orderedLayers = normalizeLayers(nodeIds, validEdges, ranks);
51
+
52
+ const laneMetrics = controlLaneMetrics(config.controlLaneCount, config);
53
+ const yOffset = laneMetrics.controlLaneOffset;
54
+
18
55
  const positioned = [];
19
- for (let rank = 0; rank < layers.length; rank++) {
20
- const ids = layers[rank];
56
+ let maxColumnHeight = 0;
57
+ for (let rank = 0; rank < orderedLayers.length; rank++) {
58
+ const ids = orderedLayers[rank];
59
+ let y = config.padding + yOffset;
21
60
  for (let index = 0; index < ids.length; index++) {
61
+ const nodeId = ids[index];
62
+ const height = heightFor(nodeId);
22
63
  positioned.push({
23
- nodeId: ids[index], rank, index,
64
+ nodeId,
65
+ rank,
66
+ index,
24
67
  x: config.padding + rank * (config.nodeWidth + config.rankGap),
25
- y: config.padding + index * (config.nodeHeight + config.nodeGap),
26
- width: config.nodeWidth, height: config.nodeHeight,
68
+ y,
69
+ width: config.nodeWidth,
70
+ height,
27
71
  });
72
+ y += height + config.nodeGap;
28
73
  }
74
+ // Drop the trailing gap after the last node in this column.
75
+ const columnHeight =
76
+ ids.length === 0 ? 0 : y - config.padding - yOffset - config.nodeGap;
77
+ maxColumnHeight = Math.max(maxColumnHeight, columnHeight);
29
78
  }
30
79
  const byId = new Map(positioned.map((node) => [node.nodeId, node]));
80
+
81
+ // Ordinary edges: stable logical-row port-y line/cubic (no port/channel/bypass).
82
+ // baseNodeHeight keeps ports on the default card axis while nodeHeights may grow.
31
83
  const routed = validEdges.map((edge) => {
32
84
  const from = byId.get(edge.from);
33
85
  const to = byId.get(edge.to);
34
- const x1 = from.x + from.width;
35
- const y1 = from.y + from.height / 2;
36
- const x2 = to.x;
37
- const y2 = to.y + to.height / 2;
38
- const bend = Math.max(36, (x2 - x1) / 2);
39
- return { ...edge, path: `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}` };
86
+ const geometry = routeDependencyEdge(from, to, { baseNodeHeight });
87
+ return {
88
+ ...edge,
89
+ points: geometry.points,
90
+ path: geometry.path,
91
+ kind: geometry.kind,
92
+ };
40
93
  });
41
- const maxRows = Math.max(...layers.map((layer) => layer.length), 1);
94
+
95
+ const width =
96
+ config.padding * 2 +
97
+ orderedLayers.length * config.nodeWidth +
98
+ Math.max(0, orderedLayers.length - 1) * config.rankGap;
99
+ const height =
100
+ config.padding * 2 +
101
+ yOffset +
102
+ Math.max(maxColumnHeight, config.nodeHeight);
103
+
42
104
  return {
43
- width: config.padding * 2 + layers.length * config.nodeWidth + (layers.length - 1) * config.rankGap,
44
- height: config.padding * 2 + maxRows * config.nodeHeight + (maxRows - 1) * config.nodeGap,
105
+ width,
106
+ height,
45
107
  nodes: positioned,
46
108
  edges: routed,
109
+ meta: {
110
+ controlLaneOffset: laneMetrics.controlLaneOffset,
111
+ controlLaneHeight: laneMetrics.controlLaneHeight,
112
+ controlLaneCount: laneMetrics.controlLaneCount,
113
+ },
114
+ };
115
+ }
116
+
117
+ function emptyMeta() {
118
+ return {
119
+ controlLaneOffset: 0,
120
+ controlLaneHeight: 0,
121
+ controlLaneCount: 0,
47
122
  };
48
123
  }
49
124
 
@@ -54,7 +129,9 @@ function normalizeLayers(nodeIds, edges, ranks) {
54
129
  if (Array.isArray(ranks)) {
55
130
  for (const rank of ranks) {
56
131
  if (!Array.isArray(rank)) continue;
57
- const ids = [...new Set(rank.filter((id) => known.has(id) && !seen.has(id)))].sort();
132
+ const ids = [
133
+ ...new Set(rank.filter((id) => known.has(id) && !seen.has(id))),
134
+ ].sort();
58
135
  ids.forEach((id) => seen.add(id));
59
136
  if (ids.length > 0) layers.push(ids);
60
137
  }
@@ -65,7 +142,10 @@ function normalizeLayers(nodeIds, edges, ranks) {
65
142
  let changed = false;
66
143
  for (const edge of edges) {
67
144
  const next = Math.min(nodeIds.length - 1, level.get(edge.from) + 1);
68
- if (next > level.get(edge.to)) { level.set(edge.to, next); changed = true; }
145
+ if (next > level.get(edge.to)) {
146
+ level.set(edge.to, next);
147
+ changed = true;
148
+ }
69
149
  }
70
150
  if (!changed) break;
71
151
  }
@@ -79,5 +159,5 @@ function normalizeLayers(nodeIds, edges, ranks) {
79
159
  for (const id of nodeIds) {
80
160
  if (!seen.has(id)) (layers[0] ??= []).push(id);
81
161
  }
82
- return layers.filter(Boolean);
162
+ return layers.filter(Boolean).map((layer) => [...layer].sort());
83
163
  }
@@ -0,0 +1,37 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="96" height="64" viewBox="4 0 88 64">
2
+ <title>Loop Agent Horizontal Mobius</title>
3
+ <style>
4
+ .loop-ink { stroke: #26251e; }
5
+ @media (prefers-color-scheme: dark) {
6
+ .loop-ink { stroke: #f7f7f4; }
7
+ }
8
+ </style>
9
+ <defs>
10
+ <mask id="horizontal-handoff" maskUnits="userSpaceOnUse" x="4" y="0" width="88" height="64">
11
+ <rect x="4" width="88" height="64" fill="#ffffff" />
12
+ <path
13
+ d="m46 32 6 6.5"
14
+ fill="none"
15
+ stroke="#000000"
16
+ stroke-linecap="round"
17
+ stroke-width="13.5"
18
+ />
19
+ </mask>
20
+ </defs>
21
+ <path
22
+ class="loop-ink"
23
+ d="M12 32c0-16 16-20 28-6l17 18c11 11 27 5 27-12S68 9 57 20L39 39C28 51 12 48 12 32Z"
24
+ fill="none"
25
+ stroke-linecap="round"
26
+ stroke-linejoin="round"
27
+ stroke-width="11"
28
+ mask="url(#horizontal-handoff)"
29
+ />
30
+ <path
31
+ d="m46.5 32.5 5 5.5"
32
+ fill="none"
33
+ stroke="#f54e00"
34
+ stroke-linecap="round"
35
+ stroke-width="9.5"
36
+ />
37
+ </svg>