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

This commit is contained in:
2026-09-04 09:49:37 +08:00
parent 8df5266abb
commit b124d757b0
309 changed files with 89358 additions and 6232 deletions
+128 -1
View File
@@ -16,7 +16,7 @@ import pytest
import gyxx_flow.workflow.steps as steps_module
from gyxx_flow.core.context import RunContext
from gyxx_flow.core.layout import DataLayout
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.ops.effects import EffectLedger
from gyxx_flow.workflow.engine import WorkflowEngine
@@ -159,6 +159,22 @@ def test_command_step_uses_argv_explicit_cwd_env_and_timeout(
assert captured["stderr"].closed is True # type: ignore[union-attr]
def test_command_step_preserves_chinese_child_diagnostics(tmp_path: Path) -> None:
child_env = dict(os.environ)
child_env.pop("PYTHONIOENCODING", None)
expected = "天猫百亿补贴导入失败"
step = CommandStep(
argv=(sys.executable, "-c", f"print({expected!r})"),
cwd=tmp_path,
env=child_env,
)
outcome = step.execute(context=_context(), timeout_seconds=10, dry_run=False)
assert outcome.exit_code == 0
assert outcome.stdout.strip() == expected
def test_command_step_dry_run_never_starts_a_process(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@@ -359,6 +375,31 @@ def test_command_step_maps_reserved_cookie_exit_to_a_skip(
assert outcome.reason == "cookie-preflight"
def test_command_step_maps_idempotent_replay_exit_to_a_skip(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class SkippedProcess:
pid = 424243
def wait(self, timeout: float | None = None) -> int:
del timeout
return 76
monkeypatch.setattr(
steps_module,
"_start_process",
lambda *_args, **_kwargs: (SkippedProcess(), None),
)
step = CommandStep(argv=("collector.exe",), cwd=tmp_path, env={})
outcome = step.execute(context=_context(), timeout_seconds=2, dry_run=False)
assert outcome.exit_code == 76
assert outcome.skipped is True
assert outcome.reason == "idempotent-replay"
def test_engine_retries_records_every_attempt_and_aggregates_failures(tmp_path: Path) -> None:
flaky = FakeStep(
StepExecution(exit_code=7, error="temporary"),
@@ -528,6 +569,49 @@ def test_effect_ledger_skips_a_successful_production_sink_replay(tmp_path: Path)
assert len(action.calls) == 1
def test_repeatable_sink_runs_again_without_business_date_effect_receipt(
tmp_path: Path,
) -> None:
action = FakeStep(StepExecution(exit_code=0), StepExecution(exit_code=0))
workflow = WorkflowDefinition(
"product.tmall_baibu_apply",
(
StepDefinition(
"old_all_bag",
action,
production_sink=True,
replay_policy="repeatable",
),
),
)
ledger = EffectLedger(tmp_path)
engine = WorkflowEngine(
LockManager(tmp_path / "locks"), effect_ledger=ledger
)
first_context = RunContext.create(
"product.tmall_baibu_apply", "2026-07-27", random_suffix="repeatable1"
)
first = engine.execute(
workflow,
context=first_context,
journal=RunJournal.create(DataLayout(tmp_path), first_context),
)
second_context = RunContext.create(
"product.tmall_baibu_apply", "2026-07-27", random_suffix="repeatable2"
)
second = engine.execute(
workflow,
context=second_context,
journal=RunJournal.create(DataLayout(tmp_path), second_context),
)
assert first.status == "success"
assert second.status == "success"
assert second.steps["old_all_bag"].reason is None
assert len(action.calls) == 2
assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == []
def test_idempotent_sink_runs_again_for_same_business_date(tmp_path: Path) -> None:
action = FakeStep(StepExecution(exit_code=0), StepExecution(exit_code=0))
workflow = WorkflowDefinition(
@@ -593,6 +677,49 @@ def test_skipped_production_sink_cancels_effect_claim_for_future_runs(
assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == []
def test_resource_busy_before_action_cancels_effect_claim(tmp_path: Path) -> None:
class BusyStepLockManager:
def acquire(self, resource, *, owner):
del owner
if resource == "module:product_commerce:product.daily":
raise ResourceBusyError(f"resource is busy: {resource}")
return LockManager(tmp_path / "locks").acquire(
resource,
owner="workflow-lock",
)
action = FakeStep()
workflow = WorkflowDefinition(
"product.daily",
(
StepDefinition(
"collect",
action,
resources=("module:product_commerce:product.daily",),
production_sink=True,
),
),
)
context = RunContext.create(
"product.daily",
"2026-08-11",
random_suffix="busy",
)
result = WorkflowEngine(
BusyStepLockManager(), # type: ignore[arg-type]
effect_ledger=EffectLedger(tmp_path),
).execute(
workflow,
context=context,
journal=RunJournal.create(DataLayout(tmp_path), context),
)
assert result.status == "failed"
assert result.steps["collect"].reason == "resource-busy-before-execution"
assert action.calls == []
assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == []
def test_failed_dependency_skips_downstream_critical_step(tmp_path: Path) -> None:
downstream = FakeStep()
workflow = WorkflowDefinition(