flowgraph 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. flowgraph/__init__.py +9 -0
  2. flowgraph/__main__.py +6 -0
  3. flowgraph/adapters/__init__.py +141 -0
  4. flowgraph/adapters/controls.py +96 -0
  5. flowgraph/adapters/cosapp.py +195 -0
  6. flowgraph/adapters/data_types.py +59 -0
  7. flowgraph/adapters/documentation.py +45 -0
  8. flowgraph/adapters/field_ops.py +121 -0
  9. flowgraph/adapters/file_conversion.py +393 -0
  10. flowgraph/adapters/files.py +191 -0
  11. flowgraph/adapters/filters.py +173 -0
  12. flowgraph/adapters/image_tools.py +394 -0
  13. flowgraph/adapters/mesh_creation_tools.py +431 -0
  14. flowgraph/adapters/mesh_generations.py +58 -0
  15. flowgraph/adapters/mesh_ops.py +362 -0
  16. flowgraph/adapters/paraview.py +39 -0
  17. flowgraph/adapters/plaid_bridge.py +205 -0
  18. flowgraph/adapters/readers.py +215 -0
  19. flowgraph/adapters/remote_files.py +118 -0
  20. flowgraph/adapters/simple_sources.py +199 -0
  21. flowgraph/adapters/sinks.py +80 -0
  22. flowgraph/adapters/user_code.py +196 -0
  23. flowgraph/adapters/workflow.py +176 -0
  24. flowgraph/adapters/workflow_interfaces.py +78 -0
  25. flowgraph/adapters/writers.py +159 -0
  26. flowgraph/application/__init__.py +1 -0
  27. flowgraph/application/node_registry.py +14 -0
  28. flowgraph/application/port_conversions.py +63 -0
  29. flowgraph/application/wasm_execution.py +132 -0
  30. flowgraph/application/workflow_cli.py +417 -0
  31. flowgraph/application/workflow_core.py +1193 -0
  32. flowgraph/application/workflow_history.py +93 -0
  33. flowgraph/application/workflow_io.py +393 -0
  34. flowgraph/application/workflow_layout.py +170 -0
  35. flowgraph/application/workflow_web_catalog.py +143 -0
  36. flowgraph/application/workflows.py +7 -0
  37. flowgraph/core_cli.py +81 -0
  38. flowgraph/domain/__init__.py +5 -0
  39. flowgraph/domain/mesh_document.py +91 -0
  40. flowgraph/examples/AllFilters.py +19 -0
  41. flowgraph/examples/convert_format.py +21 -0
  42. flowgraph/resources.py +29 -0
  43. flowgraph/testdata/AllFilters.json +390 -0
  44. flowgraph/testdata/FlowGraph.h5 +0 -0
  45. flowgraph/testdata/FlowGraph.stl +0 -0
  46. flowgraph/testdata/FlowGraph.xdmf +22 -0
  47. flowgraph/testdata/SimpleReadMesh.json +70 -0
  48. flowgraph/testdata/SpherePoints.csv +83 -0
  49. flowgraph/testdata/muscat-muscat-mesh-conversion.json +101 -0
  50. flowgraph/version.py +3 -0
  51. flowgraph-0.1.2.dist-info/METADATA +150 -0
  52. flowgraph-0.1.2.dist-info/RECORD +56 -0
  53. flowgraph-0.1.2.dist-info/WHEEL +5 -0
  54. flowgraph-0.1.2.dist-info/entry_points.txt +2 -0
  55. flowgraph-0.1.2.dist-info/licenses/LICENSE +29 -0
  56. flowgraph-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1193 @@
