From 0f86905d6b40b290e0d72682a1efd84ae0e29c46 Mon Sep 17 00:00:00 2001
From: 2233admin <2233admin@users.noreply.github.com>
Date: Tue, 28 Jul 2026 02:21:43 +0800
Subject: [PATCH 1/2] feat(studio): complete workflow authoring and OODA
sources
---
frontend/components/flow/command-palette.tsx | 30 ++-
frontend/components/flow/inspector-shell.tsx | 10 +-
frontend/components/flow/inspector.tsx | 206 +++++++++++++++---
.../components/flow/nodes/workflow-node.tsx | 49 +++--
frontend/lib/workflow/node-primitives.ts | 96 ++++++++
.../workflow/opencli-business-workflows.ts | 127 ++++++++++-
frontend/lib/workflow/studio-templates.ts | 2 +-
frontend/lib/workflow/workflow-outline.ts | 90 +++++++-
frontend/package.json | 2 +-
.../scripts/check-dify-p0-regressions.mjs | 51 ++++-
.../check-inspector-workflow-regressions.mjs | 150 +++++++++++++
.../check-opencli-business-workflows.mjs | 18 +-
.../scripts/check-workflow-regressions.mjs | 10 +-
13 files changed, 769 insertions(+), 72 deletions(-)
create mode 100644 frontend/scripts/check-inspector-workflow-regressions.mjs
diff --git a/frontend/components/flow/command-palette.tsx b/frontend/components/flow/command-palette.tsx
index 820310f..79fab15 100644
--- a/frontend/components/flow/command-palette.tsx
+++ b/frontend/components/flow/command-palette.tsx
@@ -46,7 +46,11 @@ import { workflowNodeDepthFromNetworkStack, workflowNodeLayerAtDepth } from "@/l
import { localizeNodeText, type WorkflowLanguage } from "@/lib/workflow/node-i18n"
import { groupPrimitivesForNodeMenu } from "@/lib/workflow/node-menu"
import { getNodeContractByCatalogId } from "@/lib/workflow/node-contracts"
-import { getWorkflowPrimitives, type WorkflowPrimitive } from "@/lib/workflow/node-primitives"
+import {
+ getDifyCommonWorkflowPrimitives,
+ getWorkflowPrimitives,
+ type WorkflowPrimitive,
+} from "@/lib/workflow/node-primitives"
import { openCLIAdapterNodeToCatalogItem } from "@/lib/workflow/opencli-adapter-catalog"
import { useWorkflowCapabilities } from "@/lib/workflow/use-workflow-capabilities"
import { cn } from "@/lib/utils"
@@ -642,6 +646,7 @@ export function CommandPalette({
workflowCatalogPluginProvenance(item) === null &&
item.runtimeCapability?.source !== "backend.workflow.tool_capabilities",
)
+ const catalogOperatorIds = new Set(catalogOperators.map((item) => item.id))
const pluginTools = allCatalogItems.filter(
(item) =>
catalogAcceptsConnection(item, compatiblePort) &&
@@ -679,7 +684,8 @@ export function CommandPalette({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [capabilities, catalogItems, compatiblePort, inNodeNetwork, language, queryText, workflowProfile])
const primitiveGroups = groupPrimitivesForNodeMenu(
- (inNodeNetwork ? getWorkflowPrimitives() : []).filter((item) => {
+ (inNodeNetwork ? getWorkflowPrimitives() : getDifyCommonWorkflowPrimitives()).filter((item) => {
+ if (catalogOperatorIds.has(item.id)) return false
if (!primitiveAcceptsConnection(item, compatiblePort)) return false
if (!queryText) return true
const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language)
@@ -748,9 +754,23 @@ export function CommandPalette({
{selectedPresentation.label}
{copy.requiredBeforeAdd}
- {selectedOpenCLI.args.filter((arg) => arg.required).map((arg) => (
-
- ))}
+ {selectedOpenCLI.args.filter((arg) => arg.required).map((arg) => {
+ const value = requiredValues[arg.name] ?? (arg.default == null ? "" : String(arg.default))
+ const onChange = (next: string) => setRequiredValues((current) => ({ ...current, [arg.name]: next }))
+ return (
+
+ )
+ })}
diff --git a/frontend/components/flow/inspector-shell.tsx b/frontend/components/flow/inspector-shell.tsx
index a7079bf..f7f3d9d 100644
--- a/frontend/components/flow/inspector-shell.tsx
+++ b/frontend/components/flow/inspector-shell.tsx
@@ -4,7 +4,7 @@ import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode
import { LocateFixed, Pin, X } from "lucide-react"
import { cn } from "@/lib/utils"
-const stateText: Record = {
+export const workflowStatusText: Record = {
idle: "Idle",
running: "Running",
success: "Done",
@@ -12,7 +12,7 @@ const stateText: Record = {
error: "Error",
}
-const stateDotClass: Record = {
+export const workflowStatusDotClass: Record = {
idle: "border-muted-foreground/50 bg-transparent",
running: "border-info bg-info",
success: "border-success bg-success",
@@ -54,10 +54,10 @@ function PanelStatus({ status }: { status?: string }) {
return (
-
- {stateText[status] ?? status}
+
+ {workflowStatusText[status] ?? status}
)
}
diff --git a/frontend/components/flow/inspector.tsx b/frontend/components/flow/inspector.tsx
index 486c70f..5b73202 100644
--- a/frontend/components/flow/inspector.tsx
+++ b/frontend/components/flow/inspector.tsx
@@ -4,11 +4,13 @@ import { useEffect, useState } from "react"
import Link from "next/link"
import {
AlertTriangle,
+ ChevronRight,
Database,
ExternalLink,
GitBranch,
Plus,
PlugZap,
+ Search,
Trash2,
Unplug,
} from "lucide-react"
@@ -53,7 +55,14 @@ import {
shouldPreserveNodeAuthoredText,
type WorkflowLanguage,
} from "@/lib/workflow/node-i18n"
-import { buildWorkflowOutlineRows } from "@/lib/workflow/workflow-outline"
+import {
+ buildWorkflowOutlineRows,
+ filterWorkflowOutlineRows,
+ visibleWorkflowOutlineRows,
+ workflowDirectUpstreamNodeIds,
+ workflowInputReferenceForPort,
+ workflowOutlineRowHasChildren,
+} from "@/lib/workflow/workflow-outline"
import { findWorkflowProjectNodeByCanvasId } from "@/lib/workflow/node-path"
import {
isOpenCLISourceSlotArray,
@@ -76,7 +85,13 @@ import type {
WorkflowProject,
WorkflowProjectNode,
} from "@/lib/workflow/schema"
-import { MonoRow, PanelShell, SectionCaption } from "./inspector-shell"
+import {
+ MonoRow,
+ PanelShell,
+ SectionCaption,
+ workflowStatusDotClass,
+ workflowStatusText,
+} from "./inspector-shell"
import { cn } from "@/lib/utils"
const edgeTypeOptions = [
@@ -101,6 +116,9 @@ const INSPECTOR_COPY = {
noNodes: "当前工作流还没有节点。",
nodes: "节点",
connections: "连线",
+ outlineSearch: "搜索节点、类型或说明",
+ collapseNode: "折叠节点",
+ expandNode: "展开节点",
promptSection: "节点提示词配置",
promptHelp: "这里只显示节点已保存的提示词配置和测试用例。AI 编辑生成的是待审阅提案,确认应用后才会更新工作流。",
configuredPrompt: "已配置提示词",
@@ -130,6 +148,7 @@ const INSPECTOR_COPY = {
inputsHelp: "每个输入端口只列出类型兼容的真实上游输出。可在这里重接或解绑。",
inputUnbound: "未连接",
noCompatibleOutputs: "没有可用的兼容输出。",
+ insertVariable: "引用上游输出",
fieldMapping: "字段映射",
fieldMappingGap: "上下游契约尚未提供字段 schema,暂不允许手填来源或目标字段路径。已有旧映射只读保留。",
legacyMapping: "旧映射",
@@ -175,6 +194,9 @@ const INSPECTOR_COPY = {
noNodes: "This workflow has no nodes yet.",
nodes: "nodes",
connections: "connections",
+ outlineSearch: "Search nodes, types, or descriptions",
+ collapseNode: "Collapse node",
+ expandNode: "Expand node",
promptSection: "Node prompt configuration",
promptHelp: "This shows only saved prompt configuration and test cases. AI edits remain review proposals until you apply them.",
configuredPrompt: "Configured prompt",
@@ -204,6 +226,7 @@ const INSPECTOR_COPY = {
inputsHelp: "Each input lists only type-compatible outputs from real upstream nodes. Reconnect or unbind here.",
inputUnbound: "Unbound",
noCompatibleOutputs: "No compatible outputs are available.",
+ insertVariable: "Reference upstream output",
fieldMapping: "Field mapping",
fieldMappingGap: "The upstream and downstream contracts do not expose field schemas yet. Manual source and target field paths are disabled; legacy mappings remain read-only.",
legacyMapping: "Legacy mapping",
@@ -378,10 +401,34 @@ function WorkflowOutlinePanel({
workflowProject: WorkflowProject
}) {
const copy = INSPECTOR_COPY[language]
+ const [outlineQuery, setOutlineQuery] = useState("")
+ const [collapsedNodeIds, setCollapsedNodeIds] = useState>(() => new Set())
const nodeById = new Map(nodes.map((node) => [node.id, node]))
const rows = buildWorkflowOutlineRows(nodes, edges)
- const connectedRows = rows.filter((row) => !row.disconnected)
- const disconnectedRows = rows.filter((row) => row.disconnected)
+ const outlineRowIndexById = new Map(rows.map((row, index) => [row.nodeId, index]))
+ const filteredRows = filterWorkflowOutlineRows(rows, outlineQuery, (nodeId) => {
+ const node = nodeById.get(nodeId)
+ if (!node) return ""
+ const localized = localizeNodeText(
+ getNodeDisplayId(node.data),
+ { label: node.data.label, description: node.data.description },
+ language,
+ )
+ return `${node.data.label} ${node.data.description ?? ""} ${localized.label} ${localized.description ?? ""} ${node.data.nodeType}`
+ })
+ const visibleRows = outlineQuery.trim()
+ ? filteredRows
+ : visibleWorkflowOutlineRows(filteredRows, collapsedNodeIds)
+ const connectedRows = visibleRows.filter((row) => !row.disconnected)
+ const disconnectedRows = visibleRows.filter((row) => row.disconnected)
+ const toggleOutlineNode = (nodeId: string) => {
+ setCollapsedNodeIds((current) => {
+ const next = new Set(current)
+ if (next.has(nodeId)) next.delete(nodeId)
+ else next.add(nodeId)
+ return next
+ })
+ }
const renderRows = (sectionRows: typeof rows) => (
{sectionRows.map((row) => {
@@ -409,39 +456,64 @@ function WorkflowOutlinePanel({
language,
})
: localized.label
+ const collapsed = collapsedNodeIds.has(node.id)
+ const status = node.data.status ?? "idle"
+ const rowIndex = outlineRowIndexById.get(node.id)
+ const hasChildren = rowIndex !== undefined && workflowOutlineRowHasChildren(rows, rowIndex)
return (
-
+
+
)
})}
@@ -463,6 +535,17 @@ function WorkflowOutlinePanel({
{nodes.length} {copy.nodes} · {edges.length} {copy.connections}
+
+
+ setOutlineQuery(event.target.value)}
+ placeholder={copy.outlineSearch}
+ aria-label={copy.outlineSearch}
+ className={cn(houdiniInputClass, "w-full pl-8")}
+ />
+
{nodes.length === 0 ? (
{copy.noNodes}
@@ -930,6 +1013,24 @@ export function Inspector({ compact = false, onClose }: { compact?: boolean; onC
{control}
)
+ const variableSelector = !field.readonly && upstreamVariableOptions.length > 0 ? (
+
+ ) : null
if (field.type === "json") {
const draftKey = `${configurationNodeId}:${field.id}`
@@ -1019,6 +1120,8 @@ export function Inspector({ compact = false, onClose }: { compact?: boolean; onC
if (field.type === "textarea") {
return row(
+
+ {variableSelector}
,
)
}
@@ -1154,6 +1258,8 @@ export function Inspector({ compact = false, onClose }: { compact?: boolean; onC
}
return row(
+
+ {variableSelector}
updateParameterField(field, e.target.value)}
className={houdiniInputClass}
- />,
+ />
+
,
)
}
@@ -1213,11 +1320,40 @@ export function Inspector({ compact = false, onClose }: { compact?: boolean; onC
portId: port.id,
}))
})
+ const upstreamNodeIds = workflowDirectUpstreamNodeIds(node.id, edges)
+ const upstreamVariableOptions = Array.from(new Map(nodes.flatMap((candidate) => {
+ if (!upstreamNodeIds.has(candidate.id)) return []
+ const candidateProjectNode = hydrateProjectNodeIdentity(
+ findWorkflowProjectNodeByCanvasId(workflowProject, candidate.id),
+ candidate.data,
+ )
+ const candidateContract = buildCanonicalNodeViewContract(
+ candidateProjectNode,
+ candidate.data,
+ candidate.id,
+ )
+ const localized = localizeNodeText(
+ getNodeDisplayId(candidate.data),
+ { label: candidate.data.label, description: candidate.data.description },
+ language,
+ )
+ return candidateContract.ports
+ .filter((port) => port.direction === "output")
+ .flatMap((port) => {
+ const value = workflowInputReferenceForPort(port.id)
+ return value ? [{
+ value,
+ label: `${localized.label} · ${port.id} (${port.type})`,
+ }] : []
+ })
+ }).map((option) => [option.value, option])).values())
const parameterGroups = parameterInterfaceView?.groups ?? []
const activeParameterGroupId = parameterGroups.some((group) => group.id === parameterGroupTab)
? parameterGroupTab
: parameterGroups[0]?.id
const activeParameterFields = parameterInterfaceView?.fields.filter((field) => field.groupId === activeParameterGroupId) ?? []
+ const regularParameterFields = activeParameterFields.filter((field) => field.type !== "json")
+ const advancedParameterFields = activeParameterFields.filter((field) => field.type === "json")
const blockedAction = blockedActionViewForRuntime(data)
const locateNode = () => {
const internalNode = getInternalNode(node.id)
@@ -1509,13 +1645,25 @@ export function Inspector({ compact = false, onClose }: { compact?: boolean; onC
))}
- {activeParameterFields.map((field) => renderParameterField(field))}
+ {regularParameterFields.map((field) => renderParameterField(field))}
{activeParameterFields.length === 0 ? (
{copy.noPublicParameters}
) : null}
) : null}
+ {advancedParameterFields.length > 0 ? (
+
+
+ {copy.advanced}
+ {advancedParameterFields.length}
+
+
+ {advancedParameterFields.map((field) => renderParameterField(field))}
+
+
+ ) : null}
+
{nodeContract || data.runtimeContract ? (
diff --git a/frontend/components/flow/nodes/workflow-node.tsx b/frontend/components/flow/nodes/workflow-node.tsx
index d69ecd6..396e8f4 100644
--- a/frontend/components/flow/nodes/workflow-node.tsx
+++ b/frontend/components/flow/nodes/workflow-node.tsx
@@ -1,6 +1,6 @@
"use client"
-import { memo, useEffect, type MouseEvent } from "react"
+import { memo, useEffect, type KeyboardEvent, type MouseEvent } from "react"
import { Handle, Position, useStore, useUpdateNodeInternals, type NodeProps } from "@xyflow/react"
import type { WorkflowNode as WorkflowNodeType } from "@/lib/flow/types"
import { useFlowStore } from "@/lib/flow/store"
@@ -400,14 +400,11 @@ function WorkflowNodeComponent({ id, data, selected }: NodeProps ({
- "aria-label": `${isBusinessLevel ? businessLabel : nodeViewContract.identity.label} · ${handleType === "source" ? "输出" : "输入"} · ${port.id ?? "default"} · ${port.type ?? "unknown"}`,
- "data-port-direction": handleType === "source" ? "output" : "input",
- "data-port-id": port.id ?? "default",
- "data-port-name": port.label,
- "data-port-type": port.type ?? "unknown",
- onClickCapture: (event: MouseEvent) => {
- if (!event.altKey) return
+ ) => {
+ const openPortMenu = (
+ event: MouseEvent | KeyboardEvent,
+ position: { x: number; y: number },
+ ) => {
event.preventDefault()
event.stopPropagation()
window.dispatchEvent(new CustomEvent("opencli:workflow-port-menu", {
@@ -417,12 +414,38 @@ function WorkflowNodeComponent({ id, data, selected }: NodeProps {
+ if (!event.altKey) return
+ openPortMenu(event, { x: event.clientX, y: event.clientY })
+ },
+ onContextMenu: (event: MouseEvent) => {
+ openPortMenu(event, { x: event.clientX, y: event.clientY })
+ },
+ onKeyDown: (event: KeyboardEvent) => {
+ const opensMenu = event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")
+ if (!opensMenu) return
+ const bounds = event.currentTarget.getBoundingClientRect()
+ openPortMenu(event, {
+ x: bounds.left + bounds.width / 2,
+ y: bounds.top + bounds.height / 2,
+ })
+ },
+ }
+ }
const sourceHandleStyle = (i: number) =>
outputs.length === 1
diff --git a/frontend/lib/workflow/node-primitives.ts b/frontend/lib/workflow/node-primitives.ts
index df31460..7bc4d54 100644
--- a/frontend/lib/workflow/node-primitives.ts
+++ b/frontend/lib/workflow/node-primitives.ts
@@ -1,5 +1,6 @@
import type { NodeCategory, WorkflowNodeData, WorkflowNodeType } from "@/lib/flow/types"
import type { WorkflowRuntimeCapability } from "./capabilities"
+import { DIFY_NODE_CAPABILITY_IDS } from "./dify-capability-map"
import type { WorkflowCapability, WorkflowNodeKind } from "./schema"
export type WorkflowPrimitiveCategory =
@@ -356,6 +357,72 @@ export const WORKFLOW_PRIMITIVES: WorkflowPrimitive[] = [
primitive("primitive.ops.secret-ref", "secret-ref", "Secret Ref", "声明运行时注入的 secret 引用而不暴露明文", "ops", "transform", "data", "ShieldCheck", [
out("secret", "secretRef", "Secret reference."),
], [{ id: "name", label: "name", value: "WEBHOOK_TOKEN" }], ["secret", "vault", "token", "密钥"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.start, "dify-start", "Start / User Input", "接收用户输入或工作流启动变量", "core", "trigger", "logic", "Play", [
+ out("variables", "object", "Workflow input variables."),
+ ], [{ id: "variables", label: "variables", value: "[]" }], ["dify", "start", "user input", "开始", "用户输入"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.end, "dify-end", "End", "结束工作流并映射最终输出", "output", "action", "action", "Square", [
+ inPort("result", "any", "Final workflow result."),
+ ], [{ id: "outputs", label: "outputs", value: "[]" }], ["dify", "end", "output", "结束", "输出"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.answer, "dify-answer", "Answer", "根据模板生成并返回最终回答", "output", "action", "action", "MessageSquare", [
+ inPort("variables", "object", "Answer template variables."),
+ out("answer", "string", "Rendered answer."),
+ ], [{ id: "template", label: "template", value: "{{answer}}" }], ["dify", "answer", "response", "回答"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.llm, "dify-llm", "LLM", "调用已配置的模型处理提示词并生成文本", "ai", "action", "action", "Sparkles", [
+ inPort("variables", "object", "Prompt variables."),
+ out("text", "string", "Generated text."),
+ ], [{ id: "model", label: "model", value: "" }, { id: "prompt", label: "prompt", value: "{{input}}" }], ["dify", "llm", "model", "prompt", "模型"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.agent, "dify-agent", "Agent", "让 Agent 按指令调用可用工具并完成任务", "ai", "action", "action", "Bot", [
+ inPort("input", "any", "Agent task input."),
+ out("result", "any", "Agent task result."),
+ ], [{ id: "instructions", label: "instructions", value: "" }, { id: "tools", label: "tools", value: "[]" }], ["dify", "agent", "tools", "智能体"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.knowledgeRetrieval, "dify-knowledge", "Knowledge Retrieval", "从选定知识库中检索相关文档", "input", "http", "data", "Database", [
+ inPort("query", "string", "Retrieval query."),
+ out("documents", "document[]", "Retrieved documents."),
+ ], [{ id: "knowledgeBase", label: "knowledgeBase", value: "" }, { id: "topK", label: "topK", value: "4" }], ["dify", "knowledge", "retrieval", "rag", "知识检索"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.questionClassifier, "dify-classifier", "Question Classifier", "用模型把输入问题路由到指定类别", "ai", "condition", "logic", "GitBranch", [
+ inPort("question", "string", "Question to classify."),
+ out("class", "string", "Matched class."),
+ out("fallback", "string", "Fallback class."),
+ ], [{ id: "model", label: "model", value: "" }, { id: "classes", label: "classes", value: "[]" }], ["dify", "question", "classifier", "route", "问题分类"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.humanInput, "dify-approval", "Human Approval", "暂停流程,等待人工批准或拒绝", "business", "condition", "logic", "UserCheck", [
+ inPort("request", "object", "Approval request."),
+ out("approved", "object", "Approved request."),
+ out("rejected", "object", "Rejected request."),
+ ], [{ id: "prompt", label: "prompt", value: "Please review" }], ["dify", "human", "approval", "review", "人工审批"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.iteration, "dify-iteration", "Iteration", "逐项处理输入列表并汇总结果", "logic", "condition", "logic", "Repeat", [
+ inPort("items", "items[]", "Items to iterate."),
+ out("item", "any", "Current item."),
+ out("results", "items[]", "Collected results."),
+ ], [{ id: "parallel", label: "parallel", value: "false" }], ["dify", "iteration", "foreach", "迭代"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.loop, "dify-loop", "Loop", "在满足继续条件时重复执行,最多运行指定次数", "logic", "condition", "logic", "Repeat", [
+ inPort("state", "object", "Loop state."),
+ out("body", "object", "Current loop state."),
+ out("done", "object", "Completed loop state."),
+ ], [{ id: "condition", label: "condition", value: "{{continue}}" }, { id: "maxIterations", label: "maxIterations", value: "10" }], ["dify", "loop", "repeat", "循环"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.templateTransform, "dify-template", "Template Transform", "用上游数据渲染文本模板", "transform", "transform", "data", "Braces", [
+ inPort("variables", "object", "Template variables."),
+ out("text", "string", "Rendered text."),
+ ], [{ id: "template", label: "template", value: "{{input}}" }], ["dify", "template", "transform", "jinja", "模板转换"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.variableAssign, "dify-assign", "Variable Assign", "设置或更新工作流变量", "state", "action", "data", "Variable", [
+ inPort("value", "any", "Value to assign."),
+ out("variables", "object", "Updated variables."),
+ ], [{ id: "variable", label: "variable", value: "" }], ["dify", "variable", "assign", "变量赋值"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.variableAggregate, "dify-aggregate", "Variable Aggregate", "把多个变量合并为数组、对象或首个有效值", "state", "transform", "data", "GitMerge", [
+ inPort("variables", "any[]", "Variables to aggregate."),
+ out("result", "any", "Aggregated variable."),
+ ], [{ id: "mode", label: "mode", value: "first_non_null" }], ["dify", "variable", "aggregate", "变量聚合"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.parameterExtract, "dify-parameter", "Parameter Extractor", "按 Schema 从文本中提取结构化参数", "ai", "transform", "data", "ListChecks", [
+ inPort("text", "string", "Text to inspect."),
+ out("parameters", "object", "Extracted parameters."),
+ ], [{ id: "model", label: "model", value: "" }, { id: "schema", label: "schema", value: "{}" }], ["dify", "parameter", "extract", "schema", "参数提取"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.documentExtract, "dify-document", "Document Extractor", "从支持的文件中提取可处理文本", "transform", "transform", "data", "FileText", [
+ inPort("files", "fileRef[]", "Documents to extract."),
+ out("text", "string", "Extracted text."),
+ ], [{ id: "mode", label: "mode", value: "auto" }], ["dify", "document", "extract", "file", "文档提取"]),
+ primitive(DIFY_NODE_CAPABILITY_IDS.httpRequest, "dify-http", "HTTP Request", "发送 HTTP 请求并把响应交给后续节点", "input", "http", "action", "Globe", [
+ inPort("request", "httpRequest", "HTTP request."),
+ out("response", "httpResponse", "HTTP response."),
+ ], [{ id: "method", label: "method", value: "GET" }, { id: "url", label: "url", value: "{{url}}" }], ["dify", "http", "request", "api", "请求"]),
primitive("primitive.core.manual-trigger", "n8n-manual", "Manual Trigger", "手动启动一次 workflow 调试运行", "core", "trigger", "logic", "Play", [
out("items", "items[]", "Manually triggered items."),
], [{ id: "mode", label: "mode", value: "test" }], ["n8n", "manual", "trigger", "execute", "手动"]),
@@ -480,6 +547,35 @@ export const WORKFLOW_PRIMITIVES: WorkflowPrimitive[] = [
], [{ id: "formats", label: "formats", value: "canvas,opml,markdown" }], ["turnmap", "export", "obsidian", "opml", "markdown", "导出"]),
]
+export const DIFY_COMMON_NODE_CAPABILITY_IDS = [
+ DIFY_NODE_CAPABILITY_IDS.start,
+ DIFY_NODE_CAPABILITY_IDS.end,
+ DIFY_NODE_CAPABILITY_IDS.answer,
+ DIFY_NODE_CAPABILITY_IDS.llm,
+ DIFY_NODE_CAPABILITY_IDS.agent,
+ DIFY_NODE_CAPABILITY_IDS.knowledgeRetrieval,
+ DIFY_NODE_CAPABILITY_IDS.questionClassifier,
+ DIFY_NODE_CAPABILITY_IDS.ifElse,
+ DIFY_NODE_CAPABILITY_IDS.switch,
+ DIFY_NODE_CAPABILITY_IDS.humanInput,
+ DIFY_NODE_CAPABILITY_IDS.iteration,
+ DIFY_NODE_CAPABILITY_IDS.loop,
+ DIFY_NODE_CAPABILITY_IDS.code,
+ DIFY_NODE_CAPABILITY_IDS.templateTransform,
+ DIFY_NODE_CAPABILITY_IDS.variableAssign,
+ DIFY_NODE_CAPABILITY_IDS.variableAggregate,
+ DIFY_NODE_CAPABILITY_IDS.parameterExtract,
+ DIFY_NODE_CAPABILITY_IDS.documentExtract,
+ DIFY_NODE_CAPABILITY_IDS.httpRequest,
+] as const
+
+export function getDifyCommonWorkflowPrimitives(): WorkflowPrimitive[] {
+ const primitivesById = new Map(WORKFLOW_PRIMITIVES.map((item) => [item.id, item]))
+ return DIFY_COMMON_NODE_CAPABILITY_IDS.map((id) => primitivesById.get(id)).filter(
+ (item): item is WorkflowPrimitive => item !== undefined,
+ )
+}
+
export function getWorkflowPrimitives(query = ""): WorkflowPrimitive[] {
const q = query.trim().toLowerCase()
if (!q) return WORKFLOW_PRIMITIVES
diff --git a/frontend/lib/workflow/opencli-business-workflows.ts b/frontend/lib/workflow/opencli-business-workflows.ts
index 291a330..56142c8 100644
--- a/frontend/lib/workflow/opencli-business-workflows.ts
+++ b/frontend/lib/workflow/opencli-business-workflows.ts
@@ -9,11 +9,39 @@ import {
} from "./node-catalog"
import { parseWorkflowProject, type WorkflowProjectNode } from "./schema"
+export const DOMESTIC_OODA_SOURCE_GROUPS = [
+ "market",
+ "filings",
+ "macro",
+ "news",
+ "social",
+] as const
+
+export const DOMESTIC_OODA_SOURCE_GAPS = [
+ {
+ site: "gelonghui",
+ status: "unavailable",
+ reason: "当前 OpenCLI 注册表没有格隆汇命令;保留为明确缺口,不生成伪节点。",
+ },
+ {
+ site: "jin10",
+ command: "kuaixun",
+ status: "degraded",
+ reason: "命令已注册,但最近一次真实请求为空;运行时按 empty 展示。",
+ },
+ {
+ site: "cninfo",
+ command: "disclosure-pdf",
+ status: "degraded",
+ reason: "命令已注册,但最近一次真实请求为空;保留官方 PDF 来源并公开健康状态。",
+ },
+] as const
+
export const ASHARE_OPENCLI_SOURCES: OpenCLISourceSlot[] = [
{
id: "market-breadth",
label: "沪深京 A 股行情全景",
- sourceGroup: "market-breadth",
+ sourceGroup: "market",
site: "eastmoney",
command: "gridlist",
args: { market: "hs-a", sort: "turnover", limit: 100 },
@@ -21,32 +49,88 @@ export const ASHARE_OPENCLI_SOURCES: OpenCLISourceSlot[] = [
{
id: "watchlist-quotes",
label: "A 股样本实时行情",
- sourceGroup: "quotes",
+ sourceGroup: "market",
site: "eastmoney",
command: "quote",
args: {},
positionalArgs: ["600519,000001,300750"],
},
+ {
+ id: "ths-hot",
+ label: "同花顺强势股与题材归因",
+ sourceGroup: "market",
+ site: "ths",
+ command: "hot",
+ args: { limit: 50 },
+ },
{
id: "fundamentals",
- label: "上市公司财务摘要",
- sourceGroup: "fundamentals",
+ label: "东方财富上市公司财务摘要",
+ sourceGroup: "filings",
site: "eastmoney",
command: "bbsj-summary",
args: { code: "600519", limit: 8 },
},
{
id: "announcements",
- label: "沪深京上市公司公告",
- sourceGroup: "announcements",
+ label: "东方财富沪深京上市公司公告",
+ sourceGroup: "filings",
site: "eastmoney",
command: "announcement",
args: { market: "SHA,SZA,BJA", limit: 100 },
},
+ {
+ id: "sse-announcements",
+ label: "上交所官方公告与 PDF",
+ sourceGroup: "filings",
+ site: "sse",
+ command: "announcements",
+ args: { limit: 30 },
+ },
+ {
+ id: "szse-home",
+ label: "深交所市场概况与最新公告",
+ sourceGroup: "filings",
+ site: "szse",
+ command: "home",
+ args: { limit: 30 },
+ },
+ {
+ id: "bse-announcements",
+ label: "北交所官方公告",
+ sourceGroup: "filings",
+ site: "bse",
+ command: "announcement",
+ args: { limit: 30 },
+ },
+ {
+ id: "cninfo-pdf",
+ label: "巨潮资讯公告 PDF",
+ sourceGroup: "filings",
+ site: "cninfo",
+ command: "disclosure-pdf",
+ args: { market: "沪深京", limit: 30 },
+ },
+ {
+ id: "macro-flash",
+ label: "金十数据宏观快讯",
+ sourceGroup: "macro",
+ site: "jin10",
+ command: "kuaixun",
+ args: { limit: 30 },
+ },
+ {
+ id: "macro-news",
+ label: "新浪财经宏观新闻",
+ sourceGroup: "macro",
+ site: "sinafinance",
+ command: "news",
+ args: { type: 2, limit: 30 },
+ },
{
id: "breaking-news",
label: "财联社实时电报",
- sourceGroup: "breaking-news",
+ sourceGroup: "news",
site: "cls",
command: "telegraph",
args: { limit: 30 },
@@ -54,9 +138,17 @@ export const ASHARE_OPENCLI_SOURCES: OpenCLISourceSlot[] = [
{
id: "finance-news",
label: "新浪财经新闻",
- sourceGroup: "finance-news",
+ sourceGroup: "news",
site: "sinafinance",
command: "news",
+ args: { type: 1, limit: 30 },
+ },
+ {
+ id: "xueqiu-hot",
+ label: "雪球人气个股热度榜",
+ sourceGroup: "social",
+ site: "xueqiu",
+ command: "hot-stocks",
args: { limit: 30 },
},
]
@@ -215,11 +307,22 @@ export function buildAshareMarketWorkflow(name: string) {
workflowId: "ashare-market-intelligence",
cadence: "5m",
sources: ASHARE_OPENCLI_SOURCES,
- sourceLabel: "A 股多源真实采集",
- sourceDescription: "行情、财务、公告与实时新闻并行采集;逐来源显示完成、空结果或失败",
+ sourceLabel: "国内全网 OODA 数据源",
+ sourceDescription: "行情、公告与 PDF、宏观、新闻、社交五类来源并行采集;逐来源显示完成、空结果或失败",
recordsLabel: "A 股金融数据集",
- maxItemsPerRun: 500,
- allowedDomains: ["eastmoney.com", "cls.cn", "sina.com.cn"],
+ maxItemsPerRun: 1_000,
+ allowedDomains: [
+ "eastmoney.com",
+ "10jqka.com.cn",
+ "xueqiu.com",
+ "cninfo.com.cn",
+ "sse.com.cn",
+ "szse.cn",
+ "bse.cn",
+ "jin10.com",
+ "cls.cn",
+ "sina.com.cn",
+ ],
})
}
diff --git a/frontend/lib/workflow/studio-templates.ts b/frontend/lib/workflow/studio-templates.ts
index b137c78..28b47b8 100644
--- a/frontend/lib/workflow/studio-templates.ts
+++ b/frontend/lib/workflow/studio-templates.ts
@@ -11,7 +11,7 @@ import {
import { parseWorkflowProject, workflowNodeSchema, type WorkflowProjectNode } from './schema'
export const STUDIO_TEMPLATES = [
- { id: 'ashare-market-intelligence', variant: 'collection-to-consumption', appType: 'workflow', title: 'A 股真实金融数据采集', description: '用 OpenCLI 并行采集沪深京行情、财务、公告与实时财经新闻,并写入可追溯 Records。', category: '真实业务测试', steps: ['A 股多源采集', '清洗与准入', '数据工作台'] },
+ { id: 'ashare-market-intelligence', variant: 'collection-to-consumption', appType: 'workflow', title: 'A 股真实金融数据采集', description: '用 OpenCLI 并行采集国内行情、公告财报、宏观快讯、财经媒体与社区热度,并写入可追溯 Records。', category: '真实业务测试', steps: ['国内全网 OODA 采集', '清洗与准入', '数据工作台'] },
{ id: 'opencli-situation-awareness', variant: 'collection-to-consumption', appType: 'workflow', title: 'OpenCLI 态势感知框架', description: '采集实时事件、新闻和视频字幕,保留证据血缘,并投影到数据工作台与逻辑证据页。', category: '真实业务测试', steps: ['多模态证据采集', '证据准入', '数据与证据工作台'] },
{ id: 'opencli-live-pipeline', variant: 'collection-to-consumption', appType: 'workflow', title: 'OpenCLI 实时采集清洗发送', description: '从 OpenCLI 动态数据源实时提取,完成标准化、去重、Records 入库并发送结果。', category: '完整链路', steps: ['OpenCLI 实时采集', '清洗与 Records', 'Webhook 发送'] },
{ id: 'financial-rss-intelligence', variant: 'collect', appType: 'workflow', title: '财经多源 RSS 情报', description: '并行采集央行政策、监管公告与研究动态,按来源 Group 清洗后写入成果与数据。', category: '采集与监控', steps: ['多源 RSS', 'Group 标准化', 'Records 入库'] },
diff --git a/frontend/lib/workflow/workflow-outline.ts b/frontend/lib/workflow/workflow-outline.ts
index 819e3f1..25450c7 100644
--- a/frontend/lib/workflow/workflow-outline.ts
+++ b/frontend/lib/workflow/workflow-outline.ts
@@ -44,12 +44,12 @@ export function buildWorkflowOutlineRows(
) => {
if (visited.has(nodeId)) return
visited.add(nodeId)
- rows.push({ nodeId, depth, branchLabel, disconnected })
const nextEdges = [...(outgoing.get(nodeId) ?? [])].sort((left, right) => {
const leftNode = nodeById.get(left.target)
const rightNode = nodeById.get(right.target)
return leftNode && rightNode ? compareNodes(leftNode, rightNode) : left.target.localeCompare(right.target)
})
+ rows.push({ nodeId, depth, branchLabel, disconnected })
for (const edge of nextEdges) {
visit(edge.target, depth + 1, edgeBranchLabel(edge), disconnected)
}
@@ -62,3 +62,91 @@ export function buildWorkflowOutlineRows(
}
return rows
}
+
+export function visibleWorkflowOutlineRows(
+ rows: WorkflowOutlineRow[],
+ collapsedNodeIds: ReadonlySet,
+): WorkflowOutlineRow[] {
+ const visible: WorkflowOutlineRow[] = []
+ let hiddenBelowDepth: number | undefined
+
+ for (const [index, row] of rows.entries()) {
+ if (hiddenBelowDepth !== undefined && row.depth > hiddenBelowDepth) continue
+ hiddenBelowDepth = undefined
+ visible.push(row)
+ if (workflowOutlineRowHasChildren(rows, index) && collapsedNodeIds.has(row.nodeId)) {
+ hiddenBelowDepth = row.depth
+ }
+ }
+
+ return visible
+}
+
+export function filterWorkflowOutlineRows(
+ rows: WorkflowOutlineRow[],
+ query: string,
+ searchTextForNode: (nodeId: string) => string,
+): WorkflowOutlineRow[] {
+ const normalizedQuery = query.trim().toLocaleLowerCase()
+ if (!normalizedQuery) return rows
+
+ const includedIndexes = new Set()
+ for (const [index, row] of rows.entries()) {
+ if (!searchTextForNode(row.nodeId).toLocaleLowerCase().includes(normalizedQuery)) continue
+ includedIndexes.add(index)
+ let expectedParentDepth = row.depth - 1
+ for (let ancestorIndex = index - 1; ancestorIndex >= 0 && expectedParentDepth >= 0; ancestorIndex -= 1) {
+ if (rows[ancestorIndex].depth !== expectedParentDepth) continue
+ includedIndexes.add(ancestorIndex)
+ expectedParentDepth -= 1
+ }
+ }
+ return rows.filter((_, index) => includedIndexes.has(index))
+}
+
+export function workflowOutlineRowHasChildren(
+ rows: WorkflowOutlineRow[],
+ rowIndex: number,
+): boolean {
+ const row = rows[rowIndex]
+ const next = rows[rowIndex + 1]
+ return Boolean(row && next && next.depth > row.depth)
+}
+
+export function workflowUpstreamNodeIds(
+ nodeId: string,
+ edges: WorkflowEdge[],
+): Set {
+ const sourcesByTarget = new Map()
+ for (const edge of edges) {
+ sourcesByTarget.set(edge.target, [...(sourcesByTarget.get(edge.target) ?? []), edge.source])
+ }
+
+ const upstream = new Set()
+ const queue = [...(sourcesByTarget.get(nodeId) ?? [])]
+ while (queue.length > 0) {
+ const source = queue.shift()
+ if (!source || upstream.has(source) || source === nodeId) continue
+ upstream.add(source)
+ queue.push(...(sourcesByTarget.get(source) ?? []))
+ }
+ return upstream
+}
+
+export function workflowDirectUpstreamNodeIds(
+ nodeId: string,
+ edges: WorkflowEdge[],
+): Set {
+ return new Set(
+ edges
+ .filter((edge) => edge.target === nodeId)
+ .map((edge) => edge.source),
+ )
+}
+
+const WORKFLOW_INPUT_REFERENCE_PATH = /^[A-Za-z_][\w.-]*$/
+
+export function workflowInputReferenceForPort(portId: string): string | undefined {
+ const path = portId.trim()
+ return WORKFLOW_INPUT_REFERENCE_PATH.test(path) ? `{{${path}}}` : undefined
+}
diff --git a/frontend/package.json b/frontend/package.json
index 50748f9..782a7f6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,7 @@
"dev": "next dev",
"build": "next build",
"check:login-themes": "node --test scripts/check-login-theme-regressions.mjs",
- "check:workflow-regressions": "node --test scripts/check-workflow-regressions.mjs",
+ "check:workflow-regressions": "node --test scripts/check-workflow-regressions.mjs scripts/check-inspector-workflow-regressions.mjs",
"check:navigation-transitions": "node --test scripts/check-navigation-transition-regressions.mjs",
"check:control-plane": "node --test scripts/check-control-plane-regressions.mjs scripts/check-dashboard-regressions.mjs scripts/check-inbox-regressions.mjs scripts/check-visualization-regressions.mjs",
"check:dify-p0": "node --test scripts/check-dify-p0-regressions.mjs",
diff --git a/frontend/scripts/check-dify-p0-regressions.mjs b/frontend/scripts/check-dify-p0-regressions.mjs
index fc0eb07..4ff4581 100644
--- a/frontend/scripts/check-dify-p0-regressions.mjs
+++ b/frontend/scripts/check-dify-p0-regressions.mjs
@@ -94,6 +94,53 @@ test('all 25 user-visible Dify node families resolve to stable OpenCLI capabilit
}
})
+test('root canvas exposes the curated Dify common-node set without hiding design-only primitives', async () => {
+ const [{ DIFY_NODE_CAPABILITY_IDS }, primitives, palette] = await Promise.all([
+ importTypeScript('lib/workflow/dify-capability-map.ts'),
+ importTypeScript('lib/workflow/node-primitives.ts'),
+ readFrontendSource('components/flow/command-palette.tsx'),
+ ])
+ const expectedIds = [
+ DIFY_NODE_CAPABILITY_IDS.start,
+ DIFY_NODE_CAPABILITY_IDS.end,
+ DIFY_NODE_CAPABILITY_IDS.answer,
+ DIFY_NODE_CAPABILITY_IDS.llm,
+ DIFY_NODE_CAPABILITY_IDS.agent,
+ DIFY_NODE_CAPABILITY_IDS.knowledgeRetrieval,
+ DIFY_NODE_CAPABILITY_IDS.questionClassifier,
+ DIFY_NODE_CAPABILITY_IDS.ifElse,
+ DIFY_NODE_CAPABILITY_IDS.switch,
+ DIFY_NODE_CAPABILITY_IDS.humanInput,
+ DIFY_NODE_CAPABILITY_IDS.iteration,
+ DIFY_NODE_CAPABILITY_IDS.loop,
+ DIFY_NODE_CAPABILITY_IDS.code,
+ DIFY_NODE_CAPABILITY_IDS.templateTransform,
+ DIFY_NODE_CAPABILITY_IDS.variableAssign,
+ DIFY_NODE_CAPABILITY_IDS.variableAggregate,
+ DIFY_NODE_CAPABILITY_IDS.parameterExtract,
+ DIFY_NODE_CAPABILITY_IDS.documentExtract,
+ DIFY_NODE_CAPABILITY_IDS.httpRequest,
+ ]
+
+ assert.deepEqual(primitives.DIFY_COMMON_NODE_CAPABILITY_IDS, expectedIds)
+ assert.equal(new Set(expectedIds).size, expectedIds.length)
+ for (const id of expectedIds) {
+ assert.equal(
+ primitives.WORKFLOW_PRIMITIVES.filter((item) => item.id === id).length,
+ 1,
+ `${id} must reuse one exact primitive instead of adding a duplicate`,
+ )
+ }
+ assert.deepEqual(
+ primitives.getDifyCommonWorkflowPrimitives().map((item) => item.id),
+ expectedIds,
+ )
+ assert.ok(primitives.getWorkflowPrimitives().length > expectedIds.length, 'nested networks retain the full primitive library')
+ assert.match(palette, /inNodeNetwork\s*\?\s*getWorkflowPrimitives\(\)\s*:\s*getDifyCommonWorkflowPrimitives\(\)/)
+ assert.match(palette, /catalogOperatorIds\.has\(item\.id\)/)
+ assert.doesNotMatch(palette, /getDifyCommonWorkflowPrimitives\(\)[\s\S]{0,200}catalogItemUnavailable/)
+})
+
test('Dify preview preserves sanitized source config and reports ambiguous or missing mappings', async () => {
const { translateDifyWorkflowToWorkflowProject } = await importTypeScript('lib/workflow/dify-translator.ts')
const translated = translateDifyWorkflowToWorkflowProject({
@@ -251,7 +298,9 @@ test('projected plugin nodes show provenance and stay locked in the palette', as
assert.match(catalog, /backend\.services\.plugin_registry_service/)
assert.match(catalog, /workflowCatalogItemLocked/)
assert.match(catalog, /workflowCatalogPluginProvenance/)
- assert.match(palette, /disabled=\{locked\}/)
+ assert.match(palette, /function catalogItemUnavailable/)
+ assert.match(palette, /return workflowCatalogItemLocked\(item\)/)
+ assert.match(palette, /disabled=\{catalogItemUnavailable\(item\)\}/)
assert.ok(
/workflowCatalogPluginProvenance\(item\) !== null/.test(palette) ||
!/filter\(\(item\) => item\.category === "package"\)/.test(palette),
diff --git a/frontend/scripts/check-inspector-workflow-regressions.mjs b/frontend/scripts/check-inspector-workflow-regressions.mjs
new file mode 100644
index 0000000..bf3e5ec
--- /dev/null
+++ b/frontend/scripts/check-inspector-workflow-regressions.mjs
@@ -0,0 +1,150 @@
+import assert from 'node:assert/strict'
+import { existsSync, readFileSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { registerHooks, stripTypeScriptTypes } from 'node:module'
+import { test } from 'node:test'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import path from 'node:path'
+
+const frontendRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+
+registerHooks({
+ resolve(specifier, context, nextResolve) {
+ const candidates = []
+ if (specifier.startsWith('@/')) {
+ candidates.push(path.join(frontendRoot, specifier.slice(2)))
+ } else if (specifier.startsWith('.') && context.parentURL?.startsWith('file:')) {
+ candidates.push(path.resolve(path.dirname(fileURLToPath(context.parentURL)), specifier))
+ }
+ for (const candidate of candidates) {
+ for (const resolvedPath of [candidate, `${candidate}.ts`, `${candidate}.tsx`]) {
+ if (existsSync(resolvedPath)) {
+ return { url: pathToFileURL(resolvedPath).href, shortCircuit: true }
+ }
+ }
+ }
+ return nextResolve(specifier, context)
+ },
+ load(url, context, nextLoad) {
+ if (url.endsWith('.ts')) {
+ const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), {
+ mode: 'strip',
+ sourceUrl: url,
+ })
+ return { format: 'module', source, shortCircuit: true }
+ }
+ return nextLoad(url, context)
+ },
+})
+
+const readSource = (relativePath) => readFile(path.join(frontendRoot, relativePath), 'utf8')
+const importTypeScript = (relativePath) => import(pathToFileURL(path.join(frontendRoot, relativePath)).href)
+
+const node = (id, x, y, label = id) => ({
+ id,
+ position: { x, y },
+ data: { label, nodeType: 'default', category: 'transform', icon: 'Box' },
+})
+
+test('workflow outline preserves hierarchy while filtering and collapsing', async () => {
+ const {
+ buildWorkflowOutlineRows,
+ filterWorkflowOutlineRows,
+ visibleWorkflowOutlineRows,
+ workflowOutlineRowHasChildren,
+ } = await importTypeScript('lib/workflow/workflow-outline.ts')
+
+ const nodes = [
+ node('root', 0, 0, 'Start'),
+ node('branch', 0, 100, 'Classify'),
+ node('leaf', 0, 200, 'Publish'),
+ node('other', 200, 0, 'Detached'),
+ ]
+ const edges = [
+ { id: 'root-branch', source: 'root', target: 'branch' },
+ { id: 'branch-leaf', source: 'branch', target: 'leaf', sourceHandle: 'approved' },
+ ]
+ const rows = buildWorkflowOutlineRows(nodes, edges)
+
+ assert.deepEqual(
+ rows.map(({ nodeId, depth, disconnected }) => ({
+ nodeId,
+ depth,
+ disconnected,
+ })),
+ [
+ { nodeId: 'root', depth: 0, disconnected: false },
+ { nodeId: 'branch', depth: 1, disconnected: false },
+ { nodeId: 'leaf', depth: 2, disconnected: false },
+ { nodeId: 'other', depth: 0, disconnected: true },
+ ],
+ )
+ assert.equal(workflowOutlineRowHasChildren(rows, 0), true)
+ assert.equal(workflowOutlineRowHasChildren(rows, 1), true)
+ assert.equal(workflowOutlineRowHasChildren(rows, 2), false)
+
+ assert.deepEqual(
+ visibleWorkflowOutlineRows(rows, new Set(['branch'])).map((row) => row.nodeId),
+ ['root', 'branch', 'other'],
+ )
+ assert.deepEqual(
+ filterWorkflowOutlineRows(rows, 'publish', (nodeId) => nodes.find((item) => item.id === nodeId)?.data.label ?? '')
+ .map((row) => row.nodeId),
+ ['root', 'branch', 'leaf'],
+ 'search should retain matching rows and their ancestors',
+ )
+})
+
+test('workflow upstream discovery uses only real graph ancestors', async () => {
+ const {
+ workflowDirectUpstreamNodeIds,
+ workflowInputReferenceForPort,
+ workflowUpstreamNodeIds,
+ } = await importTypeScript('lib/workflow/workflow-outline.ts')
+ const edges = [
+ { id: 'a-b', source: 'a', target: 'b' },
+ { id: 'b-c', source: 'b', target: 'c' },
+ { id: 'x-y', source: 'x', target: 'y' },
+ ]
+
+ assert.deepEqual([...workflowUpstreamNodeIds('c', edges)], ['b', 'a'])
+ assert.deepEqual([...workflowUpstreamNodeIds('y', edges)], ['x'])
+ assert.deepEqual([...workflowUpstreamNodeIds('a', edges)], [])
+ assert.deepEqual([...workflowDirectUpstreamNodeIds('c', edges)], ['b'])
+ assert.equal(workflowInputReferenceForPort('records'), '{{records}}')
+ assert.equal(workflowInputReferenceForPort('result.items'), '{{result.items}}')
+ assert.equal(workflowInputReferenceForPort('not valid'), undefined)
+})
+
+test('inspector keeps navigation gestures and exposes contract-backed controls', async () => {
+ const [inspector, shell] = await Promise.all([
+ readSource('components/flow/inspector.tsx'),
+ readSource('components/flow/inspector-shell.tsx'),
+ ])
+
+ assert.match(inspector, /data-testid="workflow-outline-search"/)
+ assert.match(inspector, /toggleOutlineNode/)
+ assert.match(inspector, /onClick=\{\(\) => onSelectNode\(node\.id\)\}/)
+ assert.match(inspector, /onDoubleClick=\{\(\) => onOpenNode\(node\.id\)\}/)
+ assert.match(inspector, /event\.key !== "Enter"/)
+ assert.match(inspector, /workflowStatusDotClass/)
+ assert.match(shell, /export const workflowStatusDotClass/)
+
+ assert.match(inspector, /upstreamVariableOptions/)
+ assert.match(inspector, /candidateContract\.ports/)
+ assert.match(inspector, /port\.direction === "output"/)
+ assert.match(inspector, /data-testid="parameter-variable-selector"/)
+ assert.match(inspector, /workflowInputReferenceForPort/)
+ assert.doesNotMatch(inspector, /function parameterReferenceValue\(nodeId/)
+ assert.match(inspector, /onValueChange=\{\(value\) => value && updateParameterField\(field, value\)\}/)
+})
+
+test('generic JSON fields render only inside the existing Advanced disclosure', async () => {
+ const inspector = await readSource('components/flow/inspector.tsx')
+
+ assert.match(inspector, /regularParameterFields/)
+ assert.match(inspector, /advancedParameterFields/)
+ assert.match(inspector, /field\.type !== "json"/)
+ assert.match(inspector, /data-testid="advanced-parameter-fields"/)
+ assert.match(inspector, /advancedParameterFields\.map\(\(field\) => renderParameterField\(field\)\)/)
+})
diff --git a/frontend/scripts/check-opencli-business-workflows.mjs b/frontend/scripts/check-opencli-business-workflows.mjs
index cef86d7..e83ceb0 100644
--- a/frontend/scripts/check-opencli-business-workflows.mjs
+++ b/frontend/scripts/check-opencli-business-workflows.mjs
@@ -13,15 +13,29 @@ test('registers two real OpenCLI business workflow templates', () => {
assert.match(templateSource, /buildOpenCLISituationAwarenessWorkflow\(name\)/)
})
-test('A-share workflow covers market, financials, announcements and live news without fixtures', () => {
- for (const command of ['gridlist', 'quote', 'bbsj-summary', 'announcement', 'telegraph', 'news']) {
+test('domestic OODA workflow covers five source groups without fixtures', () => {
+ for (const group of ['market', 'filings', 'macro', 'news', 'social']) {
+ assert.match(businessSource, new RegExp(`sourceGroup: "${group}"`))
+ }
+ for (const command of ['gridlist', 'quote', 'hot', 'bbsj-summary', 'announcement', 'announcements', 'home', 'disclosure-pdf', 'kuaixun', 'telegraph', 'news', 'hot-stocks']) {
assert.match(businessSource, new RegExp(`command: "${command}"`))
}
+ for (const site of ['eastmoney', 'ths', 'sse', 'szse', 'bse', 'cninfo', 'jin10', 'cls', 'sinafinance', 'xueqiu']) {
+ assert.match(businessSource, new RegExp(`site: "${site}"`))
+ }
assert.doesNotMatch(businessSource, /runtime:\s*"fixture"|mode:\s*"fixture"/)
assert.match(businessSource, /deterministicSimulation: false/)
assert.match(businessSource, /exposeRawSourceItems: true/)
})
+test('known domestic source gaps stay explicit instead of becoming fake runnable nodes', () => {
+ assert.match(businessSource, /site: "gelonghui"[\s\S]*status: "unavailable"/)
+ assert.match(businessSource, /site: "jin10"[\s\S]*status: "degraded"/)
+ assert.match(businessSource, /site: "cninfo"[\s\S]*status: "degraded"/)
+ assert.match(businessSource, /不生成伪节点/)
+ assert.match(businessSource, /按 empty 展示/)
+})
+
test('situation workflow collects cross-platform discovery and stable transcript evidence', () => {
assert.match(businessSource, /site: "bilibili"[\s\S]*command: "subtitle"/)
assert.match(businessSource, /site: "youtube"[\s\S]*command: "search"/)
diff --git a/frontend/scripts/check-workflow-regressions.mjs b/frontend/scripts/check-workflow-regressions.mjs
index a69f53e..fc24858 100644
--- a/frontend/scripts/check-workflow-regressions.mjs
+++ b/frontend/scripts/check-workflow-regressions.mjs
@@ -727,7 +727,7 @@ test('workflow separates lightweight canvas actions from the guided node picker'
assert.match(palette, /href="\/plugins"/)
assert.match(palette, /workflowCatalogItemIsOpenCLIAdapterPreset/)
assert.match(palette, /catalogItemUnavailable/)
- assert.match(palette, /inNodeNetwork \? getWorkflowPrimitives\(\) : \[\]/)
+ assert.match(palette, /inNodeNetwork \? getWorkflowPrimitives\(\) : getDifyCommonWorkflowPrimitives\(\)/)
assert.match(palette, /item\.category === ["']annotation["'] \|\| item\.category === ["']shape["']/)
assert.match(palette, /groupPrimitivesForNodeMenu/)
assert.match(effects, /event\.key === ["']Escape["']/)
@@ -903,12 +903,13 @@ test('the inspector host constrains long node configuration so the dock owns ver
})
test('Houdini-style wiring uses native lifecycle hooks without validation toast side effects', async () => {
- const [interactions, surface, editor, palette, commandStrip] = await Promise.all([
+ const [interactions, surface, editor, palette, commandStrip, workflowNode] = await Promise.all([
readSource('components/flow/workflow-canvas-interactions.ts'),
readSource('components/flow/workflow-canvas-surface.tsx'),
readSource('components/flow/workflow-editor.tsx'),
readSource('components/flow/command-palette.tsx'),
readSource('components/flow/command-strip.tsx'),
+ readSource('components/flow/nodes/workflow-node.tsx'),
])
const guards = sourceSection(interactions, 'export function useConnectionGuards', 'export function useCanvasViewportCompaction')
@@ -929,6 +930,11 @@ test('Houdini-style wiring uses native lifecycle hooks without validation toast
assert.match(palette, /originType === ["']unknown["'] \|\| port\.type\.trim\(\)\.toLowerCase\(\) !== ["']unknown["']/)
assert.match(palette, /const auxiliaryOperators = \(compatiblePort \? \[\] : NODE_PALETTE\)/)
assert.match(commandStrip, /autoLayout\(["']TB["'], ["']elk["'], true\)/)
+ assert.match(workflowNode, /tabIndex: 0/)
+ assert.match(workflowNode, /onContextMenu: \(event: MouseEvent\) =>/)
+ assert.match(workflowNode, /event\.key === ["']ContextMenu["'] \|\| \(event\.shiftKey && event\.key === ["']F10["']\)/)
+ assert.match(workflowNode, /if \(!event\.altKey\) return/)
+ assert.match(workflowNode, /new CustomEvent\(["']opencli:workflow-port-menu["']/)
})
test('the right inspector uses graph contracts instead of manual keys and field paths', async () => {
From 9e7458e6930dc3de25a9e3bd3f9c1e2a8c8707de Mon Sep 17 00:00:00 2001
From: 2233admin <2233admin@users.noreply.github.com>
Date: Tue, 28 Jul 2026 02:22:20 +0800
Subject: [PATCH 2/2] fix(workflow): preserve partial multi-source runs
---
backend/workflow/hda_templates.py | 6 +-
backend/workflow/opencli_hda_tracer.py | 145 ++++++++++++++++--
.../integration/test_workflow_compile_api.py | 65 +++++++-
.../test_workflow_opencli_hda_trace_api.py | 105 +++++++++++++
4 files changed, 308 insertions(+), 13 deletions(-)
diff --git a/backend/workflow/hda_templates.py b/backend/workflow/hda_templates.py
index 09dcd13..7bc8e8b 100644
--- a/backend/workflow/hda_templates.py
+++ b/backend/workflow/hda_templates.py
@@ -8,7 +8,6 @@
from pathlib import Path
from typing import Any
-from backend.workflow.tool_capabilities import resolve_workflow_tool_capability
from backend.schemas.workflow import (
WorkflowAdapterBinding,
WorkflowPackageInternals,
@@ -17,6 +16,7 @@
WorkflowProjectNode,
WorkflowTopicCollapse,
)
+from backend.workflow.tool_capabilities import resolve_workflow_tool_capability
OPENCLI_MULTI_SOURCE_TEMPLATE = "opencli-multi-source"
OPENCLI_SOURCE_POOL_CATALOG_ID = "intelligence.source.pool"
@@ -66,6 +66,9 @@ def _materialize_node(node: WorkflowProjectNode) -> WorkflowProjectNode:
sources = _source_slots(node.params.get("sources"))
if sources:
expose_raw_source_items = node.params.get("exposeRawSourceItems") is True
+ failure_mode = _read_string(
+ _read_dict(node.params.get("execution")).get("failureMode")
+ )
internals = _opencli_multi_source_internals(
sources,
expose_raw_source_items=expose_raw_source_items,
@@ -78,6 +81,7 @@ def _materialize_node(node: WorkflowProjectNode) -> WorkflowProjectNode:
"lockedInternals": node.params.get("lockedInternals", True),
"execution": {
"fanout": "parallel",
+ **({"failureMode": failure_mode} if failure_mode else {}),
},
}
ui = {**(node.ui or {}), "catalogId": OPENCLI_HDA_CATALOG_ID}
diff --git a/backend/workflow/opencli_hda_tracer.py b/backend/workflow/opencli_hda_tracer.py
index 4fb2ad9..e2d951f 100644
--- a/backend/workflow/opencli_hda_tracer.py
+++ b/backend/workflow/opencli_hda_tracer.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
-import json
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
@@ -72,17 +71,17 @@
JoyAIVLExecutionError,
execute_joyai_vl_interaction,
)
-from backend.workflow.native_node_runtime import (
- NATIVE_BINDING_IDS,
- NativeNodeValidationError,
- execute_native_node,
-)
from backend.workflow.last30days_provider import Last30DaysProviderError
from backend.workflow.native_intelligence_executor import (
NATIVE_INTELLIGENCE_ACTION_BY_TOOL_ID,
NATIVE_INTELLIGENCE_EXECUTOR,
execute_native_intelligence_action,
)
+from backend.workflow.native_node_runtime import (
+ NATIVE_BINDING_IDS,
+ NativeNodeValidationError,
+ execute_native_node,
+)
from backend.workflow.realtime_market_executor import (
OKX_MARKET_TICKER_SNAPSHOT_EXECUTOR,
RealtimeMarketExecutionError,
@@ -98,8 +97,8 @@
execute_workflow_rss_source,
)
from backend.workflow.runtime_registry import (
- DATA_OPERATOR_CATALOG_BINDINGS,
COLLECTION_OUTPUT_BINDING_ID,
+ DATA_OPERATOR_CATALOG_BINDINGS,
DEDUPE_BINDING_ID,
DIFY_GRAPHON_BINDING_ID,
EXTERNAL_TOOL_BINDING_ID,
@@ -1253,11 +1252,67 @@ async def start_workflow_run(
internal_reasons = blocked_by_package.get(package_node.id, [])
if internal_reasons:
+ descendant_ids = {
+ node.id
+ for node in runtime_nodes
+ if package_node.id in _package_ancestor_ids(node)
+ }
+ source_node_ids = {
+ node.id
+ for node in runtime_nodes
+ if node.id in descendant_ids
+ and (
+ _read_string(node.params.get("sourceGroup"))
+ or _read_string(node.params.get("source_group"))
+ )
+ }
+ terminal_events_by_node: dict[str, WorkflowNodeRunEvent] = {}
+ for event in emitter.events:
+ if (
+ event.nodeId in descendant_ids
+ and event.eventType in {"completed", "failed", "blocked"}
+ ):
+ terminal_events_by_node[event.nodeId] = event
+ source_terminal_events = [
+ event
+ for event in terminal_events_by_node.values()
+ if event.nodeId in source_node_ids
+ ]
+ has_successful_source = any(
+ event.eventType == "completed" for event in source_terminal_events
+ )
+ has_source_failure = any(
+ event.eventType in {"failed", "blocked"}
+ for event in source_terminal_events
+ )
+ has_non_source_failure = any(
+ event.eventType in {"failed", "blocked"}
+ and event.nodeId not in source_node_ids
+ for event in terminal_events_by_node.values()
+ )
+ tolerates_internal_reasons = (
+ _collects_per_source_failures(package_node)
+ and has_successful_source
+ and has_source_failure
+ and not has_non_source_failure
+ )
emitter.emit(
package_node,
"partial",
- message="Package produced partial source results before an internal block",
+ message=(
+ "Package collected available source results with per-source failures"
+ if tolerates_internal_reasons
+ else "Package produced partial source results before an internal block"
+ ),
)
+ if tolerates_internal_reasons:
+ emitter.emit(
+ package_node,
+ "completed",
+ message="Package completed with per-source failures preserved in the trace",
+ details={"sourceFailureCount": len(internal_reasons)},
+ )
+ continue
emitter.emit(
package_node,
"blocked",
@@ -2065,6 +2120,38 @@ def _run_status(
if not valid:
return "failed"
statuses = {state.status for state in node_states}
+ collect_per_source_package_ids_by_state = (
+ {
+ state.nodeId: _collect_per_source_package_ids(state, runtime_nodes)
+ for state in node_states
+ }
+ if runtime_nodes
+ else {}
+ )
+ successful_source_package_ids = {
+ package_id
+ for state in node_states
+ if state.status == "completed"
+ for package_id in collect_per_source_package_ids_by_state.get(state.nodeId, set())
+ }
+ tolerated_source_failure_ids = (
+ {
+ state.nodeId
+ for state in node_states
+ if state.status in {"failed", "blocked"}
+ and (
+ collect_per_source_package_ids_by_state.get(state.nodeId, set())
+ & successful_source_package_ids
+ )
+ }
+ if runtime_nodes
+ else set()
+ )
+ effective_statuses = {
+ state.status
+ for state in node_states
+ if state.nodeId not in tolerated_source_failure_ids
+ }
if runtime_nodes:
terminal_ids = {node.id for node in runtime_nodes if _is_builder_output(node)}
terminal_statuses = {state.status for state in node_states if state.nodeId in terminal_ids}
@@ -2076,17 +2163,53 @@ def _run_status(
and terminal_statuses.intersection({"failed", "blocked"})
):
return "partial_success"
- if "failed" in statuses:
+ if "failed" in effective_statuses:
return "failed"
- if "blocked" in statuses:
+ if "blocked" in effective_statuses:
return "blocked"
- if "running" in statuses or "partial" in statuses:
+ if "running" in effective_statuses or "partial" in effective_statuses:
return "partial"
+ if tolerated_source_failure_ids and effective_statuses and effective_statuses <= {"completed"}:
+ return "partial_success"
if statuses and statuses <= {"completed"}:
return "completed"
return "queued"
+def _collects_per_source_failures(node: CompiledWorkflowNode) -> bool:
+ execution = _read_dict(node.params.get("execution"))
+ return _read_string(execution.get("failureMode")) == "collect-per-source"
+
+
+def _collect_per_source_package_ids(
+ state: WorkflowRunNodeState,
+ runtime_nodes: list[CompiledWorkflowNode],
+) -> set[str]:
+ source_groups = getattr(state, "sourceGroups", [])
+ node_path = getattr(state, "nodePath", [])
+ runtime_node = next(
+ (node for node in runtime_nodes if node.id == state.nodeId),
+ None,
+ )
+ if (
+ not source_groups
+ or runtime_node is None
+ or not (
+ _read_string(runtime_node.params.get("sourceGroup"))
+ or _read_string(runtime_node.params.get("source_group"))
+ )
+ ):
+ return set()
+ tolerant_package_ids = {
+ node.id for node in runtime_nodes if _collects_per_source_failures(node)
+ }
+ return {
+ INTERNAL_ID_SEPARATOR.join(node_path[:depth])
+ for depth in range(1, len(node_path))
+ if INTERNAL_ID_SEPARATOR.join(node_path[:depth]) in tolerant_package_ids
+ }
+
+
def _is_builder_output(node: CompiledWorkflowNode) -> bool:
builder = _read_dict(node.params.get("builder"))
return _read_string(builder.get("nodeType")) in {
diff --git a/tests/integration/test_workflow_compile_api.py b/tests/integration/test_workflow_compile_api.py
index 3b4baf3..5d063c5 100644
--- a/tests/integration/test_workflow_compile_api.py
+++ b/tests/integration/test_workflow_compile_api.py
@@ -134,6 +134,65 @@ def test_run_status_marks_only_mixed_terminal_outcomes_partial_success():
assert _run_status(in_flight_output_states, True, in_flight_output_nodes) == "failed"
+def test_run_status_preserves_collect_per_source_failures_as_partial_success():
+ from backend.workflow.opencli_hda_tracer import _run_status
+
+ runtime_nodes = [
+ SimpleNamespace(
+ id="source-package",
+ params={"execution": {"failureMode": "collect-per-source"}},
+ runtime={},
+ ),
+ SimpleNamespace(
+ id="source-package::healthy",
+ params={"sourceGroup": "market"},
+ runtime={"node_path": ["source-package", "healthy"]},
+ ),
+ SimpleNamespace(
+ id="source-package::failed",
+ params={"sourceGroup": "filings"},
+ runtime={"node_path": ["source-package", "failed"]},
+ ),
+ SimpleNamespace(id="records-output", params={}, runtime={}),
+ ]
+ states = [
+ SimpleNamespace(
+ nodeId="source-package",
+ status="completed",
+ nodePath=["source-package"],
+ sourceGroups=[],
+ ),
+ SimpleNamespace(
+ nodeId="source-package::healthy",
+ status="completed",
+ nodePath=["source-package", "healthy"],
+ sourceGroups=["market"],
+ ),
+ SimpleNamespace(
+ nodeId="source-package::failed",
+ status="failed",
+ nodePath=["source-package", "failed"],
+ sourceGroups=["filings"],
+ ),
+ SimpleNamespace(
+ nodeId="records-output",
+ status="completed",
+ nodePath=["records-output"],
+ sourceGroups=[],
+ ),
+ ]
+
+ assert _run_status(states, True, runtime_nodes) == "partial_success"
+ all_failed_states = [
+ SimpleNamespace(**vars(state))
+ for state in states
+ ]
+ all_failed_states[1].status = "failed"
+ assert _run_status(all_failed_states, True, runtime_nodes) == "failed"
+ runtime_nodes[0].params["execution"]["failureMode"] = "fail-fast"
+ assert _run_status(states, True, runtime_nodes) == "failed"
+
+
def test_runtime_trigger_selection_isolates_each_hybrid_entry_run():
from backend.schemas.workflow import CompiledWorkflowNode
from backend.workflow.opencli_hda_tracer import _select_runtime_nodes_for_trigger
@@ -1729,6 +1788,7 @@ async def test_compile_materializes_opencli_hda_sources_from_ai_params_in_parall
"lockedInternals": True,
"execution": {
"fanout": "serial",
+ "failureMode": "collect-per-source",
},
"sources": [
{
@@ -1780,7 +1840,10 @@ async def test_compile_materializes_opencli_hda_sources_from_ai_params_in_parall
normalize = runtime["nodes"][4]
collection_output = runtime["nodes"][5]
assert package_node["params"]["execution"]["fanout"] == "parallel"
- assert package_node["params"]["execution"] == {"fanout": "parallel"}
+ assert package_node["params"]["execution"] == {
+ "fanout": "parallel",
+ "failureMode": "collect-per-source",
+ }
assert source_pool["depends_on"] == []
assert source_pool["runtime"]["binding"]["binding_id"] == (
"workflow.source-pool.parallel-fanout"
diff --git a/tests/integration/test_workflow_opencli_hda_trace_api.py b/tests/integration/test_workflow_opencli_hda_trace_api.py
index e7944e1..75c8396 100644
--- a/tests/integration/test_workflow_opencli_hda_trace_api.py
+++ b/tests/integration/test_workflow_opencli_hda_trace_api.py
@@ -967,6 +967,111 @@ async def test_opencli_hda_trace_accepts_ai_source_slots_without_static_internal
assert data["dispatches"][1]["site"] == "xiaohongshu"
+@pytest.mark.asyncio
+async def test_opencli_hda_collects_per_source_failures_without_blocking_package(
+ client,
+ monkeypatch,
+):
+ project = _multi_source_opencli_hda_project()
+ project["nodes"][0]["params"] = {
+ "execution": {"failureMode": "collect-per-source"}
+ }
+
+ async def fake_dispatch(dispatch, fleet_match, *, node):
+ if dispatch.sourceGroup == "social":
+ return [], {
+ "attempted": True,
+ "success": False,
+ "error": "source unavailable",
+ }
+ return [{"title": "market item"}], {
+ "attempted": True,
+ "success": True,
+ "itemCount": 1,
+ }
+
+ monkeypatch.setattr(
+ "backend.workflow.opencli_hda_tracer._dispatch_opencli_source_to_fleet",
+ fake_dispatch,
+ )
+
+ response = await client.post(
+ "/api/v1/workflows/runs",
+ json={
+ "project": project,
+ "packageNodeId": "multi-source-opencli",
+ "runId": "run-collect-per-source",
+ "traceId": "trace-collect-per-source",
+ },
+ )
+
+ assert response.status_code == 202
+ data = response.json()["data"]
+ assert data["status"] == "partial_success"
+ states = {state["nodeId"]: state for state in data["nodeStates"]}
+ assert states["multi-source-opencli"]["status"] == "completed"
+ assert states["multi-source-opencli::source-xiaohongshu"]["status"] == "failed"
+ events = (
+ await client.get("/api/v1/workflows/runs/run-collect-per-source/events")
+ ).json()["data"]
+ assert not any(
+ event["nodeId"] == "multi-source-opencli"
+ and event["eventType"] == "blocked"
+ for event in events
+ )
+
+
+@pytest.mark.asyncio
+async def test_opencli_hda_collect_per_source_blocks_package_when_all_sources_fail(
+ client,
+ monkeypatch,
+):
+ project = _multi_source_opencli_hda_project()
+ project["nodes"][0]["params"] = {
+ "execution": {"failureMode": "collect-per-source"}
+ }
+
+ async def fake_dispatch(dispatch, fleet_match, *, node):
+ return [], {
+ "attempted": True,
+ "success": False,
+ "error": f"{dispatch.sourceGroup} unavailable",
+ }
+
+ monkeypatch.setattr(
+ "backend.workflow.opencli_hda_tracer._dispatch_opencli_source_to_fleet",
+ fake_dispatch,
+ )
+
+ response = await client.post(
+ "/api/v1/workflows/runs",
+ json={
+ "project": project,
+ "packageNodeId": "multi-source-opencli",
+ "runId": "run-collect-per-source-all-failed",
+ "traceId": "trace-collect-per-source-all-failed",
+ },
+ )
+
+ assert response.status_code == 202
+ data = response.json()["data"]
+ assert data["status"] == "failed"
+ states = {state["nodeId"]: state for state in data["nodeStates"]}
+ assert states["multi-source-opencli"]["status"] == "blocked"
+ assert states["multi-source-opencli::source-bilibili"]["status"] == "failed"
+ assert states["multi-source-opencli::source-xiaohongshu"]["status"] == "failed"
+ events = (
+ await client.get(
+ "/api/v1/workflows/runs/run-collect-per-source-all-failed/events"
+ )
+ ).json()["data"]
+ assert not any(
+ event["nodeId"] == "multi-source-opencli"
+ and event["eventType"] == "completed"
+ for event in events
+ )
+
+
@pytest.mark.asyncio
async def test_opencli_hda_trace_reports_package_without_opencli_source_bindings(client):
project = _multi_source_opencli_hda_project()