Skip to content
Merged
114 changes: 114 additions & 0 deletions backend/workflow/capability_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def build_workflow_capabilities(
*_catalog_capabilities(
dify_runtime_ready=_dify_runtime_ready(plugin_installations or [])
),
*_tool_catalog_capabilities(),
*_plugin_catalog_capabilities(plugin_installations or []),
],
primitives=_primitive_capabilities(),
Expand Down Expand Up @@ -1365,6 +1366,119 @@ def _resource_capabilities() -> list[WorkflowRuntimeCapability]:
return rows


def _tool_catalog_capabilities() -> list[WorkflowRuntimeCapability]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

No de-duplication of catalog_id across projected tools.

_tool_catalog_capabilities() emits one row per matching tool without checking whether catalog_id collides with another tool's nodeCatalog.id (or with a static WORKFLOW_CATALOG_IDS entry). Since callers commonly build a {item.id: item} map, a collision means one capability silently disappears from the catalog rather than surfacing a config error.

Consider tracking seen ids and raising/logging on collision (or filtering) so a copy-paste mistake in a future catalog={...} block doesn't silently hide a tool node.

Also applies to: 1457-1457, 1479-1479

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/workflow/capability_projection.py` at line 1369, Update
_tool_catalog_capabilities and its related catalog capability projections to
track emitted catalog_id values across all projected tools and static
WORKFLOW_CATALOG_IDS entries; detect collisions and surface them through the
module’s established error/logging mechanism instead of allowing duplicate IDs
to overwrite entries in callers’ ID maps.

rows: list[WorkflowRuntimeCapability] = []
for tool in list_workflow_tool_capabilities().tools:
canvas = tool.manifest.get("canvas")
node_catalog = tool.manifest.get("nodeCatalog")
if (
not isinstance(canvas, dict)
or canvas.get("node") is not True
or not isinstance(node_catalog, dict)
or node_catalog.get("authority") != "backend"
):
continue

catalog_id = node_catalog.get("id")
kind = node_catalog.get("kind")
capability = node_catalog.get("capability")
if (
not isinstance(catalog_id, str)
or kind
not in {
"schedule",
"source",
"agent",
"router",
"notify",
"inbox",
"action",
"flow",
"control",
"sink",
}
or capability
not in {
"trigger",
"fetch",
"normalize",
"dedupe",
"summarize",
"score",
"tag",
"route",
"send",
"store",
"merge",
"accept",
}
):
continue

presentation = tool.manifest.get("presentation")
presentation = dict(presentation) if isinstance(presentation, dict) else {}
parameters = presentation.get("parameters")
parameters = list(parameters) if isinstance(parameters, list) else []
tool_capability = {
"id": tool.id,
"versionPin": tool.versionPin.model_dump() if tool.versionPin else None,
"inputPorts": [port.model_dump() for port in tool.inputPorts],
"outputPorts": [port.model_dump() for port in tool.outputPorts],
"executor": tool.executor.model_dump(),
}
manifest = {
**tool.manifest,
"toolCapability": tool_capability,
"presentation": {
**presentation,
"parameters": [
{
"name": "toolCapability",
"label": "系统工具绑定 / Tool binding",
"type": "object",
"required": True,
"default": {
"id": tool.id,
"versionPin": tool_capability["versionPin"],
"executor": tool_capability["executor"],
},
},
{
"name": "toolParams",
"label": "运行参数 / Runtime parameters",
"type": "object",
"required": False,
"default": dict(tool.executor.params),
},
*parameters,
],
},
}
rows.append(
_capability(
id=catalog_id,
label=tool.label,
surface="catalog",
status=tool.status,
backend_available=tool.status == "runnable",
kind=kind,
capability=capability,
provider=tool.provider,
runtime_binding=_read_manifest_runtime_binding(tool.manifest),
reason=tool.description,
missing=(
[]
if tool.status == "runnable"
else ["tool_capability_unavailable"]
),
tags=["catalog", "tool-capability", *tool.tags],
source="backend.workflow.tool_capabilities",
manifest=manifest,
)
)
return rows


def _resource(id: str, label: str) -> WorkflowRuntimeCapability:
return _capability(
id=id,
Expand Down
6 changes: 3 additions & 3 deletions backend/workflow/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,15 +388,15 @@ def _validate_project(project: WorkflowProject) -> list[WorkflowCompileError]:
def _validate_capability_version_pin(
node: WorkflowProjectNode,
) -> list[WorkflowCompileError]:
if _read_string((node.ui or {}).get("catalogId")) != "external.tool.capability":
return []

tool_capability = node.params.get("toolCapability")
if not isinstance(tool_capability, dict):
return []
tool_id = _read_string(tool_capability.get("id"))
if tool_id is None:
return []
catalog_id = _read_string((node.ui or {}).get("catalogId"))
if catalog_id not in {"external.tool.capability", tool_id}:
return []

issue = validate_workflow_tool_capability_version_pin(
tool_id,
Expand Down
19 changes: 19 additions & 0 deletions backend/workflow/node_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ def resolve_node_origin(node: WorkflowProjectNode) -> WorkflowNodeOrigin:

if catalog_id in WORKFLOW_CATALOG_IDS:
return WorkflowNodeOrigin(kind="node_library", catalog_id=catalog_id)
if _is_registered_tool_capability_node(node, catalog_id):
return WorkflowNodeOrigin(kind="node_library", catalog_id=catalog_id)
if primitive_id in WORKFLOW_PRIMITIVE_IDS:
return WorkflowNodeOrigin(kind="primitive_library", primitive_id=primitive_id)
if n8n is not None:
Expand All @@ -250,6 +252,23 @@ def resolve_node_origin(node: WorkflowProjectNode) -> WorkflowNodeOrigin:
return WorkflowNodeOrigin(kind="legacy", missing_capability=missing_capability, notes=notes)


def _is_registered_tool_capability_node(
node: WorkflowProjectNode,
catalog_id: str | None,
) -> bool:
tool = node.params.get("toolCapability")
if not isinstance(tool, dict):
return False
tool_id = _read_string(tool.get("id"))
if tool_id is None or catalog_id != tool_id:
return False

# Local import avoids making the registry depend on this origin guard at import time.
from backend.workflow.tool_capabilities import resolve_workflow_tool_capability

return resolve_workflow_tool_capability(tool_id) is not None
Comment on lines +255 to +269

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how WorkflowNodeOrigin.kind == "node_library" is consumed downstream
rg -n -B2 -A6 'node_library' backend/workflow/compiler.py backend/workflow/runtime_registry.py

Repository: 2233admin/opencli-admin

Length of output: 4707


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files 'backend/workflow/*.py' | sed -n '1,120p'

echo "== node_registry origin functions =="
fgrep -n "_is_registered_tool_capability_node\|resolve_node_origin\|_tool_catalog_capabilities\|workflow_tool_capability\|nodeCatalog\|canvas.node" backend/workflow/node_registry.py backend/workflow/capability_projection.py backend/workflow/tool_capabilities.py backend/workflow/runtime_registry.py backend/workflow/compiler.py 2>/dev/null | sed -n '1,240p'

echo "== imports == capability_projection.py =="
sed -n '1,220p' backend/workflow/capability_projection.py
echo "== tool_capabilities.py outline/imports =="
sed -n '1,220p' backend/workflow/tool_capabilities.py

Repository: 2233admin/opencli-admin

Length of output: 2473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant lines node_registry.py =="
sed -n '230,285p' backend/workflow/node_registry.py

echo "== resolve origin usage =="
rg -n -B3 -A8 "resolve_node_origin|WorkflowNodeOrigin|external.tool.capability|tool_capability|node_library" backend/workflow/compiler.py backend/workflow/runtime_registry.py backend/workflow/node_registry.py

echo "== capability_projection.py first 220 =="
sed -n '1,220p' backend/workflow/capability_projection.py

echo "== tool_capabilities.py first 220 =="
sed -n '1,220p' backend/workflow/tool_capabilities.py

Repository: 2233admin/opencli-admin

Length of output: 49698


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tool catalog projection lines =="
rg -n -B8 -A18 "_tool_catalog_capabilities|canvas\.node|nodeCatalog|authority|external.tool.capability|tool.intelligence.native" backend/workflow/capability_projection.py backend/workflow/tool_capabilities.py

echo "== resolved external tool capability flow =="
sed -n '133,150p' backend/workflow/runtime_registry.py
sed -n '784,814p' backend/workflow/runtime_registry.py
sed -n '1136,1143p' backend/workflow/runtime_registry.py

echo "== behavioral probe: node origin vs catalog eligibility data =="
python3 - <<'PY'
from pathlib import Path
import ast

node_registry = Path("backend/workflow/node_registry.py").read_text()
catalog = Path("backend/workflow/capability_projection.py").read_text()

node_origin_tree = ast.parse(node_registry)
catalog_tree = ast.parse(catalog)

for name in ("_is_registered_tool_capability_node", "_tool_catalog_capabilities"):
    for fn in ast.walk(node_origin_tree if name == "_is_registered_tool_capability_node" else catalog_tree):
        if isinstance(fn, ast.FunctionDef) and fn.name == name:
            print(f"== {name} ==")
            print(Path("backend/workflow/node_registry.py" if name == "_is_registered_tool_capability_node" else "backend/workflow/capability_projection.py").read_text().splitlines()[fn.lineno-1:fn.end_lineno])
            print("canvas.node occurrence:", fn.lineno <= next((line for line in ast.get_source_segment(catalog if name == "_tool_catalog_capabilities" else node_registry, fn) or "").splitlines() + ["no match"], ["no match"]))
PY

Repository: 2233admin/opencli-admin

Length of output: 25270


Align tool capability origin checks with catalog opt-in eligibility.

resolve_node_origin now treats any matching toolCapability.id as node_library, but _tool_catalog_capabilities() only exposes tools with manifest.canvas.node AND manifest.nodeCatalog.authority == "backend". A non-catalog-node tool can still pass as node_library, while the same id in external-tool runtime handling only goes through workflow.external-tool.capability binding. Use the catalog opt-in gate here if node_library is meant to preserve the existing external.tool.capability behavior for that path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/workflow/node_registry.py` around lines 255 - 269, Update
_is_registered_tool_capability_node to require the same catalog opt-in
conditions as _tool_catalog_capabilities, including manifest.canvas.node and
manifest.nodeCatalog.authority == "backend", before accepting a matching
toolCapability.id. Reuse the existing catalog eligibility logic or symbols
rather than treating resolve_workflow_tool_capability(tool_id) alone as
sufficient.



