feat: seed custom node contracts into workflow drafts

This commit is contained in:
eust-w 2026-03-30 03:11:57 +08:00
parent 0bdef113e7
commit 937019b9a1
5 changed files with 132 additions and 7 deletions

View File

@ -85,7 +85,7 @@ The shell now also exposes a dedicated Nodes page for project-scoped custom cont
The Workflows workspace now includes a template gallery. Projects can start from default or saved templates, or create a blank workflow directly.
The workflow editor center panel now uses a real draggable node canvas with zoom, pan, mini-map, dotted background, handle-based edge creation, persisted node positions, and localized validation feedback instead of a static list of node cards.
The workflow editor right panel now also supports saving the current workflow draft as a reusable workflow template, in addition to editing per-node runtime settings and Python hooks.
The node library now supports both click-to-append and drag-and-drop placement into the canvas. V1 connection rules block self-edges, duplicate edges, cycles, incoming edges into source nodes, outgoing edges from export nodes, and multiple upstream edges into ordinary nodes, while allowing multi-input set nodes such as `union-assets`, `intersect-assets`, and `difference-assets`.
The node library now supports both click-to-append and drag-and-drop placement into the canvas. When a node is inserted from the library, the editor now seeds its default runtime contract directly into the workflow draft, so custom Docker nodes keep their declared executor type and I/O contract without extra manual edits. V1 connection rules block self-edges, duplicate edges, cycles, incoming edges into source nodes, outgoing edges from export nodes, and multiple upstream edges into ordinary nodes, while allowing multi-input set nodes such as `union-assets`, `intersect-assets`, and `difference-assets` plus any custom node whose runtime contract declares `inputMode=multi_asset_set`.
The Runs workspace now shows project-scoped run history, run-level aggregated summaries, cancel/retry controls, and run detail views with persisted task summaries, stdout/stderr sections, result previews, and artifact links into Explore.
Selected run tasks now expose the frozen node definition id, executor config snapshot, and code-hook metadata that were captured when the run was created.
Most built-in delivery nodes now default to `executorType=docker`. When a node uses `executorType=docker` and provides `executorConfig.image`, the worker runs a real local Docker container with mounted `input.json` / `output.json` exchange files plus read-only mounts for bound asset paths. If no image is configured, the executor falls back to the lightweight simulated behavior used by older demo tasks.

View File

