horsies 0.1.0a3__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.
- horsies/core/app.py +67 -47
- horsies/core/banner.py +27 -27
- horsies/core/brokers/postgres.py +315 -288
- horsies/core/cli.py +7 -2
- horsies/core/errors.py +3 -0
- horsies/core/models/app.py +87 -64
- horsies/core/models/recovery.py +30 -21
- horsies/core/models/schedule.py +30 -19
- horsies/core/models/tasks.py +1 -0
- horsies/core/models/workflow.py +489 -202
- horsies/core/models/workflow_pg.py +3 -1
- horsies/core/scheduler/service.py +5 -1
- horsies/core/scheduler/state.py +39 -27
- horsies/core/task_decorator.py +138 -0
- horsies/core/types/status.py +14 -12
- horsies/core/utils/imports.py +10 -10
- horsies/core/worker/worker.py +197 -139
- horsies/core/workflows/engine.py +487 -352
- horsies/core/workflows/recovery.py +148 -119
- {horsies-0.1.0a3.dist-info → horsies-0.1.0a5.dist-info}/METADATA +1 -1
- horsies-0.1.0a5.dist-info/RECORD +42 -0
- horsies-0.1.0a3.dist-info/RECORD +0 -42
- {horsies-0.1.0a3.dist-info → horsies-0.1.0a5.dist-info}/WHEEL +0 -0
- {horsies-0.1.0a3.dist-info → horsies-0.1.0a5.dist-info}/entry_points.txt +0 -0
- {horsies-0.1.0a3.dist-info → horsies-0.1.0a5.dist-info}/top_level.txt +0 -0
horsies/core/models/workflow.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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(
|
|
756
|
-
|
|
757
|
-
|
|
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(
|
|
765
|
-
|
|
766
|
-
|
|
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(
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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(
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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(
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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(
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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(
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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(
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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(
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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(
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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(
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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(
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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"
|
|
945
|
-
|
|
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
|
-
'
|
|
950
|
-
'
|
|
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(
|
|
964
|
-
|
|
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(
|
|
977
|
-
|
|
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(
|
|
987
|
-
|
|
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(
|
|
992
|
-
|
|
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(
|
|
1000
|
-
|
|
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(
|
|
1012
|
-
|
|
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(
|
|
1016
|
-
|
|
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(
|
|
1022
|
-
|
|
1023
|
-
|
|
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(
|
|
1028
|
-
|
|
1029
|
-
|
|
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(
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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(
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|