def forbidden_node_definition_keys(node: WorkflowProjectNode) -> list[str]:
"""Return raw implementation keys that are never valid workflow authoring data."""

Expand Down
7 changes: 6 additions & 1 deletion backend/workflow/runtime_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1134,7 +1134,12 @@ def _is_inbox_store_node(node: WorkflowProjectNode) -> bool:


def _is_external_tool_capability(node: WorkflowProjectNode) -> bool:
return _read_string((node.ui or {}).get("catalogId")) == "external.tool.capability"
catalog_id = _read_string((node.ui or {}).get("catalogId"))
if catalog_id == "external.tool.capability":
return True
tool_capability = _read_dict(node.params.get("toolCapability"))
tool_id = _read_string(tool_capability.get("id"))
return tool_id is not None and catalog_id == tool_id


def _is_schedule_trigger(node: WorkflowProjectNode) -> bool:
Expand Down
28 changes: 27 additions & 1 deletion backend/workflow/tool_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ def _tool_capabilities() -> list[WorkflowToolCapability]:
"topK": 10,
},
),
catalog={
"id": SITUATION_AWARENESS_TOOL_CAPABILITY_ID,
"category": "processing",
"icon": "Radar",
},
),
_realtime_tool(
id=SWARM_SIMULATION_TOOL_CAPABILITY_ID,
Expand Down Expand Up @@ -335,6 +340,11 @@ def _tool_capabilities() -> list[WorkflowToolCapability]:
"enableGraphMemoryUpdate": False,
},
),
catalog={
"id": SWARM_SIMULATION_TOOL_CAPABILITY_ID,
"category": "processing",
"icon": "Network",
},
),
*[_native_intelligence_tool(action) for action in NATIVE_INTELLIGENCE_ACTIONS],
]
Expand Down Expand Up @@ -375,6 +385,7 @@ def _realtime_tool(
schema: str,
resources: list[str],
executor: WorkflowToolCapabilityExecutor | None = None,
catalog: dict[str, str] | None = None,
) -> WorkflowToolCapability:
return WorkflowToolCapability(
id=id,
Expand Down Expand Up @@ -404,6 +415,21 @@ def _realtime_tool(
"completed",
]
},
"canvas": {"node": False},
"canvas": {"node": catalog is not None},
**(
{
"nodeCatalog": {
"id": catalog["id"],
"authority": "backend",
"origin": "tool-capability",
"category": catalog["category"],
"kind": "action",
"capability": "store",
},
"presentation": {"icon": catalog["icon"]},
}
if catalog is not None
else {}
),
},
)
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"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",
"check:node-capabilities": "node --test scripts/check-node-capability-catalog-regressions.mjs",
"check:node-capabilities": "node --test scripts/check-node-capability-catalog-regressions.mjs scripts/check-tool-capability-catalog-regressions.mjs",
"check:record-hygiene": "node --test scripts/check-record-hygiene-regressions.mjs",
"check:record-relationships": "node --test scripts/check-record-relationship-regressions.mjs",
"check:opencli-business-workflows": "node --test scripts/check-opencli-business-workflows.mjs",
Expand Down
117 changes: 117 additions & 0 deletions frontend/scripts/check-tool-capability-catalog-regressions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import assert from "node:assert/strict"
import { existsSync, readFileSync } from "node:fs"
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") || url.endsWith(".tsx")) {
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), "utf8"), {
mode: "strip",
sourceUrl: url,
})
return { format: "module", source, shortCircuit: true }
}
return nextLoad(url, context)
},
})

