horsies 0.1.0a4__py3-none-any.whl → 0.1.0a5__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.
@@ -20,8 +20,14 @@ from typing import (
20
20
  import inspect
21
21
  import time
22
22
  from horsies.core.utils.loop_runner import LoopRunner
23
- from horsies.core.errors import ErrorCode, SourceLocation, ValidationReport, raise_collected
23
+ from horsies.core.errors import (
24
+ ErrorCode,
25
+ SourceLocation,
26
+ ValidationReport,
27
+ raise_collected,
28
+ )
24
29
  from pydantic import BaseModel
30
+ from sqlalchemy import text
25
31
 
26
32
  if TYPE_CHECKING:
27
33
  from horsies.core.task_decorator import TaskFunction
@@ -75,11 +81,13 @@ class WorkflowStatus(str, Enum):
75
81
  return self in WORKFLOW_TERMINAL_STATES
76
82
 
77
83
 
78
- WORKFLOW_TERMINAL_STATES: frozenset[WorkflowStatus] = frozenset({
79
- WorkflowStatus.COMPLETED,
80
- WorkflowStatus.FAILED,
81
- WorkflowStatus.CANCELLED,
82
- })
84
+ WORKFLOW_TERMINAL_STATES: frozenset[WorkflowStatus] = frozenset(
85
+ {
86
+ WorkflowStatus.COMPLETED,
87
+ WorkflowStatus.FAILED,
88
+ WorkflowStatus.CANCELLED,
89
+ }
90
+ )
83
91
 
84
92
 
85
93
  class WorkflowTaskStatus(str, Enum):
@@ -119,11 +127,13 @@ class WorkflowTaskStatus(str, Enum):
119
127
  return self in WORKFLOW_TASK_TERMINAL_STATES
120
128
 
121
129
 
122
- WORKFLOW_TASK_TERMINAL_STATES: frozenset[WorkflowTaskStatus] = frozenset({
123
- WorkflowTaskStatus.COMPLETED,
124
- WorkflowTaskStatus.FAILED,
125
- WorkflowTaskStatus.SKIPPED,
126
- })
130
+ WORKFLOW_TASK_TERMINAL_STATES: frozenset[WorkflowTaskStatus] = frozenset(
131
+ {
132
+ WorkflowTaskStatus.COMPLETED,
133
+ WorkflowTaskStatus.FAILED,
134
+ WorkflowTaskStatus.SKIPPED,
135
+ }
136
+ )
127
137
 
128
138
 
129
139
  class OnError(str, Enum):
@@ -252,6 +262,46 @@ def _task_accepts_workflow_ctx(fn: Callable[..., Any]) -> bool:
252
262
  return 'workflow_ctx' in sig.parameters
253
263
 
254
264
 
265
+ def _get_signature(fn: Callable[..., Any]) -> inspect.Signature | None:
266
+ """Return an inspectable signature for a callable, or None if unavailable."""
267
+ inspect_target: Callable[..., Any] = fn
268
+ original = getattr(fn, '_original_fn', None)
269
+ if callable(original):
270
+ inspect_target = original
271
+
272
+ try:
273
+ return inspect.signature(inspect_target)
274
+ except (TypeError, ValueError):
275
+ return None
276
+
277
+
278
+ def _signature_accepts_kwargs(sig: inspect.Signature) -> bool:
279
+ """Return True if the signature allows **kwargs."""
280
+ return any(
281
+ param.kind == inspect.Parameter.VAR_KEYWORD
282
+ for param in sig.parameters.values()
283
+ )
284
+
285
+
286
+ def _valid_kwarg_names(
287
+ sig: inspect.Signature, *, exclude: set[str] | None = None
288
+ ) -> set[str]:
289
+ """
290
+ Return names that are valid to pass by keyword.
291
+
292
+ Excludes positional-only params and *args/**kwargs.
293
+ """
294
+ names = {
295
+ param.name
296
+ for param in sig.parameters.values()
297
+ if param.kind
298
+ in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
299
+ }
300
+ if exclude:
301
+ names -= exclude
302
+ return names
303
+
304
+
255
305
  NODE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_\-:.]+$')
256
306
 
257
307
 
@@ -298,6 +348,10 @@ class TaskNode(Generic[OkT_co]):
298
348
 
299
349
  fn: TaskFunction[Any, OkT_co]
300
350
  args: tuple[Any, ...] = ()
351
+ """
352
+ - Positional arguments passed to the task function
353
+ - Not allowed when args_from or workflow_ctx_from are set; use kwargs instead
354
+ """
301
355
  kwargs: dict[str, Any] = field(default_factory=lambda: {})
