forktex-flow 0.1.0__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.
- forktex_flow/__init__.py +106 -0
- forktex_flow/domain/__init__.py +76 -0
- forktex_flow/domain/config.py +88 -0
- forktex_flow/domain/definition.py +125 -0
- forktex_flow/domain/filter.py +71 -0
- forktex_flow/domain/node.py +137 -0
- forktex_flow/domain/state.py +107 -0
- forktex_flow/domain/types.py +106 -0
- forktex_flow/errors.py +108 -0
- forktex_flow/extension.py +81 -0
- forktex_flow/flow.py +345 -0
- forktex_flow/persist/__init__.py +50 -0
- forktex_flow/persist/mappers.py +73 -0
- forktex_flow/persist/migrations/__init__.py +68 -0
- forktex_flow/persist/migrations/v0001__schema.sql +125 -0
- forktex_flow/persist/models.py +206 -0
- forktex_flow/persist/runs.py +231 -0
- forktex_flow/persist/steps.py +284 -0
- forktex_flow/persist/store.py +66 -0
- forktex_flow/py.typed +0 -0
- forktex_flow/read/__init__.py +49 -0
- forktex_flow/read/instance.py +347 -0
- forktex_flow/read/query.py +231 -0
- forktex_flow/runtime/__init__.py +51 -0
- forktex_flow/runtime/compiler.py +168 -0
- forktex_flow/runtime/context.py +53 -0
- forktex_flow/runtime/ctx.py +99 -0
- forktex_flow/runtime/driver.py +312 -0
- forktex_flow/runtime/execution.py +363 -0
- forktex_flow/runtime/registry.py +73 -0
- forktex_flow/testing.py +153 -0
- forktex_flow-0.1.0.dist-info/METADATA +357 -0
- forktex_flow-0.1.0.dist-info/RECORD +36 -0
- forktex_flow-0.1.0.dist-info/WHEEL +4 -0
- forktex_flow-0.1.0.dist-info/licenses/LICENSE +45 -0
- forktex_flow-0.1.0.dist-info/licenses/NOTICE +23 -0
forktex_flow/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copyright (C) 2026 FORKTEX S.R.L.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
|
|
4
|
+
#
|
|
5
|
+
# This file is part of ForkTex Flow.
|
|
6
|
+
#
|
|
7
|
+
# For commercial licensing -- including use in proprietary products, SaaS
|
|
8
|
+
# deployments, or any context where AGPL obligations cannot be met -- you
|
|
9
|
+
# MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
|
|
10
|
+
#
|
|
11
|
+
# This program is free software: you can redistribute it and/or modify
|
|
12
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
13
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
14
|
+
# (at your option) any later version.
|
|
15
|
+
#
|
|
16
|
+
# This program is distributed in the hope that it will be useful,
|
|
17
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
18
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
19
|
+
# GNU Affero General Public License for more details.
|
|
20
|
+
#
|
|
21
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
22
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
23
|
+
|
|
24
|
+
"""Postgres-native durable workflow execution, built on the ``forktex`` substrate.
|
|
25
|
+
|
|
26
|
+
A workflow is an ordered list of steps. Declare one of two ways::
|
|
27
|
+
|
|
28
|
+
flow = Flow(database_url="postgresql+asyncpg://...")
|
|
29
|
+
|
|
30
|
+
@flow.scheduled("cloud.backup.create", version=1, cron="0 2 * * *", state=BackupState)
|
|
31
|
+
async def backup_create(ctx: Ctx, state: BackupState) -> dict: ...
|
|
32
|
+
|
|
33
|
+
@flow.pipeline("cloud.deploy.apply", version=4, state=DeployState)
|
|
34
|
+
class DeployApply:
|
|
35
|
+
steps = [load_manifest, provision, step(dns_setup, when=lambda s: s["dns_enabled"])]
|
|
36
|
+
|
|
37
|
+
Dispatch and read::
|
|
38
|
+
|
|
39
|
+
instance = await flow.run("cloud.deploy.apply", state={...}, metadata={...})
|
|
40
|
+
page = await flow.query().workflow("cloud.deploy.apply").status("running").fetch()
|
|
41
|
+
|
|
42
|
+
A step is ``async def fn(ctx: Ctx, state: dict) -> dict`` and returns the *delta*
|
|
43
|
+
it wants merged, not the whole state. How a delta merges is declared on the
|
|
44
|
+
state class::
|
|
45
|
+
|
|
46
|
+
class DeployState(TypedDict):
|
|
47
|
+
logs: Annotated[list[str], operator.add] # accumulates across steps
|
|
48
|
+
server_id: str # last write wins
|
|
49
|
+
|
|
50
|
+
Every step is memoised on ``(run_id, step, input state)``, so a run picked up
|
|
51
|
+
after a crash re-executes from the top and skips straight past the work that
|
|
52
|
+
already happened.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from forktex_flow.domain.config import RuntimeConfig
|
|
56
|
+
from forktex_flow.domain.node import StepSpec, step
|
|
57
|
+
from forktex_flow.domain.state import apply_state_update, extract_reducers
|
|
58
|
+
from forktex_flow.domain.types import (
|
|
59
|
+
TERMINAL_STATUSES,
|
|
60
|
+
RunInfo,
|
|
61
|
+
RunStatus,
|
|
62
|
+
RunSummary,
|
|
63
|
+
StepRunInfo,
|
|
64
|
+
StepStatus,
|
|
65
|
+
)
|
|
66
|
+
from forktex_flow.errors import FlowError, StepFailed, WorkflowCancelled, WorkflowFailed
|
|
67
|
+
from forktex_flow.extension import FlowExtension
|
|
68
|
+
from forktex_flow.flow import Flow
|
|
69
|
+
from forktex_flow.persist.migrations import apply_migrations
|
|
70
|
+
from forktex_flow.read.instance import (
|
|
71
|
+
InstancePage,
|
|
72
|
+
InstanceQuery,
|
|
73
|
+
NodeInstance,
|
|
74
|
+
WorkflowInstance,
|
|
75
|
+
)
|
|
76
|
+
from forktex_flow.runtime.ctx import Ctx
|
|
77
|
+
from forktex_flow.testing import isolated_flow, make_ctx, run_to_completion
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"TERMINAL_STATUSES",
|
|
81
|
+
"Ctx",
|
|
82
|
+
"Flow",
|
|
83
|
+
"FlowError",
|
|
84
|
+
"FlowExtension",
|
|
85
|
+
"InstancePage",
|
|
86
|
+
"InstanceQuery",
|
|
87
|
+
"NodeInstance",
|
|
88
|
+
"RunInfo",
|
|
89
|
+
"RunStatus",
|
|
90
|
+
"RunSummary",
|
|
91
|
+
"RuntimeConfig",
|
|
92
|
+
"StepFailed",
|
|
93
|
+
"StepRunInfo",
|
|
94
|
+
"StepSpec",
|
|
95
|
+
"StepStatus",
|
|
96
|
+
"WorkflowCancelled",
|
|
97
|
+
"WorkflowFailed",
|
|
98
|
+
"WorkflowInstance",
|
|
99
|
+
"apply_migrations",
|
|
100
|
+
"apply_state_update",
|
|
101
|
+
"extract_reducers",
|
|
102
|
+
"isolated_flow",
|
|
103
|
+
"make_ctx",
|
|
104
|
+
"run_to_completion",
|
|
105
|
+
"step",
|
|
106
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (C) 2026 FORKTEX S.R.L.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
|
|
4
|
+
#
|
|
5
|
+
# This file is part of ForkTex Flow.
|
|
6
|
+
#
|
|
7
|
+
# For commercial licensing -- including use in proprietary products, SaaS
|
|
8
|
+
# deployments, or any context where AGPL obligations cannot be met -- you
|
|
9
|
+
# MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
|
|
10
|
+
#
|
|
11
|
+
# This program is free software: you can redistribute it and/or modify
|
|
12
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
13
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
14
|
+
# (at your option) any later version.
|
|
15
|
+
#
|
|
16
|
+
# This program is distributed in the hope that it will be useful,
|
|
17
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
18
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
19
|
+
# GNU Affero General Public License for more details.
|
|
20
|
+
#
|
|
21
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
22
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
23
|
+
|
|
24
|
+
"""Flow's vocabulary: frozen value objects and the declarative workflow shapes.
|
|
25
|
+
|
|
26
|
+
The bottom of the package's DAG — nothing here imports anything else from
|
|
27
|
+
`forktex_flow`, and nothing here performs I/O. A reader can learn what a workflow
|
|
28
|
+
*is* from this layer alone, without meeting a session or an engine.
|
|
29
|
+
|
|
30
|
+
Four modules, split by what they describe: `types` the run/step row shapes and
|
|
31
|
+
statuses, `definition` the compiled workflow (an ordered list of steps), `node`
|
|
32
|
+
the authoring decorator, and `state` the reducer that merges a step's partial
|
|
33
|
+
update into the run state.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from forktex_flow.domain.definition import (
|
|
37
|
+
NodeDef,
|
|
38
|
+
NodeFn,
|
|
39
|
+
WhenFn,
|
|
40
|
+
WorkflowDefinition,
|
|
41
|
+
)
|
|
42
|
+
from forktex_flow.domain.node import (
|
|
43
|
+
StepMeta,
|
|
44
|
+
StepSpec,
|
|
45
|
+
has_step_meta,
|
|
46
|
+
step,
|
|
47
|
+
step_meta,
|
|
48
|
+
)
|
|
49
|
+
from forktex_flow.domain.state import ReducerFn, apply_state_update, extract_reducers
|
|
50
|
+
from forktex_flow.domain.types import (
|
|
51
|
+
TERMINAL_STATUSES,
|
|
52
|
+
RunInfo,
|
|
53
|
+
RunStatus,
|
|
54
|
+
StepRunInfo,
|
|
55
|
+
StepStatus,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"TERMINAL_STATUSES",
|
|
60
|
+
"NodeDef",
|
|
61
|
+
"NodeFn",
|
|
62
|
+
"ReducerFn",
|
|
63
|
+
"RunInfo",
|
|
64
|
+
"RunStatus",
|
|
65
|
+
"StepMeta",
|
|
66
|
+
"StepRunInfo",
|
|
67
|
+
"StepSpec",
|
|
68
|
+
"StepStatus",
|
|
69
|
+
"WhenFn",
|
|
70
|
+
"WorkflowDefinition",
|
|
71
|
+
"apply_state_update",
|
|
72
|
+
"extract_reducers",
|
|
73
|
+
"has_step_meta",
|
|
74
|
+
"step",
|
|
75
|
+
"step_meta",
|
|
76
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright (C) 2026 FORKTEX S.R.L.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
|
|
4
|
+
#
|
|
5
|
+
# This file is part of ForkTex Flow.
|
|
6
|
+
#
|
|
7
|
+
# For commercial licensing -- including use in proprietary products, SaaS
|
|
8
|
+
# deployments, or any context where AGPL obligations cannot be met -- you
|
|
9
|
+
# MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
|
|
10
|
+
#
|
|
11
|
+
# This program is free software: you can redistribute it and/or modify
|
|
12
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
13
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
14
|
+
# (at your option) any later version.
|
|
15
|
+
#
|
|
16
|
+
# This program is distributed in the hope that it will be useful,
|
|
17
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
18
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
19
|
+
# GNU Affero General Public License for more details.
|
|
20
|
+
#
|
|
21
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
22
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
23
|
+
|
|
24
|
+
"""Execution tuning — the knobs the driver and executor read.
|
|
25
|
+
|
|
26
|
+
A frozen value object rather than attributes on ``Flow``, because it is the
|
|
27
|
+
whole reason the lower layers used to take the facade: they needed a handful of
|
|
28
|
+
numbers and took the entire engine to get them. Pure data, no I/O, so it sits at the
|
|
29
|
+
bottom of the DAG where every layer can read it.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from forktex.error import BadRequestError
|
|
35
|
+
from forktex.types import BaseValueObject
|
|
36
|
+
from pydantic import model_validator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RuntimeConfig(BaseValueObject):
|
|
40
|
+
"""How hard the driver polls, and how patient it is with a stalled step.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
poll_interval: Seconds between driver ticks when it holds the lock.
|
|
44
|
+
election_interval: Seconds a worker waits before standing for election
|
|
45
|
+
again after losing one. Bounds how long a takeover
|
|
46
|
+
takes after the leader dies: too long and work sits
|
|
47
|
+
idle, too short and every worker polls the lock.
|
|
48
|
+
heartbeat_interval: Seconds between heartbeat writes on a running step.
|
|
49
|
+
stale_threshold: Seconds without a heartbeat after which a step is
|
|
50
|
+
considered abandoned and may be reclaimed. Must
|
|
51
|
+
exceed ``heartbeat_interval``, or a healthy step
|
|
52
|
+
gets reclaimed out from under itself.
|
|
53
|
+
max_attempts: Default attempts per step, when ``@step`` does not
|
|
54
|
+
override it.
|
|
55
|
+
backoff: Default per-attempt delay in seconds; the last entry
|
|
56
|
+
is reused once attempts run past its length.
|
|
57
|
+
leader_lock_key: The advisory-lock key the driver competes on.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
poll_interval: float = 1.0
|
|
61
|
+
election_interval: float = 2.0
|
|
62
|
+
heartbeat_interval: float = 10.0
|
|
63
|
+
stale_threshold: float = 60.0
|
|
64
|
+
max_attempts: int = 3
|
|
65
|
+
backoff: tuple[float, ...] = (30.0, 120.0, 300.0)
|
|
66
|
+
leader_lock_key: int = 0
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def _check(self) -> RuntimeConfig:
|
|
70
|
+
if self.max_attempts < 1:
|
|
71
|
+
raise BadRequestError("max_attempts must be >= 1")
|
|
72
|
+
if not self.backoff:
|
|
73
|
+
raise BadRequestError("backoff must be a non-empty tuple")
|
|
74
|
+
if self.poll_interval <= 0:
|
|
75
|
+
raise BadRequestError("poll_interval must be > 0")
|
|
76
|
+
if self.election_interval <= 0:
|
|
77
|
+
raise BadRequestError("election_interval must be > 0")
|
|
78
|
+
if self.stale_threshold <= self.heartbeat_interval:
|
|
79
|
+
# Otherwise the reclaim scan finds a step whose heartbeat is merely
|
|
80
|
+
# *due* and hands it to a second worker, which is how one step body
|
|
81
|
+
# ends up running twice.
|
|
82
|
+
raise BadRequestError(
|
|
83
|
+
f"stale_threshold ({self.stale_threshold}) must exceed heartbeat_interval ({self.heartbeat_interval})"
|
|
84
|
+
)
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ["RuntimeConfig"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Copyright (C) 2026 FORKTEX S.R.L.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
|
|
4
|
+
#
|
|
5
|
+
# This file is part of ForkTex Flow.
|
|
6
|
+
#
|
|
7
|
+
# For commercial licensing -- including use in proprietary products, SaaS
|
|
8
|
+
# deployments, or any context where AGPL obligations cannot be met -- you
|
|
9
|
+
# MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
|
|
10
|
+
#
|
|
11
|
+
# This program is free software: you can redistribute it and/or modify
|
|
12
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
13
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
14
|
+
# (at your option) any later version.
|
|
15
|
+
#
|
|
16
|
+
# This program is distributed in the hope that it will be useful,
|
|
17
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
18
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
19
|
+
# GNU Affero General Public License for more details.
|
|
20
|
+
#
|
|
21
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
22
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
23
|
+
|
|
24
|
+
"""WorkflowDefinition — the internal model every decorator compiles to.
|
|
25
|
+
|
|
26
|
+
A workflow is an **ordered list of steps**. That is the whole topology: there is
|
|
27
|
+
no edge set, no router, no START/END sentinel and no reachability analysis,
|
|
28
|
+
because a pipeline's order *is* its graph and a scheduled workflow is a pipeline
|
|
29
|
+
of one. Branching is expressed by a step's ``when_fn`` — a pure predicate over
|
|
30
|
+
the state dict, evaluated immediately before dispatch — which is strictly weaker
|
|
31
|
+
than a router and strictly easier to reason about: a run visits every node once,
|
|
32
|
+
in order, executing the ones whose guard passes.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections.abc import Awaitable, Callable
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from forktex.error import BadRequestError
|
|
41
|
+
from forktex.types import BaseValueObject
|
|
42
|
+
from pydantic import BaseModel, Field, model_validator
|
|
43
|
+
|
|
44
|
+
from forktex_flow.domain.state import ReducerFn
|
|
45
|
+
|
|
46
|
+
#: ``async def fn(ctx: Ctx, state: dict[str, Any]) -> dict[str, Any]`` — a step
|
|
47
|
+
#: returns the *delta* it wants merged into state, not the whole state.
|
|
48
|
+
NodeFn = Callable[[Any, dict[str, Any]], Awaitable[dict[str, Any]]]
|
|
49
|
+
|
|
50
|
+
#: A guard evaluated against the current state before a node is dispatched.
|
|
51
|
+
WhenFn = Callable[[dict[str, Any]], bool]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NodeDef(BaseValueObject):
|
|
55
|
+
"""One step in the workflow.
|
|
56
|
+
|
|
57
|
+
``when_fn`` is evaluated against the current state dict before the node is
|
|
58
|
+
dispatched; ``None`` means "always execute".
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
fn: NodeFn
|
|
63
|
+
max_attempts: int
|
|
64
|
+
backoff: tuple[float, ...]
|
|
65
|
+
when_fn: WhenFn | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WorkflowDefinition(BaseModel):
|
|
69
|
+
"""Complete, validated description of a workflow.
|
|
70
|
+
|
|
71
|
+
Every decorator compiles to this, and the executor reads only from here —
|
|
72
|
+
so ``@pipeline`` and ``@scheduled`` differ by exactly one field
|
|
73
|
+
(``schedule``) rather than by an execution path.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
name: Workflow identifier, unique per version.
|
|
77
|
+
version: Bumped when the step list changes shape.
|
|
78
|
+
state_cls: The TypedDict defining the state schema. ``None`` for
|
|
79
|
+
workflows that carry an untyped ``dict`` state.
|
|
80
|
+
nodes: The steps, **in execution order**.
|
|
81
|
+
reducers: ``field_name -> merge function``, extracted from the
|
|
82
|
+
``Annotated`` hints on ``state_cls``. A field with no
|
|
83
|
+
reducer is last-write-wins.
|
|
84
|
+
schedule: Cron expression, or ``None`` for on-demand workflows.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
name: str
|
|
88
|
+
version: int
|
|
89
|
+
state_cls: type | None
|
|
90
|
+
nodes: tuple[NodeDef, ...] = ()
|
|
91
|
+
reducers: dict[str, ReducerFn] = Field(default_factory=dict)
|
|
92
|
+
schedule: str | None = None
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def _check_topology(self) -> WorkflowDefinition:
|
|
96
|
+
"""Reject a definition that cannot run, at declaration time.
|
|
97
|
+
|
|
98
|
+
A model validator rather than a method a caller must remember to call:
|
|
99
|
+
the previous shape had a ``validate()`` each compiler had to invoke, and
|
|
100
|
+
one forgetting it produced a workflow that failed on its first dispatch
|
|
101
|
+
in production rather than at import.
|
|
102
|
+
"""
|
|
103
|
+
if not self.nodes:
|
|
104
|
+
raise BadRequestError(f"workflow {self.name!r} v{self.version} has no steps")
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
for node in self.nodes:
|
|
107
|
+
if node.name in seen:
|
|
108
|
+
raise BadRequestError(
|
|
109
|
+
f"workflow {self.name!r} v{self.version}: duplicate step name {node.name!r} — "
|
|
110
|
+
"step names identify rows in step_run and must be unique within a workflow"
|
|
111
|
+
)
|
|
112
|
+
seen.add(node.name)
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def node_names(self) -> tuple[str, ...]:
|
|
116
|
+
"""Step names in execution order."""
|
|
117
|
+
return tuple(node.name for node in self.nodes)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"NodeDef",
|
|
122
|
+
"NodeFn",
|
|
123
|
+
"WhenFn",
|
|
124
|
+
"WorkflowDefinition",
|
|
125
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Copyright (C) 2026 FORKTEX S.R.L.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
|
|
4
|
+
#
|
|
5
|
+
# This file is part of ForkTex Flow.
|
|
6
|
+
#
|
|
7
|
+
# For commercial licensing -- including use in proprietary products, SaaS
|
|
8
|
+
# deployments, or any context where AGPL obligations cannot be met -- you
|
|
9
|
+
# MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
|
|
10
|
+
#
|
|
11
|
+
# This program is free software: you can redistribute it and/or modify
|
|
12
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
13
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
14
|
+
# (at your option) any later version.
|
|
15
|
+
#
|
|
16
|
+
# This program is distributed in the hope that it will be useful,
|
|
17
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
18
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
19
|
+
# GNU Affero General Public License for more details.
|
|
20
|
+
#
|
|
21
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
22
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
23
|
+
|
|
24
|
+
"""``RunFilter`` — what a caller is asking for, without any SQL.
|
|
25
|
+
|
|
26
|
+
Extracted so the read layer compiles a value object instead of reaching into a
|
|
27
|
+
query builder's private attributes, and so the same filter set can be applied to
|
|
28
|
+
the page query, the count and the summary. It previously could not be: each of
|
|
29
|
+
the three built its own ``where`` list, and the summary's list was missing the
|
|
30
|
+
metadata, state and current-node filters — so ``.metadata(k=v).summary()``
|
|
31
|
+
silently aggregated over every run in the table.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from forktex.types import BaseValueObject
|
|
40
|
+
from pydantic import Field
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RunFilter(BaseValueObject):
|
|
44
|
+
"""The predicate half of a run query. Frozen, and free of I/O.
|
|
45
|
+
|
|
46
|
+
Every field is optional; an empty ``RunFilter`` matches every run.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
workflow_name: Exact match on the workflow's name.
|
|
50
|
+
workflow_version: Exact match on its version. Only meaningful with a name.
|
|
51
|
+
statuses: Any-of match on run status.
|
|
52
|
+
metadata_filter: JSONB containment against ``run.metadata``.
|
|
53
|
+
state_filter: JSONB containment against ``run.input``.
|
|
54
|
+
current_nodes: Runs whose currently-running step is one of these.
|
|
55
|
+
since: ``started_at >= since``.
|
|
56
|
+
until: ``started_at < until``.
|
|
57
|
+
triggered_by: Any-of match on the trigger label.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
workflow_name: str | None = None
|
|
61
|
+
workflow_version: int | None = None
|
|
62
|
+
statuses: tuple[str, ...] = ()
|
|
63
|
+
metadata_filter: dict[str, Any] = Field(default_factory=dict)
|
|
64
|
+
state_filter: dict[str, Any] = Field(default_factory=dict)
|
|
65
|
+
current_nodes: tuple[str, ...] = ()
|
|
66
|
+
since: datetime | None = None
|
|
67
|
+
until: datetime | None = None
|
|
68
|
+
triggered_by: tuple[str, ...] = ()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = ["RunFilter"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Copyright (C) 2026 FORKTEX S.R.L.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
|
|
4
|
+
#
|
|
5
|
+
# This file is part of ForkTex Flow.
|
|
6
|
+
#
|
|
7
|
+
# For commercial licensing -- including use in proprietary products, SaaS
|
|
8
|
+
# deployments, or any context where AGPL obligations cannot be met -- you
|
|
9
|
+
# MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
|
|
10
|
+
#
|
|
11
|
+
# This program is free software: you can redistribute it and/or modify
|
|
12
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
13
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
14
|
+
# (at your option) any later version.
|
|
15
|
+
#
|
|
16
|
+
# This program is distributed in the hope that it will be useful,
|
|
17
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
18
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
19
|
+
# GNU Affero General Public License for more details.
|
|
20
|
+
#
|
|
21
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
22
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
23
|
+
|
|
24
|
+
"""The ``@step`` decorator and the spec it produces.
|
|
25
|
+
|
|
26
|
+
``step`` is deliberately dual-mode. Bare ``@step`` attaches a retry contract to
|
|
27
|
+
the function and returns it unchanged, so a step is still an ordinary callable a
|
|
28
|
+
test can await directly. ``step(fn, when=...)`` instead returns a
|
|
29
|
+
:class:`StepSpec` for use inside a pipeline's ``steps`` list. The two forms
|
|
30
|
+
cannot be confused because the second requires ``fn`` positionally.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from forktex.error import BadRequestError
|
|
39
|
+
from forktex.types import BaseValueObject
|
|
40
|
+
|
|
41
|
+
from forktex_flow.domain.config import RuntimeConfig
|
|
42
|
+
|
|
43
|
+
# Attribute the @step decorator attaches to a function to carry its retry
|
|
44
|
+
# contract. Named here once so call sites read it via ``step_meta`` rather
|
|
45
|
+
# than repeating the dunder string.
|
|
46
|
+
_STEP_META_ATTR = "__forktex_step_meta__"
|
|
47
|
+
|
|
48
|
+
#: The retry contract a bare `@step` gets. Read from `RuntimeConfig`'s own
|
|
49
|
+
#: defaults rather than restated here: the same two numbers written in three
|
|
50
|
+
#: places is how a consumer sets `RuntimeConfig(max_attempts=7)` and every step
|
|
51
|
+
#: still gets 3.
|
|
52
|
+
_DEFAULTS = RuntimeConfig()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class StepMeta(BaseValueObject):
|
|
56
|
+
"""Retry + backoff configuration stored on the function itself.
|
|
57
|
+
|
|
58
|
+
The executor reads it via :func:`step_meta` at dispatch time. Storing it on
|
|
59
|
+
the function rather than in a registry means the function carries its own
|
|
60
|
+
durability contract and works with any ``Flow`` without prior registration.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
max_attempts: int
|
|
64
|
+
backoff: tuple[float, ...]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class StepSpec(BaseValueObject):
|
|
68
|
+
"""Returned by ``step(fn, when=...)`` for use in a pipeline's ``steps`` list."""
|
|
69
|
+
|
|
70
|
+
fn: Callable[..., Any]
|
|
71
|
+
when: Callable[[dict[str, Any]], bool] | None = None
|
|
72
|
+
max_attempts: int | None = None
|
|
73
|
+
backoff: tuple[float, ...] | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def step_meta(fn: object) -> StepMeta | None:
|
|
77
|
+
"""Return the :class:`StepMeta` attached by :func:`step`, or ``None``.
|
|
78
|
+
|
|
79
|
+
``None`` is a legitimate result, not a broken invariant: the compilers
|
|
80
|
+
accept bare, undecorated callables and fall back to the default retry
|
|
81
|
+
config. The typed accessor centralises the attribute name and gives call
|
|
82
|
+
sites a precise return type without a per-site ``type: ignore``.
|
|
83
|
+
"""
|
|
84
|
+
meta = getattr(fn, _STEP_META_ATTR, None)
|
|
85
|
+
return meta if isinstance(meta, StepMeta) else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def has_step_meta(fn: object) -> bool:
|
|
89
|
+
"""True if ``fn`` was decorated with :func:`step`."""
|
|
90
|
+
return isinstance(getattr(fn, _STEP_META_ATTR, None), StepMeta)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def step(
|
|
94
|
+
fn: Callable[..., Any] | None = None,
|
|
95
|
+
/,
|
|
96
|
+
*,
|
|
97
|
+
max_attempts: int | None = None,
|
|
98
|
+
backoff: tuple[float, ...] | None = None,
|
|
99
|
+
when: Callable[[dict[str, Any]], bool] | None = None,
|
|
100
|
+
) -> Callable[..., Any] | StepSpec:
|
|
101
|
+
"""Attach a retry contract to a step, or build a guarded pipeline entry.
|
|
102
|
+
|
|
103
|
+
As a decorator — ``@step`` or ``@step(max_attempts=5)`` — attaches
|
|
104
|
+
:class:`StepMeta` and returns ``fn`` unchanged.
|
|
105
|
+
|
|
106
|
+
As a pipeline entry — ``step(fn, when=predicate)`` — returns a
|
|
107
|
+
:class:`StepSpec`. ``fn`` must be positional.
|
|
108
|
+
"""
|
|
109
|
+
resolved_max_attempts = max_attempts if max_attempts is not None else _DEFAULTS.max_attempts
|
|
110
|
+
resolved_backoff = backoff if backoff is not None else _DEFAULTS.backoff
|
|
111
|
+
|
|
112
|
+
def _attach_meta(target: Callable[..., Any]) -> Callable[..., Any]:
|
|
113
|
+
meta = StepMeta(max_attempts=resolved_max_attempts, backoff=resolved_backoff)
|
|
114
|
+
setattr(target, _STEP_META_ATTR, meta)
|
|
115
|
+
return target
|
|
116
|
+
|
|
117
|
+
if fn is None:
|
|
118
|
+
if when is not None:
|
|
119
|
+
# `when` selects a step *within a pipeline*, so it is meaningless
|
|
120
|
+
# without the function it guards. Raising here turns a silently
|
|
121
|
+
# ignored guard into an error at import time.
|
|
122
|
+
raise BadRequestError("step(when=...) requires fn as a positional argument")
|
|
123
|
+
return _attach_meta
|
|
124
|
+
|
|
125
|
+
if when is not None or max_attempts is not None or backoff is not None:
|
|
126
|
+
return StepSpec(fn=fn, when=when, max_attempts=max_attempts, backoff=backoff)
|
|
127
|
+
|
|
128
|
+
return _attach_meta(fn)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = [
|
|
132
|
+
"StepMeta",
|
|
133
|
+
"StepSpec",
|
|
134
|
+
"has_step_meta",
|
|
135
|
+
"step",
|
|
136
|
+
"step_meta",
|
|
137
|
+
]
|