feat: expand module workflows, dynamic config, notifications and console API
This commit is contained in:
1 parent
8df5266abb
commit
b124d757b0
309 files changed
+89357
-6231
No files matched your search
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
||||
from typing import Callable, Literal
|
||||
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.locks import LockManager
|
||||
from gyxx_flow.core.locks import LockManager, ResourceBusyError
|
||||
from gyxx_flow.core.records import RunJournal
|
||||
from gyxx_flow.core.text import bounded_head_tail
|
||||
from gyxx_flow.ops.effects import (
|
||||
@@ -203,6 +203,8 @@ class WorkflowEngine:
|
||||
self._effect_ledger.mark_applied(claim)
|
||||
elif result.status == "skipped":
|
||||
self._effect_ledger.cancel(claim)
|
||||
elif result.reason == "resource-busy-before-execution":
|
||||
self._effect_ledger.cancel(claim)
|
||||
else:
|
||||
self._effect_ledger.mark_ambiguous(
|
||||
claim, reason="step-did-not-succeed"
|
||||
@@ -221,12 +223,14 @@ class WorkflowEngine:
|
||||
for attempt in range(1, step.max_attempts + 1):
|
||||
journal_id = _attempt_journal_id(step.step_id, attempt)
|
||||
journal.start_step(journal_id, attempt=attempt)
|
||||
action_started = False
|
||||
try:
|
||||
with ExitStack() as stack:
|
||||
for resource in sorted(step.resources):
|
||||
stack.enter_context(
|
||||
self._lock_manager.acquire(resource, owner=context.run_id)
|
||||
)
|
||||
action_started = True
|
||||
outcome = step.action.execute(
|
||||
context=context,
|
||||
timeout_seconds=step.timeout_seconds,
|
||||
@@ -234,6 +238,16 @@ class WorkflowEngine:
|
||||
)
|
||||
if not isinstance(outcome, StepExecution):
|
||||
raise TypeError("step action must return StepExecution")
|
||||
except ResourceBusyError as exc:
|
||||
outcome = StepExecution(
|
||||
exit_code=1,
|
||||
error=f"ResourceBusyError: {exc}",
|
||||
reason=(
|
||||
None
|
||||
if action_started
|
||||
else "resource-busy-before-execution"
|
||||
),
|
||||
)
|
||||
except Exception as exc: # action and resource failures are retryable
|
||||
outcome = StepExecution(
|
||||
exit_code=1,
|
||||
|
||||
@@ -79,17 +79,39 @@ def compile_workflow_graph(
|
||||
*,
|
||||
run_step: GraphStepRunner,
|
||||
) -> CompiledStateGraph:
|
||||
"""Compile dependencies into a real LangGraph DAG."""
|
||||
"""Compile dependencies into a real LangGraph DAG.
|
||||
|
||||
LangGraph executes all nodes in one Pregel superstep before starting the
|
||||
next one. That global barrier is correct for a join, but it is too coarse
|
||||
for independent linear pipelines: a downstream step in one pipeline
|
||||
would otherwise wait for unrelated roots in other pipelines. Collapse
|
||||
each linear segment into its first node's runner so its steps can advance
|
||||
immediately, while retaining the step nodes and dependency edges in the
|
||||
public graph for inspection and for joins.
|
||||
"""
|
||||
|
||||
builder = StateGraph(WorkflowGraphState)
|
||||
ordered_steps = workflow.ordered_steps()
|
||||
dependents = {step.step_id: [] for step in ordered_steps}
|
||||
for step in ordered_steps:
|
||||
for dependency in step.depends_on:
|
||||
dependents[dependency].append(step.step_id)
|
||||
|
||||
chains = _linear_chains(ordered_steps, dependents)
|
||||
chain_by_start = {chain[0].step_id: chain for chain in chains}
|
||||
dependent_ids = {
|
||||
dependency
|
||||
for step in ordered_steps
|
||||
for dependency in step.depends_on
|
||||
}
|
||||
for step in ordered_steps:
|
||||
builder.add_node(step.step_id, _step_node(step, run_step))
|
||||
chain = chain_by_start.get(step.step_id)
|
||||
builder.add_node(
|
||||
step.step_id,
|
||||
_chain_node(chain, run_step)
|
||||
if chain is not None
|
||||
else _step_node(step, run_step),
|
||||
)
|
||||
if step.depends_on:
|
||||
builder.add_edge(list(step.depends_on), step.step_id)
|
||||
else:
|
||||
@@ -99,21 +121,98 @@ def compile_workflow_graph(
|
||||
return builder.compile()
|
||||
|
||||
|
||||
def _linear_chains(
|
||||
ordered_steps: tuple[StepDefinition, ...],
|
||||
dependents: dict[str, list[str]],
|
||||
) -> tuple[tuple[StepDefinition, ...], ...]:
|
||||
"""Find maximal dependency segments safe to advance without a join."""
|
||||
|
||||
steps_by_id = {step.step_id: step for step in ordered_steps}
|
||||
starts = [
|
||||
step
|
||||
for step in ordered_steps
|
||||
if _is_linear_chain_start(step, dependents)
|
||||
]
|
||||
chains: list[tuple[StepDefinition, ...]] = []
|
||||
assigned: set[str] = set()
|
||||
for start in starts:
|
||||
if start.step_id in assigned:
|
||||
continue
|
||||
chain: list[StepDefinition] = []
|
||||
current = start
|
||||
while current.step_id not in assigned:
|
||||
chain.append(current)
|
||||
assigned.add(current.step_id)
|
||||
children = dependents[current.step_id]
|
||||
if len(children) != 1:
|
||||
break
|
||||
child = steps_by_id[children[0]]
|
||||
if len(child.depends_on) != 1:
|
||||
break
|
||||
current = child
|
||||
chains.append(tuple(chain))
|
||||
|
||||
# WorkflowDefinition already rejects cycles, so this is only a defensive
|
||||
# guard for future changes to the graph construction above.
|
||||
if len(assigned) != len(ordered_steps):
|
||||
missing = [step.step_id for step in ordered_steps if step.step_id not in assigned]
|
||||
raise ValueError(f"unable to partition workflow steps into chains: {missing}")
|
||||
return tuple(chains)
|
||||
|
||||
|
||||
def _is_linear_chain_start(
|
||||
step: StepDefinition,
|
||||
dependents: dict[str, list[str]],
|
||||
) -> bool:
|
||||
if len(step.depends_on) != 1:
|
||||
return True
|
||||
parent_id = step.depends_on[0]
|
||||
return len(dependents[parent_id]) != 1
|
||||
|
||||
|
||||
def _step_node(
|
||||
step: StepDefinition,
|
||||
run_step: GraphStepRunner,
|
||||
) -> Callable[[WorkflowGraphState], dict[str, object]]:
|
||||
def execute(state: WorkflowGraphState) -> dict[str, object]:
|
||||
if step.step_id in state["step_results"]:
|
||||
return {"step_results": {}}
|
||||
result = run_step(step, state)
|
||||
return {"step_results": {step.step_id: result}}
|
||||
|
||||
return execute
|
||||
|
||||
|
||||
def _chain_node(
|
||||
chain: tuple[StepDefinition, ...] | None,
|
||||
run_step: GraphStepRunner,
|
||||
) -> Callable[[WorkflowGraphState], dict[str, object]]:
|
||||
if not chain:
|
||||
raise ValueError("linear chain must contain at least one step")
|
||||
|
||||
def execute(state: WorkflowGraphState) -> dict[str, object]:
|
||||
local_state = dict(state)
|
||||
local_results = dict(state["step_results"])
|
||||
for step in chain:
|
||||
if step.step_id in local_results:
|
||||
continue
|
||||
result = run_step(
|
||||
step,
|
||||
{
|
||||
**local_state,
|
||||
"step_results": local_results,
|
||||
},
|
||||
)
|
||||
local_results[step.step_id] = result
|
||||
return {"step_results": local_results}
|
||||
|
||||
return execute
|
||||
|
||||
|
||||
def _merge_step_results(
|
||||
current: dict[str, GraphStepResult],
|
||||
update: dict[str, GraphStepResult],
|
||||
) -> dict[str, GraphStepResult]:
|
||||
"""Merge branch-local results emitted in the same LangGraph superstep."""
|
||||
"""Merge branch-local and eagerly advanced chain results."""
|
||||
|
||||
return {**current, **update}
|
||||
@@ -16,7 +16,11 @@ if TYPE_CHECKING:
|
||||
|
||||
_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
||||
_RESOURCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]*$")
|
||||
ReplayPolicy = Literal["guarded", "idempotent"]
|
||||
# ``repeatable`` is reserved for production actions that intentionally submit
|
||||
# on every invocation. Unlike ``guarded``, it does not use the business-date
|
||||
# keyed effect ledger; unlike ``idempotent``, it does not claim that the
|
||||
# external action itself is idempotent.
|
||||
ReplayPolicy = Literal["guarded", "idempotent", "repeatable"]
|
||||
|
||||
|
||||
class WorkflowValidationError(ValueError):
|
||||
@@ -100,7 +104,7 @@ class StepDefinition:
|
||||
raise WorkflowValidationError(
|
||||
f"run_after_failure must be boolean for step {self.step_id!r}"
|
||||
)
|
||||
if self.replay_policy not in {"guarded", "idempotent"}:
|
||||
if self.replay_policy not in {"guarded", "idempotent", "repeatable"}:
|
||||
raise WorkflowValidationError(
|
||||
f"invalid replay_policy for step {self.step_id!r}: "
|
||||
f"{self.replay_policy!r}"
|
||||
|
||||
@@ -14,7 +14,10 @@ from typing import IO, Mapping
|
||||
import psutil
|
||||
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
|
||||
from gyxx_flow.core.exit_codes import (
|
||||
COOKIE_SKIP_EXIT_CODE,
|
||||
IDEMPOTENT_REPLAY_EXIT_CODE,
|
||||
)
|
||||
from gyxx_flow.core.text import bounded_head_tail
|
||||
|
||||
_CREATE_SUSPENDED = 0x00000004
|
||||
@@ -226,6 +229,15 @@ class CommandStep:
|
||||
reason="cookie-preflight",
|
||||
)
|
||||
|
||||
if return_code == IDEMPOTENT_REPLAY_EXIT_CODE:
|
||||
return StepExecution(
|
||||
exit_code=return_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
skipped=True,
|
||||
reason="idempotent-replay",
|
||||
)
|
||||
|
||||
error = None
|
||||
if return_code != 0:
|
||||
error = _command_failure_detail(
|
||||
@@ -249,9 +261,16 @@ def _start_process(
|
||||
stdout: IO[str],
|
||||
stderr: IO[str],
|
||||
) -> tuple[subprocess.Popen[str], _WindowsJob | None]:
|
||||
effective_env = dict(env)
|
||||
if os.name == "nt":
|
||||
# Python launched with redirected stdio can otherwise inherit the
|
||||
# Windows ANSI code page (typically GBK), while the parent streams
|
||||
# are opened as UTF-8. Keep child diagnostics readable in journals
|
||||
# and the console, especially for Chinese business errors.
|
||||
effective_env.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
common = {
|
||||
"cwd": cwd,
|
||||
"env": env,
|
||||
"env": effective_env,
|
||||
"shell": False,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
|
||||
Reference in new issue
Block a user