302
356
  waits_for: Sequence['TaskNode[Any] | SubWorkflowNode[Any]'] = field(
303
357
  default_factory=lambda: [],
@@ -441,6 +495,7 @@ class SubWorkflowNode(Generic[OkT_co]):
441
495
  args: tuple[Any, ...] = ()
442
496
  """
443
497
  - Positional arguments passed to workflow_def.build_with(app, *args, **kwargs)
498
+ - Not allowed when args_from or workflow_ctx_from are set; use kwargs instead
444
499
  """
445
500
 
446
501
  kwargs: dict[str, Any] = field(default_factory=lambda: {})
@@ -542,11 +597,10 @@ class SubWorkflowNode(Generic[OkT_co]):
542
597
  return NodeKey(self.node_id)
543
598
 
544
599
 
545
-
546
600
  AnyNode = TaskNode[Any] | SubWorkflowNode[Any]
547
- '''
601
+ """
548
602
  Type alias for any node type
549
- '''
603
+ """
550
604
 
551
605
  # =============================================================================
552
606
  # NodeKey (typed, stable id)
@@ -711,6 +765,12 @@ class WorkflowSpec:
711
765
  report.add(error)
712
766
  for error in self._collect_workflow_ctx_from_errors():
713
767
  report.add(error)
768
+ for error in self._collect_args_with_injection_errors():
769
+ report.add(error)
770
+ for error in self._collect_invalid_kwargs_errors():
771
+ report.add(error)
772
+ for error in self._collect_missing_required_param_errors():
773
+ report.add(error)
714
774
  for error in self._collect_output_errors():
715
775
  report.add(error)
716
776
  for error in self._collect_success_policy_errors():
@@ -752,66 +812,82 @@ class WorkflowSpec:
752
812
  node_id_from_workflow_name = task.node_id is None
753
813
  if node_id_from_workflow_name:
754
814
  if task.index is None:
755
- errors.append(WorkflowValidationError(
756
- message='TaskNode index is not set before assigning node_id',
757
- code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
758
- ))
815
+ errors.append(
816
+ WorkflowValidationError(
817
+ message='TaskNode index is not set before assigning node_id',
818
+ code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
819
+ )
820
+ )
759
821
  continue
760
822
  task.node_id = f'{slugify(self.name)}:{task.index}'
761
823
 
762
824
  node_id = task.node_id
763
825
  if node_id is None or not node_id.strip():
764
- errors.append(WorkflowValidationError(
765
- message='TaskNode node_id must be a non-empty string',
766
- code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
767
- ))
826
+ errors.append(
827
+ WorkflowValidationError(
828
+ message='TaskNode node_id must be a non-empty string',
829
+ code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
830
+ )
831
+ )
768
832
  continue
769
833
  if len(node_id) > 128:
770
834
  if node_id_from_workflow_name:
771
- errors.append(WorkflowValidationError(
772
- message='workflow name too long',
773
- code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
774
- notes=[
775
- f"workflow name: '{self.name}'",
776
- f"derived node_id would be {len(node_id)} characters (max 128)",
777
- ],
778
- help_text='use a shorter workflow name',
779
- ))
835
+ errors.append(
836
+ WorkflowValidationError(
837
+ message='workflow name too long',
838
+ code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
839
+ notes=[
840
+ f"workflow name: '{self.name}'",
841
+ f'derived node_id would be {len(node_id)} characters (max 128)',
842
+ ],
843
+ help_text='use a shorter workflow name',
844
+ )
845
+ )
780
846
  else:
781
- errors.append(WorkflowValidationError(
782
- message='TaskNode node_id exceeds 128 characters',
783
- code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
784
- notes=[f"node_id '{node_id}' has {len(node_id)} characters"],
785
- help_text='use a shorter node_id (max 128 characters)',
786
- ))
847
+ errors.append(
848
+ WorkflowValidationError(
849
+ message='TaskNode node_id exceeds 128 characters',
850
+ code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
851
+ notes=[
852
+ f"node_id '{node_id}' has {len(node_id)} characters"
853
+ ],
854
+ help_text='use a shorter node_id (max 128 characters)',
855
+ )
856
+ )
787
857
  continue
788
858
  if NODE_ID_PATTERN.match(node_id) is None:
789
859
  if node_id_from_workflow_name:
790
860
  # This should never happen since slugify() sanitizes the name
791
- errors.append(WorkflowValidationError(
792
- message='workflow name produced invalid characters (internal error)',
793
- code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
794
- notes=[
795
- f"workflow name: '{self.name}'",
796
- f"derived node_id: '{node_id}'",
797
- 'slugify() failed to sanitize the name',
798
- ],
799
- help_text='please report this bug',
800
- ))
861
+ errors.append(
862
+ WorkflowValidationError(
863
+ message='workflow name produced invalid characters (internal error)',
864
+ code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
865
+ notes=[
866
+ f"workflow name: '{self.name}'",
867
+ f"derived node_id: '{node_id}'",
868
+ 'slugify() failed to sanitize the name',
869
+ ],
870
+ help_text='please report this bug',
871
+ )
872
+ )
801
873
  else:
802
- errors.append(WorkflowValidationError(
803
- message='TaskNode node_id contains invalid characters',
804
- code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
805
- notes=[f"node_id '{node_id}'"],
806
- help_text='node_id must match pattern: [A-Za-z0-9_\\-:.]+',
807
- ))
874
+ errors.append(
875
+ WorkflowValidationError(
876
+ message='TaskNode node_id contains invalid characters',
877
+ code=ErrorCode.WORKFLOW_INVALID_NODE_ID,
878
+ notes=[f"node_id '{node_id}'"],
879
+ help_text='node_id must match pattern: [A-Za-z0-9_\\-:.]+',
880
+ )
881
+ )
808
882
  continue
809
883
  if node_id in seen_ids:
810
- errors.append(WorkflowValidationError(
811
- message=f"duplicate node_id '{node_id}'",
812
- code=ErrorCode.WORKFLOW_DUPLICATE_NODE_ID,
813
- help_text='each TaskNode must have a unique node_id within the workflow',
814
- ))
884
+ errors.append(
885
+ WorkflowValidationError(
886
+ message=f"duplicate node_id '{node_id}'",
887
+ code=ErrorCode.WORKFLOW_DUPLICATE_NODE_ID,
888
+ help_text='each TaskNode must have a unique node_id within the workflow',
889
+ )
890
+ )
815
891
  seen_ids.add(node_id)
816
892
  return errors
817
893
 
@@ -822,28 +898,32 @@ class WorkflowSpec:
822
898
  # 1. Check for roots (tasks with no dependencies)
823
899
  roots = [t for t in self.tasks if not t.waits_for]
824
900
  if not roots:
825
- errors.append(WorkflowValidationError(
826
- message='no root tasks found',
827
- code=ErrorCode.WORKFLOW_NO_ROOT_TASKS,
828
- notes=[
829
- 'all tasks have dependencies, creating an impossible start condition',
830
- ],
831
- help_text='at least one task must have empty waits_for list',
832
- ))
901
+ errors.append(
902
+ WorkflowValidationError(
903
+ message='no root tasks found',
904
+ code=ErrorCode.WORKFLOW_NO_ROOT_TASKS,
905
+ notes=[
906
+ 'all tasks have dependencies, creating an impossible start condition',
907
+ ],
908
+ help_text='at least one task must have empty waits_for list',
909
+ )
910
+ )
833
911
 
834
912
  # 2. Validate dependency references exist in workflow
835
913
  task_ids = set(id(t) for t in self.tasks)
836
914
  for task in self.tasks:
837
915
  for dep in task.waits_for:
838
916
  if id(dep) not in task_ids:
839
- errors.append(WorkflowValidationError(
840
- message='dependency references task not in workflow',
841
- code=ErrorCode.WORKFLOW_INVALID_DEPENDENCY,
842
- notes=[
843
- f"task '{task.name}' waits for a TaskNode not in this workflow",
844
- ],
845
- help_text='ensure all dependencies are included in the workflow tasks list',
846
- ))
917
+ errors.append(
918
+ WorkflowValidationError(
919
+ message='dependency references task not in workflow',
920
+ code=ErrorCode.WORKFLOW_INVALID_DEPENDENCY,
921
+ notes=[
922
+ f"task '{task.name}' waits for a TaskNode not in this workflow",
923
+ ],
924
+ help_text='ensure all dependencies are included in the workflow tasks list',
925
+ )
926
+ )
847
927
 
848
928
  # 3. Cycle detection (Kahn's algorithm) over valid dependencies only
849
929
  in_degree: dict[int, int] = {}
@@ -880,12 +960,14 @@ class WorkflowSpec:
880
960
  queue.append(task_idx)
881
961
 
882
962
  if visited != len(self.tasks):
883
- errors.append(WorkflowValidationError(
884
- message='cycle detected in workflow DAG',
885
- code=ErrorCode.WORKFLOW_CYCLE_DETECTED,
886
- notes=['workflows must be acyclic directed graphs (DAG)'],
887
- help_text='remove circular dependencies between tasks',
888
- ))
963
+ errors.append(
964
+ WorkflowValidationError(
965
+ message='cycle detected in workflow DAG',
966
+ code=ErrorCode.WORKFLOW_CYCLE_DETECTED,
967
+ notes=['workflows must be acyclic directed graphs (DAG)'],
968
+ help_text='remove circular dependencies between tasks',
969
+ )
970
+ )
889
971
 
890
972
  return errors
891
973
 
@@ -896,15 +978,17 @@ class WorkflowSpec:
896
978
  deps_ids = set(id(d) for d in task.waits_for)
897
979
  for kwarg_name, source_node in task.args_from.items():
898
980
  if id(source_node) not in deps_ids:
899
- errors.append(WorkflowValidationError(
900
- message='args_from references task not in waits_for',
901
- code=ErrorCode.WORKFLOW_INVALID_ARGS_FROM,
902
- notes=[
903
- f"task '{task.name}' args_from['{kwarg_name}'] references '{source_node.name}'",
904
- f"'{source_node.name}' must be in waits_for to inject its result",
905
- ],
906
- help_text=f"add '{source_node.name}' to waits_for list",
907
- ))
981
+ errors.append(
982
+ WorkflowValidationError(
983
+ message='args_from references task not in waits_for',
984
+ code=ErrorCode.WORKFLOW_INVALID_ARGS_FROM,
985
+ notes=[
986
+ f"task '{task.name}' args_from['{kwarg_name}'] references '{source_node.name}'",
987
+ f"'{source_node.name}' must be in waits_for to inject its result",
988
+ ],
989
+ help_text=f"add '{source_node.name}' to waits_for list",
990
+ )
991
+ )
908
992
  return errors
909
993
 
910
994
  def _collect_workflow_ctx_from_errors(self) -> list[WorkflowValidationError]:
@@ -916,15 +1000,17 @@ class WorkflowSpec:
916
1000
  deps_ids = set(id(d) for d in node.waits_for)
917
1001
  for ctx_node in node.workflow_ctx_from:
918
1002
  if id(ctx_node) not in deps_ids:
919
- errors.append(WorkflowValidationError(
920
- message='workflow_ctx_from references task not in waits_for',
921
- code=ErrorCode.WORKFLOW_INVALID_CTX_FROM,
922
- notes=[
923
- f"node '{node.name}' references '{ctx_node.name}'",
924
- f"'{ctx_node.name}' must be in waits_for to use in workflow_ctx_from",
925
- ],
926
- help_text=f"add '{ctx_node.name}' to waits_for list",
927
- ))
1003
+ errors.append(
1004
+ WorkflowValidationError(
1005
+ message='workflow_ctx_from references task not in waits_for',
1006
+ code=ErrorCode.WORKFLOW_INVALID_CTX_FROM,
1007
+ notes=[
1008
+ f"node '{node.name}' references '{ctx_node.name}'",
1009
+ f"'{ctx_node.name}' must be in waits_for to use in workflow_ctx_from",
1010
+ ],
1011
+ help_text=f"add '{ctx_node.name}' to waits_for list",
1012
+ )
1013
+ )
928
1014
 
929
1015
  # Only check function parameter for TaskNode (SubWorkflowNode has no fn)
930
1016
  if isinstance(node, SubWorkflowNode):
@@ -936,21 +1022,187 @@ class WorkflowSpec:
936
1022
  original_fn = getattr(task.fn, '_original_fn', task.fn)
937
1023
  fn_location = SourceLocation.from_function(original_fn)
938
1024
 
939
- errors.append(WorkflowValidationError(
940
- message='workflow_ctx_from declared but function missing workflow_ctx param',
941
- code=ErrorCode.WORKFLOW_CTX_PARAM_MISSING,
942
- location=fn_location, # May be None for non-function callables
1025
+ errors.append(
1026
+ WorkflowValidationError(
1027
+ message='workflow_ctx_from declared but function missing workflow_ctx param',
1028
+ code=ErrorCode.WORKFLOW_CTX_PARAM_MISSING,
1029
+ location=fn_location, # May be None for non-function callables
1030
+ notes=[
1031
+ f"workflow '{self.name}'\n"
1032
+ f"TaskNode '{task.name}' declares workflow_ctx_from=[...]\n"
1033
+ f"but function '{task.name}' has no workflow_ctx parameter",
1034
+ ],
1035
+ help_text=(
1036
+ 'either:\n'
1037
+ ' 1. add `workflow_ctx: WorkflowContext | None` param to the function above if needs context\n'
1038
+ ' 2. remove `workflow_ctx_from` from the TaskNode definition if this was a mistake'
1039
+ ),
1040
+ )
1041
+ )
1042
+ return errors
1043
+
1044
+ def _collect_args_with_injection_errors(self) -> list[WorkflowValidationError]:
1045
+ """Reject positional args when args_from/workflow_ctx_from are set."""
1046
+ errors: list[WorkflowValidationError] = []
1047
+ for node in self.tasks:
1048
+ if not node.args:
1049
+ continue
1050
+ has_args_from = bool(node.args_from)
1051
+ has_ctx_from = node.workflow_ctx_from is not None
1052
+ if not has_args_from and not has_ctx_from:
1053
+ continue
1054
+
1055
+ injected_sources: list[str] = []
1056
+ if has_args_from:
1057
+ injected_sources.append('args_from')
1058
+ if has_ctx_from:
1059
+ injected_sources.append('workflow_ctx_from')
1060
+
1061
+ injected_str = ' and '.join(injected_sources)
1062
+
1063
+ errors.append(
1064
+ WorkflowValidationError(
1065
+ message=(
1066
+ 'positional args not allowed when using args_from or workflow_ctx_from'
1067
+ ),
1068
+ code=ErrorCode.WORKFLOW_ARGS_WITH_INJECTION,
943
1069
  notes=[
944
- f"workflow '{self.name}'\n"
945
- f"TaskNode '{task.name}' declares workflow_ctx_from=[...]\n"
946
- f"but function '{task.name}' has no workflow_ctx parameter",
1070
+ f"node '{node.name}' sets args=(...) and also {injected_str}",
1071
+ 'positional args are only supported when args_from/workflow_ctx_from are not used',
947
1072
  ],
948
1073
  help_text=(
949
- 'either:\n'
950
- ' 1. add `workflow_ctx: WorkflowContext | None` param to the function above if needs context\n'
951
- ' 2. remove `workflow_ctx_from` from the TaskNode definition if this was a mistake'
1074
+ 'move static inputs into kwargs=... and reserve args_from/workflow_ctx_from '
1075
+ 'for injected values'
952
1076
  ),
953
- ))
1077
+ )
1078
+ )
1079
+ return errors
1080
+
1081
+ def _collect_invalid_kwargs_errors(self) -> list[WorkflowValidationError]:
1082
+ """Validate kwargs/args_from keys match function parameter names."""
1083
+ errors: list[WorkflowValidationError] = []
1084
+ for node in self.tasks:
1085
+ if not node.kwargs and not node.args_from:
1086
+ continue
1087
+
1088
+ # Get the function to inspect (TaskNode vs SubWorkflowNode)
1089
+ if isinstance(node, TaskNode):
1090
+ fn = node.fn
1091
+ exclude_names: set[str] = set()
1092
+ else:
1093
+ # SubWorkflowNode - validate against build_with signature
1094
+ fn = node.workflow_def.build_with
1095
+ exclude_names = {'app', 'cls'}
1096
+
1097
+ sig = _get_signature(fn)
1098
+ if sig is None:
1099
+ continue # Can't validate (uninspectable)
1100
+
1101
+ if _signature_accepts_kwargs(sig):
1102
+ continue # Accepts **kwargs, any key is valid
1103
+
1104
+ valid_names = _valid_kwarg_names(sig, exclude=exclude_names)
1105
+
1106
+ invalid_kwargs = set(node.kwargs.keys()) - valid_names
1107
+ invalid_args_from = set(node.args_from.keys()) - valid_names
1108
+ if not invalid_kwargs and not invalid_args_from:
1109
+ continue
1110
+
1111
+ notes: list[str] = []
1112
+ if invalid_kwargs:
1113
+ notes.append(
1114
+ f"node '{node.name}' has unknown kwargs: {sorted(invalid_kwargs)}"
1115
+ )
1116
+ if invalid_args_from:
1117
+ notes.append(
1118
+ f"node '{node.name}' has unknown args_from keys: {sorted(invalid_args_from)}"
1119
+ )
1120
+ notes.append(f"valid parameters: {sorted(valid_names)}")
1121
+
1122
+ errors.append(
1123
+ WorkflowValidationError(
1124
+ message='invalid kwarg key(s) for callable signature',
1125
+ code=ErrorCode.WORKFLOW_INVALID_KWARG_KEY,
1126
+ notes=notes,
1127
+ help_text='check for typos in kwarg or args_from keys',
1128
+ )
1129
+ )
1130
+ return errors
1131
+
1132
+ def _collect_missing_required_param_errors(self) -> list[WorkflowValidationError]:
1133
+ """Validate required parameters are provided via args/kwargs/args_from."""
1134
+ errors: list[WorkflowValidationError] = []
1135
+ for node in self.tasks:
1136
+ # Get the function to inspect (TaskNode vs SubWorkflowNode)
1137
+ if isinstance(node, TaskNode):
1138
+ fn = node.fn
1139
+ args_provided = list(node.args)
1140
+ injected_kwargs: set[str] = set()
1141
+ else:
1142
+ fn = node.workflow_def.build_with
1143
+ # build_with(app, *args, **kwargs) always provides the first positional
1144
+ args_provided = [object(), *node.args]
1145
+ injected_kwargs = set()
1146
+
1147
+ sig = _get_signature(fn)
1148
+ if sig is None:
1149
+ continue # Can't validate
1150
+
1151
+ provided_kwargs = set(node.kwargs.keys()) | set(node.args_from.keys())
1152
+
1153
+ # Auto-injected workflow context for TaskNode when workflow_ctx_from is set
1154
+ if isinstance(node, TaskNode):
1155
+ if node.workflow_ctx_from is not None and 'workflow_ctx' in sig.parameters:
1156
+ injected_kwargs.add('workflow_ctx')
1157
+ # WorkflowMeta is auto-injected if declared
1158
+ if 'workflow_meta' in sig.parameters:
1159
+ injected_kwargs.add('workflow_meta')
1160
+
1161
+ provided_kwargs |= injected_kwargs
1162
+
1163
+ missing: list[str] = []
1164
+ consumed_positional = 0
1165
+
1166
+ for param in sig.parameters.values():
1167
+ if param.kind in (
1168
+ inspect.Parameter.VAR_POSITIONAL,
1169
+ inspect.Parameter.VAR_KEYWORD,
1170
+ ):
1171
+ continue # *args/**kwargs do not require values
1172
+ if param.default is not inspect.Parameter.empty:
1173
+ continue # optional
1174
+
1175
+ if param.kind == inspect.Parameter.POSITIONAL_ONLY:
1176
+ if consumed_positional < len(args_provided):
1177
+ consumed_positional += 1
1178
+ continue
1179
+ missing.append(param.name)
1180
+ elif param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
1181
+ if consumed_positional < len(args_provided):
1182
+ consumed_positional += 1
1183
+ continue
1184
+ if param.name in provided_kwargs:
1185
+ continue
1186
+ missing.append(param.name)
1187
+ elif param.kind == inspect.Parameter.KEYWORD_ONLY:
1188
+ if param.name in provided_kwargs:
1189
+ continue
1190
+ missing.append(param.name)
1191
+
1192
+ if not missing:
1193
+ continue
1194
+
1195
+ target = 'task function' if isinstance(node, TaskNode) else 'build_with'
1196
+ errors.append(
1197
+ WorkflowValidationError(
1198
+ message=f'missing required parameters for {target}',
1199
+ code=ErrorCode.WORKFLOW_MISSING_REQUIRED_PARAMS,
1200
+ notes=[
1201
+ f"node '{node.name}' missing required parameter(s): {sorted(missing)}",
1202
+ ],
1203
+ help_text='provide required params via args=..., kwargs=..., or args_from',
1204
+ )
1205
+ )
954
1206
  return errors
955
1207
 
956
1208
  def _collect_output_errors(self) -> list[WorkflowValidationError]:
@@ -960,9 +1212,11 @@ class WorkflowSpec:
960
1212
  return errors
961
1213
  task_ids = set(id(t) for t in self.tasks)
962
1214
  if id(self.output) not in task_ids:
963
- errors.append(WorkflowValidationError(
964
- f"Output task '{self.output.name}' is not in workflow",
965
- ))
1215
+ errors.append(
1216
+ WorkflowValidationError(
1217
+ f"Output task '{self.output.name}' is not in workflow",
1218
+ )
1219
+ )
966
1220
  return errors
967
1221
 
968
1222
  def _collect_success_policy_errors(self) -> list[WorkflowValidationError]:
@@ -973,9 +1227,11 @@ class WorkflowSpec:
973
1227
 
974
1228
  # Validate cases list is not empty
975
1229
  if not self.success_policy.cases:
976
- errors.append(WorkflowValidationError(
977
- 'SuccessPolicy must have at least one SuccessCase',
978
- ))
1230
+ errors.append(
1231
+ WorkflowValidationError(
1232
+ 'SuccessPolicy must have at least one SuccessCase',
1233
+ )
1234
+ )
979
1235
  return errors
980
1236
 
981
1237
  task_ids = set(id(t) for t in self.tasks)
@@ -983,22 +1239,28 @@ class WorkflowSpec:
983
1239
  # Validate each success case
984
1240
  for i, case in enumerate(self.success_policy.cases):
985
1241
  if not case.required:
986
- errors.append(WorkflowValidationError(
987
- f'SuccessCase[{i}] has no required tasks',
988
- ))
1242
+ errors.append(
1243
+ WorkflowValidationError(
1244
+ f'SuccessCase[{i}] has no required tasks',
1245
+ )
1246
+ )
989
1247
  for task in case.required:
990
1248
  if id(task) not in task_ids:
991
- errors.append(WorkflowValidationError(
992
- f"SuccessCase[{i}] required task '{task.name}' is not in workflow",
993
- ))
1249
+ errors.append(
1250
+ WorkflowValidationError(
1251
+ f"SuccessCase[{i}] required task '{task.name}' is not in workflow",
1252
+ )
1253
+ )
994
1254
 
995
1255
  # Validate optional tasks
996
1256
  if self.success_policy.optional:
997
1257
  for task in self.success_policy.optional:
998
1258
  if id(task) not in task_ids:
999
- errors.append(WorkflowValidationError(
1000
- f"SuccessPolicy optional task '{task.name}' is not in workflow",
1001
- ))
1259
+ errors.append(
1260
+ WorkflowValidationError(
1261
+ f"SuccessPolicy optional task '{task.name}' is not in workflow",
1262
+ )
1263
+ )
1002
1264
 
1003
1265
  return errors
1004
1266
 
@@ -1008,26 +1270,34 @@ class WorkflowSpec:
1008
1270
  for task in self.tasks:
1009
1271
  if task.join == 'quorum':
1010
1272
  if task.min_success is None:
1011
- errors.append(WorkflowValidationError(
1012
- f"Task '{task.name}' has join='quorum' but min_success is not set",
1013
- ))
1273
+ errors.append(
1274
+ WorkflowValidationError(
1275
+ f"Task '{task.name}' has join='quorum' but min_success is not set",
1276
+ )
1277
+ )
1014
1278
  elif task.min_success < 1:
1015
- errors.append(WorkflowValidationError(
1016
- f"Task '{task.name}' min_success must be >= 1, got {task.min_success}",
1017
- ))
1279
+ errors.append(
1280
+ WorkflowValidationError(
1281
+ f"Task '{task.name}' min_success must be >= 1, got {task.min_success}",
1282
+ )
1283
+ )
1018
1284
  else:
1019
1285
  dep_count = len(task.waits_for)
1020
1286
  if task.min_success > dep_count:
1021
- errors.append(WorkflowValidationError(
1022
- f"Task '{task.name}' min_success ({task.min_success}) exceeds "
1023
- f'dependency count ({dep_count})',
1024
- ))
1287
+ errors.append(
1288
+ WorkflowValidationError(
1289
+ f"Task '{task.name}' min_success ({task.min_success}) exceeds "
1290
+ f'dependency count ({dep_count})',
1291
+ )
1292
+ )
1025
1293
  elif task.join in ('all', 'any'):
1026
1294
  if task.min_success is not None:
1027
- errors.append(WorkflowValidationError(
1028
- f"Task '{task.name}' has min_success set but join='{task.join}' "
1029
- "(min_success is only used with join='quorum')",
1030
- ))
1295
+ errors.append(
1296
+ WorkflowValidationError(
1297
+ f"Task '{task.name}' has min_success set but join='{task.join}' "
1298
+ "(min_success is only used with join='quorum')",
1299
+ )
1300
+ )
1031
1301
  return errors
1032
1302
 
1033
1303
  def _validate_conditions(self) -> None:
@@ -1057,15 +1327,17 @@ class WorkflowSpec:
1057
1327
  """DFS visit with cycle detection via recursion stack."""
1058
1328
  if workflow_name in stack:
1059
1329
  # Found a back-edge - this is a cycle
1060
- errors.append(WorkflowValidationError(
1061
- message='cycle detected in nested workflows',
1062
- code=ErrorCode.WORKFLOW_CYCLE_DETECTED,
1063
- notes=[
1064
- f"workflow '{workflow_name}' creates a circular reference",
1065
- 'cycles in nested workflows are not allowed',
1066
- ],
1067
- help_text='remove the circular SubWorkflowNode reference',
1068
- ))
1330
+ errors.append(
1331
+ WorkflowValidationError(
1332
+ message='cycle detected in nested workflows',
1333
+ code=ErrorCode.WORKFLOW_CYCLE_DETECTED,
1334
+ notes=[
1335
+ f"workflow '{workflow_name}' creates a circular reference",
1336
+ 'cycles in nested workflows are not allowed',
1337
+ ],
1338
+ help_text='remove the circular SubWorkflowNode reference',
1339
+ )
1340
+ )
1069
1341
  return
1070
1342
 
1071
1343
  if workflow_name in visited:
@@ -1102,15 +1374,17 @@ class WorkflowSpec:
1102
1374
  if not isinstance(node, SubWorkflowNode):
1103
1375
  continue
1104
1376
  if node.retry_mode != SubWorkflowRetryMode.RERUN_FAILED_ONLY:
1105
- errors.append(WorkflowValidationError(
1106
- message='unsupported SubWorkflowRetryMode',
1107
- code=ErrorCode.WORKFLOW_INVALID_SUBWORKFLOW_RETRY_MODE,
1108
- notes=[
1109
- f"node '{node.name}' uses retry_mode='{node.retry_mode.value}'",
1110
- "only 'rerun_failed_only' is supported in this release",
1111
- ],
1112
- help_text='use SubWorkflowRetryMode.RERUN_FAILED_ONLY',
1113
- ))
1377
+ errors.append(
1378
+ WorkflowValidationError(
1379
+ message='unsupported SubWorkflowRetryMode',
1380
+ code=ErrorCode.WORKFLOW_INVALID_SUBWORKFLOW_RETRY_MODE,
1381
+ notes=[
1382
+ f"node '{node.name}' uses retry_mode='{node.retry_mode.value}'",
1383
+ "only 'rerun_failed_only' is supported in this release",
1384
+ ],
1385
+ help_text='use SubWorkflowRetryMode.RERUN_FAILED_ONLY',
1386
+ )
1387
+ )
1114
1388
  return errors
1115
1389
 
1116
1390
  def _register_for_conditions(self) -> None:
@@ -1365,6 +1639,57 @@ class WorkflowContext(BaseModel):
1365
1639
  )
1366
1640
 
1367
1641
 
1642
+ # =============================================================================
1643
+ # SQL constants for WorkflowHandle
1644
+ # =============================================================================
1645
+
1646
+ GET_WORKFLOW_STATUS_SQL = text("""
1647
+ SELECT status FROM horsies_workflows WHERE id = :wf_id
1648
+ """)
1649
+
1650
+ GET_WORKFLOW_RESULT_SQL = text("""
1651
+ SELECT result FROM horsies_workflows WHERE id = :wf_id
1652
+ """)
1653
+
1654
+ GET_WORKFLOW_ERROR_SQL = text("""
1655
+ SELECT error, status FROM horsies_workflows WHERE id = :wf_id
1656
+ """)
1657
+
1658
+ GET_WORKFLOW_TASK_RESULTS_SQL = text("""
1659
+ SELECT node_id, result
1660
+ FROM horsies_workflow_tasks
1661
+ WHERE workflow_id = :wf_id
1662
+ AND result IS NOT NULL
1663
+ """)
1664
+
1665
+ GET_WORKFLOW_TASK_RESULT_BY_NODE_SQL = text("""
1666
+ SELECT result
1667
+ FROM horsies_workflow_tasks
1668
+ WHERE workflow_id = :wf_id
1669
+ AND node_id = :node_id
1670
+ AND result IS NOT NULL
1671
+ """)
1672
+
1673
+ GET_WORKFLOW_TASKS_SQL = text("""
1674
+ SELECT node_id, task_index, task_name, status, result, started_at, completed_at
1675
+ FROM horsies_workflow_tasks
1676
+ WHERE workflow_id = :wf_id
1677
+ ORDER BY task_index
1678
+ """)
1679
+
1680
+ CANCEL_WORKFLOW_SQL = text("""
1681
+ UPDATE horsies_workflows
1682
+ SET status = 'CANCELLED', updated_at = NOW()
1683
+ WHERE id = :wf_id AND status IN ('PENDING', 'RUNNING', 'PAUSED')
1684
+ """)
1685
+
1686
+ SKIP_WORKFLOW_TASKS_ON_CANCEL_SQL = text("""
1687
+ UPDATE horsies_workflow_tasks
1688
+ SET status = 'SKIPPED'
1689
+ WHERE workflow_id = :wf_id AND status IN ('PENDING', 'READY')
1690
+ """)
1691
+
1692
+
1368
1693
  # =============================================================================
1369
1694
  # WorkflowHandle
1370
1695
  # =============================================================================
@@ -1409,11 +1734,9 @@ class WorkflowHandle:
1409
1734
 
1410
1735
  async def status_async(self) -> WorkflowStatus:
1411
1736
  """Async version of status()."""
1412
- from sqlalchemy import text
1413
-
1414
1737
  async with self.broker.session_factory() as session:
1415
1738
  result = await session.execute(
1416
- text('SELECT status FROM horsies_workflows WHERE id = :wf_id'),
1739
+ GET_WORKFLOW_STATUS_SQL,
1417
1740
  {'wf_id': self.workflow_id},
1418
1741
  )
1419
1742
  row = result.fetchone()
@@ -1480,14 +1803,12 @@ class WorkflowHandle:
1480
1803
 
1481
1804
  async def _get_result(self) -> TaskResult[Any, TaskError]:
1482
1805
  """Fetch completed workflow result."""
1483
- from sqlalchemy import text
1484
-
1485
1806
  from horsies.core.models.tasks import TaskResult
1486
1807
  from horsies.core.codec.serde import loads_json, task_result_from_json
1487
1808
 
1488
1809
  async with self.broker.session_factory() as session:
1489
1810
  result = await session.execute(
1490
- text('SELECT result FROM horsies_workflows WHERE id = :wf_id'),
1811
+ GET_WORKFLOW_RESULT_SQL,
1491
1812
  {'wf_id': self.workflow_id},
1492
1813
  )
1493
1814
  row = result.fetchone()
@@ -1497,14 +1818,12 @@ class WorkflowHandle:
1497
1818
 
1498
1819
  async def _get_error(self) -> TaskResult[Any, TaskError]:
1499
1820
  """Fetch failed workflow error."""
1500
- from sqlalchemy import text
1501
-
1502
1821
  from horsies.core.models.tasks import TaskResult, TaskError
1503
1822
  from horsies.core.codec.serde import loads_json
1504
1823
 
1505
1824
  async with self.broker.session_factory() as session:
1506
1825
  result = await session.execute(
1507
- text('SELECT error, status FROM horsies_workflows WHERE id = :wf_id'),
1826
+ GET_WORKFLOW_ERROR_SQL,
1508
1827
  {'wf_id': self.workflow_id},
1509
1828
  )
1510
1829
  row = result.fetchone()
@@ -1571,18 +1890,11 @@ class WorkflowHandle:
1571
1890
  Keys are `node_id` values. If a TaskNode did not specify a node_id,
1572
1891
  WorkflowSpec auto-assigns one as "{workflow_name}:{task_index}".
1573
1892
  """
1574
- from sqlalchemy import text
1575
-
1576
1893
  from horsies.core.codec.serde import loads_json, task_result_from_json
1577
1894
 
1578
1895
  async with self.broker.session_factory() as session:
1579
1896
  result = await session.execute(
1580
- text("""
1581
- SELECT node_id, result
1582
- FROM horsies_workflow_tasks
1583
- WHERE workflow_id = :wf_id
1584
- AND result IS NOT NULL
1585
- """),
1897
+ GET_WORKFLOW_TASK_RESULTS_SQL,
1586
1898
  {'wf_id': self.workflow_id},
1587
1899
  )
1588
1900
 
@@ -1629,8 +1941,6 @@ class WorkflowHandle:
1629
1941
  self, node: TaskNode[OkT] | NodeKey[OkT]
1630
1942
  ) -> 'TaskResult[OkT, TaskError]':
