Files
pikasTech-unidesk/src/components/frontend/src/pipeline.tsx
T
Codex 79166574e8 feat: integrate codex queue and pipeline oa flow
- add Codex Queue microservice/frontend integration and related deployment docs
- document 100% Pipeline OA event-flow requirements and E2E gates
- harden Pipeline frontend Gantt/timeline E2E assertions and rendering
2026-05-07 18:11:43 +00:00

4124 lines
207 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from "react";
import { Background, BaseEdge, Controls, Handle, MarkerType, Position, ReactFlow, type Edge, type Node } from "@xyflow/react";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
const useRef: any = React.useRef;
const pipelineInputPorts: AnyRecord[] = [
{ id: "in-left", side: "left", position: Position.Left, style: { top: "50%" } },
{ id: "in-top-left", side: "top", slot: "left", slotIndex: -1, position: Position.Top, style: { left: "28%" } },
{ id: "in-top-mid", side: "top", slot: "mid", slotIndex: 0, position: Position.Top, style: { left: "50%" } },
{ id: "in-top-right", side: "top", slot: "right", slotIndex: 1, position: Position.Top, style: { left: "72%" } },
{ id: "in-bottom-left", side: "bottom", slot: "left", slotIndex: -1, position: Position.Bottom, style: { left: "28%" } },
{ id: "in-bottom-mid", side: "bottom", slot: "mid", slotIndex: 0, position: Position.Bottom, style: { left: "50%" } },
{ id: "in-bottom-right", side: "bottom", slot: "right", slotIndex: 1, position: Position.Bottom, style: { left: "72%" } },
];
const pipelineOutputPorts: AnyRecord[] = [
{ id: "out-right", position: Position.Right, style: { top: "50%" } },
];
const pipelineOverlapEdgePalette = ["#4eb7a8", "#d7a13a", "#69aee8", "#e0835f", "#b7d86b", "#d98bd2", "#5fc6bf"];
const pipelineNodeWidth = 236;
const pipelineNodeHeight = 88;
const pipelineAutoRefreshMs = 15000;
const pipelineSnapshotRunLimit = 10;
const pipelineGanttTimeAxisWidth = 96;
const pipelineGanttNodeColumnWidth = 72;
const pipelineGanttHeaderHeight = 64;
const pipelineGanttArrowTipInsetPx = 12;
function pipelinePercent(value: any, fallback: number): number {
const number = Number.parseFloat(String(value || ""));
return Number.isFinite(number) ? number / 100 : fallback;
}
function pipelineEndpointScore(port: AnyRecord, loads: Map<string, number>, laneHint: number): number {
const side = String(port.side || "");
if (side !== "top" && side !== "bottom") return 0;
const slotIndex = Number(port.slotIndex || 0);
const midId = side === "top" ? "in-top-mid" : "in-bottom-mid";
const load = loads.get(port.id) || 0;
const midLoad = loads.get(midId) || 0;
if (slotIndex === 0) return midLoad === 0 ? -26 : 28 + load * 74;
const lanePreference = laneHint === 0 ? Math.abs(slotIndex) * 2 : (Math.sign(laneHint) === Math.sign(slotIndex) ? -3 : 3);
if (midLoad > 0 && load === 0) return -14 + lanePreference;
return 8 + load * 74 + lanePreference;
}
function pipelineSmoothPath(points: Array<{ x: number; y: number }>): string {
const compact = points.filter((point, index) => {
const previous = points[index - 1];
return !previous || Math.abs(previous.x - point.x) > 0.5 || Math.abs(previous.y - point.y) > 0.5;
});
if (compact.length < 2) return "";
let path = `M ${compact[0].x},${compact[0].y}`;
let last = compact[0];
for (let index = 1; index < compact.length - 1; index += 1) {
const previous = compact[index - 1];
const current = compact[index];
const next = compact[index + 1];
const previousDistance = Math.hypot(current.x - previous.x, current.y - previous.y);
const nextDistance = Math.hypot(next.x - current.x, next.y - current.y);
const radius = Math.min(64, previousDistance * 0.42, nextDistance * 0.42);
if (radius < 2) {
path += ` L ${current.x},${current.y}`;
last = current;
continue;
}
const entry = {
x: current.x + ((previous.x - current.x) / previousDistance) * radius,
y: current.y + ((previous.y - current.y) / previousDistance) * radius,
};
const exit = {
x: current.x + ((next.x - current.x) / nextDistance) * radius,
y: current.y + ((next.y - current.y) / nextDistance) * radius,
};
if (Math.abs(entry.x - last.x) > 0.5 || Math.abs(entry.y - last.y) > 0.5) path += ` L ${entry.x},${entry.y}`;
path += ` Q ${current.x},${current.y} ${exit.x},${exit.y}`;
last = exit;
}
const end = compact[compact.length - 1];
return `${path} L ${end.x},${end.y}`;
}
function pipelineCurvePath(sourceX: number, sourceY: number, targetX: number, targetY: number, targetPosition: Position, laneOffset: number, routeMode = ""): string {
const forward = targetX >= sourceX;
const horizontalDistance = Math.max(1, Math.abs(targetX - sourceX));
const verticalDistance = Math.abs(targetY - sourceY);
const handleOffset = Math.max(34, Math.min(118, horizontalDistance * 0.26));
const scatter = Math.min(280, Math.abs(laneOffset));
const directShortLine = forward && targetPosition === Position.Left && scatter < 4 && verticalDistance < 28 && horizontalDistance < 420;
if (directShortLine) return `M ${sourceX},${sourceY} C ${sourceX + handleOffset},${sourceY} ${targetX - handleOffset},${targetY} ${targetX},${targetY}`;
const directForwardLeft = forward && targetPosition === Position.Left && (routeMode === "direct-forward-left" || (horizontalDistance <= 260 && verticalDistance <= 210));
if (directForwardLeft) {
const bend = Math.max(42, Math.min(140, horizontalDistance * 0.48));
const fanoutLift = Math.max(-28, Math.min(28, laneOffset * 0.18));
return `M ${sourceX},${sourceY} C ${sourceX + bend},${sourceY + fanoutLift} ${targetX - bend},${targetY} ${targetX},${targetY}`;
}
if (forward) {
const sourceRailX = sourceX + handleOffset;
if (targetPosition === Position.Top || targetPosition === Position.Bottom) {
const verticalSign = targetPosition === Position.Top ? -1 : 1;
const railY = targetY + verticalSign * (54 + scatter * 0.42);
return pipelineSmoothPath([
{ x: sourceX, y: sourceY },
{ x: sourceRailX, y: sourceY },
{ x: sourceRailX + Math.min(120, horizontalDistance * 0.18), y: railY },
{ x: targetX, y: railY },
{ x: targetX, y: targetY + verticalSign * 34 },
{ x: targetX, y: targetY },
]);
}
const targetRailX = targetX - handleOffset;
const railY = (sourceY + targetY) / 2 + laneOffset;
return pipelineSmoothPath([
{ x: sourceX, y: sourceY },
{ x: sourceRailX, y: sourceY },
{ x: sourceRailX + Math.min(110, horizontalDistance * 0.16), y: railY },
{ x: targetRailX - Math.min(90, horizontalDistance * 0.12), y: railY },
{ x: targetRailX, y: targetY },
{ x: targetX, y: targetY },
]);
}
const targetSign = targetPosition === Position.Bottom ? 1 : targetPosition === Position.Top ? -1 : (laneOffset >= 0 ? 1 : -1);
const detourX = Math.max(sourceX, targetX) + 92 + Math.min(180, scatter * 0.52);
const railY = targetSign < 0
? Math.min(sourceY, targetY) - 84 - scatter * 0.62
: Math.max(sourceY, targetY) + 84 + scatter * 0.62;
if (targetPosition === Position.Top || targetPosition === Position.Bottom) {
return pipelineSmoothPath([
{ x: sourceX, y: sourceY },
{ x: sourceX + handleOffset, y: sourceY },
{ x: detourX, y: railY },
{ x: targetX, y: railY },
{ x: targetX, y: targetY + targetSign * 38 },
{ x: targetX, y: targetY },
]);
}
return pipelineSmoothPath([
{ x: sourceX, y: sourceY },
{ x: sourceX + handleOffset, y: sourceY },
{ x: detourX, y: railY },
{ x: targetX - handleOffset, y: railY },
{ x: targetX - handleOffset, y: targetY },
{ x: targetX, y: targetY },
]);
}
function PipelineFlowNode({ data }: AnyRecord) {
return h("div", { className: "pipeline-flow-node-body" },
pipelineInputPorts.map((port) => h(Handle, {
key: port.id,
id: port.id,
type: "target",
position: port.position,
isConnectable: false,
className: `pipeline-flow-handle input ${port.side} slot-${port.slot || "mid"}`,
style: port.style,
})),
pipelineOutputPorts.map((port) => h(Handle, {
key: port.id,
id: port.id,
type: "source",
position: port.position,
isConnectable: false,
className: "pipeline-flow-handle output right",
style: port.style,
})),
data?.label,
);
}
function PipelineCurveEdge({ id, sourceX, sourceY, targetX, targetY, targetPosition, markerEnd, markerStart, style, data }: AnyRecord) {
const laneOffset = Number(data?.laneOffset || 0);
const path = pipelineCurvePath(sourceX, sourceY, targetX, targetY, targetPosition, laneOffset, String(data?.routeMode || ""));
return h(BaseEdge, { id, path, markerEnd, markerStart, style, interactionWidth: 28 });
}
const pipelineEdgeTypes: any = { pipelineCurve: PipelineCurveEdge };
const pipelineNodeTypes: any = { pipelineNode: PipelineFlowNode };
function errorMessage(error: unknown, fallback = "操作失败"): string {
return error instanceof Error ? error.message : String(error || fallback);
}
function fmtDate(value: any): string {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { hour12: false });
}
function fmtClock(value: Date): string {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function fmtClockValue(value: any): string {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return fmtClock(date);
}
function fmtDurationMs(value: any): string {
const ms = Number(value);
if (!Number.isFinite(ms) || ms < 0) return "--";
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function isRecord(value: any): value is AnyRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asArray(value: any): any[] {
return Array.isArray(value) ? value : [];
}
function timeMs(value: any): number | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.getTime();
}
function isoFromMs(value: number | null | undefined): string {
return Number.isFinite(Number(value)) ? new Date(Number(value)).toISOString() : "";
}
function firstIso(...values: any[]): string {
for (const value of values) {
const ms = timeMs(value);
if (ms !== null) return new Date(ms).toISOString();
}
return "";
}
function latestIso(...values: any[]): string {
const candidates = values.map(timeMs).filter((value): value is number => value !== null);
return candidates.length > 0 ? new Date(Math.max(...candidates)).toISOString() : "";
}
function terminalStatus(status: any): boolean {
return ["succeeded", "failed", "skipped", "cancelled", "canceled", "completed"].includes(String(status || "").toLowerCase());
}
function runningStatus(status: any): boolean {
const value = statusValue(status).toLowerCase();
return ["running", "active", "in-progress", "in_progress"].includes(value);
}
function statusCounts(items: any[], key = "status"): AnyRecord {
return items.reduce((counts: AnyRecord, item: any) => {
const status = String(item?.[key] || "unknown").toLowerCase();
counts[status] = (counts[status] || 0) + 1;
return counts;
}, {});
}
function parseJsonLine(value: any): AnyRecord | null {
if (!value || typeof value !== "string") return null;
try {
const parsed = JSON.parse(value) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function tailSummary(lines: any[]): AnyRecord {
const records = lines.map(parseJsonLine).filter((item): item is AnyRecord => Boolean(item));
const timestamps = records.flatMap((record) => [record.timestamp, record.createdAt, record.updatedAt]).filter(Boolean);
const lastAt = latestIso(...timestamps);
const eventKinds = Array.from(new Set(records.map((record) => String(record.event || record.action || record.type || "")).filter(Boolean))).slice(0, 3);
return {
total: lines.length,
parsed: records.length,
lastAt,
eventKinds,
};
}
function summarizeValue(value: any): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "是" : "否";
if (typeof value === "number") return String(value);
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}...` : value;
if (Array.isArray(value)) return `${value.length} 项`;
if (typeof value === "object") return `${Object.keys(value).length} 字段`;
return String(value);
}
function previewText(value: any, maxLength = 280): string {
if (value === null || value === undefined) return "";
const text = typeof value === "string" ? value : String(value);
const normalized = text.replace(/\r\n/gu, "\n").trim();
return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 1))}...` : normalized;
}
function statusValue(value: any): string {
if (typeof value === "string") return value;
if (isRecord(value)) return String(value.status || value.state || value.phase || "unknown");
return "unknown";
}
function tokenFacts(tokens: any): string[] {
if (!isRecord(tokens)) return [];
return [
typeof tokens.total === "number" ? `tokens ${tokens.total}` : "",
typeof tokens.input === "number" ? `in ${tokens.input}` : "",
typeof tokens.output === "number" ? `out ${tokens.output}` : "",
typeof tokens.reasoning === "number" ? `reason ${tokens.reasoning}` : "",
typeof tokens.cacheRead === "number" ? `cache read ${tokens.cacheRead}` : "",
typeof tokens.cacheWrite === "number" ? `cache write ${tokens.cacheWrite}` : "",
].filter(Boolean);
}
function kvItems(items: any[]): any[] {
return items.filter((item) => item && item.value !== undefined && item.value !== null && String(item.value) !== "");
}
function PipelineKvGrid({ items }: AnyRecord) {
const safeItems = kvItems(asArray(items));
return h("div", { className: "pipeline-kv-grid" }, safeItems.map((item: AnyRecord) => h("span", { key: item.label },
h("b", null, item.label),
h("span", null, item.value),
)));
}
function PipelineChipRow({ items }: AnyRecord) {
const safeItems = asArray(items).map((item) => String(item || "")).filter(Boolean);
if (safeItems.length === 0) return null;
return h("div", { className: "pipeline-chip-row" }, safeItems.map((item, index) => h("span", { key: `${index}-${item}` }, item)));
}
function findProcedureRun(details: any, interval: any): AnyRecord | null {
const procedureRunId = String(interval?.procedureRunId || "");
const procedures = asArray(details?.procedureRuns);
return procedures.find((procedure: any) => String(procedureRunIdOf(procedure)) === procedureRunId) || procedures.at(-1) || null;
}
function opencodeSteps(attempt: any): AnyRecord[] {
return asArray(attempt?.opencodeMessages?.steps).filter(isRecord);
}
function opencodePartFacts(part: any): string[] {
return [
`type ${part?.type || "unknown"}`,
part?.tool ? `tool ${part.tool}` : "",
part?.status ? `status ${part.status}` : "",
part?.durationMs !== undefined && part?.durationMs !== null ? `duration ${fmtDurationMs(part.durationMs)}` : "",
part?.outputSize !== undefined ? `output ${part.outputSize} chars` : "",
].filter(Boolean);
}
function PipelineFieldList({ fields }: AnyRecord) {
const safeFields = asArray(fields).filter((field: any) => field?.key);
if (safeFields.length === 0) return null;
return h("dl", { className: "pipeline-field-list" }, safeFields.flatMap((field: AnyRecord) => [
h("dt", { key: `${field.key}-k` }, field.key),
h("dd", { key: `${field.key}-v` }, previewText(field.value, 260)),
]));
}
function firstFieldValue(fields: any, keys: string[]): string {
const normalized = new Set(keys.map((key) => key.toLowerCase()));
for (const field of asArray(fields)) {
const key = String(field?.key || "").toLowerCase();
if (normalized.has(key)) return String(field?.value || "");
}
return "";
}
function stepRoleLabel(role: any): string {
const value = String(role || "unknown").toLowerCase();
if (value === "user") return "用户";
if (value === "assistant") return "助手";
if (value === "system") return "系统";
return value || "其他";
}
function stepRoleTone(role: any): string {
const value = String(role || "unknown").toLowerCase();
return ["user", "assistant", "system"].includes(value) ? value : "unknown";
}
function pipelineNoisePartType(type: any): boolean {
const normalized = String(type || "").toLowerCase();
return normalized === "step-start" || normalized === "step-finish";
}
function partKindLabel(part: any): string {
const type = String(part?.type || "unknown").toLowerCase();
if (type === "text") return "正文";
if (type === "reasoning") return "思考";
if (type === "tool") return "工具";
if (type === "step-start") return "开始";
if (type === "step-finish") return "结束";
return type || "part";
}
function partStatusTone(part: any): string {
const status = String(part?.status || "").toLowerCase();
if (["error", "failed", "failure"].includes(status)) return "failed";
if (["completed", "succeeded", "success"].includes(status)) return "succeeded";
if (["running", "started", "in_progress"].includes(status)) return "running";
return "unknown";
}
function stepStatusTone(step: any): string {
const tools = asArray(step?.parts).filter((part: any) => String(part?.type || "").toLowerCase() === "tool");
if (tools.some((part: any) => partStatusTone(part) === "failed")) return "failed";
if (!step?.completedAt && String(step?.role || "").toLowerCase() === "assistant") return "running";
return "succeeded";
}
function stepTokenLine(tokens: any): string {
const facts = tokenFacts(tokens);
return facts.length > 0 ? facts.join(" / ") : "tokens --";
}
function pipelineStructuredSummary(value: any): AnyRecord {
if (Array.isArray(value)) {
const first = value[0];
const keys = isRecord(first) ? Object.keys(first).slice(0, 4) : [];
return {
shape: "array",
facts: [`${value.length} items`, ...keys.map((key) => `key ${key}`)],
kv: [],
};
}
if (isRecord(value)) {
const entries = Object.entries(value);
const primitiveEntries = entries.filter(([, item]) => item === null || ["string", "number", "boolean"].includes(typeof item)).slice(0, 6);
return {
shape: "object",
facts: [`${entries.length} fields`, ...entries.slice(0, 4).map(([key]) => `key ${key}`)],
kv: primitiveEntries.map(([key, item]) => ({ label: key, value: summarizeValue(item) })),
};
}
return {
shape: typeof value,
facts: [summarizeValue(value)],
kv: [],
};
}
function readBalancedStructuredBlock(text: string, startIndex: number): AnyRecord | null {
const opening = text[startIndex];
if (opening !== "{" && opening !== "[") return null;
const pairs: AnyRecord = { "{": "}", "[": "]" };
const stack = [opening];
let inString = false;
let escaped = false;
for (let index = startIndex + 1; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === "\"") inString = false;
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === "{" || char === "[") {
stack.push(char);
continue;
}
if (char === "}" || char === "]") {
const expected = pairs[stack[stack.length - 1]];
if (char !== expected) return null;
stack.pop();
if (stack.length === 0) return { raw: text.slice(startIndex, index + 1), end: index + 1 };
}
}
return null;
}
function appendPipelineStructuredAwareText(blocks: AnyRecord[], text: string, defaultLabel = "structured payload"): void {
const source = String(text || "").trim();
if (!source) return;
const standalonePattern = /(^|\n)(?=[\[{])/gu;
let cursor = 0;
while (cursor < source.length) {
standalonePattern.lastIndex = cursor;
const match = standalonePattern.exec(source);
if (!match) {
const remainder = source.slice(cursor).trim();
if (remainder) blocks.push({ type: "text", text: remainder });
return;
}
const start = match.index + match[1].length;
const before = source.slice(cursor, start).trim();
if (before) blocks.push({ type: "text", text: before });
const parsedBlock = readBalancedStructuredBlock(source, start);
if (!parsedBlock) {
blocks.push({ type: "text", text: `${defaultLabel}: structured payload preview unavailable` });
const nextBoundary = source.indexOf("\n\n", start);
cursor = nextBoundary >= 0 ? nextBoundary + 2 : source.length;
continue;
}
try {
blocks.push({ type: "structured", label: defaultLabel, value: JSON.parse(parsedBlock.raw) });
} catch {
blocks.push({ type: "text", text: `${defaultLabel}: structured payload` });
}
cursor = parsedBlock.end;
while (cursor < source.length && /\s/u.test(source[cursor])) cursor += 1;
}
}
function pipelineStructuredTextBlocks(text: any): AnyRecord[] {
const source = typeof text === "string" ? text.replace(/\r\n/gu, "\n").trim() : "";
if (!source) return [];
const blocks: AnyRecord[] = [];
let cursor = 0;
const headingPattern = /(^|\n)([^\n]{1,120}):\n(?=[\[{])/gu;
while (cursor < source.length) {
headingPattern.lastIndex = cursor;
const match = headingPattern.exec(source);
if (!match) {
appendPipelineStructuredAwareText(blocks, source.slice(cursor));
break;
}
const matchStart = match.index + match[1].length;
appendPipelineStructuredAwareText(blocks, source.slice(cursor, matchStart));
const label = String(match[2] || "").trim();
const jsonStart = headingPattern.lastIndex;
const parsedBlock = readBalancedStructuredBlock(source, jsonStart);
if (!parsedBlock) {
blocks.push({ type: "text", text: `${label}: structured payload preview unavailable` });
const nextBoundary = source.indexOf("\n\n", jsonStart);
cursor = nextBoundary >= 0 ? nextBoundary + 2 : source.length;
continue;
}
try {
const parsed = JSON.parse(parsedBlock.raw);
blocks.push({ type: "structured", label, value: parsed });
cursor = parsedBlock.end;
while (cursor < source.length && /\s/u.test(source[cursor])) cursor += 1;
continue;
} catch {
const fallbackChunk = `${label}: structured payload`;
blocks.push({ type: "text", text: fallbackChunk });
cursor = parsedBlock.end;
}
}
return blocks;
}
function PipelineStructuredPayloadBlock({ label, value }: AnyRecord) {
const summary = pipelineStructuredSummary(value);
return h("section", { className: "pipeline-structured-payload" },
h("div", { className: "pipeline-structured-payload-head" },
h("b", null, label || "structured payload"),
h("span", null, summary.shape),
),
h(PipelineChipRow, { items: summary.facts }),
summary.kv.length > 0 ? h(PipelineKvGrid, { items: summary.kv }) : null,
);
}
function PipelineStepTextBlock({ label, text, tone = "", previewLimit = 760, maxBlocks = Number.POSITIVE_INFINITY, compactText = false }: AnyRecord) {
const safePreviewLimit = Number.isFinite(Number(previewLimit)) ? Number(previewLimit) : 760;
const safeMaxBlocks = Number.isFinite(Number(maxBlocks)) ? Math.max(1, Number(maxBlocks)) : Number.POSITIVE_INFINITY;
const blocks = pipelineStructuredTextBlocks(text);
if (blocks.length === 0) return null;
const visibleBlocks = blocks.slice(0, safeMaxBlocks);
const hiddenCount = Math.max(0, blocks.length - visibleBlocks.length);
return h("div", { className: "pipeline-step-text-stack" },
visibleBlocks.map((block: AnyRecord, index: number) => {
if (block.type === "structured") {
return h(PipelineStructuredPayloadBlock, { key: `structured-${index}-${block.label || "payload"}`, label: block.label, value: block.value });
}
const sourceText = compactText ? String(block.text || "").replace(/\s+/gu, " ").trim() : block.text;
const preview = previewText(sourceText, safePreviewLimit);
if (!preview) return null;
return h("section", { key: `text-${index}`, className: `pipeline-step-text-block ${tone}` },
h("b", null, label),
h("p", null, preview),
);
}),
hiddenCount > 0 ? h("div", { className: "pipeline-step-text-overflow" }, `已折叠 ${hiddenCount} 段`) : null,
);
}
function normalizedPipelinePreview(value: any): string {
return previewText(value, 3200).replace(/\s+/gu, " ").trim().toLowerCase();
}
function uniquePipelinePartPreviews(parts: any[], blockedValues: Set<string> = new Set()): string[] {
const seen = new Set<string>();
const values: string[] = [];
for (const part of asArray(parts)) {
const text = previewText(part?.textPreview, 3200);
const normalized = normalizedPipelinePreview(text);
if (!normalized || seen.has(normalized) || blockedValues.has(normalized)) continue;
seen.add(normalized);
values.push(text);
}
return values;
}
function pipelineSummaryBlockProps(step: any, textParts: any[], reasoningParts: any[]): AnyRecord | null {
const role = String(step?.role || "").toLowerCase();
const reasoningValues = uniquePipelinePartPreviews(reasoningParts);
const reasoningSet = new Set(reasoningValues.map((item) => normalizedPipelinePreview(item)).filter(Boolean));
const textValues = uniquePipelinePartPreviews(textParts, role === "assistant" ? reasoningSet : new Set());
const textSummary = textValues.join("\n\n");
const stepPreview = previewText(step?.textPreview, 3200);
const normalizedStepPreview = normalizedPipelinePreview(stepPreview);
const hasIndependentStepPreview = Boolean(
normalizedStepPreview
&& !reasoningSet.has(normalizedStepPreview)
&& !Array.from(reasoningSet).some((item) => item && normalizedStepPreview.includes(item)),
);
if (role === "assistant") {
if (textSummary) {
return { label: "消息摘要", text: textSummary, tone: "assistant", previewLimit: 180, maxBlocks: 2, compactText: true };
}
if (hasIndependentStepPreview) {
return { label: "消息摘要", text: stepPreview, tone: "assistant", previewLimit: 180, maxBlocks: 2, compactText: true };
}
return null;
}
const summaryText = textSummary || stepPreview;
if (!summaryText) return null;
return {
label: role === "user" ? "用户输入" : role === "system" ? "系统输入" : "消息摘要",
text: summaryText,
tone: role,
previewLimit: role === "user" ? 200 : 180,
maxBlocks: 2,
compactText: true,
};
}
function pipelineReasoningSummaryText(reasoningParts: any[]): string {
return uniquePipelinePartPreviews(reasoningParts).join("\n\n");
}
function pipelineMessageCardRows(step: any, textParts: any[], reasoningParts: any[]): AnyRecord[] {
const role = String(step?.role || "").toLowerCase();
const roleTone = stepRoleTone(role);
const messageSummary = pipelineSummaryBlockProps(step, textParts, reasoningParts);
const reasoningSummary = role === "assistant" ? pipelineReasoningSummaryText(reasoningParts) : "";
const normalizedReasoning = normalizedPipelinePreview(reasoningSummary);
const normalizedMessage = normalizedPipelinePreview(messageSummary?.text || "");
const duplicateSummary = Boolean(
normalizedReasoning
&& normalizedMessage
&& (
normalizedReasoning === normalizedMessage
|| normalizedReasoning.includes(normalizedMessage)
|| normalizedMessage.includes(normalizedReasoning)
),
);
const rows: AnyRecord[] = [];
if (reasoningSummary) rows.push({ key: "reasoning", label: "思考", text: reasoningSummary, tone: "reasoning", previewLimit: 104 });
if (messageSummary?.text && !(role === "assistant" && duplicateSummary)) rows.push({
key: "message",
label: role === "assistant" ? "消息" : messageSummary.label,
text: messageSummary.text,
tone: messageSummary.tone || roleTone,
previewLimit: role === "user" ? 124 : 110,
});
if (rows.length === 0 && role === "assistant") rows.push({ key: "message", label: "消息", text: "无正文输出,仅工具调用。", tone: roleTone, previewLimit: 120 });
return rows;
}
function pipelineToolPrimarySummary(part: any): AnyRecord {
const inputFields = asArray(part?.inputFields);
const outputPreview = previewText(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : "", 620);
const tool = String(part?.tool || part?.type || "tool").toUpperCase();
const description = firstFieldValue(inputFields, ["description", "justification", "purpose", "summary"]);
const query = firstFieldValue(inputFields, ["q", "query"]);
const command = firstFieldValue(inputFields, ["command", "cmd"]);
const filePath = firstFieldValue(inputFields, ["filePath", "filepath", "path"]);
const primaryText = description
|| query
|| (tool === "BASH" ? command || filePath : filePath || command)
|| previewText(part?.textPreview || outputPreview || part?.title || tool, 220);
return {
tool,
text: previewText(String(primaryText || "").replace(/\s+/gu, " ").trim(), 150),
};
}
function pipelineToolSummaryItems(parts: any[]): AnyRecord[] {
const seen = new Set<string>();
const items: AnyRecord[] = [];
for (const part of asArray(parts)) {
const summary = pipelineToolPrimarySummary(part);
const key = `${summary.tool}:${normalizedPipelinePreview(summary.text)}`;
if (!summary.text || seen.has(key)) continue;
seen.add(key);
items.push(summary);
}
return items;
}
function pipelineSessionFacts(steps: any[], sessionIds: string[]): string[] {
const agents = uniqueStrings(steps.map((step: any) => step?.agent)).slice(0, 3);
const models = uniqueStrings(steps.map((step: any) => step?.model)).slice(0, 3);
const sessions = sessionIds.length <= 2
? sessionIds.map((sessionId) => `session ${sessionId}`)
: [`sessions ${sessionIds.length}`, ...sessionIds.slice(0, 2).map((sessionId) => `session ${sessionId}`)];
return [
...agents.map((agent) => `agent ${agent}`),
...models.map((model) => `model ${model}`),
...sessions,
];
}
function PipelineStepMessageCard({ rows, compact = false, role = "", matched = false }: AnyRecord) {
const safeRows = asArray(rows).filter((row: any) => row?.text);
if (safeRows.length === 0) return null;
if (compact) {
return h("section", { className: `pipeline-step-message-card compact ${role}` },
h("div", { className: "pipeline-step-message-rows" },
safeRows.map((row: AnyRecord, index: number) => {
const text = previewText(String(row.text || "").replace(/\s+/gu, " ").trim(), row.previewLimit || (index === 0 ? 116 : 124));
return h("section", { key: row.key || `${row.label}-${index}`, className: `pipeline-step-message-row compact ${row.tone || ""}` },
h("p", null,
h("b", null, row.label),
h("span", null, text || "--"),
),
);
}),
),
);
}
return h("section", { className: `pipeline-step-message-card ${compact ? "compact" : "expanded"} ${role}` },
h("div", { className: "pipeline-step-message-card-head" },
h("span", { className: `pipeline-step-role ${role}` }, stepRoleLabel(role)),
matched ? h("span", { className: "pipeline-step-match-badge" }, "匹配点") : null,
),
h("div", { className: "pipeline-step-message-rows" },
safeRows.map((row: AnyRecord, index: number) => {
return h("div", { key: row.key || `${row.label}-${index}`, className: `pipeline-step-message-row expanded ${row.tone || ""}` },
h(PipelineStepTextBlock, {
label: row.label,
text: row.text,
tone: row.tone || role,
previewLimit: row.previewLimitFull || 420,
maxBlocks: 3,
compactText: false,
}),
);
}),
),
);
}
function PipelineStepTimeSummary({ step, role = "", matched = false }: AnyRecord) {
const durationText = step?.durationMs !== undefined && step?.durationMs !== null ? fmtDurationMs(step.durationMs) : "";
const meta = [
`Step ${step?.index ?? "?"}`,
durationText,
step?.finish ? String(step.finish) : "",
].filter(Boolean).join(" / ");
return h("section", { className: "pipeline-step-time-card" },
h("b", null, "时间"),
h("strong", null, `${fmtClockValue(step?.createdAt)} -> ${fmtClockValue(step?.completedAt)}`),
h("div", { className: "pipeline-step-time-meta" },
meta ? h("span", null, meta) : null,
role ? h("span", { className: `pipeline-step-role ${role}` }, stepRoleLabel(role)) : null,
matched ? h("span", { className: "pipeline-step-match-badge" }, "匹配点") : null,
),
);
}
function PipelineStepToolSummary({ tools }: AnyRecord) {
const items = pipelineToolSummaryItems(tools);
return h("section", { className: `pipeline-step-tool-summary ${items.length > 0 ? "has-items" : "empty"}` },
h("b", null, "工具调用"),
items.length === 0
? h("p", null, "无")
: h("div", { className: "pipeline-step-tool-summary-list" },
items.slice(0, 2).map((item: AnyRecord, index: number) => h("div", { key: `${item.tool}-${index}`, className: "pipeline-step-tool-summary-item" },
h("span", null, item.tool),
h("p", null, item.text),
)),
items.length > 2 ? h("small", null, `+${items.length - 2} more`) : null,
),
);
}
function PipelineOpenCodePart({ part }: AnyRecord) {
const type = String(part?.type || "unknown").toLowerCase();
if (pipelineNoisePartType(type)) return null;
const title = String(part?.title || part?.tool || partKindLabel(part));
const facts = opencodePartFacts(part);
const tone = partStatusTone(part);
const inputFields = asArray(part?.inputFields);
const metadataFields = asArray(part?.metadataFields);
const outputPreview = previewText(part?.outputPreview && part.outputPreview !== "--" ? part.outputPreview : "", 620);
const command = firstFieldValue(inputFields, ["command", "cmd"]);
const filePath = firstFieldValue(inputFields, ["filePath", "filepath", "path"]);
const compactSummary = command || filePath || previewText(part?.textPreview || outputPreview || title, 120);
if (type === "text") {
return h(PipelineStepTextBlock, { label: "用户输入 / 上下文", text: part?.textPreview, tone: "user-text" });
}
if (type === "reasoning") {
return h("details", { className: "pipeline-opencode-part reasoning" },
h("summary", null,
h("span", { className: "pipeline-part-kind" }, "思考"),
h("strong", null, previewText(part?.textPreview || "reasoning", 96)),
h(PipelineChipRow, { items: facts }),
),
h("div", { className: "pipeline-opencode-part-body" },
h(PipelineStepTextBlock, { label: "reasoning preview", text: part?.textPreview, tone: "reasoning" }),
metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null,
),
);
}
if (type === "tool") {
return h("details", { className: `pipeline-opencode-part tool ${tone}` },
h("summary", null,
h("span", { className: `pipeline-tool-badge ${tone}` }, part?.tool || "tool"),
h("strong", null, compactSummary || title),
),
h("div", { className: "pipeline-opencode-part-body" },
facts.length > 0 ? h(PipelineChipRow, { items: facts }) : null,
inputFields.length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: inputFields })) : null,
outputPreview ? h(PipelineStepTextBlock, { label: "输出摘要", text: outputPreview, tone: tone === "failed" ? "failed" : "tool-output" }) : null,
metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null,
),
);
}
return h("details", { className: `pipeline-opencode-part ${tone}` },
h("summary", null,
h("span", { className: "pipeline-part-kind" }, partKindLabel(part)),
h("strong", null, title),
h(PipelineChipRow, { items: facts }),
),
h("div", { className: "pipeline-opencode-part-body" },
part?.textPreview ? h(PipelineStepTextBlock, { label: partKindLabel(part), text: part.textPreview }) : null,
inputFields.length > 0 ? h("div", null, h("b", null, "输入字段"), h(PipelineFieldList, { fields: inputFields })) : null,
outputPreview ? h(PipelineStepTextBlock, { label: "输出摘要", text: outputPreview }) : null,
metadataFields.length > 0 ? h("div", null, h("b", null, "元数据"), h(PipelineFieldList, { fields: metadataFields })) : null,
),
);
}
function opencodeStepKey(step: any, fallbackIndex = 0): string {
const key = String(step?.messageId || step?.index || "");
return key || `step-${fallbackIndex}`;
}
function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) {
const parts = asArray(step?.parts);
const visibleParts = parts.filter((part: any) => !pipelineNoisePartType(part?.type));
const tools = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "tool");
const textParts = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "text");
const reasoningParts = visibleParts.filter((part: any) => String(part?.type || "").toLowerCase() === "reasoning");
const otherParts = visibleParts.filter((part: any) => !["tool", "text", "reasoning"].includes(String(part?.type || "").toLowerCase()));
const partTypes = isRecord(step?.partTypes)
? Object.entries(step.partTypes)
.filter(([type, count]) => !pipelineNoisePartType(type) && Number(count) > 0)
.map(([type, count]) => `${type} ${count}`)
: [];
const roleTone = stepRoleTone(step?.role);
const statusTone = stepStatusTone(step);
const stepStartMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt);
const stepEndMs = timeMs(step?.completedAt) ?? stepStartMs;
const toolNames = uniqueStrings([...asArray(step?.tools), ...tools.map((part: any) => part?.tool)]).slice(0, 5);
const facts = [
step?.finish ? `finish ${step.finish}` : "",
step?.durationMs !== undefined && step?.durationMs !== null ? `duration ${fmtDurationMs(step.durationMs)}` : "",
`parts ${visibleParts.length}`,
tools.length > 0 ? `tools ${tools.length}` : "",
...toolNames.map((tool) => `tool ${tool}`),
].filter(Boolean);
const messageRows = pipelineMessageCardRows(step, textParts, reasoningParts);
return h("details", {
className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`,
open: matched ? true : undefined,
"data-testid": "pipeline-opencode-step",
"data-step-start-ms": stepStartMs !== null ? String(stepStartMs) : "",
"data-step-end-ms": stepEndMs !== null ? String(stepEndMs) : "",
},
h("summary", { className: "pipeline-opencode-step-summary", "data-testid": "pipeline-opencode-step-summary" },
h(PipelineStepTimeSummary, { step, role: roleTone, matched }),
h(PipelineStepMessageCard, { rows: messageRows, compact: true, role: roleTone, matched }),
h(PipelineStepToolSummary, { tools }),
),
h("div", { className: "pipeline-step-body", "data-testid": "pipeline-opencode-step-body" },
h("div", { className: "pipeline-step-factbar" },
h(StatusBadge, { status: statusTone }, statusTone),
h("span", { className: "pipeline-step-token-line" }, stepTokenLine(step?.tokens)),
h(PipelineChipRow, { items: facts }),
partTypes.length > 0 ? h(PipelineChipRow, { items: partTypes }) : null,
),
h(PipelineStepMessageCard, { rows: messageRows, role: roleTone, matched }),
tools.length > 0 ? h("div", { className: "pipeline-tool-call-strip" },
h("div", { className: "pipeline-tool-call-title" }, h("b", null, "工具调用"), h("span", null, `${tools.length} calls`)),
h("div", { className: "pipeline-opencode-part-list" }, tools.map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || `tool-${index}`, part }))),
) : null,
otherParts.length > 0 ? h("div", { className: "pipeline-opencode-part-list meta-list" }, otherParts.map((part: any, index: number) => h(PipelineOpenCodePart, { key: part?.id || `part-${index}`, part }))) : null,
),
);
}
function structuredEventRecords(value: any): AnyRecord[] {
return asArray(value).flatMap((item: any) => {
if (isRecord(item)) return [item];
const parsed = parseJsonLine(item);
return parsed ? [parsed] : [];
});
}
function eventKind(record: any): string {
return String(record?.event || record?.action || record?.requestedAction || record?.type || "").toLowerCase();
}
function eventTimestampIso(record: any): string {
return firstIso(record?.timestamp, record?.createdAt, record?.updatedAt, record?.startedAt, record?.finishedAt);
}
function eventTimestampMs(record: any): number | null {
return timeMs(eventTimestampIso(record));
}
function attemptLabel(attempt: any): string {
return String(attempt?.attempt || attempt?.id || "");
}
function uniqueStrings(values: any[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const value of values) {
const text = String(value || "");
if (!text || seen.has(text)) continue;
seen.add(text);
ordered.push(text);
}
return ordered;
}
function sourceKindLabel(value: any): string {
switch (String(value || "").toLowerCase()) {
case "monitor": return "monitor";
case "webui": return "webui";
case "cli": return "cli";
case "system": return "runner";
default: return String(value || "--");
}
}
function controlActionValue(record: any): string {
return String(record?.requestedAction || record?.action || "").toLowerCase();
}
function controlActionLabel(record: any): string {
switch (controlActionValue(record)) {
case "guide": return "引导";
case "modify": return "修改";
case "approve": return "审核通过";
case "restart": return "重启";
case "redo": return "重做";
default: return String(record?.requestedAction || record?.action || "控制");
}
}
function eventLabel(record: any): string {
switch (eventKind(record)) {
case "initial-prompt-delivered": return "初始 prompt";
case "append-prompt-delivered": return "追加 prompt";
case "append-prompt-queued": return "追加 prompt 已排队";
case "monitor-prompt-delivered": return "Monitor prompt";
case "node-long-running-observation": return "长任务观察";
case "node-finished": return "节点完成";
case "oa-policy-downstream-evaluated": return "OA 下游策略";
case "control-command-queued": return `${controlActionLabel(record)} 已发起`;
case "control-command-applied": return `${controlActionLabel(record)} 已生效`;
case "control-command-ignored": return `${controlActionLabel(record)} 已忽略`;
default: return String(record?.event || record?.action || record?.requestedAction || "event");
}
}
function eventPreview(record: any): string {
return previewText(record?.promptPreview || record?.reasonPreview || record?.prompt || record?.reason || "", 240);
}
function eventTextBlocks(record: any): AnyRecord[] {
const prompt = String(record?.prompt || "");
const reason = String(record?.reason || record?.restartReason || "");
const promptPreview = prompt ? "" : String(record?.promptPreview || "");
const reasonPreview = reason ? "" : String(record?.reasonPreview || "");
return [
prompt || promptPreview ? { label: prompt ? "prompt" : "prompt preview", value: prompt || promptPreview } : null,
reason || reasonPreview ? { label: reason ? "reason" : "reason preview", value: reason || reasonPreview } : null,
asArray(record?.resetNodeIds).length > 0 ? { label: "reset nodes", value: asArray(record.resetNodeIds).join(", ") } : null,
asArray(record?.runningResetNodeIds).length > 0 ? { label: "interrupted running nodes", value: asArray(record.runningResetNodeIds).join(", ") } : null,
asArray(record?.interruptedProcedureRunIds).length > 0 ? { label: "interrupted procedures", value: asArray(record.interruptedProcedureRunIds).join(", ") } : null,
record?.interruptedProcedureRunId ? { label: "interrupted procedure", value: String(record.interruptedProcedureRunId) } : null,
].filter(Boolean) as AnyRecord[];
}
function attemptWindow(attempt: any): AnyRecord {
const steps = opencodeSteps(attempt);
const started = steps.map((step: any) => timeMs(step?.createdAt)).filter((value): value is number => value !== null);
const finished = steps.map((step: any) => timeMs(step?.completedAt) ?? timeMs(step?.createdAt)).filter((value): value is number => value !== null);
const eventTimes = structuredEventRecords(attempt?.controlEventRecords).map((record) => eventTimestampMs(record)).filter((value): value is number => value !== null);
const outputTimes = asArray(attempt?.assistantOutputs).map((item: any) => timeMs(item?.updatedAt)).filter((value): value is number => value !== null);
const startMs = started[0] ?? eventTimes[0] ?? outputTimes[0] ?? null;
const endMs = finished.at(-1) ?? eventTimes.at(-1) ?? outputTimes.at(-1) ?? startMs;
return { startMs, endMs };
}
function nearestProcedureForNode(details: any, runId: string, nodeId: string, markerMs: number | null, procedureRunId = ""): AnyRecord | null {
const procedures = asArray(details?.procedureRuns).filter((procedure: any) => inferProcedureNodeId(procedure, runId) === nodeId);
if (procedures.length === 0) return null;
if (procedureRunId) {
const direct = procedures.find((procedure: any) => procedureRunIdOf(procedure) === procedureRunId);
if (direct) return direct;
}
if (markerMs === null) return procedures.at(-1) || null;
const containing = procedures.find((procedure: any) => {
const startMs = timeMs(procedureStartIso(procedure, details));
const endMs = timeMs(procedureEndIso(procedure, details)) ?? startMs;
return startMs !== null && endMs !== null && markerMs >= startMs - 1000 && markerMs <= endMs + 1000;
});
if (containing) return containing;
return procedures.slice().sort((left: any, right: any) => {
const leftStart = timeMs(procedureStartIso(left, details)) ?? markerMs;
const leftEnd = timeMs(procedureEndIso(left, details)) ?? leftStart;
const rightStart = timeMs(procedureStartIso(right, details)) ?? markerMs;
const rightEnd = timeMs(procedureEndIso(right, details)) ?? rightStart;
const leftDistance = Math.min(Math.abs(leftStart - markerMs), Math.abs(leftEnd - markerMs));
const rightDistance = Math.min(Math.abs(rightStart - markerMs), Math.abs(rightEnd - markerMs));
return leftDistance - rightDistance;
})[0] || null;
}
function nearestAttemptForMarker(procedure: any, marker: any): AnyRecord | null {
const attempts = asArray(procedure?.attempts).filter(isRecord);
if (attempts.length === 0) return null;
const direct = String(marker?.attempt || "");
if (direct) {
const matched = attempts.find((attempt: any) => attemptLabel(attempt) === direct);
if (matched) return matched;
}
const markerMs = Number.isFinite(Number(marker?.ms)) ? Number(marker.ms) : null;
if (markerMs === null) return attempts.at(-1) || null;
const containing = attempts.find((attempt: any) => {
const window = attemptWindow(attempt);
return Number.isFinite(window.startMs) && Number.isFinite(window.endMs) && markerMs >= Number(window.startMs) - 1000 && markerMs <= Number(window.endMs) + 1000;
});
if (containing) return containing;
return attempts.slice().sort((left: any, right: any) => {
const leftWindow = attemptWindow(left);
const rightWindow = attemptWindow(right);
const leftDistance = Math.min(Math.abs(Number(leftWindow.startMs ?? markerMs) - markerMs), Math.abs(Number(leftWindow.endMs ?? markerMs) - markerMs));
const rightDistance = Math.min(Math.abs(Number(rightWindow.startMs ?? markerMs) - markerMs), Math.abs(Number(rightWindow.endMs ?? markerMs) - markerMs));
return leftDistance - rightDistance;
})[0] || attempts.at(-1) || null;
}
function nearestStepForMarker(attempt: any, markerMs: number | null): AnyRecord {
const steps = opencodeSteps(attempt);
if (steps.length === 0) return { step: null, stepIndex: -1, stepKey: "" };
if (markerMs === null) {
const step = steps[0];
return { step, stepIndex: 0, stepKey: opencodeStepKey(step, 0) };
}
for (let index = 0; index < steps.length; index += 1) {
const step = steps[index];
const startMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt);
const endMs = timeMs(step?.completedAt) ?? startMs;
if (startMs !== null && endMs !== null && markerMs >= startMs - 1000 && markerMs <= endMs + 1000) {
return { step, stepIndex: index, stepKey: opencodeStepKey(step, index) };
}
}
const afterIndex = steps.findIndex((step: any) => {
const startMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt);
return startMs !== null && startMs >= markerMs;
});
if (afterIndex >= 0) {
const step = steps[afterIndex];
return { step, stepIndex: afterIndex, stepKey: opencodeStepKey(step, afterIndex) };
}
const fallbackIndex = Math.max(0, steps.length - 1);
return { step: steps[fallbackIndex], stepIndex: fallbackIndex, stepKey: opencodeStepKey(steps[fallbackIndex], fallbackIndex) };
}
function pipelineSelectionContext(runDetails: any, selection: any): AnyRecord {
const runId = String(selection?.runId || runDetails?.runId || "");
if (String(selection?.mode || "") === "interval") {
const interval = selection?.interval || {};
const procedure = findProcedureRun(runDetails, interval) || interval.raw || {};
return {
mode: "interval",
runId,
interval,
marker: null,
nodeId: String(interval?.nodeId || inferProcedureNodeId(procedure, runId) || ""),
procedure,
attempt: null,
matchedStep: null,
matchedStepIndex: -1,
matchedStepKey: "",
};
}
const marker = isRecord(selection?.marker) ? selection.marker : {};
const markerMs = Number.isFinite(Number(marker?.ms)) ? Number(marker.ms) : null;
const nodeId = String(marker?.nodeId || "");
const procedure = nodeId ? nearestProcedureForNode(runDetails, runId, nodeId, markerMs, String(marker?.procedureRunId || "")) : null;
const attempt = procedure ? nearestAttemptForMarker(procedure, marker) : null;
const matched = attempt ? nearestStepForMarker(attempt, markerMs) : { step: null, stepIndex: -1, stepKey: "" };
return {
mode: "event",
runId,
interval: null,
marker,
nodeId,
procedure,
attempt,
matchedStep: matched.step,
matchedStepIndex: matched.stepIndex,
matchedStepKey: matched.stepKey,
};
}
function PipelineProcedureAttemptList({ procedure, matchedStepKey = "", matchedAttemptId = "" }: AnyRecord) {
const attempts = asArray(procedure?.attempts);
if (attempts.length === 0) return h(EmptyState, { title: "暂无 attempt 详情", text: "后端还未返回该 procedure 的 attempt / opencodeMessages。" });
return attempts.map((attempt: any, attemptIndex: number) => {
const messages = attempt?.opencodeMessages || {};
const steps = opencodeSteps(attempt);
const sessionIds = asArray(messages.sessionIds).map((item) => String(item)).filter(Boolean);
const sessionFacts = pipelineSessionFacts(steps, sessionIds);
const currentAttemptId = attemptLabel(attempt) || `attempt-${attemptIndex + 1}`;
const failedToolCount = steps.reduce((sum, step: any) => sum + asArray(step?.parts).filter((part: any) =>
String(part?.type || "").toLowerCase() === "tool" && partStatusTone(part) === "failed").length, 0);
return h("article", { key: currentAttemptId, className: `pipeline-attempt-card ${matchedAttemptId === currentAttemptId ? "matched" : ""}` },
h("div", { className: "pipeline-attempt-head" },
h("div", null,
h("strong", null, currentAttemptId),
h("span", null, messages.source || "opencode"),
),
h("div", { className: "pipeline-attempt-badges" },
h("span", null, `${steps.length} steps`),
h("span", null, `${messages.toolCallCount ?? "--"} tools`),
failedToolCount > 0 ? h("span", { className: "danger" }, `${failedToolCount} failed`) : null,
),
),
h(PipelineKvGrid, { items: [
{ label: "messages", value: messages.messageCount ?? "--" },
{ label: "steps", value: messages.stepCount ?? steps.length },
{ label: "tools", value: messages.toolCallCount ?? "--" },
{ label: "updated", value: fmtDate(messages.updatedAt) },
{ label: "sessions", value: sessionIds.join(", ") || "--" },
] }),
steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-control 已重建并重新抓取。") :
h("section", { className: "pipeline-opencode-timeline", "data-testid": "pipeline-step-timeline" },
h("div", { className: "pipeline-opencode-timeline-head" },
h("div", null,
h("b", null, "OpenCode Step Timeline"),
h("span", null, "step 外层只保留时间 / 消息 / 工具调用;agent 与 model 聚合到 session 头部,统计信息默认折叠。"),
),
h("div", { className: "pipeline-opencode-session-head", "data-testid": "pipeline-step-timeline-session" },
h("span", null, `${steps.length} steps / ${sessionIds.length} sessions`),
sessionFacts.length > 0 ? h(PipelineChipRow, { items: sessionFacts }) : null,
),
),
h("div", { className: "pipeline-opencode-flow" }, steps.map((step: any, stepIndex: number) => {
const stepKey = opencodeStepKey(step, stepIndex);
return h(PipelineOpenCodeStep, { key: stepKey, step, matched: matchedStepKey === stepKey });
})),
),
);
});
}
function pipelineGanttDetailPayload(baseDetails: any, nodeDetails: any, runId: string, nodeId: string): any {
if (!isRecord(nodeDetails) || String(nodeDetails.runId || "") !== runId || String(nodeDetails.nodeId || "") !== nodeId) return baseDetails;
const nodeProcedures = asArray(nodeDetails.procedureRuns);
const base = isRecord(baseDetails) ? baseDetails : {};
return {
...base,
...nodeDetails,
// Keep run-level evidence unless the node endpoint has a more focused copy.
controlCommands: asArray(nodeDetails.controlCommands).length > 0 ? nodeDetails.controlCommands : base.controlCommands,
controlEvents: asArray(nodeDetails.controlEvents).length > 0 ? nodeDetails.controlEvents : base.controlEvents,
procedureRuns: nodeProcedures.length > 0 ? nodeProcedures : base.procedureRuns,
};
}
function PipelineGanttDetailPanel({ selection, runDetails, nodeDetails, onRaw }: AnyRecord) {
if (!selection?.mode) {
return h("aside", { className: "pipeline-gantt-detail-panel empty", "data-testid": "pipeline-gantt-detail-panel" },
h(EmptyState, { title: "选择一条执行线或一个控制点", text: "点击甘特图中的 node 执行线、prompt 点或控制点,在这里查看结构化过程和 OpenCode step。" }),
);
}
const runId = String(selection?.runId || "");
const selectedNodeId = String(selection?.interval?.nodeId || selection?.marker?.nodeId || "");
const baseDetails = runDetails?.runId === runId ? runDetails.details : null;
const details = pipelineGanttDetailPayload(baseDetails, nodeDetails, runId, selectedNodeId);
const loading = (String(runDetails?.runId || "") !== runId || Boolean(runDetails?.loading)) && !details;
const error = String(runDetails?.runId || "") === runId ? String(runDetails?.error || "") : "";
const context = details ? pipelineSelectionContext(details, selection) : null;
const interval = context?.interval || selection?.interval || null;
const marker = context?.marker || selection?.marker || null;
const procedure = context?.procedure || (details ? findProcedureRun(details, interval) : null) || interval?.raw || {};
const matchedAttemptId = attemptLabel(context?.attempt);
const matchedStepKey = String(context?.matchedStepKey || "");
const status = statusValue(procedure?.status || interval?.status || marker?.status || marker?.event);
const detailTitle = selection?.mode === "event" ? (marker?.label || eventLabel(marker?.raw || marker) || "event") : (context?.nodeId || interval?.nodeId || "node");
const markerBlocks = marker ? eventTextBlocks(marker?.raw || marker) : [];
const eventFacts = marker ? [
eventKind(marker?.raw || marker) ? `event ${eventKind(marker?.raw || marker)}` : "",
marker?.promptEvent ? `prompt ${marker.promptEvent}` : "",
marker?.action ? `action ${marker.action}` : "",
marker?.sourceKind ? `source ${sourceKindLabel(marker.sourceKind)}` : "",
marker?.sourceNodeId ? `from ${marker.sourceNodeId}` : "",
marker?.targetNodeId ? `to ${marker.targetNodeId}` : "",
marker?.snapReason ? `draw ${marker.snapReason}` : "",
].filter(Boolean) : [];
return h("aside", { className: "pipeline-gantt-detail-panel", "data-testid": "pipeline-gantt-detail-panel" },
h("div", { className: "pipeline-gantt-detail-head" },
h("div", null,
h("span", { className: "panel-eyebrow" }, selection?.mode === "event" ? "Gantt Event Detail" : "Gantt Line Detail"),
h("h3", null, detailTitle),
),
h(StatusBadge, { status }, status),
),
marker ? h("article", { className: "pipeline-event-card" },
h("div", { className: "pipeline-event-card-head" },
h("strong", null, marker?.label || eventLabel(marker?.raw || marker)),
h(PipelineChipRow, { items: eventFacts }),
),
h(PipelineKvGrid, { items: [
{ label: "event time", value: fmtDate(marker?.timestampIso || marker?.timestamp || "--") },
marker?.snapped ? { label: "drawn time", value: fmtDate(marker?.renderedTimestampIso || marker?.ms) } : null,
{ label: "node", value: marker?.nodeId || "--" },
{ label: "procedure", value: marker?.procedureRunId || procedureRunIdOf(procedure) || "--" },
{ label: "attempt", value: marker?.attempt || matchedAttemptId || "--" },
{ label: "source kind", value: marker?.sourceKind ? sourceKindLabel(marker.sourceKind) : "--" },
{ label: "source node", value: marker?.sourceNodeId || "--" },
{ label: "target node", value: marker?.targetNodeId || "--" },
{ label: "command", value: marker?.commandId || marker?.eventId || "--" },
marker?.snapReason ? { label: "placement", value: marker.snapReason } : null,
] }),
markerBlocks.length > 0 ? h("div", { className: "pipeline-event-blocks" },
markerBlocks.map((block: AnyRecord, index: number) => h("section", { key: `${block.label}-${index}`, className: "pipeline-event-text-block" },
h("b", null, block.label),
h("p", null, block.value),
)),
) : null,
eventPreview(marker?.raw || marker) ? h("p", { className: "pipeline-text-preview" }, eventPreview(marker?.raw || marker)) : null,
) : null,
h(PipelineKvGrid, { items: [
{ label: "epoch", value: runId || interval?.runId || "--" },
{ label: "node", value: context?.nodeId || interval?.nodeId || marker?.nodeId || "--" },
{ label: "procedure", value: interval?.procedureRunId || marker?.procedureRunId || procedureRunIdOf(procedure) || "--" },
{ label: "started", value: fmtDate(interval?.startedAt || procedure?.startedAt) },
{ label: "finished", value: fmtDate(interval?.finishedAt || procedure?.finishedAt) },
{ label: "duration", value: fmtDurationMs(interval?.durationMs || procedure?.durationMs) },
{ label: "fetched", value: runDetails?.fetchedAt ? fmtClock(runDetails.fetchedAt) : "--" },
context?.matchedStep ? { label: "matched step", value: `Step ${context.matchedStep.index ?? context.matchedStepIndex + 1}` } : null,
] }),
loading ? h("div", { className: "form-success" }, "正在抓取 epoch 执行过程..." ) : null,
error ? h("div", { className: "form-error" }, error) : null,
h("div", { className: "pipeline-gantt-detail-actions" },
h(RawButton, { title: `Procedure ${interval?.procedureRunId || marker?.procedureRunId || context?.nodeId || "node"}`, data: procedure, onOpen: onRaw, testId: "raw-pipeline-gantt-procedure" }),
marker ? h(RawButton, { title: `Pipeline event ${marker?.id || marker?.commandId || marker?.eventId || context?.nodeId || "event"}`, data: marker?.raw || marker, onOpen: onRaw, testId: "raw-pipeline-gantt-event" }) : null,
details ? h(RawButton, { title: `Pipeline run ${runId || "--"}`, data: details, onOpen: onRaw, testId: "raw-pipeline-gantt-node-details" }) : null,
),
!loading && !procedureRunIdOf(procedure) && !marker ? h(EmptyState, { title: "暂无过程详情", text: "当前选择还没有可匹配的 procedure 运行记录。" }) : null,
!loading && procedureRunIdOf(procedure) ? h(PipelineProcedureAttemptList, { procedure, matchedStepKey, matchedAttemptId }) : null,
);
}
function GanttHeaderLabel({ value }: AnyRecord) {
const text = String(value || "--");
const pieces = text.split(/([_-])/u);
return h(React.Fragment, null, pieces.map((piece, index) => (
piece === "-" || piece === "_" ? h(React.Fragment, { key: index }, piece, h("wbr", null)) : h(React.Fragment, { key: index }, piece)
)));
}
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { text };
}
if (!response.ok || body?.ok === false) {
const message = body?.error?.message || body?.error || `HTTP ${response.status}`;
const error = new Error(message);
(error as Error & { status?: number }).status = response.status;
throw error;
}
return body;
}
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h("h2", null, title),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
}, "查看原始JSON");
}
function EvidenceIndexRow({ title, subtitle, facts, data, onRaw, testId }: AnyRecord) {
const safeFacts = asArray(facts).map((item) => String(item || "")).filter(Boolean);
return h("article", { className: "pipeline-evidence-row" },
h("div", { className: "pipeline-evidence-main" },
h("strong", null, title),
subtitle ? h("span", null, subtitle) : null,
),
h("div", { className: "pipeline-evidence-facts" }, safeFacts.map((fact, index) => h("span", { key: `${index}-${fact.slice(0, 16)}` }, fact))),
data !== undefined ? h(RawButton, { title, data, onOpen: onRaw, testId }) : null,
);
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function microserviceRepository(service: any): AnyRecord {
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
}
function pipelineSnapshotArrays(snapshot: any): { components: any[]; pipelines: any[]; runs: any[] } {
return {
components: Array.isArray(snapshot?.registry?.components) ? snapshot.registry.components : [],
pipelines: Array.isArray(snapshot?.pipelines) ? snapshot.pipelines : [],
runs: Array.isArray(snapshot?.runs) ? snapshot.runs : [],
};
}
function pipelineArrayCount(snapshot: any, path: string, fallback: number): number {
const meta = snapshot?._unidesk?.arrayLimits?.[path];
const original = Number(meta?.originalLength);
return Number.isFinite(original) ? original : fallback;
}
function pipelineComponentRef(value: any): string {
if (!value || typeof value !== "object" || Array.isArray(value)) return "--";
return `${value.componentClass || "--"}/${value.id || "--"}`;
}
function pipelineComponentRefKey(value: any): string {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const componentClass = String(value.componentClass || "").trim();
const id = String(value.id || "").trim();
return componentClass && id ? `${componentClass}/${id}` : "";
}
function pipelineConfigObject(pipeline: any): AnyRecord {
return pipeline?.config && typeof pipeline.config === "object" && !Array.isArray(pipeline.config) ? pipeline.config : {};
}
function pipelineConfigNodes(pipeline: any): AnyRecord[] {
const config = pipelineConfigObject(pipeline);
const rawNodes = Array.isArray(config.nodes) ? config.nodes : Array.isArray(pipeline?.nodes) ? pipeline.nodes : [];
const nodeById: Map<string, AnyRecord> = new Map();
for (const node of rawNodes) {
const id = String(node?.id || node?.nodeId || "");
if (id) nodeById.set(id, { ...node, id });
}
const edges = pipelineConfigEdges(pipeline);
const addMissing = (id: string) => {
if (id && !nodeById.has(id)) nodeById.set(id, { id });
};
for (const batch of pipelineGraphBatches(pipeline)) pipelineRawNodeIds(batch).forEach(addMissing);
for (const edge of edges) {
addMissing(String(edge?.from || edge?.source || ""));
addMissing(String(edge?.to || edge?.target || ""));
}
return Array.from(nodeById.values());
}
function pipelineConfigEdges(pipeline: any): AnyRecord[] {
const config = pipelineConfigObject(pipeline);
return Array.isArray(config.edges) ? config.edges : Array.isArray(pipeline?.edges) ? pipeline.edges : [];
}
function pipelineGraphBatches(pipeline: any): any[] {
const config = pipelineConfigObject(pipeline);
return Array.isArray(config.topologicalBatches) ? config.topologicalBatches : Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : [];
}
function pipelineComponentLookup(components: any[]): Map<string, AnyRecord> {
const byRef: Map<string, AnyRecord> = new Map();
for (const component of components) {
const directKey = pipelineComponentRefKey(component);
if (directKey) byRef.set(directKey, component);
const refs = Array.isArray(component?.refs) ? component.refs : [];
for (const ref of refs) {
const refKey = pipelineComponentRefKey(ref);
if (refKey) byRef.set(refKey, component);
}
}
return byRef;
}
function pipelineNodeComponent(node: any, componentByRef: Map<string, AnyRecord>): AnyRecord | null {
const explicit = componentByRef.get(pipelineComponentRefKey(node?.componentRef));
if (explicit) return explicit;
const fallbackKey = pipelineComponentRefKey({ componentClass: node?.kind, id: node?.id });
return fallbackKey ? componentByRef.get(fallbackKey) || null : null;
}
function pipelineRunNodeStatus(run: any, nodeId: string): string {
const node = pipelineRunNodeRecord(run, nodeId);
return String(node?.status || "pending");
}
function pipelineRunNodeRecord(run: any, nodeId: string): AnyRecord | null {
const nodes = Array.isArray(run?.nodes) ? run.nodes : [];
return nodes.find((item: any) => item?.nodeId === nodeId || item?.id === nodeId) || null;
}
function pipelineStatusCounts(runs: any[]): AnyRecord {
return runs.reduce((counts: AnyRecord, run: any) => {
const status = String(run?.status || "unknown").toLowerCase();
counts[status] = (counts[status] || 0) + 1;
return counts;
}, {});
}
function pipelineRunScorers(run: any): AnyRecord[] {
if (Array.isArray(run?.scorers)) return run.scorers.filter(isRecord);
if (Array.isArray(run?.summary?.scorers)) return run.summary.scorers.filter(isRecord);
if (Array.isArray(run?.artifact?.summary?.scorers)) return run.artifact.summary.scorers.filter(isRecord);
return [];
}
function pipelineDetailedRun(details: any): AnyRecord | null {
if (isRecord(details?.run)) return details.run;
if (isRecord(details?.runSummary)) return details.runSummary;
return null;
}
function mergePipelineRunSummary(base: any, detail: any): AnyRecord | null {
if (!isRecord(base) && !isRecord(detail)) return null;
if (!isRecord(base)) return detail;
if (!isRecord(detail)) return base;
return {
...base,
...detail,
request: isRecord(base.request) || isRecord(detail.request) ? { ...(isRecord(base.request) ? base.request : {}), ...(isRecord(detail.request) ? detail.request : {}) } : detail.request ?? base.request,
artifact: isRecord(base.artifact) || isRecord(detail.artifact) ? { ...(isRecord(base.artifact) ? base.artifact : {}), ...(isRecord(detail.artifact) ? detail.artifact : {}) } : detail.artifact ?? base.artifact,
summary: isRecord(base.summary) || isRecord(detail.summary) ? { ...(isRecord(base.summary) ? base.summary : {}), ...(isRecord(detail.summary) ? detail.summary : {}) } : detail.summary ?? base.summary,
};
}
function pipelineScoreSummary(run: any): AnyRecord {
const scorers = pipelineRunScorers(run);
const primary = scorers.find((scorer) => isRecord(scorer?.score)) || scorers[0] || null;
const score = isRecord(primary?.score) ? primary.score : {};
const passed = Number(score.passed);
const total = Number(score.total);
const ratio = Number(score.ratio);
const computedRatio = Number.isFinite(ratio)
? ratio
: Number.isFinite(passed) && Number.isFinite(total) && total > 0 ? passed / total : null;
const percent = computedRatio === null ? null : Math.round(Math.max(0, Math.min(100, computedRatio <= 1 ? computedRatio * 100 : computedRatio)));
const text = String(score.text || (Number.isFinite(passed) && Number.isFinite(total) ? `${passed}/${total}` : ""));
return {
scorer: primary,
scorers,
score,
passed: Number.isFinite(passed) ? passed : null,
total: Number.isFinite(total) ? total : null,
percent,
text,
};
}
function pipelineScoreText(run: any): string {
const summary = pipelineScoreSummary(run);
return summary.text || (summary.scorers.length > 0 ? String(summary.scorer?.status || "pending") : "--");
}
function pipelineScoreTone(run: any): string {
const summary = pipelineScoreSummary(run);
if (summary.total > 0 && summary.passed === summary.total) return "succeeded";
if (summary.total > 0 && summary.passed > 0) return "running";
if (summary.scorers.length > 0) return "failed";
return "pending";
}
function pipelineScorerItems(scorer: any): AnyRecord[] {
return Array.isArray(scorer?.items) ? scorer.items.filter(isRecord) : [];
}
function PipelineScoreBadge({ run }: AnyRecord) {
const text = pipelineScoreText(run);
return h("span", { className: `pipeline-score-badge ${pipelineScoreTone(run)}` }, `score ${text}`);
}
function PipelineScoreBoard({ run, onRaw }: AnyRecord) {
const summary = pipelineScoreSummary(run);
const scorers = summary.scorers;
if (!run) return h(EmptyState, { title: "暂无评分", text: "选择一个 epoch 后会显示 scorer 结果。" });
if (scorers.length === 0) return h("div", { className: "pipeline-score-empty" },
h("strong", null, "评分器等待中"),
h("span", null, "DAG 完成后,Pipeline control backend 会把 scorer summary 追加到 run artifact,并通过 UniDesk 显示。"),
);
return h("div", { className: "pipeline-score-board", "data-testid": "pipeline-score-board" },
scorers.map((scorer: AnyRecord, index: number) => {
const localSummary = pipelineScoreSummary({ scorers: [scorer] });
const items = pipelineScorerItems(scorer);
const percent = localSummary.percent ?? 0;
return h("article", { key: `${scorer.scorerId || scorer.component || index}`, className: `pipeline-score-card ${pipelineScoreTone({ scorers: [scorer] })}` },
h("div", { className: "pipeline-score-head" },
h("div", null,
h("span", null, scorer.scorerId || scorer.component || "scorer"),
h("strong", null, localSummary.text || scorer.status || "--"),
),
h(StatusBadge, { status: scorer.status || "unknown" }, scorer.status || "unknown"),
),
h("div", { className: "pipeline-score-meter", "aria-label": `score ${percent}%` }, h("span", { style: { width: `${percent}%` } })),
h("div", { className: "pipeline-score-facts" },
h("span", null, `${percent}%`),
h("span", null, scorer.component || "--"),
h("span", null, scorer.applicationCheckoutRef || "--"),
),
items.length > 0 ? h("div", { className: "pipeline-score-items" }, items.map((item: AnyRecord) => h("span", {
key: `${item.id || item.filter}`,
className: `pipeline-score-item ${String(item.status || "").toLowerCase()}`,
title: `${item.filter || "--"} / ran=${item.ran ?? "?"}`,
}, h("b", null, item.id || "--"), h("small", null, item.status || "--")))) : h("p", { className: "muted paragraph" }, "当前 scorer 尚未返回 item 级结果。"),
scorer.error ? h("p", { className: "pipeline-score-error" }, previewText(scorer.error, 360)) : null,
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: `Scorer ${scorer.scorerId || index}`, data: scorer, onOpen: onRaw, testId: "raw-pipeline-score" })),
);
}),
);
}
function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> {
const counts = components.reduce((memo: AnyRecord, component: any) => {
const name = String(component?.componentClass || "unknown");
memo[name] = (memo[name] || 0) + 1;
return memo;
}, {});
return Object.entries(counts).map(([name, count]) => ({ name, count: Number(count) })).sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
}
function pipelineRawNodeIds(value: any): string[] {
if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? item : String(item?.id || item?.nodeId || "")).filter(Boolean);
if (Array.isArray(value?.nodes)) return pipelineRawNodeIds(value.nodes);
if (Array.isArray(value?.nodeIds)) return pipelineRawNodeIds(value.nodeIds);
return [];
}
function pipelineMonitorInputs(node: any): AnyRecord {
return isRecord(node?.instanceInputs?.monitor) ? node.instanceInputs.monitor : {};
}
function pipelineNodeIsMonitor(node: any, component?: any): boolean {
if (String(node?.kind || "").toLowerCase() !== "procedure") return false;
const monitorInputs = pipelineMonitorInputs(node);
if (node?.instanceInputs?.monitorMode === true || monitorInputs.enabled === true) return true;
const componentRef = pipelineComponentRef(node?.componentRef);
return String(component?.id || component?.config?.id || componentRef || "").toLowerCase().includes("monitor");
}
function pipelineMonitorNodeIds(pipelineNodes: AnyRecord[]): string[] {
return pipelineNodes
.filter((node: any) => pipelineNodeIsMonitor(node))
.map((node: any) => String(node?.id || ""))
.filter(Boolean);
}
function pipelineOrderWithLeadingMonitors(nodeIds: string[], monitorNodeIds: string[]): string[] {
if (monitorNodeIds.length === 0) return nodeIds;
const monitorSet = new Set(monitorNodeIds);
const leading = monitorNodeIds.filter((nodeId) => nodeIds.includes(nodeId));
if (leading.length === 0) return nodeIds;
return [...leading, ...nodeIds.filter((nodeId) => !monitorSet.has(nodeId))];
}
function pipelineColumnsWithLeadingMonitors(columns: string[][], monitorNodeIds: string[]): string[][] {
if (monitorNodeIds.length === 0) return columns;
const monitorSet = new Set(monitorNodeIds);
const leading = monitorNodeIds.filter((nodeId) => columns.some((column) => column.includes(nodeId)));
if (leading.length === 0) return columns;
const remaining = columns
.map((column) => column.filter((nodeId) => !monitorSet.has(nodeId)))
.filter((column) => column.length > 0);
return [leading, ...remaining];
}
function pipelineGraphColumns(pipeline: any, pipelineNodes: AnyRecord[], pipelineEdges: AnyRecord[]): string[][] {
const rawBatches = pipelineGraphBatches(pipeline);
const explicit = rawBatches.map(pipelineRawNodeIds).filter((batch: string[]) => batch.length > 0);
if (explicit.length > 0) return explicit;
const ids: string[] = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
const idSet = new Set(ids);
const incoming: Map<string, number> = new Map(ids.map((id) => [id, 0]));
const outgoing: Map<string, string[]> = new Map(ids.map((id) => [id, []]));
for (const edge of pipelineEdges) {
const from = String(edge?.from || edge?.source || "");
const to = String(edge?.to || edge?.target || "");
if (!idSet.has(from) || !idSet.has(to)) continue;
outgoing.get(from)?.push(to);
incoming.set(to, (incoming.get(to) || 0) + 1);
}
const levels = new Map<string, number>();
const queue = ids.filter((id) => (incoming.get(id) || 0) === 0);
for (const id of queue) levels.set(id, 0);
while (queue.length > 0) {
const current = queue.shift()!;
const nextLevel = (levels.get(current) || 0) + 1;
for (const next of outgoing.get(current) || []) {
incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1));
levels.set(next, Math.max(levels.get(next) || 0, nextLevel));
if ((incoming.get(next) || 0) === 0) queue.push(next);
}
}
ids.forEach((id) => { if (!levels.has(id)) levels.set(id, 0); });
const maxLevel = Math.max(0, ...Array.from(levels.values()));
return Array.from({ length: maxLevel + 1 }, (_, level) => ids.filter((id) => levels.get(id) === level)).filter((batch) => batch.length > 0);
}
function pipelineGraphNodeOrder(pipeline: any, pipelineNodes: AnyRecord[], pipelineEdges: AnyRecord[]): string[] {
const rawBatches = pipelineGraphBatches(pipeline);
const explicit = rawBatches.map(pipelineRawNodeIds).filter((batch: string[]) => batch.length > 0);
const ordered = explicit.length > 0 ? explicit.flatMap((batch) => batch) : (() => {
const ids = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
const idSet = new Set(ids);
const forwardEdges = pipelineEdges.filter((edge: AnyRecord) => String(edge?.edgeType || "").toLowerCase() !== "rework");
const incoming = new Map(ids.map((id) => [id, 0]));
const outgoing = new Map(ids.map((id) => [id, [] as string[]]));
for (const edge of forwardEdges) {
const from = String(edge?.from || edge?.source || "");
const to = String(edge?.to || edge?.target || "");
if (!idSet.has(from) || !idSet.has(to)) continue;
outgoing.get(from)?.push(to);
incoming.set(to, (incoming.get(to) || 0) + 1);
}
const levels = new Map<string, number>();
const queue = ids.filter((id) => (incoming.get(id) || 0) === 0);
for (const id of queue) levels.set(id, 0);
while (queue.length > 0) {
const current = queue.shift()!;
const nextLevel = (levels.get(current) || 0) + 1;
for (const next of outgoing.get(current) || []) {
incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1));
levels.set(next, Math.max(levels.get(next) || 0, nextLevel));
if ((incoming.get(next) || 0) === 0) queue.push(next);
}
}
ids.forEach((id) => { if (!levels.has(id)) levels.set(id, 0); });
const maxLevel = Math.max(0, ...Array.from(levels.values()));
return Array.from({ length: maxLevel + 1 }, (_, level) => ids.filter((id) => levels.get(id) === level)).flatMap((batch) => batch);
})();
const seen = new Set(ordered);
for (const node of pipelineNodes) {
const nodeId = String(node?.id || "");
if (!nodeId || seen.has(nodeId)) continue;
ordered.push(nodeId);
seen.add(nodeId);
}
return pipelineOrderWithLeadingMonitors(ordered, pipelineMonitorNodeIds(pipelineNodes));
}
function pipelineFlowEdgeKey(edge: AnyRecord): string {
return `${edge.source}->${edge.target}-${edge.index}`;
}
function pipelineFlowElements(pipeline: any, latestRun: any, components: any[]): { nodes: Node[]; edges: Edge[] } {
const pipelineNodes = pipelineConfigNodes(pipeline);
const pipelineEdges = pipelineConfigEdges(pipeline);
const componentByRef = pipelineComponentLookup(components);
const nodeById: Map<string, AnyRecord> = new Map(pipelineNodes.map((node: any) => [String(node?.id || ""), node]));
const monitorNodeIds = pipelineNodes
.filter((node: any) => pipelineNodeIsMonitor(node, pipelineNodeComponent(node, componentByRef)))
.map((node: any) => String(node?.id || ""))
.filter(Boolean);
const columns = pipelineColumnsWithLeadingMonitors(pipelineGraphColumns(pipeline, pipelineNodes, pipelineEdges), monitorNodeIds);
const flowNodes: Node[] = [];
const nodeLayout: Map<string, { column: number; row: number; y: number }> = new Map();
const columnGap = 330;
const rowGap = 122;
columns.forEach((batch, columnIndex) => {
const columnHeight = batch.length * rowGap;
batch.forEach((nodeId, rowIndex) => {
const node = nodeById.get(nodeId) || { id: nodeId };
const component = pipelineNodeComponent(node, componentByRef);
const status = pipelineRunNodeStatus(latestRun, nodeId).toLowerCase();
const kind = String(node.kind || component?.componentClass || "node").toLowerCase();
const componentRef = pipelineComponentRef(node.componentRef || component);
const componentVersion = String(component?.config?.version || component?.version || "");
const componentDescription = String(component?.config?.description || component?.description || "");
const y = rowIndex * rowGap - Math.floor(columnHeight / 2);
nodeLayout.set(nodeId, { column: columnIndex, row: rowIndex, y });
flowNodes.push({
id: nodeId,
type: "pipelineNode",
position: {
x: columnIndex * columnGap,
y,
},
data: {
exportLabel: {
id: nodeId,
kind,
componentRef,
componentVersion,
componentDescription,
status,
},
label: h("div", { className: "flow-node-label" },
h("strong", null, nodeId),
h("span", null, kind),
h("code", { title: componentDescription || componentRef }, componentVersion ? `${componentRef}@${componentVersion}` : componentRef),
h(StatusBadge, { status }, status),
),
},
className: `pipeline-flow-node ${kind} ${status}`,
});
});
});
const graphEdges = pipelineEdges.flatMap((edge: any, index: number) => {
const source = String(edge?.from || edge?.source || "");
const target = String(edge?.to || edge?.target || "");
if (!nodeById.has(source) || !nodeById.has(target)) return [];
return [{ source, target, index, condition: edge?.condition, edgeType: edge?.edgeType }];
});
const sourceTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => memo.set(edge.source, (memo.get(edge.source) || 0) + 1), new Map<string, number>());
const targetTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => memo.set(edge.target, (memo.get(edge.target) || 0) + 1), new Map<string, number>());
const pairTotals = graphEdges.reduce((memo: Map<string, number>, edge: AnyRecord) => {
const key = `${edge.source}->${edge.target}`;
return memo.set(key, (memo.get(key) || 0) + 1);
}, new Map<string, number>());
const sourceSeen = new Map<string, number>();
const targetSeen = new Map<string, number>();
const pairSeen = new Map<string, number>();
const targetHandleByEdge = new Map<string, AnyRecord>();
const targetPortLoads: Map<string, Map<string, number>> = new Map();
const adjacentForwardFanOutByEdge = new Map<string, { slot: number; count: number }>();
const adjacentForwardClusters = graphEdges.reduce((memo: Map<string, AnyRecord[]>, edge: AnyRecord) => {
const sourceLayout = nodeLayout.get(edge.source);
const targetLayout = nodeLayout.get(edge.target);
const rawSpan = (targetLayout?.column || 0) - (sourceLayout?.column || 0);
const backward = rawSpan <= 0 || String(edge.edgeType || "").toLowerCase() === "rework";
if (backward || rawSpan !== 1) return memo;
const key = `${edge.source}->column:${targetLayout?.column ?? ""}`;
const list = memo.get(key) || [];
list.push(edge);
memo.set(key, list);
return memo;
}, new Map<string, AnyRecord[]>());
for (const cluster of adjacentForwardClusters.values()) {
if (cluster.length < 2) continue;
cluster
.slice()
.sort((left: AnyRecord, right: AnyRecord) => {
const leftTarget = nodeLayout.get(left.target);
const rightTarget = nodeLayout.get(right.target);
return (leftTarget?.y || 0) - (rightTarget?.y || 0) || left.index - right.index;
})
.forEach((edge: AnyRecord, index: number, ordered: AnyRecord[]) => {
adjacentForwardFanOutByEdge.set(pipelineFlowEdgeKey(edge), { slot: index - (ordered.length - 1) / 2, count: ordered.length });
});
}
const sortedIncomingEdges = [...graphEdges].sort((left: AnyRecord, right: AnyRecord) => {
const leftSource = nodeLayout.get(left.source);
const leftTarget = nodeLayout.get(left.target);
const rightSource = nodeLayout.get(right.source);
const rightTarget = nodeLayout.get(right.target);
const leftScore = Math.abs((leftTarget?.column || 0) - (leftSource?.column || 0)) * columnGap + Math.abs((leftTarget?.y || 0) - (leftSource?.y || 0));
const rightScore = Math.abs((rightTarget?.column || 0) - (rightSource?.column || 0)) * columnGap + Math.abs((rightTarget?.y || 0) - (rightSource?.y || 0));
return leftScore - rightScore || left.index - right.index;
});
sortedIncomingEdges.forEach((edge: AnyRecord) => {
const sourceLayout = nodeLayout.get(edge.source) || { column: 0, row: 0, y: 0 };
const targetLayout = nodeLayout.get(edge.target) || { column: 0, row: 0, y: 0 };
const rawSpan = targetLayout.column - sourceLayout.column;
const span = Math.max(0, rawSpan);
const backward = rawSpan <= 0 || String(edge.edgeType || "").toLowerCase() === "rework";
const verticalDelta = sourceLayout.y - targetLayout.y;
const targetIncomingCount = targetTotals.get(edge.target) || 1;
const adjacentFanOut = adjacentForwardFanOutByEdge.has(pipelineFlowEdgeKey(edge));
const primaryForwardInput = !backward && span <= 1 && (adjacentFanOut || targetIncomingCount === 1);
const loads = targetPortLoads.get(edge.target) || new Map<string, number>();
targetPortLoads.set(edge.target, loads);
const port = pipelineInputPorts.slice().sort((left, right) => {
const score = (item: AnyRecord): number => {
const side = String(item.side);
let value = 0;
if (backward) {
if (side === "left") value += 86;
if (side === "top") value += targetLayout.y <= 0 ? -22 : 12;
if (side === "bottom") value += targetLayout.y >= 0 ? -22 : 12;
if (Math.abs(targetLayout.y) < 12 && side !== "left") value += edge.index % 2 === 0 ? (side === "top" ? -6 : 6) : (side === "bottom" ? -6 : 6);
return value;
}
if (primaryForwardInput) {
if (side === "left") value -= adjacentFanOut ? 72 : 44;
if (side !== "left") value += adjacentFanOut ? 72 : 44;
return value + Math.abs(verticalDelta) * 0.02;
}
if (side === "left") value += span <= 1 ? 0 : 24;
if (side === "top") value += verticalDelta < -36 ? -18 : 42;
if (side === "bottom") value += verticalDelta > 36 ? -18 : 42;
if (span <= 1 && Math.abs(verticalDelta) <= 82 && side !== "left") value += 38;
if (span > 1 && side !== "left") value -= 10;
return value;
};
const rawLaneHint = sourceLayout.y - targetLayout.y;
const laneHint = rawLaneHint !== 0 ? rawLaneHint : (edge.index % 2 === 0 ? -1 : 1);
const totalScore = (item: AnyRecord): number => {
const load = loads.get(item.id) || 0;
return score(item) + load * 64 + pipelineEndpointScore(item, loads, laneHint);
};
return totalScore(left) - totalScore(right) || String(left.id).localeCompare(String(right.id));
})[0];
loads.set(port.id, (loads.get(port.id) || 0) + 1);
targetHandleByEdge.set(pipelineFlowEdgeKey(edge), port);
});
const edgeDrafts: AnyRecord[] = graphEdges.map((edge: AnyRecord) => {
const targetStatus = pipelineRunNodeStatus(latestRun, edge.target).toLowerCase();
const pairKey = `${edge.source}->${edge.target}`;
const sourceSlot = sourceSeen.get(edge.source) || 0;
const targetSlot = targetSeen.get(edge.target) || 0;
const pairSlot = pairSeen.get(pairKey) || 0;
sourceSeen.set(edge.source, sourceSlot + 1);
targetSeen.set(edge.target, targetSlot + 1);
pairSeen.set(pairKey, pairSlot + 1);
const sourceLane = sourceSlot - ((sourceTotals.get(edge.source) || 1) - 1) / 2;
const targetLane = targetSlot - ((targetTotals.get(edge.target) || 1) - 1) / 2;
const pairLane = pairSlot - ((pairTotals.get(pairKey) || 1) - 1) / 2;
const sourceLayout = nodeLayout.get(edge.source);
const targetLayout = nodeLayout.get(edge.target);
const rawSpan = (targetLayout?.column || 0) - (sourceLayout?.column || 0);
const span = Math.max(1, Math.abs(rawSpan));
const backward = rawSpan <= 0 || String(edge.edgeType || "").toLowerCase() === "rework";
const verticalDelta = Math.abs((targetLayout?.y || 0) - (sourceLayout?.y || 0));
const fanOutMeta = adjacentForwardFanOutByEdge.get(pipelineFlowEdgeKey(edge));
const adjacentFanIn = !backward && rawSpan === 1 && (targetTotals.get(edge.target) || 0) > 1;
const lane = fanOutMeta ? fanOutMeta.slot : pairLane * 2 + sourceLane + targetLane * 0.45;
const railDirection = lane === 0 ? (edge.index % 2 === 0 ? -1 : 1) : Math.sign(lane);
const targetPort = targetHandleByEdge.get(pipelineFlowEdgeKey(edge)) || pipelineInputPorts[1];
const portDirection = targetPort.side === "top" ? -1 : targetPort.side === "bottom" ? 1 : railDirection;
const shouldScatter = backward || span > 1 || verticalDelta > 96 || Math.abs(lane) > 0.2 || targetPort.side !== "left";
const baseScatter = backward ? 118 + span * 18 : 22 + span * 16;
const sideScatter = targetPort.side === "left" ? 0 : 28;
const laneOffset = shouldScatter ? Math.max(-280, Math.min(280, portDirection * Math.min(180, baseScatter + sideScatter + verticalDelta * 0.22) + lane * 28)) : 0;
const sourceHandleIndex = Math.max(0, Math.min(pipelineOutputPorts.length - 1, Math.round(sourceLane + (pipelineOutputPorts.length - 1) / 2)));
const sourcePort = pipelineOutputPorts[sourceHandleIndex] || pipelineOutputPorts[1];
const baseEdgeColor = targetStatus === "succeeded" ? "var(--accent-2)" : targetStatus === "running" ? "var(--accent)" : targetStatus === "failed" ? "var(--danger)" : "rgba(129, 147, 159, 0.78)";
const sourceColumn = sourceLayout?.column || 0;
const targetColumn = targetLayout?.column || 0;
const railSign = laneOffset === 0 ? 0 : Math.sign(laneOffset);
const overlapGroup = backward
? `feedback:${sourceColumn}->${targetColumn}:${railSign}`
: fanOutMeta
? `fanout:${sourceColumn}->${targetColumn}:${edge.source}`
: adjacentFanIn
? `fanin:${sourceColumn}->${targetColumn}:${edge.target}`
: targetPort.side !== "left" || span > 1
? `corridor:${sourceColumn}->${targetColumn}:${targetPort.side}:${railSign}:${Math.round(Math.abs(laneOffset) / 56)}`
: "";
return {
id: `${edge.source}->${edge.target}-${edge.index}`,
source: edge.source,
target: edge.target,
sourceHandle: sourcePort.id,
targetHandle: targetPort.id,
type: "pipelineCurve",
zIndex: 12,
animated: targetStatus === "running",
data: { baseEdgeColor, laneOffset, routeMode: fanOutMeta && targetPort.side === "left" ? "direct-forward-left" : "", targetSide: targetPort.side, isFeedback: backward, overlapGroup },
targetStatus,
};
});
const overlapGroupCounts = edgeDrafts.reduce((memo: Map<string, number>, edge: AnyRecord) => {
const key = String(edge.data?.overlapGroup || "");
return key ? memo.set(key, (memo.get(key) || 0) + 1) : memo;
}, new Map<string, number>());
const overlapGroupSeen = new Map<string, number>();
const edges: Edge[] = edgeDrafts.map((edge: AnyRecord) => {
const draftTargetStatus = String(edge.targetStatus || "pending");
const flowEdge: AnyRecord = { ...edge };
delete flowEdge.targetStatus;
const groupKey = String(edge.data?.overlapGroup || "");
const groupCount = groupKey ? overlapGroupCounts.get(groupKey) || 0 : 0;
const overlapSlot = groupCount > 1 ? overlapGroupSeen.get(groupKey) || 0 : -1;
if (groupCount > 1) overlapGroupSeen.set(groupKey, overlapSlot + 1);
const edgeColor = overlapSlot >= 0 ? pipelineOverlapEdgePalette[overlapSlot % pipelineOverlapEdgePalette.length] : String(edge.data.baseEdgeColor);
const style: AnyRecord = { stroke: edgeColor };
if (edge.data.isFeedback) style.strokeDasharray = "9 7";
return {
...flowEdge,
data: { ...edge.data, edgeColor, overlapSlot, overlapCount: groupCount },
style,
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor },
className: `pipeline-flow-edge ${draftTargetStatus} ${edge.data.isFeedback ? "feedback" : ""} ${overlapSlot >= 0 ? "overlap-colored" : ""}`,
} as Edge;
});
return { nodes: flowNodes, edges };
}
function escapeSvg(value: any): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function exportColor(value: any): string {
const text = String(value || "");
if (text.includes("--accent-2")) return "#4eb7a8";
if (text.includes("--accent")) return "#d7a13a";
if (text.includes("--danger")) return "#cf6a54";
return text.startsWith("#") ? text : "#81939f";
}
function exportMarkerId(color: string): string {
return `arrow-${color.replace(/[^a-zA-Z0-9_-]+/g, "")}`;
}
function safeExportFileTitle(title: string, fallback = "pipeline"): string {
return String(title || fallback).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || fallback;
}
function targetPortPosition(node: Node, handle: string): { x: number; y: number; position: Position } {
const x = node.position.x;
const y = node.position.y;
const port = pipelineInputPorts.find((item) => item.id === handle);
if (port?.side === "top") return { x: x + pipelineNodeWidth * pipelinePercent(port.style?.left, 0.5), y, position: Position.Top };
if (port?.side === "bottom") return { x: x + pipelineNodeWidth * pipelinePercent(port.style?.left, 0.5), y: y + pipelineNodeHeight, position: Position.Bottom };
return { x, y: y + pipelineNodeHeight / 2, position: Position.Left };
}
function sourcePortPosition(node: Node): { x: number; y: number } {
return { x: node.position.x + pipelineNodeWidth, y: node.position.y + pipelineNodeHeight / 2 };
}
function pipelineGraphSvg(flow: { nodes: Node[]; edges: Edge[] }, title: string): { svg: string; width: number; height: number } {
const minX = Math.min(...flow.nodes.map((node) => node.position.x), 0) - 220;
const minY = Math.min(...flow.nodes.map((node) => node.position.y), 0) - 220;
const maxX = Math.max(...flow.nodes.map((node) => node.position.x + pipelineNodeWidth), 1) + 220;
const maxY = Math.max(...flow.nodes.map((node) => node.position.y + pipelineNodeHeight), 1) + 220;
const width = Math.ceil(maxX - minX);
const height = Math.ceil(maxY - minY);
const nodeById = new Map(flow.nodes.map((node) => [node.id, node]));
const edgeColors = flow.edges.map((edge) => exportColor((edge.data as AnyRecord)?.edgeColor || (edge.style as AnyRecord)?.stroke));
const markerColors = Array.from(new Set(["#4eb7a8", "#d7a13a", "#cf6a54", "#81939f", ...edgeColors]));
const markers = markerColors.map((color) =>
`<marker id="${exportMarkerId(color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/></marker>`,
).join("");
const edgeSvg = flow.edges.flatMap((edge) => {
const source = nodeById.get(edge.source);
const target = nodeById.get(edge.target);
if (!source || !target) return [];
const sourcePoint = sourcePortPosition(source);
const targetPoint = targetPortPosition(target, String(edge.targetHandle || "in-left"));
const path = pipelineCurvePath(sourcePoint.x, sourcePoint.y, targetPoint.x, targetPoint.y, targetPoint.position, Number((edge.data as AnyRecord)?.laneOffset || 0), String((edge.data as AnyRecord)?.routeMode || ""));
const color = exportColor((edge.data as AnyRecord)?.edgeColor || (edge.style as AnyRecord)?.stroke);
const dash = (edge.data as AnyRecord)?.isFeedback ? ` stroke-dasharray="9 7"` : "";
return `<path d="${escapeSvg(path)}" fill="none" stroke="${color}" stroke-width="2.35" stroke-linecap="round" stroke-linejoin="round" opacity="0.82"${dash} marker-end="url(#${exportMarkerId(color)})"/>`;
}).join("\n");
const nodeSvg = flow.nodes.map((node) => {
const label = ((node.data as AnyRecord)?.exportLabel || {}) as AnyRecord;
const status = String(label.status || "pending").toLowerCase();
const stroke = status === "succeeded" ? "#4eb7a8" : status === "running" ? "#d7a13a" : status === "failed" ? "#cf6a54" : "#81939f";
const x = node.position.x;
const y = node.position.y;
const inputHandles = pipelineInputPorts.map((port) => {
const point = targetPortPosition(node, port.id);
if (port.side === "top" || port.side === "bottom") return `<rect x="${point.x - 9}" y="${point.y - 5}" width="18" height="10" rx="2" fill="#071016" stroke="#4eb7a8"/>`;
return `<rect x="${point.x - 5}" y="${point.y - 9}" width="10" height="18" rx="2" fill="#071016" stroke="#4eb7a8"/>`;
}).join("\n");
return `<g>
<rect x="${x}" y="${y}" width="${pipelineNodeWidth}" height="${pipelineNodeHeight}" rx="2" fill="#0d171f" stroke="${stroke}" stroke-width="1.2"/>
${inputHandles}
<rect x="${x + pipelineNodeWidth - 5}" y="${y + pipelineNodeHeight / 2 - 9}" width="10" height="18" rx="2" fill="#071016" stroke="#d7a13a"/>
<text x="${x + 12}" y="${y + 22}" fill="#e5edf2" font-size="12" font-family="monospace" font-weight="700">${escapeSvg(label.id || node.id)}</text>
<text x="${x + 12}" y="${y + 42}" fill="#81939f" font-size="10" font-family="monospace">${escapeSvg(label.kind || "node")}</text>
<text x="${x + 12}" y="${y + 60}" fill="#d7a13a" font-size="10" font-family="monospace">${escapeSvg(label.componentRef || "--")}</text>
<text x="${x + 12}" y="${y + 78}" fill="${stroke}" font-size="10" font-family="monospace">${escapeSvg(status)}</text>
</g>`;
}).join("\n");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>${markers}<pattern id="grid" width="22" height="22" patternUnits="userSpaceOnUse"><path d="M 22 0 L 0 0 0 22" fill="none" stroke="#18303a" stroke-width="0.6"/></pattern></defs>
<rect width="100%" height="100%" fill="#081118"/>
<rect width="100%" height="100%" fill="url(#grid)" opacity="0.55"/>
<text x="24" y="34" fill="#d7a13a" font-size="13" font-family="monospace" letter-spacing="2">${escapeSvg(title)}</text>
<g transform="translate(${-minX} ${-minY})">${nodeSvg}<g>${edgeSvg}</g></g>
</svg>`;
return { svg, width, height };
}
function pipelineGanttExportStatusColor(status: any): string {
const value = String(status || "").toLowerCase();
if (value === "succeeded" || value === "completed") return "#4eb7a8";
if (value === "failed") return "#cf6a54";
if (runningStatus(value)) return "#69aee8";
return "#d7a13a";
}
function pipelineGanttExportMarkerColor(marker: AnyRecord): string {
const kind = String(marker?.kind || "");
const tone = String(marker?.tone || marker?.status || "").toLowerCase();
if (kind === "prompt" && tone === "initial") return "#d7a13a";
if (kind === "prompt" && tone === "monitor") return "#69aee8";
if (kind === "prompt") return "#4eb7a8";
if (tone === "modify") return "#e0b95a";
if (tone === "approve" || tone === "guide" || tone === "monitor") return "#4eb7a8";
if (tone === "restart" || tone === "redo") return "#d7a13a";
if (tone === "ignored") return "#81939f";
if (tone === "webui") return "#69aee8";
if (tone === "cli") return "#d7a13a";
return "#a7bac5";
}
function pipelineGanttExportArrowColor(arrow: AnyRecord): string {
const sourceKind = String(arrow?.sourceKind || "").toLowerCase();
const action = String(arrow?.action || "").toLowerCase();
const status = String(arrow?.status || "").toLowerCase();
if (action === "observe" || status === "observation" || sourceKind === "monitor") return "#4eb7a8";
if (sourceKind === "webui") return "#69aee8";
if (sourceKind === "cli") return "#d7a13a";
if (status.includes("ignored")) return "#81939f";
return "#8aa0ad";
}
function pipelineGanttExportMarkerSvg(marker: AnyRecord, x: number, y: number): string {
const color = pipelineGanttExportMarkerColor(marker);
const kind = String(marker?.kind || "");
if (kind === "control-source") {
return `<rect x="${x - 5.5}" y="${y - 5.5}" width="11" height="11" rx="2" transform="rotate(45 ${x} ${y})" fill="${color}" stroke="rgba(255,255,255,0.26)" stroke-width="1"/>`;
}
if (kind === "control-target") {
const fill = String(marker?.tone || "").toLowerCase() === "approve" ? "rgba(78,183,168,0.22)" : "#081118";
return `<circle cx="${x}" cy="${y}" r="5.5" fill="${fill}" stroke="${color}" stroke-width="2"/>`;
}
const radius = kind === "prompt" ? 4.5 : 5.5;
return `<circle cx="${x}" cy="${y}" r="${radius}" fill="${color}" stroke="rgba(255,255,255,0.24)" stroke-width="1"/>`;
}
function pipelineGanttSvg(input: AnyRecord): { svg: string; width: number; height: number } {
const nodeIds = asArray(input.visibleNodeIds).map((nodeId) => String(nodeId || "")).filter(Boolean);
const intervals = asArray(input.intervals).filter(isRecord);
const markers = asArray(input.markers).filter(isRecord);
const arrows = asArray(input.arrows).filter(isRecord);
const ticks = asArray(input.ticks).filter(isRecord);
const bounds = isRecord(input.bounds) ? input.bounds : {};
const backendLayout = isRecord(input.backendLayout) ? input.backendLayout : null;
const chartHeight = Math.max(240, Math.round(Number(input.chartHeight || 360)));
const exportColumnWidth = Math.max(pipelineGanttNodeColumnWidth, 108);
const timeAxisWidth = 128;
const padding = 24;
const titleHeight = 58;
const headerHeight = 56;
const boardWidth = timeAxisWidth + Math.max(1, nodeIds.length) * exportColumnWidth;
const width = Math.max(760, boardWidth + padding * 2);
const height = titleHeight + headerHeight + chartHeight + padding;
const boardX = padding;
const boardY = titleHeight;
const chartY = boardY + headerHeight;
const nodeX = (index: number) => boardX + timeAxisWidth + index * exportColumnWidth;
const nodeCenterX = (index: number) => nodeX(index) + exportColumnWidth / 2;
const meta = asArray(input.meta).map((item) => String(item || "")).filter(Boolean).slice(0, 4).join(" · ");
const markerById = new Map(markers.map((marker) => [String(marker.id || ""), marker]));
const markerColors = Array.from(new Set(["#4eb7a8", "#69aee8", "#d7a13a", "#cf6a54", "#8aa0ad", ...arrows.map(pipelineGanttExportArrowColor)]));
const defs = markerColors.map((color) =>
`<marker id="${exportMarkerId(color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/></marker>`,
).join("");
const tickSvg = ticks.map((tick) => {
const y = chartY + pipelineGanttTickY(tick, bounds, chartHeight, backendLayout);
return `<g>
<line x1="${boardX}" y1="${y}" x2="${boardX + boardWidth}" y2="${y}" stroke="rgba(215,161,58,0.18)" stroke-width="1"/>
<text x="${boardX + 8}" y="${y - 4}" fill="#dce6ec" font-size="10" font-family="monospace">${escapeSvg(fmtDate(tick.ms))}</text>
<text x="${boardX + 8}" y="${y + 10}" fill="#81939f" font-size="9" font-family="monospace">+${escapeSvg(fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs))))}</text>
</g>`;
}).join("\n");
const headerSvg = [
`<rect x="${boardX}" y="${boardY}" width="${timeAxisWidth}" height="${headerHeight}" fill="#101d25" stroke="rgba(255,255,255,0.08)"/>`,
`<text x="${boardX + 10}" y="${boardY + 20}" fill="#d7a13a" font-size="10" font-family="monospace" letter-spacing="1.2">TIME</text>`,
...nodeIds.map((nodeId, index) => {
const x = nodeX(index);
const label = nodeId.length > 18 ? `${nodeId.slice(0, 16)}…` : nodeId;
return `<g>
<rect x="${x}" y="${boardY}" width="${exportColumnWidth}" height="${headerHeight}" fill="#101d25" stroke="rgba(255,255,255,0.08)"/>
<text x="${x + exportColumnWidth / 2}" y="${boardY + 21}" fill="#dce6ec" font-size="10" font-family="monospace" text-anchor="middle">${escapeSvg(label)}</text>
<text x="${x + exportColumnWidth / 2}" y="${boardY + 39}" fill="#81939f" font-size="9" font-family="monospace" text-anchor="middle">node ${index + 1}</text>
</g>`;
}),
].join("\n");
const columnSvg = nodeIds.map((_nodeId, index) => {
const x = nodeX(index);
return `<rect x="${x}" y="${chartY}" width="${exportColumnWidth}" height="${chartHeight}" fill="${index % 2 === 0 ? "rgba(255,255,255,0.018)" : "rgba(255,255,255,0.008)"}" stroke="rgba(255,255,255,0.045)"/>`;
}).join("\n");
const intervalSvg = intervals.map((interval) => {
const index = nodeIds.indexOf(String(interval.nodeId || ""));
if (index < 0) return "";
const top = chartY + pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout);
const barHeight = Math.max(2, pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout));
const color = pipelineGanttExportStatusColor(interval.status);
const x = nodeCenterX(index) - 3.5;
const liveOverlay = interval.live ? `<rect x="${x - 1}" y="${top}" width="9" height="${barHeight}" rx="5" fill="none" stroke="rgba(255,255,255,0.54)" stroke-width="1" stroke-dasharray="4 5"/>` : "";
const label = barHeight >= 28
? `<text x="${x + 12}" y="${top + 12}" fill="${color}" font-size="9" font-family="monospace">${escapeSvg(String(interval.status || "working"))}</text>
<text x="${x + 12}" y="${top + 24}" fill="#81939f" font-size="8" font-family="monospace">${escapeSvg(fmtDurationMs(interval.durationMs))}</text>`
: "";
return `<g>
<rect x="${x}" y="${top}" width="7" height="${barHeight}" rx="4" fill="${color}" stroke="rgba(0,0,0,0.42)" stroke-width="1"/>
${liveOverlay}
${label}
</g>`;
}).join("\n");
const markerSvg = markers.map((marker) => {
const index = nodeIds.indexOf(String(marker.nodeId || ""));
if (index < 0) return "";
const y = chartY + pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout);
return pipelineGanttExportMarkerSvg(marker, nodeCenterX(index), y);
}).join("\n");
const arrowSvg = arrows.map((arrow) => {
const targetMarker = markerById.get(String(arrow.targetMarkerId || ""));
if (!targetMarker) return "";
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || "");
const targetNodeId = String(targetMarker.nodeId || arrow.targetNodeId || "");
const sourceIndex = nodeIds.indexOf(sourceNodeId);
const targetIndex = nodeIds.indexOf(targetNodeId);
if (sourceIndex < 0 || targetIndex < 0) return "";
const sourceX = nodeCenterX(sourceIndex) - boardX - timeAxisWidth;
const targetX = nodeCenterX(targetIndex) - boardX - timeAxisWidth;
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1)
?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout));
const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout);
const color = pipelineGanttExportArrowColor(arrow);
const dash = String(arrow.action || "").toLowerCase() === "observe" ? "3 4" : "6 5";
const path = escapeSvg(pipelineControlArrowPath(sourceX, sourceY, targetX, targetY));
return `<path d="${path}" fill="none" stroke="rgba(8,17,24,0.92)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="${path}" fill="none" stroke="${color}" stroke-width="2.45" stroke-dasharray="${dash}" stroke-linecap="round" stroke-linejoin="round" opacity="0.96" marker-end="url(#${exportMarkerId(color)})"/>`;
}).join("\n");
const emptySvg = nodeIds.length === 0 ? `<text x="${boardX + timeAxisWidth + 24}" y="${chartY + 42}" fill="#81939f" font-size="12" font-family="monospace">No visible Gantt nodes</text>` : "";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>${defs}<pattern id="ganttGrid" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#18303a" stroke-width="0.55"/></pattern></defs>
<rect width="100%" height="100%" fill="#081118"/>
<rect width="100%" height="100%" fill="url(#ganttGrid)" opacity="0.55"/>
<rect x="${boardX}" y="${chartY}" width="${boardWidth}" height="${chartHeight}" fill="rgba(255,255,255,0.012)" stroke="rgba(78,183,168,0.24)"/>
<text x="${padding}" y="26" fill="#d7a13a" font-size="13" font-family="monospace" font-weight="700" letter-spacing="1.4">${escapeSvg(input.title || "Pipeline Epoch Gantt")}</text>
<text x="${padding}" y="45" fill="#81939f" font-size="10" font-family="monospace">${escapeSvg(meta)}</text>
${headerSvg}
<rect x="${boardX}" y="${chartY}" width="${timeAxisWidth}" height="${chartHeight}" fill="rgba(215,161,58,0.055)" stroke="rgba(215,161,58,0.2)"/>
${columnSvg}
${tickSvg}
${intervalSvg}
<g transform="translate(${boardX + timeAxisWidth} ${chartY})">${arrowSvg}</g>
${markerSvg}
${emptySvg}
</svg>`;
return { svg, width, height };
}
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title: string): Promise<void> {
const safeTitle = safeExportFileTitle(title, "pipeline");
const { svg, width, height } = pipelineGraphSvg(flow, title);
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
try {
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("svg image load failed"));
image.src = url;
});
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unavailable");
ctx.drawImage(image, 0, 0);
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!pngBlob) throw new Error("png export failed");
downloadBlob(pngBlob, `${safeTitle}.png`);
} catch {
downloadBlob(svgBlob, `${safeTitle}.svg`);
} finally {
URL.revokeObjectURL(url);
}
}
async function exportPipelineGantt(input: AnyRecord): Promise<void> {
const safeTitle = safeExportFileTitle(String(input?.title || "pipeline-gantt"), "pipeline-gantt");
const { svg, width, height } = pipelineGanttSvg(input);
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
try {
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("gantt svg image load failed"));
image.src = url;
});
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unavailable");
ctx.drawImage(image, 0, 0);
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!pngBlob) throw new Error("gantt png export failed");
downloadBlob(pngBlob, `${safeTitle}.png`);
} catch {
downloadBlob(svgBlob, `${safeTitle}.svg`);
} finally {
URL.revokeObjectURL(url);
}
}
async function exportPipelineGraphs(items: Array<{ flow: { nodes: Node[]; edges: Edge[] }; title: string }>): Promise<void> {
for (const item of items) {
if (item.flow.nodes.length === 0) continue;
await exportPipelineGraph(item.flow, item.title);
await new Promise((resolve) => setTimeout(resolve, 750));
}
}
function pipelineLatestRun(runs: any[], pipelineId: string): any {
return runs.find((run) => String(run?.pipelineId || "") === pipelineId) || null;
}
function pipelineRunStartMs(run: any): number {
return timeMs(run?.startedAt) ?? timeMs(run?.artifact?.startedAt) ?? timeMs(run?.request?.createdAt) ?? timeMs(run?.updatedAt) ?? 0;
}
function pipelineEpochRuns(runs: any[], pipelineId: string): any[] {
return runs
.filter((run: any) => String(run?.pipelineId || "") === pipelineId)
.slice()
.sort((left: any, right: any) => pipelineRunStartMs(left) - pipelineRunStartMs(right) || String(left?.runId || "").localeCompare(String(right?.runId || "")));
}
function pipelineEpochLabel(epochs: any[], run: any): string {
const runId = String(run?.runId || "");
const index = epochs.findIndex((item: any) => String(item?.runId || "") === runId);
const number = index >= 0 ? index + 1 : epochs.length;
const status = String(run?.status || "--");
return `Epoch ${number} / ${runId || "--"} / ${status}`;
}
function procedureRunIdOf(procedure: any): string {
return String(procedure?.procedureRunId || procedure?.runId || "");
}
function inferProcedureNodeId(procedure: any, parentRunId: string): string {
const direct = String(procedure?.nodeId || procedure?.request?.nodeId || "");
if (direct) return direct;
const procedureRunId = procedureRunIdOf(procedure);
const prefix = `${parentRunId}__`;
if (procedureRunId.startsWith(prefix)) return procedureRunId.slice(prefix.length).replace(/__\d+$/u, "");
return "";
}
function procedureStartIso(procedure: any, run: any): string {
const artifact = isRecord(procedure?.artifact) ? procedure.artifact : {};
const request = isRecord(procedure?.request) ? procedure.request : {};
return firstIso(
procedure?.startedAt,
artifact.startedAt,
request.createdAt,
request.startedAt,
procedure?.createdAt,
procedure?.updatedAt,
run?.startedAt,
run?.request?.createdAt,
);
}
function procedureEndIso(procedure: any, run: any): string {
const status = String(procedure?.status?.status || procedure?.artifact?.status || procedure?.status || "").toLowerCase();
const artifact = isRecord(procedure?.artifact) ? procedure.artifact : {};
const terminal = terminalStatus(status);
return firstIso(
procedure?.finishedAt,
artifact.finishedAt,
procedure?.completedAt,
terminal ? procedure?.updatedAt : undefined,
terminal ? artifact.updatedAt : undefined,
terminal ? run?.updatedAt : undefined,
);
}
function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[], nowMs = Date.now()): AnyRecord[] {
const runId = String(run?.runId || "");
const knownNodeIds = new Set(pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean));
return asArray(run?.procedureRuns).flatMap((procedure: any) => {
const nodeId = inferProcedureNodeId(procedure, runId);
if (!nodeId) return [];
const status = String(procedure?.status?.status || procedure?.artifact?.status || procedure?.status || "unknown").toLowerCase();
const startIso = procedureStartIso(procedure, run);
const startMs = timeMs(startIso);
if (startMs === null) return [];
const explicitEndIso = procedureEndIso(procedure, run);
const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : nowMs);
const safeEndMs = Math.max(startMs + 1000, endMs);
return [{
nodeId,
knownNode: knownNodeIds.has(nodeId),
procedureRunId: procedureRunIdOf(procedure),
status,
startMs,
endMs: safeEndMs,
startedAt: isoFromMs(startMs),
finishedAt: isoFromMs(safeEndMs),
durationMs: safeEndMs - startMs,
runId,
raw: procedure,
}];
}).sort((left: AnyRecord, right: AnyRecord) => left.startMs - right.startMs || left.endMs - right.endMs || left.nodeId.localeCompare(right.nodeId));
}
function pipelineRunTimeBounds(run: any, intervals: AnyRecord[]): AnyRecord {
const starts = intervals.map((item) => Number(item.startMs)).filter(Number.isFinite);
const ends = intervals.map((item) => Number(item.endMs)).filter(Number.isFinite);
const runStart = timeMs(run?.startedAt) ?? timeMs(run?.artifact?.startedAt) ?? timeMs(run?.request?.createdAt);
const runEnd = timeMs(run?.finishedAt) ?? timeMs(run?.artifact?.finishedAt) ?? timeMs(run?.updatedAt);
if (runStart !== null) starts.push(runStart);
if (runEnd !== null) ends.push(runEnd);
const now = Date.now();
const startMs = starts.length > 0 ? Math.min(...starts) : now - 60_000;
const endMs = Math.max(startMs + 60_000, ends.length > 0 ? Math.max(...ends) : now);
return { startMs, endMs, durationMs: endMs - startMs };
}
const pipelineGanttScaleBasePxPerMinute = 12;
const pipelineGanttScaleExponentBase = 20;
const pipelineDefaultGanttPxPerMinute = 100;
const pipelineDefaultGanttAutoHideIdle = false;
function clampPipelineGanttScale(value: any): number {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
return Math.max(0, Math.min(100, Math.round(number * 100) / 100));
}
function pipelineScaleValueForPxPerMinute(pxPerMinute: number): number {
const safePxPerMinute = Math.max(pipelineGanttScaleBasePxPerMinute, Number(pxPerMinute || pipelineGanttScaleBasePxPerMinute));
const normalized = Math.log(safePxPerMinute / pipelineGanttScaleBasePxPerMinute) / Math.log(pipelineGanttScaleExponentBase);
return clampPipelineGanttScale(normalized * 100);
}
const pipelineDefaultGanttScale = pipelineScaleValueForPxPerMinute(pipelineDefaultGanttPxPerMinute);
function pipelineGanttScaleConfig(value: number): AnyRecord {
const normalized = clampPipelineGanttScale(value) / 100;
const pxPerMinute = pipelineGanttScaleBasePxPerMinute * Math.pow(pipelineGanttScaleExponentBase, normalized);
const label = normalized < 0.24 ? "全局" : normalized < 0.64 ? "均衡" : "细节";
return { value: clampPipelineGanttScale(normalized * 100), pxPerMinute, label };
}
function pipelineDisplayPxPerMinute(value: any): number {
const rounded = Math.round(Number(value));
return Math.abs(rounded - pipelineDefaultGanttPxPerMinute) <= 1 ? pipelineDefaultGanttPxPerMinute : rounded;
}
function pipelineGanttHeight(bounds: AnyRecord, scaleValue = pipelineDefaultGanttScale): number {
const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000);
const scale = pipelineGanttScaleConfig(scaleValue);
return Math.round(Math.max(360, Math.min(7200, minutes * Number(scale.pxPerMinute || 48))));
}
function pipelineGanttTicks(bounds: AnyRecord, count = 7): AnyRecord[] {
const duration = Math.max(1, Number(bounds.endMs || 0) - Number(bounds.startMs || 0));
return Array.from({ length: count }, (_item, index) => {
const ratio = count === 1 ? 0 : index / (count - 1);
const ms = Number(bounds.startMs) + duration * ratio;
return { ms, percent: ratio * 100 };
});
}
function intervalOverlaps(interval: AnyRecord, range: AnyRecord): boolean {
return Number(interval.startMs) <= Number(range.endMs) && Number(interval.endMs) >= Number(range.startMs);
}
function markerPercent(ms: number, bounds: AnyRecord): number {
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
return Math.max(0, Math.min(100, ((ms - Number(bounds.startMs)) / duration) * 100));
}
function pipelineGanttBackendLayout(gantt: any): AnyRecord | null {
const layout = isRecord(gantt?.layout) ? gantt.layout : null;
return layout && Number.isFinite(Number(layout.chartHeight)) && Number.isFinite(Number(layout.startMs)) && Number.isFinite(Number(layout.endMs)) ? layout : null;
}
function pipelineGanttNumber(value: any): number | null {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function pipelineGanttIntervalIsRunning(interval: AnyRecord): boolean {
return runningStatus(interval?.status) && !terminalStatus(interval?.status);
}
function pipelineGanttMsToY(ms: number, layout: AnyRecord | null): number | null {
if (!layout) return null;
const startMs = pipelineGanttNumber(layout?.startMs);
const endMs = pipelineGanttNumber(layout?.endMs);
const chartHeight = pipelineGanttNumber(layout?.chartHeight);
if (startMs === null || endMs === null || chartHeight === null) return null;
const durationMs = Math.max(1, endMs - startMs);
return Math.max(0, ((ms - startMs) / durationMs) * chartHeight);
}
function pipelineGanttLiveEndMs(interval: AnyRecord, nowMs: number): number {
const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs;
const currentEndMs = pipelineGanttNumber(interval?.endMs) ?? startMs + 1000;
if (!pipelineGanttIntervalIsRunning(interval)) return Math.max(startMs + 1000, currentEndMs);
return Math.max(startMs + 1000, currentEndMs, nowMs);
}
function pipelineGanttLiveBackendLayout(layout: AnyRecord | null, intervals: AnyRecord[], nowMs: number): AnyRecord | null {
if (!layout) return null;
const startMs = pipelineGanttNumber(layout?.startMs);
const endMs = pipelineGanttNumber(layout?.endMs);
const chartHeight = pipelineGanttNumber(layout?.chartHeight);
if (startMs === null || endMs === null || chartHeight === null) return layout;
const liveEndMs = intervals.reduce((maxMs, interval) => Math.max(maxMs, pipelineGanttLiveEndMs(interval, nowMs)), endMs);
if (liveEndMs <= endMs) return layout;
const pxPerMs = chartHeight / Math.max(1, endMs - startMs);
return {
...layout,
endMs: liveEndMs,
durationMs: Math.max(1, liveEndMs - startMs),
chartHeight: Math.max(chartHeight, Math.round((liveEndMs - startMs) * pxPerMs)),
liveExtended: true,
};
}
function pipelineGanttNormalizeLiveInterval(interval: AnyRecord, layout: AnyRecord | null, nowMs: number): AnyRecord {
if (!pipelineGanttIntervalIsRunning(interval)) return interval;
const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs;
const endMs = pipelineGanttLiveEndMs(interval, nowMs);
const y1 = pipelineGanttMsToY(startMs, layout);
const y2 = pipelineGanttMsToY(endMs, layout);
const safeY1 = pipelineGanttNumber(y1 ?? interval?.y1 ?? interval?.startY) ?? 0;
const safeY2 = pipelineGanttNumber(y2 ?? interval?.y2 ?? interval?.endY) ?? safeY1 + 10;
const height = Math.max(24, safeY2 - safeY1);
return {
...interval,
live: true,
startMs,
endMs,
durationMs: Math.max(1000, endMs - startMs),
finishedAt: isoFromMs(endMs),
y1: safeY1,
y2: safeY2,
startY: safeY1,
endY: safeY2,
height,
};
}
function pipelineGanttFallbackY(ms: number, bounds: AnyRecord, chartHeight: number): number {
return (markerPercent(ms, bounds) / 100) * chartHeight;
}
function pipelineGanttItemY(item: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null, fields: string[]): number {
if (backendLayout) {
for (const field of fields) {
const y = pipelineGanttNumber(item?.[field]);
if (y !== null) return y;
}
}
const ms = pipelineGanttNumber(item?.ms ?? item?.eventMs ?? item?.startMs);
return pipelineGanttFallbackY(ms ?? Number(bounds.startMs), bounds, chartHeight);
}
function pipelineGanttIntervalTop(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
return pipelineGanttItemY(interval, bounds, chartHeight, backendLayout, ["y1", "startY"]);
}
function pipelineGanttIntervalBottom(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
if (backendLayout) {
const endY = pipelineGanttNumber(interval?.y2 ?? interval?.endY);
if (endY !== null) return endY;
}
const endMs = pipelineGanttNumber(interval?.endMs) ?? Number(bounds.endMs);
return pipelineGanttFallbackY(endMs, bounds, chartHeight);
}
function pipelineGanttIntervalHeight(interval: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
if (backendLayout) {
const height = pipelineGanttNumber(interval?.height);
if (height !== null) return Math.max(1, height);
}
return Math.max(10, pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) - pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout));
}
function pipelineGanttMarkerY(marker: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
return pipelineGanttItemY(marker, bounds, chartHeight, backendLayout, ["y", "timeAxisY"]);
}
function pipelineGanttTickY(tick: AnyRecord, bounds: AnyRecord, chartHeight: number, backendLayout: AnyRecord | null): number {
if (backendLayout) {
const y = pipelineGanttNumber(tick?.y);
if (y !== null) return y;
}
const percent = pipelineGanttNumber(tick?.percent);
if (percent !== null) return (percent / 100) * chartHeight;
const ms = pipelineGanttNumber(tick?.ms) ?? Number(bounds.startMs);
return pipelineGanttFallbackY(ms, bounds, chartHeight);
}
function pipelineGanttNormalizeBackendMarker(marker: AnyRecord): AnyRecord {
const kind = String(marker?.kind || "event");
const event = String(marker?.event || "");
const timestampIso = String(marker?.timestampIso || marker?.timestamp || isoFromMs(pipelineGanttNumber(marker?.eventMs ?? marker?.ms)));
const actionRecord = { ...marker, event, action: marker?.action || marker?.requestedAction };
const label = marker?.label || (kind === "prompt"
? pipelinePromptMarkerLabel(actionRecord, event)
: kind === "control-source"
? `${controlActionLabel(actionRecord)} 发起`
: kind === "control-target"
? controlTargetLabel(actionRecord, String(marker?.nodeId || marker?.targetNodeId || ""))
: eventLabel(actionRecord));
const tone = marker?.tone || (kind === "prompt"
? pipelinePromptMarkerTone(actionRecord, event)
: kind === "control-source"
? controlSourceTone(actionRecord)
: controlTargetTone(actionRecord));
const status = marker?.status || (event.startsWith("control-command-") ? event.replace(/^control-command-/u, "") : "");
return {
...marker,
kind,
tone,
status,
label,
timestampIso,
renderedTimestampIso: marker?.renderedTimestampIso || isoFromMs(pipelineGanttNumber(marker?.renderedMs ?? marker?.ms)),
snapped: Number(marker?.ms || 0) !== Number(marker?.eventMs ?? marker?.ms ?? 0),
raw: marker?.raw || marker,
};
}
function pipelineObservedPromptSourceNodeId(marker: AnyRecord): string {
const promptEvent = String(marker?.promptEvent || marker?.raw?.promptEvent || marker?.event || "").toLowerCase();
if (!["node-long-running-observation", "node-finished"].includes(promptEvent)) return "";
const sourceNodeId = String(marker?.sourceNodeId || marker?.raw?.sourceNodeId || marker?.raw?.detail?.nodeId || "");
const monitorNodeId = String(marker?.nodeId || marker?.targetNodeId || "");
return sourceNodeId && sourceNodeId !== monitorNodeId ? sourceNodeId : "";
}
function pipelineGanttAddObservationArrows(markers: AnyRecord[], arrows: AnyRecord[]): AnyRecord {
const arrowKeys = new Set(arrows.map((arrow) => [
String(arrow.sourceNodeId || ""),
String(arrow.targetNodeId || ""),
String(arrow.targetMarkerId || ""),
String(arrow.action || ""),
].join(":")));
const nextArrows = [...arrows];
for (const marker of markers) {
const observedNodeId = pipelineObservedPromptSourceNodeId(marker);
const monitorNodeId = String(marker?.nodeId || "");
const targetMarkerId = String(marker?.id || "");
if (!observedNodeId || !monitorNodeId || !targetMarkerId) continue;
const arrowKey = [observedNodeId, monitorNodeId, targetMarkerId, "observe"].join(":");
if (arrowKeys.has(arrowKey)) continue;
arrowKeys.add(arrowKey);
nextArrows.push({
id: `observation-arrow:${targetMarkerId}:${observedNodeId}:${monitorNodeId}`,
commandId: String(marker?.commandId || marker?.eventId || targetMarkerId),
sourceNodeId: observedNodeId,
targetNodeId: monitorNodeId,
sourceMarkerId: "",
targetMarkerId,
sourceKind: "monitor",
action: "observe",
status: "observation",
});
}
return { markers, arrows: nextArrows };
}
function pipelineBackendGanttSignals(details: any): AnyRecord {
const gantt = isRecord(details?.gantt) ? details.gantt : {};
const rawMarkers = asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker);
const arrows = asArray(gantt.arrows).filter(isRecord);
return pipelineGanttAddObservationArrows(rawMarkers, arrows);
}
function pipelinePromptMarkerTone(record: any, fallbackKind = ""): string {
const kind = eventKind(record) || fallbackKind;
const promptEvent = String(record?.promptEvent || "");
if (kind === "initial-prompt-delivered") return "initial";
if (promptEvent === "node-finished" || promptEvent === "node-long-running-observation" || promptEvent.startsWith("monitor-")) return "monitor";
if (kind === "monitor-prompt-delivered" || String(record?.sourceKind || "").toLowerCase() === "monitor" || fallbackKind === "monitor-prompt-queued") return "monitor";
return "append";
}
function pipelineRecordTags(record: any): string[] {
return asArray(record?.tags || record?.raw?.tags).map((tag) => String(tag || "")).filter(Boolean);
}
function pipelinePromptMarkerLabel(record: any, fallbackKind = ""): string {
const kind = eventKind(record) || fallbackKind;
const promptEvent = String(record?.promptEvent || "");
if (kind === "initial-prompt-delivered") return "初始 prompt";
if (promptEvent === "node-long-running-observation") return "长任务观察";
if (promptEvent === "node-finished") return pipelineRecordTags(record).includes("monitor.audit") ? "节点完成 / OA 审核" : "节点完成";
if (promptEvent === "monitor-interval") return "旧版轮询";
if (promptEvent === "monitor-start") return "Monitor start";
if (promptEvent === "monitor-stop") return "Monitor stop";
if (kind === "monitor-prompt-delivered" || fallbackKind === "monitor-prompt-queued") return "Monitor prompt";
if (kind === "append-prompt-queued") return "追加 prompt 已排队";
return "追加 prompt";
}
function controlEventPriority(record: any): number {
const kind = eventKind(record);
if (kind === "control-command-applied") return 3;
if (kind === "control-command-ignored") return 2;
if (kind === "control-command-queued") return 1;
return 0;
}
function controlRecordGroupKey(record: any, index: number): string {
const commandId = String(record?.commandId || "");
if (commandId) return `command:${commandId}`;
const timestamp = eventTimestampIso(record) || firstIso(record?.createdAt, record?.timestamp) || `index-${index}`;
return [
"fallback",
timestamp,
String(record?.sourceKind || ""),
String(record?.sourceNodeId || ""),
String(record?.targetNodeId || ""),
controlActionValue(record),
].join(":");
}
function controlTargetNodeIds(record: any): string[] {
return uniqueStrings([
record?.targetNodeId,
...asArray(record?.resetNodeIds),
]);
}
function controlTargetLabel(record: any, targetNodeId: string): string {
const action = controlActionLabel(record);
const kind = eventKind(record);
const primaryTarget = String(record?.targetNodeId || "");
const isResetSideEffect = Boolean(primaryTarget) && targetNodeId !== primaryTarget;
if (kind === "control-command-applied") return isResetSideEffect ? `${action} 波及` : `${action} 生效`;
if (kind === "control-command-ignored") return `${action} 忽略`;
if (kind === "control-command-queued") return `${action} 已发起`;
return isResetSideEffect ? `${action} 波及` : action;
}
function controlTargetTone(record: any): string {
const kind = eventKind(record);
if (kind === "control-command-ignored") return "ignored";
const action = controlActionValue(record);
if (action === "restart" || action === "redo") return "restart";
if (action === "modify") return "modify";
if (action === "approve") return "approve";
if (action === "guide") return "guide";
return "pending";
}
function controlSourceTone(record: any): string {
const sourceKind = String(record?.sourceKind || "").toLowerCase();
if (sourceKind === "monitor") return "monitor";
if (sourceKind === "webui") return "webui";
if (sourceKind === "cli") return "cli";
return "system";
}
function controlTargetMarkerPlacement(intervals: AnyRecord[], nodeId: string, eventMs: number, record: any): AnyRecord {
const nodeIntervals = intervals
.filter((interval: AnyRecord) => String(interval.nodeId || "") === nodeId)
.sort((left: AnyRecord, right: AnyRecord) => Number(left.startMs) - Number(right.startMs));
const containing = nodeIntervals.find((interval: AnyRecord) => eventMs >= Number(interval.startMs) - 1000 && eventMs <= Number(interval.endMs) + 1000);
if (containing) {
return { ms: eventMs, onInterval: true, snapReason: "inside-interval", procedureRunId: String(containing.procedureRunId || "") };
}
const action = controlActionValue(record);
const previous = nodeIntervals.slice().reverse().find((interval: AnyRecord) => Number(interval.endMs) <= eventMs + 1000);
if (previous && action === "approve") {
return { ms: Number(previous.endMs), onInterval: true, snapReason: "previous-interval-end", procedureRunId: String(previous.procedureRunId || "") };
}
const next = nodeIntervals.find((interval: AnyRecord) => Number(interval.startMs) >= eventMs - 1000);
if (next && ["guide", "modify", "restart", "redo"].includes(action)) {
return { ms: Number(next.startMs), onInterval: true, snapReason: "next-interval-start", procedureRunId: String(next.procedureRunId || "") };
}
return { ms: eventMs, onInterval: false, snapReason: "event-time", procedureRunId: String(record?.procedureRunId || "") };
}
function pipelineControlArrowPath(sourceX: number, sourceY: number, targetX: number, targetY: number): string {
const distance = Math.hypot(targetX - sourceX, targetY - sourceY);
const inset = distance > pipelineGanttArrowTipInsetPx ? pipelineGanttArrowTipInsetPx : 0;
const endX = inset > 0 ? targetX - ((targetX - sourceX) / distance) * inset : targetX;
const endY = inset > 0 ? targetY - ((targetY - sourceY) / distance) * inset : targetY;
const deltaX = endX - sourceX;
const bend = Math.max(16, Math.min(42, Math.abs(deltaX) * 0.45 + 12));
const sign = deltaX === 0 ? 1 : Math.sign(deltaX);
return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${endX - sign * bend},${endY} ${endX},${endY}`;
}
function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
const runId = String(details?.runId || activeRun?.runId || "");
const intervals = pipelineRunIntervals({ ...(isRecord(activeRun) ? activeRun : {}), ...(isRecord(details) ? details : {}), runId, procedureRuns: asArray(details?.procedureRuns).length > 0 ? details.procedureRuns : activeRun?.procedureRuns }, []);
const promptMarkers: AnyRecord[] = [];
const controlMarkers: AnyRecord[] = [];
const controlArrows: AnyRecord[] = [];
const markerIds = new Set<string>();
const sourceMarkerByCommand = new Map<string, string>();
const addMarker = (marker: AnyRecord, bucket: AnyRecord[]) => {
if (!marker.nodeId || !Number.isFinite(Number(marker.ms))) return;
if (markerIds.has(marker.id)) return;
markerIds.add(marker.id);
bucket.push(marker);
};
for (const procedure of asArray(details?.procedureRuns)) {
const nodeId = inferProcedureNodeId(procedure, runId);
const procedureRunId = procedureRunIdOf(procedure);
if (!nodeId) continue;
for (const attempt of asArray(procedure?.attempts)) {
const attemptId = attemptLabel(attempt);
const deliveredIds = new Set<string>();
const deliveredFallbackKeys = new Set<string>();
for (const record of structuredEventRecords(attempt?.controlEventRecords)) {
const kind = eventKind(record);
if (!["initial-prompt-delivered", "append-prompt-delivered", "monitor-prompt-delivered"].includes(kind)) continue;
const timestampIso = eventTimestampIso(record);
const ms = timeMs(timestampIso);
if (ms === null) continue;
const eventId = String(record?.eventId || "");
if (eventId) deliveredIds.add(eventId);
deliveredFallbackKeys.add(`${kind}:${timestampIso}:${String(record?.sourceKind || "")}:${String(record?.promptPreview || "")}`);
addMarker({
id: `prompt:${eventId || `${procedureRunId}:${attemptId}:${kind}:${ms}`}`,
runId,
nodeId,
procedureRunId,
attempt: attemptId,
kind: "prompt",
tone: pipelinePromptMarkerTone(record, kind),
status: "delivered",
label: pipelinePromptMarkerLabel(record, kind),
ms,
timestampIso,
sourceKind: String(record?.sourceKind || ""),
sourceNodeId: String(record?.sourceNodeId || ""),
targetNodeId: nodeId,
action: "",
eventId,
commandId: String(record?.commandId || ""),
raw: record,
}, promptMarkers);
}
const fallbackSources = [
{ records: structuredEventRecords(attempt?.controlPromptRecords), fallbackKind: "append-prompt-queued" },
{ records: structuredEventRecords(attempt?.monitorPromptRecords), fallbackKind: "monitor-prompt-queued" },
];
for (const source of fallbackSources) {
for (const record of source.records) {
const timestampIso = eventTimestampIso(record);
const ms = timeMs(timestampIso);
if (ms === null) continue;
const eventId = String(record?.eventId || "");
if (eventId && deliveredIds.has(eventId)) continue;
const deliveredKind = source.fallbackKind === "monitor-prompt-queued" ? "monitor-prompt-delivered" : "append-prompt-delivered";
const fallbackKey = `${deliveredKind}:${timestampIso}:${String(record?.sourceKind || "")}:${String(record?.promptPreview || "")}`;
if (deliveredFallbackKeys.has(fallbackKey)) continue;
addMarker({
id: `prompt-fallback:${eventId || `${procedureRunId}:${attemptId}:${source.fallbackKind}:${ms}`}`,
runId,
nodeId,
procedureRunId,
attempt: attemptId,
kind: "prompt",
tone: pipelinePromptMarkerTone(record, source.fallbackKind),
status: "queued",
label: pipelinePromptMarkerLabel(record, source.fallbackKind),
ms,
timestampIso,
sourceKind: String(record?.sourceKind || ""),
sourceNodeId: String(record?.sourceNodeId || ""),
targetNodeId: nodeId,
action: "",
eventId,
commandId: String(record?.commandId || ""),
raw: record,
}, promptMarkers);
}
}
}
}
const grouped = new Map<string, AnyRecord>();
structuredEventRecords(details?.controlEvents).forEach((record: AnyRecord, index: number) => {
const key = controlRecordGroupKey(record, index);
const group = grouped.get(key) || { key, events: [], commands: [] };
group.events.push(record);
grouped.set(key, group);
});
asArray(details?.controlCommands).filter(isRecord).forEach((record: AnyRecord, index: number) => {
const key = controlRecordGroupKey(record, index);
const group = grouped.get(key) || { key, events: [], commands: [] };
group.commands.push(record);
grouped.set(key, group);
});
for (const group of grouped.values()) {
const events = asArray(group.events).slice().sort((left: any, right: any) => controlEventPriority(right) - controlEventPriority(left));
const commands = asArray(group.commands);
const queuedRecord = asArray(group.events).find((record: any) => eventKind(record) === "control-command-queued") || commands[0] || null;
const outcomeRecord = events[0] || commands[0] || queuedRecord;
if (!queuedRecord && !outcomeRecord) continue;
const sourceNodeId = String(queuedRecord?.sourceNodeId || outcomeRecord?.sourceNodeId || "");
const sourceKind = String(queuedRecord?.sourceKind || outcomeRecord?.sourceKind || "");
const sourceTimestampIso = eventTimestampIso(queuedRecord) || eventTimestampIso(outcomeRecord) || firstIso(queuedRecord?.createdAt, outcomeRecord?.createdAt);
const sourceMs = timeMs(sourceTimestampIso);
const commandId = String(outcomeRecord?.commandId || queuedRecord?.commandId || group.key);
const outcomeStatus = (eventKind(outcomeRecord) || "control-command-queued").replace(/^control-command-/u, "");
let sourceMarkerId = "";
if (sourceNodeId && sourceMs !== null) {
sourceMarkerId = `control-source:${commandId}:${sourceNodeId}`;
sourceMarkerByCommand.set(commandId, sourceMarkerId);
addMarker({
id: sourceMarkerId,
runId,
nodeId: sourceNodeId,
procedureRunId: String(queuedRecord?.procedureRunId || outcomeRecord?.procedureRunId || ""),
attempt: "",
kind: "control-source",
tone: controlSourceTone(queuedRecord || outcomeRecord),
status: outcomeStatus,
label: `${controlActionLabel(queuedRecord || outcomeRecord)} 发起`,
ms: sourceMs,
timestampIso: sourceTimestampIso,
action: controlActionValue(queuedRecord || outcomeRecord),
sourceKind,
sourceNodeId,
targetNodeId: String(outcomeRecord?.targetNodeId || queuedRecord?.targetNodeId || ""),
commandId,
raw: queuedRecord || outcomeRecord,
}, controlMarkers);
}
const targetRecord = outcomeRecord || queuedRecord;
const targetTimestampIso = eventTimestampIso(targetRecord) || sourceTimestampIso;
const targetMs = timeMs(targetTimestampIso);
if (targetMs === null) continue;
const targetIds = controlTargetNodeIds(targetRecord);
for (const targetNodeId of targetIds) {
const placement = controlTargetMarkerPlacement(intervals, targetNodeId, targetMs, targetRecord);
const targetMarkerId = `control-target:${commandId}:${targetNodeId}`;
addMarker({
id: targetMarkerId,
runId,
nodeId: targetNodeId,
procedureRunId: placement.procedureRunId,
attempt: "",
kind: "control-target",
tone: controlTargetTone(targetRecord),
status: outcomeStatus,
label: controlTargetLabel(targetRecord, targetNodeId),
ms: placement.ms,
eventMs: targetMs,
onInterval: placement.onInterval,
snapReason: placement.snapReason,
snapped: Number(placement.ms) !== targetMs,
timestampIso: targetTimestampIso,
renderedTimestampIso: isoFromMs(Number(placement.ms)),
action: controlActionValue(targetRecord),
sourceKind,
sourceNodeId,
targetNodeId,
commandId,
raw: targetRecord,
}, controlMarkers);
if (sourceMarkerId && sourceNodeId && sourceNodeId !== targetNodeId) {
controlArrows.push({
id: `control-arrow:${commandId}:${sourceNodeId}:${targetNodeId}`,
commandId,
sourceNodeId,
targetNodeId,
sourceMarkerId,
targetMarkerId,
sourceKind,
action: controlActionValue(targetRecord),
status: outcomeStatus,
});
}
}
}
const markers = [...promptMarkers, ...controlMarkers].sort((left: AnyRecord, right: AnyRecord) =>
Number(left.ms) - Number(right.ms)
|| String(left.nodeId).localeCompare(String(right.nodeId))
|| String(left.id).localeCompare(String(right.id)));
return { ...pipelineGanttAddObservationArrows(markers, controlArrows), sourceMarkerByCommand };
}
function PipelineNodeExecutionIndex({ details, selectedNodeId, selectedNodeRuntime, control, onRaw }: AnyRecord) {
if (!details) {
return h("span", { className: "muted" }, "点击“抓取过程”读取 node 运行材料;主界面只显示结构化摘要,完整内容需点开原始 JSON。");
}
const procedureRuns = asArray(details.procedureRuns);
const latestProcedure = procedureRuns.at(-1) || {};
const attempts = asArray(latestProcedure.attempts);
const latestAttempt = attempts.at(-1) || {};
const workerLogTail = asArray(latestProcedure.workerLogTail);
const controlEventsTail = asArray(latestAttempt.controlEventsTail);
const controlPromptsTail = asArray(latestAttempt.controlPromptsTail);
const monitorPromptsTail = asArray(latestAttempt.monitorPromptsTail);
const eventTail = tailSummary(controlEventsTail);
const controlPromptTail = tailSummary(controlPromptsTail);
const monitorPromptTail = tailSummary(monitorPromptsTail);
const latestMessage = latestAttempt.opencodeMessages || {};
return h("div", { className: "pipeline-evidence-list compact" },
h(EvidenceIndexRow, {
title: "Node runtime",
subtitle: selectedNodeId || "--",
facts: [
`status ${selectedNodeRuntime?.status || "pending"}`,
`attempts ${selectedNodeRuntime?.attempts ?? attempts.length}`,
`procedure ${selectedNodeRuntime?.currentProcedureRunId || procedureRunIdOf(latestProcedure) || "--"}`,
control.fetchedAt ? `fetched ${fmtClock(control.fetchedAt)}` : "not fetched",
],
data: details.node || details,
onRaw,
testId: "raw-pipeline-node-runtime",
}),
h(EvidenceIndexRow, {
title: "Procedure runs",
subtitle: `${procedureRuns.length} groups`,
facts: [
`latest ${latestProcedure.status?.status || latestProcedure.status || "--"}`,
`steps ${asArray(latestProcedure.recentSteps).length}`,
`duration ${fmtDurationMs(timeMs(latestProcedure.finishedAt) && timeMs(latestProcedure.startedAt) ? Number(timeMs(latestProcedure.finishedAt)) - Number(timeMs(latestProcedure.startedAt)) : latestProcedure.durationMs)}`,
],
data: procedureRuns,
onRaw,
testId: "raw-pipeline-node-procedures",
}),
h(EvidenceIndexRow, {
title: "OpenCode messages",
subtitle: String(latestMessage.exists ? "available" : "not indexed"),
facts: [
`messages ${summarizeValue(latestMessage.messageCount)}`,
`size ${summarizeValue(latestMessage.size)}`,
`updated ${fmtDate(latestMessage.updatedAt)}`,
],
data: latestMessage,
onRaw,
testId: "raw-pipeline-node-messages",
}),
h(EvidenceIndexRow, {
title: "Control prompts",
subtitle: "manual / monitor append queues",
facts: [
`manual tail ${controlPromptTail.total}`,
`monitor tail ${monitorPromptTail.total}`,
`last ${fmtDate(latestIso(controlPromptTail.lastAt, monitorPromptTail.lastAt))}`,
],
data: { controlPromptsTail, monitorPromptsTail },
onRaw,
testId: "raw-pipeline-node-prompts",
}),
h(EvidenceIndexRow, {
title: "Control events",
subtitle: eventTail.eventKinds.length > 0 ? eventTail.eventKinds.join(", ") : "event tail",
facts: [
`tail ${eventTail.total}`,
`parsed ${eventTail.parsed}`,
`last ${fmtDate(eventTail.lastAt)}`,
],
data: controlEventsTail,
onRaw,
testId: "raw-pipeline-node-events",
}),
h(EvidenceIndexRow, {
title: "Worker log",
subtitle: "tail is hidden on main canvas",
facts: [`tail ${workerLogTail.length} lines`, `raw only via button`, `procedure ${procedureRunIdOf(latestProcedure) || "--"}`],
data: workerLogTail,
onRaw,
testId: "raw-pipeline-node-worker-log",
}),
);
}
function PipelineRunMaterialIndex({ activeRun, onRaw }: AnyRecord) {
if (!activeRun) return h(EmptyState, { title: "暂无运行材料", text: "没有 Pipeline epoch 时不会展示运行材料索引。" });
const nodes = asArray(activeRun.nodes);
const procedures = asArray(activeRun.procedureRuns);
const submissions = asArray(activeRun.submissions);
const workerLogTail = asArray(activeRun.workerLogTail);
const nodeCounts = statusCounts(nodes);
const procedureCounts = statusCounts(procedures);
const failedProcedures = procedures.filter((procedure: any) => String(procedure?.status || "").toLowerCase() === "failed");
const latestProcedureAt = latestIso(...procedures.flatMap((procedure: any) => [procedure.updatedAt, procedure.finishedAt, procedure.startedAt]));
return h("div", { className: "pipeline-evidence-list" },
h(EvidenceIndexRow, {
title: "Epoch overview",
subtitle: activeRun.runId || "--",
facts: [
`pipeline ${activeRun.pipelineId || "--"}`,
`status ${activeRun.status || "--"}`,
`started ${fmtDate(activeRun.startedAt)}`,
`updated ${fmtDate(activeRun.updatedAt)}`,
],
data: activeRun,
onRaw,
testId: "raw-pipeline-run",
}),
h(EvidenceIndexRow, {
title: "Node states",
subtitle: `${nodes.length} nodes`,
facts: [
`running ${nodeCounts.running || 0}`,
`succeeded ${nodeCounts.succeeded || 0}`,
`failed ${nodeCounts.failed || 0}`,
`pending ${nodeCounts.pending || 0}`,
],
data: nodes,
onRaw,
testId: "raw-pipeline-run-nodes",
}),
h(EvidenceIndexRow, {
title: "Procedure run index",
subtitle: `${procedures.length} procedure records`,
facts: [
`succeeded ${procedureCounts.succeeded || 0}`,
`failed ${procedureCounts.failed || 0}`,
`latest ${fmtDate(latestProcedureAt)}`,
`errors ${failedProcedures.length}`,
],
data: procedures,
onRaw,
testId: "raw-pipeline-run-procedures",
}),
h(EvidenceIndexRow, {
title: "OA submissions",
subtitle: `${submissions.length} submission files`,
facts: [`records ${submissions.length}`, `task ${summarizeValue(activeRun.task)}`, `raw grouped by run`],
data: submissions,
onRaw,
testId: "raw-pipeline-run-submissions",
}),
h(EvidenceIndexRow, {
title: "Worker log tail",
subtitle: "hidden from main interface",
facts: [`tail ${workerLogTail.length} lines`, `display raw only after click`, `updated ${fmtDate(activeRun.updatedAt)}`],
data: workerLogTail,
onRaw,
testId: "raw-pipeline-run-worker-log",
}),
);
}
function PipelineOaEventFlowPanel({ diagnostics, onRaw }: AnyRecord) {
const runs = asArray(diagnostics?.runs).filter(isRecord);
const forbiddenResiduals = asArray(diagnostics?.forbiddenResiduals);
const guarantees = isRecord(diagnostics?.guarantees) ? diagnostics.guarantees : {};
const evidenceOk = diagnostics?.hasNeutralNodeFinishedEvidence === true
&& diagnostics?.hasNoAuditPolicyEvidence === true
&& diagnostics?.hasAuditPolicyEvidence === true;
const ok = diagnostics?.ok === true && evidenceOk && forbiddenResiduals.length === 0;
const latestRun = runs[0] || null;
const guaranteeItems = [
{ label: "中性完成事实", ok: guarantees.neutralNodeFinished === true, hint: "node-finished 不携带流程策略" },
{ label: "Config 策略判定", ok: guarantees.auditPolicyFromConfig === true, hint: "OA backend 读取当前 epoch 配置" },
{ label: "控制命令来自 OA", ok: guarantees.runnerConsumesControlCommandsFromOaEvents === true, hint: "runner 只消费 OA control.command" },
{ label: "无独立审核事件", ok: guarantees.noIndependentAuditRequestEvent === true, hint: "审核由 node-finished + policy 派生" },
{ label: "无批次门禁", ok: guarantees.noBatchFinishedControlGate === true, hint: "下游启动由每个 node 完成驱动" },
];
return h("div", { className: "pipeline-oa-panel", "data-testid": "pipeline-oa-event-flow-panel" },
h("div", { className: "metric-grid compact" },
h(MetricCard, { label: "OA Flow", value: ok ? "100%" : "--", hint: String(diagnostics?.mode || "waiting diagnostics"), tone: ok ? "ok" : "warn" }),
h(MetricCard, { label: "禁止残留", value: forbiddenResiduals.length, hint: forbiddenResiduals.length === 0 ? "source scan clean" : "needs cleanup", tone: forbiddenResiduals.length === 0 ? "ok" : "warn" }),
h(MetricCard, { label: "No-audit", value: diagnostics?.hasNoAuditPolicyEvidence ? "OK" : "--", hint: "OA 下游策略证据", tone: diagnostics?.hasNoAuditPolicyEvidence ? "ok" : "warn" }),
h(MetricCard, { label: "Monitor 审核", value: diagnostics?.hasAuditPolicyEvidence ? "OK" : "--", hint: "OA 控制事件闭环", tone: diagnostics?.hasAuditPolicyEvidence ? "ok" : "warn" }),
),
h("div", { className: "pipeline-oa-guarantees" },
guaranteeItems.map((item) => h("article", { key: item.label, className: `pipeline-oa-guarantee ${item.ok ? "ok" : "warn"}` },
h(StatusBadge, { status: item.ok ? "online" : "warn" }, item.ok ? "OK" : "MISS"),
h("div", null, h("strong", null, item.label), h("span", null, item.hint)),
)),
),
h("div", { className: "pipeline-evidence-list compact" },
runs.slice(0, 6).map((run: AnyRecord) => h(EvidenceIndexRow, {
key: run.runId,
title: String(run.runId || "--"),
subtitle: [
Number(run.monitorAuditNodeFinishedCount || 0) > 0 ? "monitor audit" : "",
Number(run.noAuditPolicyCount || 0) > 0 ? "no-audit policy" : "",
].filter(Boolean).join(" / ") || "event evidence",
facts: [
`events ${run.eventCount || 0}`,
`node-finished ${run.nodeFinishedCount || 0}`,
`policy-in-detail ${run.nodeFinishedWithPolicyCount || 0}`,
`queued ${run.controlQueuedCount || 0}`,
`applied ${run.controlAppliedCount || 0}`,
],
data: run,
onRaw,
testId: `raw-pipeline-oa-run-${String(run.runId || "run").replace(/[^a-zA-Z0-9_.-]+/g, "-")}`,
})),
),
latestRun ? h("p", { className: "muted paragraph" }, `最新证据 ${latestRun.runId}: ${latestRun.nodeFinishedCount || 0} 个 node-finished${latestRun.controlAppliedCount || 0} 个控制结果。`) : h(EmptyState, { title: "暂无 OA 事件流证据", text: "等待 Pipeline backend 暴露 diagnostics。" }),
diagnostics ? h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline OA Event Flow Diagnostics", data: diagnostics, onOpen: onRaw, testId: "raw-pipeline-oa-event-flow" })) : null,
);
}
function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, pipelineEdges, runDetails, nodeDetails, ganttScale = pipelineDefaultGanttScale, onGanttScaleChange, onRunChange, onIntervalSelect, onMarkerSelect, selection, onRaw }: AnyRecord) {
const [autoHideIdle, setAutoHideIdle] = useState(pipelineDefaultGanttAutoHideIdle);
const [visibleRange, setVisibleRange] = useState({ startY: 0, endY: 0, startMs: 0, endMs: 0 });
const [liveNowMs, setLiveNowMs] = useState(Date.now());
const viewportRef = useRef(null);
const activeRunId = String(activeRun?.runId || "");
const timeScale = clampPipelineGanttScale(ganttScale ?? pipelineDefaultGanttScale);
const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes, liveNowMs);
const runDetailPayload = String(runDetails?.runId || "") === activeRunId ? runDetails?.details : null;
const backendGantt = isRecord(runDetailPayload?.gantt) ? runDetailPayload.gantt : null;
const baseBackendLayout = pipelineGanttBackendLayout(backendGantt);
const hasBackendLayout = Boolean(baseBackendLayout);
const rawIntervals = hasBackendLayout
? asArray(backendGantt?.intervals).filter(isRecord).map((interval: AnyRecord) => ({ ...interval, runId: activeRunId }))
: fallbackIntervals;
const hasLiveRunning = rawIntervals.some(pipelineGanttIntervalIsRunning);
useEffect(() => {
if (!activeRunId || !hasLiveRunning) return undefined;
const timer = window.setInterval(() => setLiveNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [activeRunId, hasLiveRunning]);
const backendLayout = hasBackendLayout ? pipelineGanttLiveBackendLayout(baseBackendLayout, rawIntervals, liveNowMs) : baseBackendLayout;
const intervals = hasBackendLayout
? rawIntervals.map((interval: AnyRecord) => pipelineGanttNormalizeLiveInterval(interval, backendLayout, liveNowMs))
: rawIntervals;
const bounds = hasBackendLayout ? {
startMs: Number(backendLayout?.startMs),
endMs: Number(backendLayout?.endMs),
durationMs: Math.max(1, Number(backendLayout?.durationMs ?? Number(backendLayout?.endMs) - Number(backendLayout?.startMs))),
} : pipelineRunTimeBounds(activeRun, intervals);
const backendScaleValue = hasBackendLayout ? Number(backendLayout?.scale ?? timeScale) : timeScale;
const fallbackScaleConfig = pipelineGanttScaleConfig(backendScaleValue);
const timeScaleConfig = hasBackendLayout ? {
...fallbackScaleConfig,
pxPerMinute: Number(backendLayout?.pxPerMinute ?? fallbackScaleConfig.pxPerMinute),
} : pipelineGanttScaleConfig(timeScale);
const chartHeight = hasBackendLayout ? Math.round(Number(backendLayout?.chartHeight || 360)) : pipelineGanttHeight(bounds, timeScale);
const ganttSignals = hasBackendLayout ? pipelineBackendGanttSignals(runDetailPayload) : runDetailPayload ? pipelineRunGanttSignals(runDetailPayload, activeRun) : { markers: [], arrows: [] };
const allMarkers = asArray(ganttSignals.markers);
const graphNodeOrder = pipelineGraphNodeOrder(activePipeline, pipelineNodes, Array.isArray(pipelineEdges) ? pipelineEdges : []);
const configuredNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean);
const layoutNodeIds = hasBackendLayout ? asArray(backendLayout?.nodeOrder).map((nodeId: any) => String(nodeId || "")).filter(Boolean) : [];
const intervalNodeIds = intervals.map((interval: AnyRecord) => String(interval.nodeId || "")).filter(Boolean);
const markerNodeIds = allMarkers.map((marker: AnyRecord) => String(marker.nodeId || "")).filter(Boolean);
const allNodeIds = Array.from(new Set([
...graphNodeOrder,
...layoutNodeIds,
...configuredNodeIds,
...intervalNodeIds,
...markerNodeIds,
]));
const defaultVisibleRange = { startY: 0, endY: chartHeight, startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) };
const safeRange = Number(visibleRange?.endY || 0) > 0 ? visibleRange : defaultVisibleRange;
const intervalIsVisible = (interval: AnyRecord): boolean => {
if (hasBackendLayout) {
return pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout) <= Number(safeRange.endY)
&& pipelineGanttIntervalBottom(interval, bounds, chartHeight, backendLayout) >= Number(safeRange.startY);
}
return intervalOverlaps(interval, safeRange);
};
const markerIsVisible = (marker: AnyRecord): boolean => {
if (hasBackendLayout) {
const y = pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout);
return y >= Number(safeRange.startY) && y <= Number(safeRange.endY);
}
return Number(marker.ms) >= Number(safeRange.startMs) && Number(marker.ms) <= Number(safeRange.endMs);
};
const activeNodeIds = new Set(allNodeIds.filter((nodeId) =>
intervals.some((interval: AnyRecord) => interval.nodeId === nodeId && intervalIsVisible(interval))
|| allMarkers.some((marker: AnyRecord) => marker.nodeId === nodeId && markerIsVisible(marker))));
const visibleNodeIds = autoHideIdle ? allNodeIds.filter((nodeId) => activeNodeIds.has(nodeId)) : allNodeIds;
const gridTemplateColumns = `${pipelineGanttTimeAxisWidth}px ${visibleNodeIds.length > 0 ? visibleNodeIds.map(() => `${pipelineGanttNodeColumnWidth}px`).join(" ") : "minmax(160px, 1fr)"}`;
const backendTicks = hasBackendLayout ? asArray(backendLayout?.ticks).filter(isRecord) : [];
const ticks = backendTicks.length > 0 ? backendTicks : pipelineGanttTicks(bounds, Math.max(5, Math.min(18, Math.round(chartHeight / 150))));
const selectedIntervalKey = String(selection?.mode === "interval" ? selection?.interval?.procedureRunId || "" : "");
const selectedMarkerKey = String(selection?.mode === "event" ? selection?.marker?.id || "" : "");
const updateVisibleRange = () => {
const element = viewportRef.current as HTMLElement | null;
if (!element) {
setVisibleRange(defaultVisibleRange);
return;
}
const topY = Math.max(0, element.scrollTop - pipelineGanttHeaderHeight);
const visiblePx = Math.max(120, element.clientHeight - pipelineGanttHeaderHeight);
const bottomY = Math.min(chartHeight, topY + visiblePx);
const nextRange: AnyRecord = { startY: topY, endY: bottomY, startMs: Number(bounds.startMs), endMs: Number(bounds.endMs) };
if (!hasBackendLayout) {
const startRatio = Math.max(0, Math.min(1, topY / chartHeight));
const endRatio = Math.max(startRatio, Math.min(1, bottomY / chartHeight));
const duration = Math.max(1, Number(bounds.endMs) - Number(bounds.startMs));
nextRange.startMs = Number(bounds.startMs) + duration * startRatio;
nextRange.endMs = Number(bounds.startMs) + duration * endRatio;
}
setVisibleRange(nextRange);
};
useEffect(() => {
const element = viewportRef.current as HTMLElement | null;
const timer = window.setTimeout(updateVisibleRange, 0);
element?.addEventListener("scroll", updateVisibleRange);
window.addEventListener("resize", updateVisibleRange);
return () => {
window.clearTimeout(timer);
element?.removeEventListener("scroll", updateVisibleRange);
window.removeEventListener("resize", updateVisibleRange);
};
}, [activeRunId, bounds.startMs, bounds.endMs, chartHeight, hasBackendLayout]);
const hiddenCount = Math.max(0, allNodeIds.length - visibleNodeIds.length);
const visibleMarkerIds = new Set(allMarkers.filter((marker: AnyRecord) =>
visibleNodeIds.includes(String(marker.nodeId || "")) && markerIsVisible(marker)).map((marker: AnyRecord) => String(marker.id)));
const markerById = new Map(allMarkers.map((marker: AnyRecord) => [String(marker.id), marker]));
const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) => {
const targetVisible = visibleMarkerIds.has(String(arrow.targetMarkerId || ""));
if (!targetVisible) return false;
if (String(arrow.action || "") === "observe") return visibleNodeIds.includes(String(arrow.sourceNodeId || ""));
return visibleMarkerIds.has(String(arrow.sourceMarkerId || ""));
});
const boardMinWidth = pipelineGanttTimeAxisWidth + Math.max(1, visibleNodeIds.length) * pipelineGanttNodeColumnWidth;
const setScaleFromSlider = (event: any) => {
const nextScale = clampPipelineGanttScale(event.target.value);
if (typeof onGanttScaleChange === "function") onGanttScaleChange(nextScale);
window.setTimeout(updateVisibleRange, 0);
};
const exportCurrentGantt = () => exportPipelineGantt({
title: `${activePipeline?.id || "pipeline"}-${activeRunId || "epoch"}-gantt`,
meta: [
`run ${activeRunId || "--"}`,
`${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`,
`duration ${fmtDurationMs(bounds.durationMs)}`,
`${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`,
`${visibleNodeIds.length}/${allNodeIds.length} nodes`,
`${allMarkers.length} markers`,
],
visibleNodeIds,
intervals,
markers: allMarkers.filter((marker: AnyRecord) => visibleNodeIds.includes(String(marker.nodeId || ""))),
arrows: visibleArrows,
ticks,
bounds,
chartHeight,
backendLayout,
});
const diagnostics = isRecord(backendGantt?.diagnostics) ? backendGantt.diagnostics : null;
return h(Panel, {
title: "Epoch 甘特图",
eyebrow: `${activePipeline?.id || "pipeline"} / ${epochs.length} epochs`,
className: "pipeline-wide-panel",
actions: h("div", { className: "pipeline-gantt-actions" },
h("select", {
value: activeRunId,
disabled: epochs.length === 0,
onChange: (event: any) => onRunChange(event.target.value),
"data-testid": "pipeline-epoch-select",
}, epochs.map((run: any) => h("option", { key: run.runId, value: run.runId }, pipelineEpochLabel(epochs, run)))),
h("label", { className: "pipeline-gantt-toggle" },
h("input", {
type: "checkbox",
"data-testid": "pipeline-gantt-auto-hide-idle",
checked: autoHideIdle,
onChange: (event: any) => {
setAutoHideIdle(Boolean(event.target.checked));
window.setTimeout(updateVisibleRange, 0);
},
}),
h("span", null, "自动隐藏空闲列"),
),
h("label", { className: "pipeline-gantt-scale" },
h("span", null,
h("b", null, "时间尺度"),
h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`),
),
h("input", {
type: "range",
min: 0,
max: 100,
step: 0.01,
value: timeScale,
onChange: setScaleFromSlider,
"aria-label": "调整甘特图时间尺度",
"data-testid": "pipeline-gantt-time-scale",
}),
h("small", null, h("span", null, "全局"), h("span", null, "细节")),
),
activeRun ? h("button", {
type: "button",
className: "ghost-btn",
onClick: exportCurrentGantt,
disabled: visibleNodeIds.length === 0,
"data-testid": "pipeline-export-gantt",
}, "导出甘特图") : null,
activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null,
),
},
!activeRun ? h(EmptyState, { title: "暂无 Epoch", text: "当前 pipeline 还没有完整运行记录。" }) :
intervals.length === 0 ? h(EmptyState, { title: "暂无时间区间", text: "等待 D601 Pipeline backend 在 procedure summary 中返回 startedAt / finishedAt。" }) :
h("div", { className: "pipeline-gantt-wrap" },
h("div", { className: "pipeline-gantt-detail-layout" },
h("div", { className: "pipeline-gantt-main" },
h("div", { className: "pipeline-gantt-meta" },
h("span", null, `time ${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`),
h("span", null, `duration ${fmtDurationMs(bounds.durationMs)}`),
h("span", null, `scale ${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`),
h("span", null, `layout ${hasBackendLayout ? "backend-y" : "fallback"}`),
diagnostics ? h("span", null, `align ${diagnostics.timeAxisAlignmentOk === false ? "check" : "ok"}`) : null,
h("span", null, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`),
runDetailPayload ? h("span", null, `markers ${allMarkers.length}`) : null,
autoHideIdle && hiddenCount > 0 ? h("span", null, `hidden idle ${hiddenCount}`) : null,
),
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "", "data-run-id": activeRunId },
h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${boardMinWidth}px` } },
h("div", { className: "pipeline-gantt-head time" }, "Time"),
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-head empty" }, "当前时间窗无工作节点") :
visibleNodeIds.map((nodeId) => h("div", { key: `head-${nodeId}`, className: "pipeline-gantt-head node", title: nodeId, "data-testid": "pipeline-gantt-head-node", "data-node-id": nodeId }, h(GanttHeaderLabel, { value: nodeId }))),
h("div", { className: "pipeline-gantt-time-axis", style: { height: `${chartHeight}px` } },
ticks.map((tick: AnyRecord) => {
const tickY = pipelineGanttTickY(tick, bounds, chartHeight, backendLayout);
return h("div", { key: `tick-${tick.ms}-${tickY}`, className: "pipeline-gantt-tick", style: { top: `${tickY}px` } },
h("b", null, fmtDate(tick.ms)),
h("span", null, `+${fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs)))}`),
);
}),
),
visibleNodeIds.length > 0 ? h("svg", {
className: "pipeline-gantt-arrow-layer",
width: visibleNodeIds.length * pipelineGanttNodeColumnWidth,
height: chartHeight,
viewBox: `0 0 ${visibleNodeIds.length * pipelineGanttNodeColumnWidth} ${chartHeight}`,
style: {
left: `${pipelineGanttTimeAxisWidth}px`,
top: `${pipelineGanttHeaderHeight}px`,
width: `${visibleNodeIds.length * pipelineGanttNodeColumnWidth}px`,
height: `${chartHeight}px`,
},
"aria-hidden": "true",
},
h("defs", null,
h("marker", {
id: "pipeline-gantt-arrowhead",
viewBox: "0 0 10 10",
refX: 9,
refY: 5,
markerWidth: 6,
markerHeight: 6,
orient: "auto-start-reverse",
},
h("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "context-stroke" }),
),
),
visibleArrows.map((arrow: AnyRecord) => {
const targetMarker = markerById.get(String(arrow.targetMarkerId || ""));
if (!targetMarker) return null;
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || "");
const sourceIndex = visibleNodeIds.indexOf(sourceNodeId);
const targetIndex = visibleNodeIds.indexOf(String(targetMarker.nodeId || ""));
if (sourceIndex < 0 || targetIndex < 0) return null;
const sourceX = sourceIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2;
const targetX = targetIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2;
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1)
?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout));
const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout);
return h("path", {
key: arrow.id,
className: `pipeline-gantt-arrow ${String(arrow.sourceKind || "").toLowerCase()} ${String(arrow.status || "").toLowerCase()} ${String(arrow.action || "").toLowerCase()}`,
d: pipelineControlArrowPath(sourceX, sourceY, targetX, targetY),
markerEnd: "url(#pipeline-gantt-arrowhead)",
"data-testid": String(arrow.action || "") === "observe" ? "pipeline-gantt-observation-arrow" : "pipeline-gantt-arrow",
"data-source-node-id": String(arrow.sourceNodeId || ""),
"data-target-node-id": String(arrow.targetNodeId || ""),
"data-target-marker-id": String(arrow.targetMarkerId || ""),
"data-action": String(arrow.action || ""),
});
}),
) : null,
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-empty-col", style: { height: `${chartHeight}px` } }, "滚动到有活动的时间段后,相关 node 列会自动出现。") :
visibleNodeIds.map((nodeId) => {
const nodeIntervals = intervals.filter((interval: AnyRecord) => interval.nodeId === nodeId);
const nodeMarkers = allMarkers.filter((marker: AnyRecord) => String(marker.nodeId || "") === nodeId);
return h("div", { key: `col-${nodeId}`, className: "pipeline-gantt-node-col", style: { height: `${chartHeight}px` } },
nodeIntervals.map((interval: AnyRecord) => {
const top = pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout);
const height = pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout);
const intervalKey = String(interval.procedureRunId || `${nodeId}-${interval.startMs}`);
return h("button", {
key: intervalKey,
type: "button",
className: `pipeline-gantt-bar ${interval.status} ${interval.live ? "live" : ""} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
style: { top: `${top}px`, height: `${height}px` },
title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt || interval.startMs)} -> ${fmtDate(interval.finishedAt || interval.endMs)}`,
onClick: () => onIntervalSelect(interval),
"data-testid": "pipeline-gantt-line",
"data-node-id": nodeId,
"data-procedure-run-id": String(interval.procedureRunId || ""),
"data-status": String(interval.status || ""),
"data-live": interval.live ? "true" : "false",
},
h("strong", null, interval.status || "working"),
h("span", null, fmtDurationMs(interval.durationMs)),
);
}),
nodeMarkers.map((marker: AnyRecord) => h("button", {
key: marker.id,
type: "button",
className: `pipeline-gantt-marker ${marker.kind} ${marker.tone || ""} ${marker.status || ""} ${selectedMarkerKey === String(marker.id) ? "selected" : ""}`,
style: { top: `${pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout)}px` },
title: `${marker.label || "event"} / ${fmtDate(marker.timestampIso || marker.timestamp || marker.ms)}`,
onClick: () => onMarkerSelect(marker),
"data-testid": marker.kind === "prompt" ? "pipeline-gantt-prompt-marker" : "pipeline-gantt-control-marker",
"data-marker-id": String(marker.id || ""),
})),
);
}),
),
),
),
h(PipelineGanttDetailPanel, { selection, runDetails, nodeDetails, onRaw }),
),
),
);
}
function pipelineNodeControlState(): AnyRecord {
return {
loading: false,
actionLoading: "",
error: "",
message: "",
details: null,
fetchedAt: null,
appendPrompt: "",
guidePrompt: "",
modifyPrompt: "",
approveReason: "",
redoReason: "",
};
}
function pipelineGanttSelectionState(): AnyRecord {
return {
mode: "",
runId: "",
interval: null,
marker: null,
};
}
function pipelineRunDetailsState(): AnyRecord {
return {
runId: "",
loading: false,
error: "",
details: null,
fetchedAt: null,
};
}
function pipelineProxyPath(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/pipeline/proxy${path}`;
}
function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRunChange, selectedNodeId, selectedNodeConfig, selectedNodeRuntime, control, onControlChange, onFetch, onAction, onRaw }: AnyRecord) {
const runId = String(activeRun?.runId || "");
const status = String(selectedNodeRuntime?.status || "pending");
const disabled = !runId || !selectedNodeId || control.loading || Boolean(control.actionLoading);
const updateText = (field: string) => (event: any) => onControlChange({ [field]: event.target.value, error: "", message: "" });
const runOptions = pipelineRuns.length > 0 ? pipelineRuns : (activeRun ? [activeRun] : []);
return h("aside", { className: "pipeline-node-control", "data-testid": "pipeline-node-control" },
h("div", { className: "pipeline-node-control-head" },
h("div", null,
h("p", { className: "panel-eyebrow" }, "Manual Node Control"),
h("h3", null, selectedNodeId || "点击控制图中的 node"),
),
selectedNodeId ? h(StatusBadge, { status }, status) : h(StatusBadge, { status: "pending" }, "idle"),
),
h("div", { className: "pipeline-control-runbar" },
h("label", null,
h("span", null, "目标 run"),
h("select", {
value: runId || selectedRunId,
disabled: runOptions.length === 0,
onChange: (event: any) => onRunChange(event.target.value),
"data-testid": "pipeline-node-run-select",
}, runOptions.map((run: any) => h("option", { key: run.runId, value: run.runId }, `${run.runId || "--"} / ${run.status || "--"}`))),
),
h("button", {
type: "button",
className: "ghost-btn",
disabled,
onClick: onFetch,
"data-testid": "pipeline-node-fetch",
}, control.loading ? "抓取中" : "抓取过程"),
control.details ? h(RawButton, { title: `Pipeline Node ${selectedNodeId}`, data: control.details, onOpen: onRaw, testId: "raw-pipeline-node-control" }) : null,
),
h("div", { className: "pipeline-control-meta" },
h("span", null, h("b", null, "kind"), String(selectedNodeConfig?.kind || "--")),
h("span", null, h("b", null, "procedure"), String(selectedNodeRuntime?.currentProcedureRunId || "--")),
h("span", null, h("b", null, "attempts"), String(selectedNodeRuntime?.attempts ?? "--")),
h("span", null, h("b", null, "updated"), fmtDate(activeRun?.updatedAt)),
),
!selectedNodeId ? h(EmptyState, { title: "未选择 node", text: "点击 React Flow 控制图中的任意 node 后,可抓取执行过程、追加 prompt、下发引导、增量修改、审核通过或重做。" }) : null,
control.error ? h("div", { className: "form-error wide" }, control.error) : null,
control.message ? h("div", { className: "form-success wide" }, control.message) : null,
h("div", { className: "pipeline-control-actions" },
h("label", null,
h("span", null, "实时追加 prompt(仅 running node"),
h("textarea", { value: control.appendPrompt, onChange: updateText("appendPrompt"), placeholder: "让当前执行中的 agent 继续、补充检查或调整当前步骤...", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-append-input" }),
h("button", {
type: "button",
className: "primary-btn compact",
disabled: disabled || !String(control.appendPrompt || "").trim(),
onClick: () => onAction("append"),
"data-testid": "pipeline-node-append-button",
}, control.actionLoading === "append" ? "追加中" : "追加到运行中 node"),
),
h("label", null,
h("span", null, "下次尝试引导 prompt"),
h("textarea", { value: control.guidePrompt, onChange: updateText("guidePrompt"), placeholder: "给该 node 下一次 attempt 的执行提示;不会立即打断当前 session。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-guide-input" }),
h("button", {
type: "button",
className: "ghost-btn compact",
disabled: disabled || !String(control.guidePrompt || "").trim(),
onClick: () => onAction("guide"),
"data-testid": "pipeline-node-guide-button",
}, control.actionLoading === "guide" ? "下发中" : "下发 guide"),
),
h("label", null,
h("span", null, "完成后增量修改 prompt"),
h("textarea", { value: control.modifyPrompt, onChange: updateText("modifyPrompt"), placeholder: "在该 node 已完成结果基础上追加修改要求;runner 会重跑目标 node,并保留同 node 既有 OA 输出作为上下文。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-modify-input" }),
h("button", {
type: "button",
className: "ghost-btn compact",
disabled: disabled || !String(control.modifyPrompt || "").trim(),
onClick: () => onAction("modify"),
"data-testid": "pipeline-node-modify-button",
}, control.actionLoading === "modify" ? "排队中" : "增量修改 node"),
),
h("label", null,
h("span", null, "Monitor 审核通过原因"),
h("textarea", { value: control.approveReason, onChange: updateText("approveReason"), placeholder: "当流程配置开启 monitor 审核时,记录审核通过原因并释放后续 node。", rows: 3, disabled: !selectedNodeId, "data-testid": "pipeline-node-approve-input" }),
h("button", {
type: "button",
className: "primary-btn compact",
disabled: disabled || !String(control.approveReason || "").trim(),
onClick: () => onAction("approve"),
"data-testid": "pipeline-node-approve-button",
}, control.actionLoading === "approve" ? "提交中" : "审核通过"),
),
h("label", null,
h("span", null, "重做 / restart 原因"),
h("textarea", { value: control.redoReason, onChange: updateText("redoReason"), placeholder: "说明为什么需要重做;runner 会重置目标 node 以及非 rework 下游 node。", rows: 4, disabled: !selectedNodeId, "data-testid": "pipeline-node-redo-input" }),
h("button", {
type: "button",
className: "danger-btn compact",
disabled: disabled || !String(control.redoReason || "").trim(),
onClick: () => onAction("redo"),
"data-testid": "pipeline-node-redo-button",
}, control.actionLoading === "redo" ? "排队中" : "重做 node"),
),
),
h("div", { className: "pipeline-control-evidence" },
h("strong", null, "Node 过程索引"),
h(PipelineNodeExecutionIndex, {
details: control.details,
selectedNodeId,
selectedNodeRuntime,
control,
onRaw,
}),
),
);
}
export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "pipeline") || null;
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, oaDiagnostics: null, refreshedAt: null });
const [selectedPipelineId, setSelectedPipelineId] = useState("");
const [selectedRunId, setSelectedRunId] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState("");
const [nodeControl, setNodeControl] = useState(pipelineNodeControlState());
const [ganttSelection, setGanttSelection] = useState(pipelineGanttSelectionState());
const [runDetails, setRunDetails] = useState(pipelineRunDetailsState());
const [ganttScale, setGanttScale] = useState(pipelineDefaultGanttScale);
const loadRequestRef = useRef(0);
const loadInFlightRef = useRef(false);
const runDetailsRequestRef = useRef(0);
const runDetailsInFlightRef = useRef("");
async function load(options: AnyRecord = {}): Promise<void> {
const silent = options.silent === true;
if (!service) return;
if (loadInFlightRef.current) return;
loadInFlightRef.current = true;
const requestId = loadRequestRef.current + 1;
loadRequestRef.current = requestId;
if (!silent) setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const snapshotQuery = `__unideskArrayLimit=registry.components:80,runs:${pipelineSnapshotRunLimit}&_=${Date.now()}`;
const [snapshot, oaDiagnostics] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" }),
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/oa-event-flow/diagnostics?_=${Date.now()}`, { cache: "no-store" })
.catch((error: unknown) => ({ ok: false, error: errorMessage(error, "OA event flow diagnostics failed") })),
]);
if (requestId !== loadRequestRef.current) return;
const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-control snapshot" };
setState({ loading: false, error: "", health, snapshot, oaDiagnostics, refreshedAt: new Date() });
} catch (err) {
if (requestId !== loadRequestRef.current) return;
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") }));
} finally {
loadInFlightRef.current = false;
}
}
useEffect(() => {
load();
if (!service) return undefined;
const timer = window.setInterval(() => {
load({ silent: true });
}, pipelineAutoRefreshMs);
return () => window.clearInterval(timer);
}, [service?.id, service?.runtime?.providerStatus, apiBaseUrl]);
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
const snapshot = state.snapshot || {};
const oaDiagnostics = state.oaDiagnostics || null;
const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot);
const latestPipelineId = String(runs[0]?.pipelineId || "");
const defaultPipeline = (latestPipelineId ? pipelines.find((pipeline: any) => String(pipeline.id || "") === latestPipelineId) : null) || pipelines[0] || {};
const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || defaultPipeline;
const activePipelineId = String(activePipeline.id || "");
const pipelineNodes = pipelineConfigNodes(activePipeline);
const pipelineEdges = pipelineConfigEdges(activePipeline);
const latestRun = pipelineLatestRun(runs, activePipelineId);
const pipelineRuns = pipelineEpochRuns(runs, activePipelineId);
const activeRunBase = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
const activeRunDetail = String(runDetails.runId || "") === String(activeRunBase?.runId || "") ? pipelineDetailedRun(runDetails.details) : null;
const activeRun = mergePipelineRunSummary(activeRunBase, activeRunDetail);
const activeRunId = String(activeRun?.runId || "");
const selectedNodeConfig = pipelineNodes.find((node: any) => String(node?.id || "") === selectedNodeId) || null;
const selectedNodeRuntime = selectedNodeId ? pipelineRunNodeRecord(activeRun, selectedNodeId) : null;
const statusCounts = pipelineStatusCounts(runs);
const componentClasses = pipelineComponentClassCounts(components);
const componentCount = Number(state.health?.components) || pipelineArrayCount(snapshot, "registry.components", components.length);
const runCount = pipelineArrayCount(snapshot, "runs", runs.length);
const baseFlow = pipelineFlowElements(activePipeline, activeRun, components);
const flow = {
nodes: baseFlow.nodes.map((node) => node.id === selectedNodeId ? { ...node, selected: true, className: `${node.className || ""} selected-control-node` } : node),
edges: baseFlow.edges,
};
const exportItems = pipelines.map((pipeline: any) => {
const pipelineId = String(pipeline.id || "pipeline");
const run = pipelineLatestRun(runs, pipelineId);
return {
title: `${pipelineId}-${run?.runId || "snapshot"}`,
flow: pipelineFlowElements(pipeline, run, components),
};
});
const pipelineRunIds = pipelineRuns.map((run: any) => String(run?.runId || "")).filter(Boolean).join("|");
const pipelineNodeIds = pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean).join("|");
useEffect(() => {
if (!selectedRunId || pipelineRunIds.split("|").includes(selectedRunId)) return;
setSelectedRunId("");
}, [selectedRunId, pipelineRunIds]);
useEffect(() => {
if (!selectedNodeId || pipelineNodeIds.split("|").includes(selectedNodeId)) return;
setSelectedNodeId("");
setNodeControl(pipelineNodeControlState());
setGanttSelection(pipelineGanttSelectionState());
}, [selectedNodeId, pipelineNodeIds]);
async function fetchRunDetails(runId = activeRunId, options: AnyRecord = {}): Promise<void> {
if (!runId) {
setRunDetails(pipelineRunDetailsState());
return;
}
const scale = clampPipelineGanttScale(options.scale ?? ganttScale ?? pipelineDefaultGanttScale);
const requestKey = `${runId}:${scale}`;
if (runDetailsInFlightRef.current === requestKey) return;
runDetailsInFlightRef.current = requestKey;
const silent = options.silent === true;
const requestId = runDetailsRequestRef.current + 1;
runDetailsRequestRef.current = requestId;
setRunDetails((prev: AnyRecord) => ({
runId,
scale,
loading: !silent || String(prev.runId || "") !== runId || !prev.details,
error: "",
details: silent && prev.runId === runId ? prev.details : prev.runId === runId ? prev.details : null,
fetchedAt: prev.runId === runId ? prev.fetchedAt : null,
}));
try {
const [details, runSummary] = await Promise.all([
requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" }),
requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/runs/${encodeURIComponent(runId)}`)}?_=${Date.now()}`, { cache: "no-store" }).catch((error: unknown) => ({ ok: false, runSummaryError: errorMessage(error, "抓取评分失败") })),
]);
if (requestId !== runDetailsRequestRef.current) return;
setRunDetails({ runId, scale, loading: false, error: "", details: { ...details, run: isRecord(runSummary?.run) ? runSummary.run : undefined, runSummaryError: runSummary?.runSummaryError }, fetchedAt: new Date() });
} catch (err) {
if (requestId !== runDetailsRequestRef.current) return;
setRunDetails((prev: AnyRecord) => ({
runId,
scale,
loading: false,
error: errorMessage(err, "抓取 epoch 执行过程失败"),
details: prev.runId === runId ? prev.details : null,
fetchedAt: prev.runId === runId ? prev.fetchedAt : null,
}));
} finally {
if (runDetailsInFlightRef.current === requestKey) runDetailsInFlightRef.current = "";
}
}
useEffect(() => {
if (!activeRunId) {
setRunDetails(pipelineRunDetailsState());
return undefined;
}
void fetchRunDetails(activeRunId);
const timer = window.setInterval(() => {
void fetchRunDetails(activeRunId, { silent: true });
}, pipelineAutoRefreshMs);
return () => window.clearInterval(timer);
}, [activeRunId, apiBaseUrl, ganttScale]);
async function fetchNodeDetails(runId = activeRunId, nodeId = selectedNodeId): Promise<void> {
if (!runId || !nodeId) {
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" }));
return;
}
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: true, error: "", message: "" }));
try {
const details = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}?tail=160`));
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, details, fetchedAt: new Date(), error: "" }));
} catch (err) {
setNodeControl((prev: AnyRecord) => ({ ...prev, loading: false, error: errorMessage(err, "抓取 node 执行过程失败") }));
}
}
async function selectGanttInterval(interval: AnyRecord): Promise<void> {
const runId = String(interval?.runId || activeRunId || "");
const nodeId = String(interval?.nodeId || "");
setGanttSelection({ mode: "interval", runId, interval, marker: null });
if (!runId || !nodeId) return;
if (runId !== activeRunId) setSelectedRunId(runId);
setSelectedNodeId(nodeId);
setNodeControl(pipelineNodeControlState());
void fetchRunDetails(runId, { silent: true });
await fetchNodeDetails(runId, nodeId);
}
async function selectGanttMarker(marker: AnyRecord): Promise<void> {
const runId = String(marker?.runId || activeRunId || "");
const nodeId = String(marker?.nodeId || "");
setGanttSelection({ mode: "event", runId, interval: null, marker });
if (!runId) return;
if (runId !== activeRunId) setSelectedRunId(runId);
void fetchRunDetails(runId, { silent: true });
if (!nodeId) return;
setSelectedNodeId(nodeId);
setNodeControl(pipelineNodeControlState());
await fetchNodeDetails(runId, nodeId);
}
async function postNodeAction(action: "append" | "guide" | "modify" | "approve" | "redo"): Promise<void> {
if (!activeRunId || !selectedNodeId) {
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "请先选择 run 和 node", message: "" }));
return;
}
const endpoint = action === "append" ? "prompts" : action;
const text =
action === "append" ? nodeControl.appendPrompt :
action === "guide" ? nodeControl.guidePrompt :
action === "modify" ? nodeControl.modifyPrompt :
action === "approve" ? nodeControl.approveReason :
nodeControl.redoReason;
if (!String(text || "").trim()) {
setNodeControl((prev: AnyRecord) => ({ ...prev, error: "操作内容不能为空", message: "" }));
return;
}
setNodeControl((prev: AnyRecord) => ({ ...prev, actionLoading: action, error: "", message: "" }));
try {
const body = action === "redo" || action === "approve"
? { reason: text, source: "unidesk-frontend", sourceKind: "webui" }
: { prompt: text, source: "unidesk-frontend", sourceKind: "webui" };
const result = await requestJson(pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(activeRunId)}/nodes/${encodeURIComponent(selectedNodeId)}/${endpoint}`), {
method: "POST",
body: JSON.stringify(body),
});
setNodeControl((prev: AnyRecord) => ({
...prev,
actionLoading: "",
details: result,
fetchedAt: new Date(),
appendPrompt: action === "append" ? "" : prev.appendPrompt,
guidePrompt: action === "guide" ? "" : prev.guidePrompt,
modifyPrompt: action === "modify" ? "" : prev.modifyPrompt,
approveReason: action === "approve" ? "" : prev.approveReason,
redoReason: action === "redo" ? "" : prev.redoReason,
message:
action === "append" ? "已追加到运行中 node" :
action === "guide" ? "已下发 guide,等待 runner 处理" :
action === "modify" ? "已排队增量修改命令" :
action === "approve" ? "已提交审核通过决策" :
"已排队重做命令",
}));
await fetchNodeDetails(activeRunId, selectedNodeId);
await fetchRunDetails(activeRunId, { silent: true });
if (action !== "append") await load();
} catch (err) {
setNodeControl((prev: AnyRecord) => ({ ...prev, actionLoading: "", error: errorMessage(err, "node 控制操作失败") }));
}
}
if (!service) return h(EmptyState, { title: "Pipeline 未登记", text: "请在 config.json 的 microservices 中登记 id=pipeline" });
return h("div", { className: "pipeline-page", "data-testid": "pipeline-page" },
h(Panel, {
title: "Pipeline v2 工作台",
eyebrow: "D601 Snapshot Microservice",
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "pipeline-refresh-button" }, state.loading ? "刷新中" : "刷新"),
h(RawButton, { title: "Pipeline Microservice", data: service, onOpen: onRaw, testId: "raw-pipeline-service" }),
),
},
h("div", { className: "pipeline-hero" },
h("div", null,
h("div", { className: "node-version-line" },
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
h("span", null, service.providerId),
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
),
h("p", { className: "muted paragraph" }, service.description),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Repo"),
h("strong", null, repository.url || "--"),
h("code", null, repository.commitId || "--"),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "D601 Docker"),
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`),
),
),
state.error ? h("div", { className: "form-error wide" }, state.error) : null,
),
h("div", { className: "pipeline-grid" },
h(Panel, { title: "观测指标", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Snapshot" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "Health", value: state.health?.ok ? "OK" : "--", hint: state.health?.service || "D601 /health", tone: state.health?.ok ? "ok" : "warn" }),
h(MetricCard, { label: "组件", value: componentCount, hint: "components registry", tone: snapshot?.registry?.ok === false ? "warn" : "ok" }),
h(MetricCard, { label: "Pipeline", value: pipelines.length, hint: `${pipelineNodes.length} nodes / ${pipelineEdges.length} edges` }),
h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }),
h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }),
h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }),
h(MetricCard, { label: "Score", value: pipelineScoreText(activeRun), hint: activeRun?.runId || "selected epoch", tone: pipelineScoreTone(activeRun) }),
),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })),
),
h(Panel, { title: "评分器", eyebrow: activeRun?.runId || "selected epoch" },
h(PipelineScoreBoard, { run: activeRun, onRaw }),
),
h(Panel, { title: "OA 事件流", eyebrow: "100% event-driven diagnostics", className: "pipeline-wide-panel" },
h(PipelineOaEventFlowPanel, { diagnostics: oaDiagnostics, onRaw }),
),
h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` },
componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) :
h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" },
h("span", null, item.name),
h("strong", null, item.count),
))),
h("div", { className: "pipeline-component-list" },
components.slice(0, 12).map((component: any) => h("span", { key: component.key, className: "data-chip" }, h("b", null, component.componentClass || "--"), h("span", null, component.id || component.key || "--"))),
),
),
h(Panel, {
title: "控制图",
eyebrow: `${activePipeline.id || "pipeline"} / run ${activeRun?.status || "--"}`,
className: "pipeline-wide-panel",
actions: h("div", { className: "pipeline-toolbar" },
h("select", {
value: activePipelineId,
disabled: pipelines.length === 0,
onChange: (event: any) => {
setSelectedPipelineId(event.target.value);
setSelectedRunId("");
setSelectedNodeId("");
setNodeControl(pipelineNodeControlState());
setGanttSelection(pipelineGanttSelectionState());
},
"data-testid": "pipeline-select",
}, pipelines.map((pipeline: any) => h("option", { key: pipeline.id, value: pipeline.id }, pipeline.id || pipeline.key))),
h("select", {
value: activeRunId,
disabled: pipelineRuns.length === 0,
onChange: (event: any) => {
setSelectedRunId(event.target.value);
setNodeControl(pipelineNodeControlState());
setGanttSelection(pipelineGanttSelectionState());
if (selectedNodeId) void fetchNodeDetails(event.target.value, selectedNodeId);
},
"data-testid": "pipeline-run-select",
}, pipelineRuns.map((run: any) => h("option", { key: run.runId, value: run.runId }, pipelineEpochLabel(pipelineRuns, run)))),
h("button", {
type: "button",
className: "ghost-btn",
disabled: flow.nodes.length === 0,
onClick: () => exportPipelineGraph(flow, `${activePipeline.id || "pipeline"}-${activeRun?.runId || "snapshot"}`),
"data-testid": "pipeline-export-graph",
}, "导出渲染图"),
h("button", {
type: "button",
className: "ghost-btn",
disabled: exportItems.every((item) => item.flow.nodes.length === 0),
onClick: () => exportPipelineGraphs(exportItems),
"data-testid": "pipeline-export-all-graphs",
}, "批量导出"),
),
},
pipelineNodes.length === 0 ? h(EmptyState, { title: "暂无控制图", text: "等待 D601 pipeline backend 返回 config.nodes / config.edges" }) :
h("div", { className: "pipeline-control-shell" },
h("div", { className: "pipeline-flow-frame", "data-testid": "pipeline-react-flow" },
h(ReactFlow, {
nodes: flow.nodes,
edges: flow.edges,
nodeTypes: pipelineNodeTypes,
edgeTypes: pipelineEdgeTypes,
fitView: true,
fitViewOptions: { padding: 0.18 },
nodesDraggable: false,
nodesConnectable: false,
elementsSelectable: true,
minZoom: 0.25,
maxZoom: 1.4,
proOptions: { hideAttribution: true },
onNodeClick: (_event: any, node: Node) => {
const nodeId = String(node.id);
setSelectedNodeId(nodeId);
setNodeControl(pipelineNodeControlState());
if (activeRunId) void fetchNodeDetails(activeRunId, nodeId);
},
},
h(Background, { gap: 22, size: 1, color: "rgba(215, 161, 58, 0.24)" }),
h(Controls, { showInteractive: false }),
),
),
h(PipelineNodeControlPanel, {
activeRun,
pipelineRuns,
selectedRunId,
onRunChange: (runId: string) => {
setSelectedRunId(runId);
setNodeControl(pipelineNodeControlState());
setGanttSelection(pipelineGanttSelectionState());
if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId);
},
selectedNodeId,
selectedNodeConfig,
selectedNodeRuntime,
control: nodeControl,
onControlChange: (patch: AnyRecord) => setNodeControl((prev: AnyRecord) => ({ ...prev, ...patch })),
onFetch: () => fetchNodeDetails(),
onAction: postNodeAction,
onRaw,
}),
),
h("div", { className: "pipeline-flow-summary" },
h("span", null, `${flow.nodes.length} nodes`),
h("span", null, `${flow.edges.length} edges`),
h("span", null, `${pipelines.length} pipelines`),
h("span", null, `source config+components(${components.length})`),
h("span", null, `run ${activeRun?.runId || "--"}`),
h("span", null, `score ${pipelineScoreText(activeRun)}`),
h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
),
),
h(PipelineEpochGantt, {
epochs: pipelineRuns,
activeRun,
activePipeline,
pipelineNodes,
pipelineEdges,
selection: ganttSelection,
runDetails,
nodeDetails: nodeControl.details,
ganttScale,
onGanttScaleChange: setGanttScale,
onIntervalSelect: selectGanttInterval,
onMarkerSelect: selectGanttMarker,
onRunChange: (runId: string) => {
setSelectedRunId(runId);
setNodeControl(pipelineNodeControlState());
setGanttSelection(pipelineGanttSelectionState());
if (selectedNodeId) void fetchNodeDetails(runId, selectedNodeId);
},
onRaw,
}),
h(Panel, { title: "Epoch 列表", eyebrow: `${pipelineRuns.length}/${runCount} preview` },
pipelineRuns.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。" }) :
h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => {
const cardRun = String(run?.runId || "") === activeRunId ? activeRun : run;
return h("article", {
key: run.runId,
className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`,
role: "button",
tabIndex: 0,
onClick: () => {
setSelectedRunId(String(run.runId || ""));
setGanttSelection(pipelineGanttSelectionState());
},
onKeyDown: (event: any) => {
if (event.key === "Enter" || event.key === " ") {
setSelectedRunId(String(run.runId || ""));
setGanttSelection(pipelineGanttSelectionState());
}
},
},
h("div", { className: "node-card-head" }, h("strong", null, pipelineEpochLabel(pipelineRuns, run)), h(StatusBadge, { status: run.status }, run.status || "--")),
h("div", { className: "docker-meta compact" },
h("span", null, cardRun?.pipelineId || "--"),
h("span", null, `nodes ${Array.isArray(cardRun?.nodes) ? cardRun.nodes.length : 0}`),
h("span", null, `oa ${Array.isArray(cardRun?.submissions) ? cardRun.submissions.length : 0}`),
h("span", null, `procedures ${Array.isArray(cardRun?.procedureRuns) ? cardRun.procedureRuns.length : 0}`),
h(PipelineScoreBadge, { run: cardRun }),
),
h("p", { className: "muted paragraph" }, summarizeValue(cardRun?.task)),
h("span", { className: "pipeline-run-time" }, fmtDate(cardRun?.updatedAt)),
);
})),
),
h(Panel, { title: "运行材料索引", eyebrow: activeRun?.runId || "selected epoch", className: "pipeline-wide-panel" },
h(PipelineRunMaterialIndex, { activeRun, onRaw }),
),
),
);
}