test("backend tool capability becomes an executable catalog node", async () => {
const { createWorkflowNodeFromCatalog, getWorkflowNodeCatalog } = await import(
pathToFileURL(path.join(frontendRoot, "lib/workflow/node-catalog.ts")).href
)
const toolCapability = {
id: "tool.osint.metasearch",
versionPin: {
package: "opencli-admin",
packageVersion: "0.1.0",
capabilityVersion: "1.0.0",
provenance: "built-in",
},
executor: { mode: "fixture", params: { limit: 20 } },
}
const runtimeCapability = {
id: "tool.osint.metasearch",
label: "OSINT Metasearch",
surface: "catalog",
status: "runnable",
backendAvailable: true,
kind: "action",
capability: "store",
provider: "opencli-admin",
runtimeBinding: "workflow.external-tool.capability",
reason: "Search verified OSINT providers.",
missing: [],
tags: ["catalog", "tool-capability", "osint"],
source: "backend.workflow.tool_capabilities",
manifest: {
canvas: { node: true },
nodeCatalog: {
authority: "backend",
origin: "tool-capability",
category: "processing",
},
presentation: {
icon: "Search",
parameters: [
{
name: "toolCapability",
label: "Tool binding",
type: "object",
required: true,
default: toolCapability,
},
{
name: "toolParams",
label: "Runtime parameters",
type: "object",
default: { limit: 20 },
},
],
},
},
}
const capabilities = {
version: "test",
catalog: [runtimeCapability],
primitives: [],
channels: [],
notifiers: [],
triggers: [],
resources: [],
}

const item = getWorkflowNodeCatalog("intelligence", capabilities).find(
(candidate) => candidate.id === runtimeCapability.id,
)
assert.ok(item)
assert.equal(item.kind, "action")
assert.equal(item.capability, "store")
assert.deepEqual(item.params.toolCapability, toolCapability)
assert.deepEqual(item.params.toolParams, { limit: 20 })

const node = createWorkflowNodeFromCatalog(item, "osint-search", { x: 80, y: 120 })
assert.deepEqual(node.params.toolCapability, toolCapability)
assert.deepEqual(node.params.toolParams, { limit: 20 })
assert.equal(node.ui.catalogId, "tool.osint.metasearch")
})
Loading