1631
1943
  """Async version of result_for(). See result_for() for full documentation."""
1632
- from sqlalchemy import text
1633
-
1634
1944
  from horsies.core.codec.serde import loads_json, task_result_from_json
1635
1945
 
1636
1946
  node_id: str | None
@@ -1647,13 +1957,7 @@ class WorkflowHandle:
1647
1957
 
1648
1958
  async with self.broker.session_factory() as session:
1649
1959
  result = await session.execute(
1650
- text("""
1651
- SELECT result
1652
- FROM horsies_workflow_tasks
1653
- WHERE workflow_id = :wf_id
1654
- AND node_id = :node_id
1655
- AND result IS NOT NULL
1656
- """),
1960
+ GET_WORKFLOW_TASK_RESULT_BY_NODE_SQL,
1657
1961
  {'wf_id': self.workflow_id, 'node_id': node_id},
1658
1962
  )
1659
1963
  row = result.fetchone()
@@ -1694,18 +1998,11 @@ class WorkflowHandle:
1694
1998
 
1695
1999
  async def tasks_async(self) -> list[WorkflowTaskInfo]:
1696
2000
  """Async version of tasks()."""
1697
- from sqlalchemy import text
1698
-
1699
2001
  from horsies.core.codec.serde import loads_json, task_result_from_json
