✨ 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 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.
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user