feat: expand module workflows, dynamic config, notifications and console API

This commit is contained in:
wangyunlong committed 2026-09-04 09:49:37 +08:00
1 parent 8df5266abb
commit b124d757b0
309 files changed
+89357 -6231

No files matched your search

+15 -1
View File
@@ -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,
+102 -3
View File
@@ -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}
+6 -2
View File
@@ -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}"
+21 -2
View File
@@ -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,