1
+ """Typed, UI-independent workflow graph validation and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass, field, replace
8
+ from enum import Enum
9
+ from types import MappingProxyType
10
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, get_args, get_origin
11
+
12
+ if TYPE_CHECKING:
13
+ from flowgraph.application.port_conversions import PortConversion
14
+
15
+ T = TypeVar("T")
16
+ NodeExecutor = Callable[[Mapping[str, Any], Mapping[str, Any]], Mapping[str, Any] | None]
17
+ PortResolver = Callable[[Mapping[str, Any]], tuple["PortDefinition", ...]]
18
+ InstancePortResolver = Callable[["NodeInstance"], tuple["PortDefinition", ...]]
19
+ InstanceExecutor = Callable[
20
+ ["NodeInstance", Mapping[str, Any], Mapping[str, Any], "NodeRegistry"], Mapping[str, Any] | None
21
+ ]
22
+ SubworkflowFactory = Callable[[], "WorkflowGraph"]
23
+ WorkflowExecutorFunction = Callable[
24
+ [Mapping[str, Any], Mapping[str, Any] | None], Mapping[str, Any]
25
+ ]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DataType(Generic[T]):
30
+ """Stable workflow type identity paired with a runtime Python type."""
31
+
32
+ id: str
33
+ label: str
34
+ python_type: type[T] | tuple[type[Any], ...]
35
+
36
+ def accepts(self, value: object) -> bool:
37
+ """Return whether *value* satisfies this workflow type at runtime."""
38
+ origin = get_origin(self.python_type)
39
+ if origin is not None:
40
+ if isinstance(value, origin):
41
+ if origin == list:
42
+ args = get_args(self.python_type)
43
+ for v in value:
44
+ if not isinstance(v, args):
45
+ return False
46
+ return True
47
+ else:
48
+ return False
49
+ return isinstance(value, self.python_type)
50
+
51
+ def is_compatible_with(self, target: DataType[Any]) -> bool:
52
+ """Use stable type identity; conversions require an explicit node."""
53
+ if self.python_type is object or target.python_type is object:
54
+ return True
55
+ return self.python_type == target.python_type
56
+
57
+
58
+ class PortDirection(str, Enum):
59
+ INPUT = "input"
60
+ OUTPUT = "output"
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ParameterOption:
65
+ """One serializable choice offered by a parameter editor."""
66
+
67
+ value: str | int | float
68
+ label: str
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ParameterDefinition:
73
+ """Serializable configuration contract declared alongside a workflow node."""
74
+
75
+ name: str
76
+ kind: DataType[Any]
77
+ label: str
78
+ default: Any
79
+ placeholder: str = ""
80
+ options: tuple[ParameterOption, ...] = ()
81
+ file_patterns: tuple[str, ...] = ()
82
+ port: bool = True # if this param must be export as a port
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class PortDefinition:
87
+ """A named, typed endpoint on a node or workflow definition.
88
+
89
+ ``accepted_data_types`` adds explicit alternatives for inputs that support
90
+ more than one wire type. The declared ``data_type`` remains the primary
91
+ type shown by existing clients and used for output validation.
92
+ """
93
+
94
+ name: str
95
+ direction: PortDirection
96
+ data_type: DataType[Any]
97
+ label: str | None = None
98
+ required: bool = True
99
+ is_param: bool = False
100
+ accepted_data_types: tuple[DataType[Any], ...] = ()
101
+
102
+ @property
103
+ def display_label(self) -> str:
104
+ return self.label or self.name
105
+
106
+ @property
107
+ def accepted_types(self) -> tuple[DataType[Any], ...]:
108
+ """Return the primary type followed by explicitly accepted alternatives."""
109
+ return (self.data_type, *self.accepted_data_types)
110
+
111
+ def accepts(self, value: object) -> bool:
112
+ """Return whether this port accepts a runtime input value."""
113
+ return any(data_type.accepts(value) for data_type in self.accepted_types)
114
+
115
+ def accepts_type(self, source: DataType[Any]) -> bool:
116
+ """Return whether this input port accepts a source port's type directly."""
117
+ return any(source.is_compatible_with(data_type) for data_type in self.accepted_types)
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class WorkflowPort:
122
+ """A public workflow port mapped to a port on one node instance."""
123
+
124
+ name: str
125
+ node_id: str
126
+ node_port: str
127
+ direction: PortDirection
128
+ data_type: DataType[Any]
129
+ label: str | None = None
130
+ is_param: bool = False
131
+
132
+ @property
133
+ def display_label(self) -> str:
134
+ return self.label or self.name
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class NodeDefinition:
139
+ """Reusable behavior and port contract for a kind of workflow node."""
140
+
141
+ id: str
142
+ icon: str
143
+ label: str
144
+ ports: tuple[PortDefinition, ...]
145
+ executor: NodeExecutor
146
+ description: str = ""
147
+ parameters: tuple[ParameterDefinition, ...] = ()
148
+ default_parameters: Mapping[str, Any] = field(default_factory=dict)
149
+ presentation: str = "standard"
150
+ port_resolver: PortResolver | None = None
151
+ instance_port_resolver: InstancePortResolver | None = None
152
+ instance_executor: InstanceExecutor | None = None
153
+ subworkflow_factory: SubworkflowFactory | None = None
154
+ color: str | None = None
155
+
156
+ def __post_init__(self) -> None:
157
+
158
+ # add paramters as ports
159
+ parameter_ports = tuple(
160
+ PortDefinition(
161
+ p.name, PortDirection.INPUT, p.kind, p.label, required=False, is_param=True
162
+ )
163
+ for p in self.parameters
164
+ if p.port
165
+ )
166
+
167
+ object.__setattr__(
168
+ self,
169
+ "ports",
170
+ self.ports + parameter_ports,
171
+ )
172
+
173
+ names = [(port.direction, port.name) for port in self.ports]
174
+ if len(names) != len(set(names)):
175
+ raise ValueError(f"Node definition {self.id!r} has duplicate port names")
176
+ parameter_names = [parameter.name for parameter in self.parameters]
177
+ if len(parameter_names) != len(set(parameter_names)):
178
+ raise ValueError(f"Node definition {self.id!r} has duplicate parameters")
179
+ schema_defaults = {
180
+ parameter.name: deepcopy(parameter.default) for parameter in self.parameters
181
+ }
182
+ if (
183
+ self.parameters
184
+ and self.default_parameters
185
+ and schema_defaults != dict(self.default_parameters)
186
+ ):
187
+ raise ValueError(
188
+ f"Node definition {self.id!r} parameter defaults do not match its schema"
189
+ )
190
+ if self.parameters and not self.default_parameters:
191
+ object.__setattr__(self, "default_parameters", schema_defaults)
192
+
193
+ def ports_for(self, parameters: Mapping[str, Any] | None = None) -> tuple[PortDefinition, ...]:
194
+ """Return this node's static ports plus any instance-specific ports."""
195
+ if self.port_resolver is None:
196
+ return self.ports
197
+ return self.port_resolver(parameters or {})
198
+
199
+ def ports_for_instance(self, instance: NodeInstance) -> tuple[PortDefinition, ...]:
200
+ """Return ports resolved from an individual configured node instance."""
201
+ if self.instance_port_resolver is not None:
202
+ return self.instance_port_resolver(instance)
203
+ return self.ports_for(instance.parameters)
204
+
205
+ @staticmethod
206
+ def _inputs(ports: tuple[PortDefinition, ...]) -> tuple[PortDefinition, ...]:
207
+ return tuple(port for port in ports if port.direction is PortDirection.INPUT)
208
+
209
+ @staticmethod
210
+ def _outputs(ports: tuple[PortDefinition, ...]) -> tuple[PortDefinition, ...]:
211
+ return tuple(port for port in ports if port.direction is PortDirection.OUTPUT)
212
+
213
+ @property
214
+ def inputs(self) -> tuple[PortDefinition, ...]:
215
+ return self._inputs(self.ports)
216
+
217
+ @property
218
+ def outputs(self) -> tuple[PortDefinition, ...]:
219
+ return self._outputs(self.ports)
220
+
221
+ def input(
222
+ self, name: str, parameters: Mapping[str, Any] | None = None
223
+ ) -> PortDefinition | None:
224
+ return next(
225
+ (port for port in self._inputs(self.ports_for(parameters)) if port.name == name), None
226
+ )
227
+
228
+ def output(
229
+ self, name: str, parameters: Mapping[str, Any] | None = None
230
+ ) -> PortDefinition | None:
231
+ return next(
232
+ (port for port in self._outputs(self.ports_for(parameters)) if port.name == name), None
233
+ )
234
+
235
+ def input_for_instance(self, name: str, instance: NodeInstance) -> PortDefinition | None:
236
+ """Return one input port resolved from an individual node instance."""
237
+ return next(
238
+ (port for port in self._inputs(self.ports_for_instance(instance)) if port.name == name),
239
+ None,
240
+ )
241
+
242
+ def output_for_instance(self, name: str, instance: NodeInstance) -> PortDefinition | None:
243
+ """Return one output port resolved from an individual node instance."""
244
+ return next(
245
+ (
246
+ port
247
+ for port in self._outputs(self.ports_for_instance(instance))
248
+ if port.name == name
249
+ ),
250
+ None,
251
+ )
252
+
253
+ def execute_instance(
254
+ self,
255
+ instance: NodeInstance,
256
+ inputs: Mapping[str, Any],
257
+ registry: NodeRegistry,
258
+ ) -> Mapping[str, Any] | None:
259
+ """Execute one instance through its specialized or regular executor."""
260
+ parameters = MappingProxyType(instance.parameters)
261
+ if self.instance_executor is not None:
262
+ return self.instance_executor(instance, inputs, parameters, registry)
263
+ return self.executor(inputs, parameters)
264
+
265
+ def create_instance(
266
+ self,
267
+ instance_id: str,
268
+ *,
269
+ parameters: Mapping[str, Any] | None = None,
270
+ x: float = 0,
271
+ y: float = 0,
272
+ ) -> NodeInstance:
273
+ """Create an independently configured instance of this definition."""
274
+ configured_parameters = deepcopy(dict(self.default_parameters))
275
+ if parameters is not None:
276
+ configured_parameters.update(deepcopy(dict(parameters)))
277
+ return NodeInstance(
278
+ id=instance_id,
279
+ definition_id=self.id,
280
+ parameters=configured_parameters,
281
+ x=x,
282
+ y=y,
283
+ subworkflow=None if self.subworkflow_factory is None else self.subworkflow_factory(),
284
+ )
285
+
286
+ def create_icon_arguments(self) -> dict[str, str]:
287
+ """Return toolbar button arguments that add this registered node type."""
288
+ return {
289
+ "icon": self.icon,
290
+ "variant": "text",
291
+ "size": "small",
292
+ "title": self.label,
293
+ "click": f"trigger('AddNode', [{self.id!r}])",
294
+ }
295
+
296
+
297
+ @dataclass
298
+ class NodeInstance:
299
+ """A configured and positioned occurrence of a registered node definition."""
300
+
301
+ id: str
302
+ definition_id: str
303
+ parameters: dict[str, Any] = field(default_factory=dict)
304
+ x: float = 0
305
+ y: float = 0
306
+ subworkflow: WorkflowGraph | None = None
307
+
308
+ def pprint(self):
309
+ print(f"{self.id}")
310
+ print(f" type: {self.definition_id}")
311
+ for k, v in self.parameters.items():
312
+ print(f" {k}:{v}")
313
+
314
+
315
+ @dataclass(frozen=True)
316
+ class WorkflowEdge:
317
+ """A directed connection between one named output and one named input."""
318
+
319
+ source_node: str
320
+ source_port: str
321
+ target_node: str
322
+ target_port: str
323
+
324
+ @property
325
+ def id(self) -> str:
326
+ return f"{self.source_node}({self.source_port})->{self.target_node}({self.target_port})"
327
+
328
+
329
+ class ValidationCode(str, Enum):
330
+ UNKNOWN_DEFINITION = "unknown_definition"
331
+ UNKNOWN_NODE = "unknown_node"
332
+ UNKNOWN_SOURCE_PORT = "unknown_source_port"
333
+ UNKNOWN_TARGET_PORT = "unknown_target_port"
334
+ TYPE_MISMATCH = "type_mismatch"
335
+ DUPLICATE_EDGE = "duplicate_edge"
336
+ INPUT_ALREADY_CONNECTED = "input_already_connected"
337
+ MISSING_REQUIRED_INPUT = "missing_required_input"
338
+ CYCLE = "cycle"
339
+ EXPORTED_INPUT_CONNECTED = "exported_input_connected"
340
+ INVALID_EXPORTED_PORT = "invalid_exported_port"
341
+
342
+
343
+ @dataclass(frozen=True)
344
+ class ValidationIssue:
345
+ code: ValidationCode
346
+ message: str
347
+ node_id: str | None = None
348
+ edge_id: str | None = None
349
+
350
+
351
+ class WorkflowError(Exception):
352
+ """Base error for invalid graphs and failed workflow runs."""
353
+
354
+
355
+ class WorkflowValidationError(WorkflowError):
356
+ def __init__(self, issues: tuple[ValidationIssue, ...]) -> None:
357
+ self.issues = issues
358
+ super().__init__("; ".join(issue.message for issue in issues))
359
+
360
+
361
+ class NodeExecutionError(WorkflowError):
362
+ def __init__(
363
+ self,
364
+ node_id: str,
365
+ definition_id: str,
366
+ message: str,
367
+ partial_result: WorkflowRunResult | None = None,
368
+ ) -> None:
369
+ self.node_id = node_id
370
+ self.definition_id = definition_id
371
+ self.partial_result = partial_result
372
+ super().__init__(f"Node {node_id!r} ({definition_id!r}) failed: {message}")
373
+
374
+
375
+ class NodeRegistry:
376
+ """Registry of node definitions available to workflow graphs."""
377
+
378
+ def __init__(self) -> None:
379
+ self._definitions: dict[str, NodeDefinition] = {}
380
+
381
+ def register(self, definition: NodeDefinition) -> None:
382
+ if definition.id in self._definitions:
383
+ raise ValueError(f"Node definition {definition.id!r} is already registered")
384
+ self._definitions[definition.id] = definition
385
+
386
+ def get(self, definition_id: str) -> NodeDefinition | None:
387
+ return self._definitions.get(definition_id)
388
+
389
+ def require(self, definition_id: str) -> NodeDefinition:
390
+ definition = self.get(definition_id)
391
+ if definition is None:
392
+ raise KeyError(f"Unknown node definition {definition_id!r}")
393
+ return definition
394
+
395
+ def create_instance(
396
+ self,
397
+ definition_id: str,
398
+ instance_id: str,
399
+ *,
400
+ parameters: Mapping[str, Any] | None = None,
401
+ x: float = 0,
402
+ y: float = 0,
403
+ ) -> NodeInstance:
404
+ """Create an instance through its registered definition."""
405
+ return self.require(definition_id).create_instance(
406
+ instance_id,
407
+ parameters=parameters,
408
+ x=x,
409
+ y=y,
410
+ )
411
+
412
+ @property
413
+ def definitions(self) -> Mapping[str, NodeDefinition]:
414
+ return MappingProxyType(self._definitions)
415
+
416
+
417
+ class WorkflowGraph:
418
+ """Authoritative mutable workflow structure."""
419
+
420
+ def __init__(
421
+ self,
422
+ nodes: tuple[NodeInstance, ...] | list[NodeInstance] = (),
423
+ edges: tuple[WorkflowEdge, ...] | list[WorkflowEdge] = (),
424
+ inputs: tuple[WorkflowPort, ...] | list[WorkflowPort] = (),
425
+ outputs: tuple[WorkflowPort, ...] | list[WorkflowPort] = (),
426
+ ) -> None:
427
+ self._nodes: dict[str, NodeInstance] = {}
428
+ self._edges: list[WorkflowEdge] = []
429
+ self._inputs: dict[str, WorkflowPort] = {}
430
+ self._outputs: dict[str, WorkflowPort] = {}
431
+ for node in nodes:
432
+ self.add_node(node)
433
+ for edge in edges:
434
+ self._edges.append(edge)
435
+ self._inputs = {port.name: port for port in inputs}
436
+ self._outputs = {port.name: port for port in outputs}
437
+
438
+ @property
439
+ def nodes(self) -> tuple[NodeInstance, ...]:
440
+ return tuple(self._nodes.values())
441
+
442
+ @property
443
+ def edges(self) -> tuple[WorkflowEdge, ...]:
444
+ return tuple(self._edges)
445
+
446
+ @property
447
+ def inputs(self) -> tuple[WorkflowPort, ...]:
448
+ return tuple(self._inputs.values())
449
+
450
+ @property
451
+ def outputs(self) -> tuple[WorkflowPort, ...]:
452
+ return tuple(self._outputs.values())
453
+
454
+ def export_input(
455
+ self,
456
+ name: str,
457
+ node_id: str,
458
+ node_port: str,
459
+ registry: NodeRegistry,
460
+ *,
461
+ label: str | None = None,
462
+ ) -> WorkflowPort:
463
+ """Expose a free node input, including a parameter port, as a workflow input."""
464
+ port = self._workflow_port(name, node_id, node_port, PortDirection.INPUT, registry, label)
465
+ if any(
466
+ edge.target_node == node_id and edge.target_port == node_port for edge in self.edges
467
+ ):
468
+ raise WorkflowValidationError(
469
+ (
470
+ ValidationIssue(
471
+ ValidationCode.EXPORTED_INPUT_CONNECTED,
472
+ f"Exported input {node_id!r}.{node_port!r} must not be connected",
473
+ node_id=node_id,
474
+ ),
475
+ )
476
+ )
477
+ if name in self._inputs:
478
+ raise ValueError(f"Workflow input {name!r} already exists")
479
+ self._inputs[name] = port
480
+ return port
481
+
482
+ add_input = export_input
483
+
484
+ def export_output(
485
+ self,
486
+ name: str,
487
+ node_id: str,
488
+ node_port: str,
489
+ registry: NodeRegistry,
490
+ *,
491
+ label: str | None = None,
492
+ ) -> WorkflowPort:
493
+ """Expose a node output as a workflow output."""
494
+ port = self._workflow_port(name, node_id, node_port, PortDirection.OUTPUT, registry, label)
495
+ if name in self._outputs:
496
+ raise ValueError(f"Workflow output {name!r} already exists")
497
+ self._outputs[name] = port
498
+ return port
499
+
500
+ add_output = export_output
501
+
502
+ def input(self, name: str) -> WorkflowPort | None:
503
+ return self._inputs.get(name)
504
+
505
+ def output(self, name: str) -> WorkflowPort | None:
506
+ return self._outputs.get(name)
507
+
508
+ def executor(self, registry: NodeRegistry) -> WorkflowExecutorFunction:
509
+ """Return a node-compatible callable for this workflow."""
510
+ return lambda inputs, _parameters=None: self.execute(inputs, registry)
511
+
512
+ def execute(self, inputs: Mapping[str, Any], registry: NodeRegistry) -> Mapping[str, Any]:
513
+ """Execute this workflow with exported inputs and return exported outputs."""
514
+ values = dict(inputs)
515
+ unknown = set(values).difference(self._inputs)
516
+ if unknown:
517
+ raise WorkflowValidationError(
518
+ (
519
+ ValidationIssue(
520
+ ValidationCode.INVALID_EXPORTED_PORT,
521
+ f"Unknown workflow inputs: {sorted(unknown)!r}",
522
+ ),
523
+ )
524
+ )
525
+ injected: dict[str, dict[str, Any]] = {}
526
+ for name, workflow_port in self._inputs.items():
527
+ if name not in values:
528
+ raise WorkflowValidationError(
529
+ (
530
+ ValidationIssue(
531
+ ValidationCode.MISSING_REQUIRED_INPUT,
532
+ f"Workflow input {name!r} is not provided",
533
+ ),
534
+ )
535
+ )
536
+ value = values[name]
537
+ if not workflow_port.data_type.accepts(value):
538
+ raise WorkflowValidationError(
539
+ (
540
+ ValidationIssue(
541
+ ValidationCode.TYPE_MISMATCH,
542
+ f"Workflow input {name!r} expected {workflow_port.data_type.label}, got {type(value).__name__}",
543
+ ),
544
+ )
545
+ )
546
+ injected.setdefault(workflow_port.node_id, {})[workflow_port.node_port] = value
547
+ result = WorkflowExecutor(registry).run(self, initial_inputs=injected)
548
+ return {
549
+ name: result.node_outputs[port.node_id][port.node_port]
550
+ for name, port in self._outputs.items()
551
+ }
552
+
553
+ def _workflow_port(
554
+ self,
555
+ name: str,
556
+ node_id: str,
557
+ node_port: str,
558
+ direction: PortDirection,
559
+ registry: NodeRegistry,
560
+ label: str | None,
561
+ ) -> WorkflowPort:
562
+ if not isinstance(name, str) or not name.strip():
563
+ raise ValueError("Workflow port names must be non-empty strings")
564
+ definition = registry.get(self.require_node(node_id).definition_id)
565
+ port = (
566
+ None
567
+ if definition is None
568
+ else (
569
+ definition.input_for_instance(node_port, self.require_node(node_id))
570
+ if direction is PortDirection.INPUT
571
+ else definition.output_for_instance(node_port, self.require_node(node_id))
572
+ )
573
+ )
574
+ if port is None:
575
+ raise WorkflowValidationError(
576
+ (
577
+ ValidationIssue(
578
+ ValidationCode.INVALID_EXPORTED_PORT,
579
+ f"Node {node_id!r} has no {direction.value} port {node_port!r}",
580
+ node_id=node_id,
581
+ ),
582
+ )
583
+ )
584
+ return WorkflowPort(
585
+ name.strip(), node_id, node_port, direction, port.data_type, label, port.is_param
586
+ )
587
+
588
+ def get_node(self, node_id: str) -> NodeInstance | None:
589
+ return self._nodes.get(node_id)
590
+
591
+ def require_node(self, node_id: str) -> NodeInstance:
592
+ node = self.get_node(node_id)
593
+ if node is None:
594
+ raise KeyError(f"Unknown workflow node {node_id!r}")
595
+ return node
596
+
597
+ def add_node(self, node: NodeInstance) -> None:
598
+ if node.id in self._nodes:
599
+ raise ValueError(f"Workflow node {node.id!r} already exists")
600
+ self._nodes[node.id] = node
601
+
602
+ def remove_node(self, node_id: str) -> None:
603
+ self.require_node(node_id)
604
+ del self._nodes[node_id]
605
+ self._edges = [
606
+ edge
607
+ for edge in self._edges
608
+ if edge.source_node != node_id and edge.target_node != node_id
609
+ ]
610
+ self._inputs = {
611
+ name: port for name, port in self._inputs.items() if port.node_id != node_id
612
+ }
613
+ self._outputs = {
614
+ name: port for name, port in self._outputs.items() if port.node_id != node_id
615
+ }
616
+
617
+ def sync_boundary_nodes(self, registry: NodeRegistry) -> None:
618
+ """Synchronize public ports declared by workflow boundary nodes.
619
+
620
+ Parameters
621
+ ----------
622
+ registry:
623
+ Registry used to resolve boundary node port types.
624
+
625
+ Raises
626
+ ------
627
+ ValueError
628
+ If a boundary node has a blank or duplicate public name.
629
+ """
630
+ boundary_ids = {"workflow-input", "workflow-output"}
631
+ inputs = {
632
+ name: port
633
+ for name, port in self._inputs.items()
634
+ if self.require_node(port.node_id).definition_id not in boundary_ids
635
+ }
636
+ outputs = {
637
+ name: port
638
+ for name, port in self._outputs.items()
639
+ if self.require_node(port.node_id).definition_id not in boundary_ids
640
+ }
641
+
642
+ for node in self.nodes:
643
+ if node.definition_id not in boundary_ids:
644
+ continue
645
+ name = node.parameters.get("name")
646
+ if not isinstance(name, str) or not name.strip():
647
+ raise ValueError("Workflow boundary names must be non-empty strings")
648
+ name = name.strip()
649
+ if node.definition_id == "workflow-input":
650
+ if name in inputs:
651
+ raise ValueError(f"Workflow input {name!r} already exists")
652
+ inputs[name] = self._boundary_workflow_port(
653
+ name, node, PortDirection.INPUT, registry
654
+ )
655
+ else:
656
+ if name in outputs:
657
+ raise ValueError(f"Workflow output {name!r} already exists")
658
+ port = self._boundary_workflow_port(name, node, PortDirection.OUTPUT, registry)
659
+ outputs[name] = port
660
+
661
+ self._inputs = inputs
662
+ self._outputs = outputs
663
+
664
+ def boundary_port_type(self, node: NodeInstance, registry: NodeRegistry) -> DataType[Any]:
665
+ """Infer the public type exposed by one workflow boundary node.
666
+
667
+ Workflow inputs use a type that can safely reach every connected internal
668
+ target through a direct connection or registered conversion. Workflow
669
+ outputs use the type supplied by their connected internal source. An
670
+ unconnected or ambiguous boundary retains its declared ``Any`` type.
671
+ """
672
+ definition = registry.require(node.definition_id)
673
+ fallback = definition.input_for_instance("value", node)
674
+ if fallback is None:
675
+ raise ValueError(f"Workflow boundary node {node.id!r} has no value input")
676
+ if node.definition_id == "workflow-output":
677
+ source_edge = next(
678
+ (
679
+ edge
680
+ for edge in self.edges
681
+ if edge.target_node == node.id and edge.target_port == "value"
682
+ ),
683
+ None,
684
+ )
685
+ if source_edge is None:
686
+ return fallback.data_type
687
+ source = self.get_node(source_edge.source_node)
688
+ if source is None:
689
+ return fallback.data_type
690
+ source_definition = registry.get(source.definition_id)
691
+ source_port = (
692
+ None
693
+ if source_definition is None
694
+ else source_definition.output_for_instance(source_edge.source_port, source)
695
+ )
696
+ return fallback.data_type if source_port is None else source_port.data_type
697
+ if node.definition_id != "workflow-input":
698
+ raise ValueError(f"Node {node.id!r} is not a workflow boundary node")
699
+
700
+ target_types: list[DataType[Any]] = []
701
+ for edge in self.edges:
702
+ if edge.source_node != node.id or edge.source_port != "value":
703
+ continue
704
+ target = self.get_node(edge.target_node)
705
+ if target is None:
706
+ continue
707
+ target_definition = registry.get(target.definition_id)
708
+ target_port = (
709
+ None
710
+ if target_definition is None
711
+ else target_definition.input_for_instance(edge.target_port, target)
712
+ )
713
+ if target_port is not None:
714
+ target_types.append(target_port.data_type)
715
+ return self._common_source_type(target_types, fallback.data_type)
716
+
717
+ def _boundary_workflow_port(
718
+ self,
719
+ name: str,
720
+ node: NodeInstance,
721
+ direction: PortDirection,
722
+ registry: NodeRegistry,
723
+ ) -> WorkflowPort:
724
+ """Build one inferred public interface port from a boundary node."""
725
+ return WorkflowPort(
726
+ name,
727
+ node.id,
728
+ "value",
729
+ direction,
730
+ self.boundary_port_type(node, registry),
731
+ )
732
+
733
+ @staticmethod
734
+ def _common_source_type(
735
+ target_types: list[DataType[Any]], fallback: DataType[Any]
736
+ ) -> DataType[Any]:
737
+ """Return one specific type that can safely supply every target type."""
738
+ if not target_types:
739
+ return fallback
740
+ from flowgraph.application.port_conversions import resolve_port_conversion
741
+
742
+ candidates: list[DataType[Any]] = []
743
+ for target_type in target_types:
744
+ if target_type.python_type is object or any(
745
+ target_type.id == candidate.id for candidate in candidates
746
+ ):
747
+ continue
748
+ candidates.append(target_type)
749
+ for candidate in candidates:
750
+ if all(
751
+ candidate.is_compatible_with(target_type)
752
+ or resolve_port_conversion(candidate, target_type) is not None
753
+ for target_type in target_types
754
+ ):
755
+ return candidate
756
+ return fallback
757
+
758
+ def rename_node(self, node_id: str, new_id: str) -> None:
759
+ """Rename a node and update all incident edges atomically."""
760
+ self.require_node(node_id)
761
+ if not isinstance(new_id, str) or not new_id.strip():
762
+ raise ValueError("Workflow node IDs must be non-empty strings")
763
+ new_id = new_id.strip()
764
+ if new_id != node_id and new_id in self._nodes:
765
+ raise ValueError(f"Workflow node {new_id!r} already exists")
766
+ if new_id == node_id:
767
+ return
768
+
769
+ node = self._nodes.pop(node_id)
770
+ node.id = new_id
771
+ self._nodes[new_id] = node
772
+ self._edges = [
773
+ replace(
774
+ edge,
775
+ source_node=new_id if edge.source_node == node_id else edge.source_node,
776
+ target_node=new_id if edge.target_node == node_id else edge.target_node,
777
+ )
778
+ for edge in self._edges
779
+ ]
780
+ self._inputs = {
781
+ name: replace(port, node_id=new_id if port.node_id == node_id else port.node_id)
782
+ for name, port in self._inputs.items()
783
+ }
784
+ self._outputs = {
785
+ name: replace(port, node_id=new_id if port.node_id == node_id else port.node_id)
786
+ for name, port in self._outputs.items()
787
+ }
788
+
789
+ def add_edge(self, edge: WorkflowEdge, registry: NodeRegistry) -> None:
790
+ """Add an edge transactionally if the resulting structure is valid."""
791
+ candidate = [*self._edges, edge]
792
+ issues = self._validate_edges(candidate, registry)
793
+ if (edge.target_node, edge.target_port) in {
794
+ (port.node_id, port.node_port) for port in self.inputs
795
+ }:
796
+ issues += (
797
+ ValidationIssue(
798
+ ValidationCode.EXPORTED_INPUT_CONNECTED,
799
+ f"Exported input {edge.target_node!r}.{edge.target_port!r} must not be connected",
800
+ node_id=edge.target_node,
801
+ edge_id=edge.id,
802
+ ),
803
+ )
804
+ if issues:
805
+ raise WorkflowValidationError(issues)
806
+ if self._has_cycle(candidate):
807
+ raise WorkflowValidationError(
808
+ (
809
+ ValidationIssue(
810
+ ValidationCode.CYCLE,
811
+ f"Connection {edge.id} would create a directed cycle",
812
+ edge_id=edge.id,
813
+ ),
814
+ )
815
+ )
816
+ self._edges.append(edge)
817
+
818
+ def remove_edge(self, edge: WorkflowEdge) -> None:
819
+ self._edges.remove(edge)
820
+
821
+ def edge_conversion(self, edge: WorkflowEdge, registry: NodeRegistry) -> PortConversion | None:
822
+ """Return the registered conversion used by a valid edge, if it needs one."""
823
+ from flowgraph.application.port_conversions import resolve_port_conversion
824
+
825
+ source = self.get_node(edge.source_node)
826
+ target = self.get_node(edge.target_node)
827
+ if source is None or target is None:
828
+ return None
829
+ source_definition = registry.get(source.definition_id)
830
+ target_definition = registry.get(target.definition_id)
831
+ if source_definition is None or target_definition is None:
832
+ return None
833
+ source_port = source_definition.output_for_instance(edge.source_port, source)
834
+ target_port = target_definition.input_for_instance(edge.target_port, target)
835
+ if source_port is None or target_port is None:
836
+ return None
837
+ if target_port.accepts_type(source_port.data_type):
838
+ return None
839
+ return resolve_port_conversion(source_port.data_type, target_port.data_type)
840
+
841
+ def validate(
842
+ self, registry: NodeRegistry, *, require_complete: bool = True
843
+ ) -> tuple[ValidationIssue, ...]:
844
+ issues: list[ValidationIssue] = []
845
+ for node in self.nodes:
846
+ if registry.get(node.definition_id) is None:
847
+ issues.append(
848
+ ValidationIssue(
849
+ ValidationCode.UNKNOWN_DEFINITION,
850
+ f"Node {node.id!r} uses unknown definition {node.definition_id!r}",
851
+ node_id=node.id,
852
+ )
853
+ )
854
+ issues.extend(self._validate_edges(self._edges, registry))
855
+ exported_inputs = {(port.node_id, port.node_port) for port in self.inputs}
856
+ issues.extend(
857
+ ValidationIssue(
858
+ ValidationCode.EXPORTED_INPUT_CONNECTED,
859
+ f"Exported input {edge.target_node!r}.{edge.target_port!r} must not be connected",
860
+ node_id=edge.target_node,
861
+ edge_id=edge.id,
862
+ )
863
+ for edge in self.edges
864
+ if (edge.target_node, edge.target_port) in exported_inputs
865
+ )
866
+ if self._has_cycle(self._edges):
867
+ issues.append(
868
+ ValidationIssue(ValidationCode.CYCLE, "Workflow contains a directed cycle")
869
+ )
870
+ if require_complete:
871
+ connected = {(edge.target_node, edge.target_port) for edge in self._edges}
872
+ for node in self.nodes:
873
+ definition = registry.get(node.definition_id)
874
+ if definition is None:
875
+ continue
876
+ for port in definition._inputs(definition.ports_for_instance(node)):
877
+ if port.required and (node.id, port.name) not in connected:
878
+ issues.append(
879
+ ValidationIssue(
880
+ ValidationCode.MISSING_REQUIRED_INPUT,
881
+ f"Required input {node.id!r}.{port.name!r} is not connected",
882
+ node_id=node.id,
883
+ )
884
+ )
885
+ return tuple(issues)
886
+
887
+ def topological_order(self) -> tuple[str, ...]:
888
+ """Return stable insertion-ordered topological node IDs."""
889
+ successors: dict[str, list[str]] = {}
890
+ indegree = {node.id: 0 for node in self.nodes}
891
+ for edge in self._edges:
892
+ if edge.source_node in indegree and edge.target_node in indegree:
893
+ successors.setdefault(edge.source_node, []).append(edge.target_node)
894
+ indegree[edge.target_node] += 1
895
+ ready = [node.id for node in self.nodes if indegree[node.id] == 0]
896
+ order: list[str] = []
897
+ while ready:
898
+ node_id = ready.pop(0)
899
+ order.append(node_id)
900
+ for target in successors.get(node_id, []):
901
+ indegree[target] -= 1
902
+ if indegree[target] == 0:
903
+ ready.append(target)
904
+ if len(order) != len(self._nodes):
905
+ raise WorkflowValidationError(
906
+ (ValidationIssue(ValidationCode.CYCLE, "Workflow contains a directed cycle"),)
907
+ )
908
+ return tuple(order)
909
+
910
+ def _validate_edges(
911
+ self, edges: list[WorkflowEdge], registry: NodeRegistry
912
+ ) -> tuple[ValidationIssue, ...]:
913
+ issues: list[ValidationIssue] = []
914
+ seen_edges: set[WorkflowEdge] = set()
915
+ connected_inputs: set[tuple[str, str]] = set()
916
+ for edge in edges:
917
+ if edge in seen_edges:
918
+ issues.append(
919
+ ValidationIssue(
920
+ ValidationCode.DUPLICATE_EDGE,
921
+ f"Duplicate connection {edge.id}",
922
+ edge_id=edge.id,
923
+ )
924
+ )
925
+ continue
926
+ seen_edges.add(edge)
927
+ input_address = (edge.target_node, edge.target_port)
928
+ if input_address in connected_inputs:
929
+ issues.append(
930
+ ValidationIssue(
931
+ ValidationCode.INPUT_ALREADY_CONNECTED,
932
+ f"Input {edge.target_node!r}.{edge.target_port!r} already has a connection",
933
+ edge_id=edge.id,
934
+ )
935
+ )
936
+ connected_inputs.add(input_address)
937
+
938
+ source = self.get_node(edge.source_node)
939
+ target = self.get_node(edge.target_node)
940
+ if source is None or target is None:
941
+ missing = edge.source_node if source is None else edge.target_node
942
+ issues.append(
943
+ ValidationIssue(
944
+ ValidationCode.UNKNOWN_NODE,
945
+ f"Connection {edge.id} references unknown node {missing!r}",
946
+ edge_id=edge.id,
947
+ )
948
+ )
949
+ continue
950
+ source_definition = registry.get(source.definition_id)
951
+ target_definition = registry.get(target.definition_id)
952
+ if source_definition is None or target_definition is None:
953
+ continue
954
+ source_port = source_definition.output_for_instance(edge.source_port, source)
955
+ target_port = target_definition.input_for_instance(edge.target_port, target)
956
+ if source_port is None:
957
+ issues.append(
958
+ ValidationIssue(
959
+ ValidationCode.UNKNOWN_SOURCE_PORT,
960
+ f"Node {source.id!r} has no output port {edge.source_port!r}",
961
+ edge_id=edge.id,
962
+ )
963
+ )
964
+ if target_port is None:
965
+ issues.append(
966
+ ValidationIssue(
967
+ ValidationCode.UNKNOWN_TARGET_PORT,
968
+ f"Node {target.id!r} has no input port {edge.target_port!r}",
969
+ edge_id=edge.id,
970
+ )
971
+ )
972
+ if (
973
+ source_port is not None
974
+ and target_port is not None
975
+ and not target_port.accepts_type(source_port.data_type)
976
+ and self.edge_conversion(edge, registry) is None
977
+ ):
978
+ issues.append(
979
+ ValidationIssue(
980
+ ValidationCode.TYPE_MISMATCH,
981
+ f"Cannot connect {source_port.data_type.id!r} output to "
982
+ f"{target_port.data_type.id!r} input ({edge.id})",
983
+ edge_id=edge.id,
984
+ )
985
+ )
986
+ return tuple(issues)
987
+
988
+ def _has_cycle(self, edges: list[WorkflowEdge]) -> bool:
989
+ successors: dict[str, list[str]] = {}
990
+ for edge in edges:
991
+ if edge.source_node in self._nodes and edge.target_node in self._nodes:
992
+ successors.setdefault(edge.source_node, []).append(edge.target_node)
993
+ visiting: set[str] = set()
994
+ visited: set[str] = set()
995
+
996
+ def visit(node_id: str) -> bool:
997
+ if node_id in visiting:
998
+ return True
999
+ if node_id in visited:
1000
+ return False
1001
+ visiting.add(node_id)
1002
+ if any(visit(target) for target in successors.get(node_id, [])):
1003
+ return True
1004
+ visiting.remove(node_id)
1005
+ visited.add(node_id)
1006
+ return False
1007
+
1008
+ return any(visit(node.id) for node in self.nodes if node.id not in visited)
1009
+
1010
+
1011
+ @dataclass(frozen=True)
1012
+ class WorkflowRunResult:
1013
+ execution_order: tuple[str, ...]
1014
+ node_outputs: Mapping[str, Mapping[str, Any]]
1015
+ node_inputs: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
1016
+
1017
+
1018
+ class WorkflowExecutor:
1019
+ """Execute a validated DAG synchronously and retain every node output."""
1020
+
1021
+ def __init__(self, registry: NodeRegistry) -> None:
1022
+ self._registry = registry
1023
+
1024
+ def run(
1025
+ self,
1026
+ graph: WorkflowGraph,
1027
+ *,
1028
+ node_ids: set[str] | None = None,
1029
+ initial_outputs: Mapping[str, Mapping[str, Any]] | None = None,
1030
+ initial_inputs: Mapping[str, Mapping[str, Any]] | None = None,
1031
+ ) -> WorkflowRunResult:
1032
+ """Execute all nodes, or only *node_ids* using previously resolved outputs."""
1033
+ # Missing required connections are execution-time node failures, not
1034
+ # graph-wide failures. A malformed node must not prevent independent
1035
+ # branches from running and producing useful partial results.
1036
+ issues = graph.validate(self._registry, require_complete=False)
1037
+ if issues:
1038
+ raise WorkflowValidationError(issues)
1039
+ full_order = graph.topological_order()
1040
+ selected_ids = set(full_order) if node_ids is None else set(node_ids)
1041
+ unknown_ids = selected_ids.difference(full_order)
1042
+ if unknown_ids:
1043
+ raise KeyError(f"Unknown workflow nodes {sorted(unknown_ids)!r}")
1044
+ order = tuple(node_id for node_id in full_order if node_id in selected_ids)
1045
+ outputs: dict[str, Mapping[str, Any]] = dict(initial_outputs or {})
1046
+ resolved_inputs: dict[str, Mapping[str, Any]] = {}
1047
+ first_error: NodeExecutionError | None = None
1048
+ incoming = {node.id: [] for node in graph.nodes}
1049
+ for edge in graph.edges:
1050
+ incoming[edge.target_node].append(edge)
1051
+
1052
+ for node_id in order:
1053
+ node = graph.require_node(node_id)
1054
+ definition = self._registry.require(node.definition_id)
1055
+ injected_inputs = dict((initial_inputs or {}).get(node_id, {}))
1056
+ connected_ports = {edge.target_port for edge in incoming[node_id]} | set(
1057
+ injected_inputs
1058
+ )
1059
+ missing_required = [
1060
+ port.name
1061
+ for port in definition._inputs(definition.ports_for_instance(node))
1062
+ if port.required and port.name not in connected_ports
1063
+ ]
1064
+ if missing_required:
1065
+ if first_error is None:
1066
+ first_error = NodeExecutionError(
1067
+ node.id,
1068
+ node.definition_id,
1069
+ "required inputs are not connected: " + ", ".join(missing_required),
1070
+ )
1071
+ continue
1072
+ missing_sources = [
1073
+ edge for edge in incoming[node_id] if edge.source_node not in outputs
1074
+ ]
1075
+ if missing_sources:
1076
+ missing = ", ".join(edge.source_node for edge in missing_sources)
1077
+ if first_error is None:
1078
+ first_error = NodeExecutionError(
1079
+ node.id,
1080
+ node.definition_id,
1081
+ f"upstream outputs are unavailable from: {missing}",
1082
+ )
1083
+ continue
1084
+ node_inputs: dict[str, Any] = dict(injected_inputs)
1085
+ input_error = False
1086
+ for edge in incoming[node_id]:
1087
+ value = outputs[edge.source_node][edge.source_port]
1088
+ conversion = graph.edge_conversion(edge, self._registry)
1089
+ if conversion is not None:
1090
+ try:
1091
+ value = conversion.convert(value)
1092
+ except Exception as error: # noqa: BLE001
1093
+ if first_error is None:
1094
+ first_error = NodeExecutionError(
1095
+ node.id,
1096
+ node.definition_id,
1097
+ f"conversion {conversion.label} failed for {edge.id}: {error}",
1098
+ )
1099
+ input_error = True
1100
+ continue
1101
+ target_port = definition.input_for_instance(edge.target_port, node)
1102
+ if target_port is None or not target_port.accepts(value):
1103
+ if first_error is None:
1104
+ expected = "unknown" if target_port is None else target_port.data_type.label
1105
+ first_error = NodeExecutionError(
1106
+ node.id,
1107
+ node.definition_id,
1108
+ f"input {edge.target_port!r} expected {expected}, "
1109
+ f"got {type(value).__name__}",
1110
+ )
1111
+ input_error = True
1112
+ continue
1113
+ node_inputs[edge.target_port] = value
1114
+ if input_error:
1115
+ continue
1116
+ resolved_inputs[node_id] = MappingProxyType(node_inputs)
1117
+ parameter_inputs = {
1118
+ port.name: node_inputs[port.name]
1119
+ for port in definition._inputs(definition.ports_for_instance(node))
1120
+ if port.is_param and port.name in node_inputs
1121
+ }
1122
+ # A connected parameter port is the authoritative source for that
1123
+ # parameter. Persist its resolved value so the UI and saved
1124
+ # workflow reflect the value used for this execution.
1125
+ node.parameters.update(parameter_inputs)
1126
+ try:
1127
+ raw_outputs = definition.execute_instance(
1128
+ node, MappingProxyType(node_inputs), self._registry
1129
+ )
1130
+ node_outputs = {} if raw_outputs is None else dict(raw_outputs)
1131
+ except WorkflowError as error:
1132
+ if isinstance(error, NodeExecutionError):
1133
+ if first_error is None:
1134
+ first_error = error
1135
+ continue
1136
+ raise
1137
+ except Exception as error: # noqa: BLE001
1138
+ if first_error is None:
1139
+ first_error = NodeExecutionError(node.id, node.definition_id, str(error))
1140
+ continue
1141
+ try:
1142
+ self._validate_outputs(node, definition, node_outputs)
1143
+ except NodeExecutionError as error:
1144
+ if first_error is None:
1145
+ first_error = error
1146
+ continue
1147
+ outputs[node_id] = MappingProxyType(node_outputs)
1148
+
1149
+ if first_error is not None:
1150
+ first_error.partial_result = self._partial_result(order, outputs, resolved_inputs)
1151
+ raise first_error
1152
+
1153
+ return WorkflowRunResult(
1154
+ order,
1155
+ MappingProxyType({node_id: outputs[node_id] for node_id in order}),
1156
+ MappingProxyType(resolved_inputs),
1157
+ )
1158
+
1159
+ @staticmethod
1160
+ def _partial_result(
1161
+ order: tuple[str, ...],
1162
+ outputs: Mapping[str, Mapping[str, Any]],
1163
+ resolved_inputs: Mapping[str, Mapping[str, Any]],
1164
+ ) -> WorkflowRunResult:
1165
+ """Build a result containing every successfully completed node."""
1166
+ completed = tuple(node_id for node_id in order if node_id in outputs)
1167
+ return WorkflowRunResult(
1168
+ completed,
1169
+ MappingProxyType({node_id: outputs[node_id] for node_id in completed}),
1170
+ MappingProxyType({node_id: resolved_inputs[node_id] for node_id in completed}),
1171
+ )
1172
+
1173
+ @staticmethod
1174
+ def _validate_outputs(
1175
+ node: NodeInstance, definition: NodeDefinition, outputs: dict[str, Any]
1176
+ ) -> None:
1177
+ expected = {
1178
+ port.name: port for port in definition._outputs(definition.ports_for_instance(node))
1179
+ }
1180
+ if set(outputs) != set(expected):
1181
+ raise NodeExecutionError(
1182
+ node.id,
1183
+ node.definition_id,
1184
+ f"expected outputs {sorted(expected)}, received {sorted(outputs)}",
1185
+ )
1186
+ for name, value in outputs.items():
1187
+ data_type = expected[name].data_type
1188
+ if not data_type.accepts(value):
1189
+ raise NodeExecutionError(
1190
+ node.id,
1191
+ node.definition_id,
1192
+ f"output {name!r} expected {data_type.label}, got {type(value).__name__}",
1193
+ )