1700
2002
 
1701
2003
  async with self.broker.session_factory() as session:
1702
2004
  result = await session.execute(
1703
- text("""
1704
- SELECT node_id, task_index, task_name, status, result, started_at, completed_at
1705
- FROM horsies_workflow_tasks
1706
- WHERE workflow_id = :wf_id
1707
- ORDER BY task_index
1708
- """),
2005
+ GET_WORKFLOW_TASKS_SQL,
1709
2006
  {'wf_id': self.workflow_id},
1710
2007
  )
1711
2008
 
@@ -1736,26 +2033,16 @@ class WorkflowHandle:
1736
2033
 
1737
2034
  async def cancel_async(self) -> None:
1738
2035
  """Async version of cancel()."""
1739
- from sqlalchemy import text
1740
-
1741
2036
  async with self.broker.session_factory() as session:
1742
2037
  # Cancel workflow
1743
2038
  await session.execute(
1744
- text("""
1745
- UPDATE horsies_workflows
1746
- SET status = 'CANCELLED', updated_at = NOW()
1747
- WHERE id = :wf_id AND status IN ('PENDING', 'RUNNING', 'PAUSED')
1748
- """),
2039
+ CANCEL_WORKFLOW_SQL,
1749
2040
  {'wf_id': self.workflow_id},
1750
2041
  )
1751
2042
 
1752
2043
  # Skip pending/ready tasks
1753
2044
  await session.execute(
1754
- text("""
1755
- UPDATE horsies_workflow_tasks
1756
- SET status = 'SKIPPED'
1757
- WHERE workflow_id = :wf_id AND status IN ('PENDING', 'READY')
1758
- """),
2045
+ SKIP_WORKFLOW_TASKS_ON_CANCEL_SQL,
1759
2046
  {'wf_id': self.workflow_id},
1760
2047
  )
1761
2048