@ -79,6 +79,40 @@ test("add node at explicit canvas position without auto-connecting it", () => {
);
});
test("add custom docker node seeds runtime defaults from the node definition", () => {
const base = createDefaultWorkflowDraft();
const result = addNodeToDraft(base, {
id: "custom-merge-assets",
name: "Custom Merge Assets",
category: "Utility",
defaultExecutorType: "docker",
defaultExecutorConfig: {
image: "python:3.11-alpine",
contract: {
inputMode: "multi_asset_set",
outputMode: "asset_set",
artifactType: "json",
},
},
allowsMultipleIncoming: true,
supportsCodeHook: false,
});
assert.equal(result.nodeId, "custom-merge-assets-1");
assert.equal(
getNodeRuntimeConfig(result.draft, "custom-merge-assets-1")?.executorType,
"docker",
);
assert.equal(
(
getNodeRuntimeConfig(result.draft, "custom-merge-assets-1")?.executorConfig as {
contract?: { inputMode?: string };
}
)?.contract?.inputMode,
"multi_asset_set",
);
});
test("remove node prunes attached edges and serialize emits workflow version payload", () => {
const draft = workflowDraftFromVersion({
visualGraph: { viewport: { x: 0, y: 0, zoom: 1 } },
@ -287,3 +321,48 @@ test("allow multi-inbound utility set nodes while still blocking cycles", () =>
"target_disallows_incoming",
);
});
test("allow multi-inbound custom nodes when their seeded runtime contract expects multiple asset sets", () => {
const base = workflowDraftFromVersion({
logicGraph: {
nodes: [
{ id: "source-a", type: "source" },
{ id: "source-b", type: "source" },
],
edges: [],
},
runtimeGraph: {
nodeBindings: {
"source-a": "source-asset",
"source-b": "source-asset",
},
nodeConfigs: {},
},
});
const appended = addNodeToDraftAtPosition(
base,
{
id: "custom-merge-assets",
name: "Custom Merge Assets",
category: "Utility",
defaultExecutorType: "docker",
defaultExecutorConfig: {
image: "python:3.11-alpine",
contract: {
inputMode: "multi_asset_set",
outputMode: "asset_set",
artifactType: "json",
},
},
allowsMultipleIncoming: true,
},
{ x: 540, y: 200 },
);
const withFirst = connectNodesInDraft(appended.draft, "source-a", appended.nodeId);
assert.equal(
canConnectNodesInDraft(withFirst, "source-b", appended.nodeId).ok,
true,
);
});

View File

@ -85,6 +85,36 @@ const DEFAULT_NODE_LAYOUT: Record<string, WorkflowPoint> = {
};
const MULTI_INPUT_NODE_DEFINITION_IDS = new Set(["union-assets", "intersect-assets", "difference-assets"]);
function cloneRuntimeConfig(config: WorkflowNodeRuntimeConfig) {
return {
...config,
executorConfig: config.executorConfig ? { ...config.executorConfig } : undefined,
codeHookSpec: config.codeHookSpec ? { ...config.codeHookSpec } : undefined,
};
}
function createDefaultRuntimeConfigForDefinition(
definition: WorkflowNodeDefinitionSummary,
): WorkflowNodeRuntimeConfig | undefined {
const executorType = definition.defaultExecutorType;
const executorConfig = definition.defaultExecutorConfig ? { ...definition.defaultExecutorConfig } : undefined;
if (!executorType && !executorConfig) {
return undefined;
}
const config: WorkflowNodeRuntimeConfig = {
definitionId: definition.id,
};
if (executorType) {
config.executorType = executorType;
}
if (executorConfig && Object.keys(executorConfig).length > 0) {
config.executorConfig = executorConfig;
}
return config;
}
function createDefaultNodePosition(index: number): WorkflowPoint {
const column = index % 3;
const row = Math.floor(index / 3);
@ -126,11 +156,7 @@ function cloneDraft(draft: WorkflowDraft): WorkflowDraft {
nodeConfigs: Object.fromEntries(
Object.entries(draft.runtimeGraph.nodeConfigs ?? {}).map(([nodeId, config]) => [
nodeId,
{
...config,
executorConfig: config.executorConfig ? { ...config.executorConfig } : undefined,
codeHookSpec: config.codeHookSpec ? { ...config.codeHookSpec } : undefined,
},
cloneRuntimeConfig(config),
]),
),
},
@ -286,6 +312,10 @@ function addNodeToDraftInternal(
next.runtimeGraph.nodeBindings ??= {};
next.runtimeGraph.nodeBindings[nodeId] = definition.id;
next.runtimeGraph.nodeConfigs ??= {};
const defaultRuntimeConfig = createDefaultRuntimeConfigForDefinition(definition);
if (defaultRuntimeConfig) {
next.runtimeGraph.nodeConfigs[nodeId] = defaultRuntimeConfig;
}
next.visualGraph.nodePositions[nodeId] =
options.position ?? createDefaultNodePosition(next.logicGraph.nodes.length - 1);
@ -351,7 +381,17 @@ function nodeDisallowsIncoming(node: WorkflowLogicNode) {
function allowsMultipleIncoming(draft: WorkflowDraft, nodeId: string) {
const definitionId = resolveDefinitionIdForNode(draft, nodeId);
return MULTI_INPUT_NODE_DEFINITION_IDS.has(definitionId);
if (MULTI_INPUT_NODE_DEFINITION_IDS.has(definitionId)) {
return true;
}
const contract =
draft.runtimeGraph.nodeConfigs?.[nodeId]?.executorConfig &&
typeof draft.runtimeGraph.nodeConfigs[nodeId]?.executorConfig === "object" &&
!Array.isArray(draft.runtimeGraph.nodeConfigs[nodeId]?.executorConfig)
? (draft.runtimeGraph.nodeConfigs[nodeId]?.executorConfig as { contract?: { inputMode?: string } }).contract
: undefined;
return contract?.inputMode === "multi_asset_set";
}
function wouldCreateCycle(draft: WorkflowDraft, sourceNodeId: string, targetNodeId: string) {

View File

@ -104,6 +104,8 @@ V1 node categories:
The current V1 runtime also supports project-level custom Docker nodes. A custom node is registered separately from the workflow graph, then exposed through the same node-definition surface as built-in nodes.
When the user drops one of these node definitions into the editor, the draft should immediately inherit the node's default runtime snapshot. In practice this means the seeded `nodeConfig` already carries the declared executor type, executor config, and contract before the user opens the right-side panel.
## Node Definition Contract
Each node definition must expose:
@ -172,6 +174,8 @@ Expected output shape:
If the custom node declares an `asset_set` style output contract, `result.assetIds` must be a string array. This is what allows downstream nodes to inherit the narrowed asset set.
If the custom node declares `contract.inputMode = "multi_asset_set"`, the canvas should treat that node as multi-input at authoring time instead of forcing the user through single-input validation rules. The graph validator should derive this capability from the seeded runtime contract, not from a hardcoded node id list alone.
The current V1 worker executes trusted-local Python hooks when a `run_task` carries a `codeHookSpec`. The hook is executed through a constrained Python harness with the task snapshot and execution context passed in as JSON. Hook stdout is captured into `stdoutLines`, hook failures populate `stderrLines`, and the returned object becomes the task artifact payload.
The current V1 Docker executor now has two modes:

View File

@ -187,6 +187,8 @@ The current V1 authoring rules intentionally keep the graph model constrained so
- set-operation utility nodes may accept multiple inbound edges
- cycles are blocked
Custom nodes follow the same rule system, but the decision is contract-driven. When a custom Docker node is added to the canvas, the editor seeds its runtime defaults into the draft immediately; if that contract declares `inputMode=multi_asset_set`, the node is treated like a multi-input utility node from the first connection attempt.
The current built-in node library also exposes Docker-first runtime defaults in the editor. Most built-ins now render with `docker` preselected, while still allowing the user to override the executor, image, and optional Python code hook from the right-side configuration panel.
The runtime header also now exposes a visible `中文 / English` language toggle and the main shell plus workflow authoring surface are translated through a lightweight i18n layer.