✨ feat: seed custom node contracts into workflow drafts
This commit is contained in:
parent
0bdef113e7
commit
937019b9a1
@ -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 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 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 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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@ -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", () => {
|
test("remove node prunes attached edges and serialize emits workflow version payload", () => {
|
||||||
const draft = workflowDraftFromVersion({
|
const draft = workflowDraftFromVersion({
|
||||||
visualGraph: { viewport: { x: 0, y: 0, zoom: 1 } },
|
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",
|
"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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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"]);
|
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 {
|
function createDefaultNodePosition(index: number): WorkflowPoint {
|
||||||
const column = index % 3;
|
const column = index % 3;
|
||||||
const row = Math.floor(index / 3);
|
const row = Math.floor(index / 3);
|
||||||
@ -126,11 +156,7 @@ function cloneDraft(draft: WorkflowDraft): WorkflowDraft {
|
|||||||
nodeConfigs: Object.fromEntries(
|
nodeConfigs: Object.fromEntries(
|
||||||
Object.entries(draft.runtimeGraph.nodeConfigs ?? {}).map(([nodeId, config]) => [
|
Object.entries(draft.runtimeGraph.nodeConfigs ?? {}).map(([nodeId, config]) => [
|
||||||
nodeId,
|
nodeId,
|
||||||
{
|
cloneRuntimeConfig(config),
|
||||||
...config,
|
|
||||||
executorConfig: config.executorConfig ? { ...config.executorConfig } : undefined,
|
|
||||||
codeHookSpec: config.codeHookSpec ? { ...config.codeHookSpec } : undefined,
|
|
||||||
},
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -286,6 +312,10 @@ function addNodeToDraftInternal(
|
|||||||
next.runtimeGraph.nodeBindings ??= {};
|
next.runtimeGraph.nodeBindings ??= {};
|
||||||
next.runtimeGraph.nodeBindings[nodeId] = definition.id;
|
next.runtimeGraph.nodeBindings[nodeId] = definition.id;
|
||||||
next.runtimeGraph.nodeConfigs ??= {};
|
next.runtimeGraph.nodeConfigs ??= {};
|
||||||
|
const defaultRuntimeConfig = createDefaultRuntimeConfigForDefinition(definition);
|
||||||
|
if (defaultRuntimeConfig) {
|
||||||
|
next.runtimeGraph.nodeConfigs[nodeId] = defaultRuntimeConfig;
|
||||||
|
}
|
||||||
next.visualGraph.nodePositions[nodeId] =
|
next.visualGraph.nodePositions[nodeId] =
|
||||||
options.position ?? createDefaultNodePosition(next.logicGraph.nodes.length - 1);
|
options.position ?? createDefaultNodePosition(next.logicGraph.nodes.length - 1);
|
||||||
|
|
||||||
@ -351,7 +381,17 @@ function nodeDisallowsIncoming(node: WorkflowLogicNode) {
|
|||||||
|
|
||||||
function allowsMultipleIncoming(draft: WorkflowDraft, nodeId: string) {
|
function allowsMultipleIncoming(draft: WorkflowDraft, nodeId: string) {
|
||||||
const definitionId = resolveDefinitionIdForNode(draft, nodeId);
|
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) {
|
function wouldCreateCycle(draft: WorkflowDraft, sourceNodeId: string, targetNodeId: string) {
|
||||||
|
|||||||
@ -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.
|
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
|
## Node Definition Contract
|
||||||
|
|
||||||
Each node definition must expose:
|
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 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 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:
|
The current V1 Docker executor now has two modes:
|
||||||
|
|||||||
@ -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
|
- set-operation utility nodes may accept multiple inbound edges
|
||||||
- cycles are blocked
|
- 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 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.
|
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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user