diff --git a/README.md b/README.md index fcf5c4d..3617274 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/web/src/runtime/workflow-editor-state.test.ts b/apps/web/src/runtime/workflow-editor-state.test.ts index b0c490c..4663b5b 100644 --- a/apps/web/src/runtime/workflow-editor-state.test.ts +++ b/apps/web/src/runtime/workflow-editor-state.test.ts @@ -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, + ); +}); diff --git a/apps/web/src/runtime/workflow-editor-state.ts b/apps/web/src/runtime/workflow-editor-state.ts index e0c2d4f..c494c9e 100644 --- a/apps/web/src/runtime/workflow-editor-state.ts +++ b/apps/web/src/runtime/workflow-editor-state.ts @@ -85,6 +85,36 @@ const DEFAULT_NODE_LAYOUT: Record = { }; 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) { diff --git a/design/03-workflows/workflow-execution-model.md b/design/03-workflows/workflow-execution-model.md index 452d5d8..fba3453 100644 --- a/design/03-workflows/workflow-execution-model.md +++ b/design/03-workflows/workflow-execution-model.md @@ -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: diff --git a/design/04-ui-ux/information-architecture-and-key-screens.md b/design/04-ui-ux/information-architecture-and-key-screens.md index d191eb7..0922347 100644 --- a/design/04-ui-ux/information-architecture-and-key-screens.md +++ b/design/04-ui-ux/information-architecture-and-key-screens.md @@ -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.