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
+165
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
@@ -17,9 +19,23 @@ from gyxx_flow.adapters.native import (
ModuleSourceRoots,
)
from gyxx_flow.catalog import WorkflowEntry
from gyxx_flow.core.config import Settings
from gyxx_flow.core.context import RunContext
from gyxx_flow.notification_routing import (
ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID,
GYXX_NOTIFICATION_APP_PROFILE,
GYXX_NOTIFICATION_HERMES_PROFILE,
GYXX_NOTIFICATION_RECIPIENTS_JSON,
GYXX_NOTIFICATION_ROUTE_MODE,
GYXX_NOTIFICATION_ROUTE_REVISION,
GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID,
NotificationRoutingStore,
resolve_notification_route,
)
from gyxx_flow.workflow.steps import StepExecution
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _entry(**overrides: object) -> WorkflowEntry:
values: dict[str, object] = {
@@ -43,6 +59,15 @@ def _context() -> RunContext:
)
def _context_for(workflow_id: str) -> RunContext:
return RunContext.create(
workflow_id,
"2026-07-27",
now=datetime(2026, 7, 27, tzinfo=timezone.utc),
random_suffix="native2",
)
def test_module_adapter_resolves_only_inside_new_module_root(tmp_path: Path) -> None:
root = tmp_path / "product_commerce" / "runtime"
entry = root / "jobs" / "backfill.py"
@@ -273,3 +298,143 @@ def test_module_adapter_rejects_unsafe_acceptance_recipient(tmp_path: Path) -> N
with pytest.raises(WorkflowAcceptancePolicyError, match="Wang Yunlong"):
adapter.build(_entry(entry="job.py", args=()), context=_context())
def test_module_adapter_injects_stable_notification_route_before_acceptance(
tmp_path: Path,
) -> None:
project_root = tmp_path / "project"
config_root = project_root / "config"
config_root.mkdir(parents=True)
shutil.copyfile(
PROJECT_ROOT / "config" / "notification-routing.json",
config_root / "notification-routing.json",
)
data_root = tmp_path / "data"
settings = Settings(project_root=project_root, data_root=data_root)
store = NotificationRoutingStore(settings)
payload, revision = store.snapshot()
payload["routes"]["content.marketing_report.daily"] = {
"enabled": True,
"app_profile": "hermes-analyzer",
"person_ids": ["he_yingwei", "wang_yunlong"],
}
store.update(payload, expected_revision=revision)
module_root = tmp_path / "content_marketing"
module_root.mkdir()
(module_root / "daily.py").write_text("pass", encoding="utf-8")
adapter = ModuleCommandAdapter(
ModuleSourceRoots({"content_marketing": module_root}),
base_env={"GYXX_WORKFLOW_ACCEPTANCE": "1"},
project_root=project_root,
data_root=data_root,
)
entry = _entry(
workflow_id="content.marketing_report.daily",
module="content_marketing",
entry="daily.py",
args=(),
)
context = RunContext.create(
entry.workflow_id,
"2026-07-27",
now=datetime(2026, 7, 27, tzinfo=timezone.utc),
random_suffix="notify1",
)
command = adapter.build(entry, context=context)
assert command.env[GYXX_NOTIFICATION_ROUTE_MODE] == "enabled"
assert command.env[GYXX_NOTIFICATION_APP_PROFILE] == "hermes-analyzer"
assert json.loads(command.env[GYXX_NOTIFICATION_RECIPIENTS_JSON]) == [
ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID
]
assert (
command.env["GYXX_NOTIFICATION_RECIPIENT_OPEN_ID"]
== WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
)
assert resolve_notification_route(entry.workflow_id, (), command.env).open_ids == (
ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID,
)
def test_module_adapter_strips_route_snapshot_from_synthetic_manual_command(
tmp_path: Path,
) -> None:
module_root = tmp_path / "product_commerce"
module_root.mkdir()
(module_root / "run_alerts.py").write_text("pass", encoding="utf-8")
adapter = ModuleCommandAdapter(
ModuleSourceRoots({"product_commerce": module_root}),
base_env={
GYXX_NOTIFICATION_ROUTE_MODE: "enabled",
GYXX_NOTIFICATION_APP_PROFILE: "hermes-analyzer",
GYXX_NOTIFICATION_HERMES_PROFILE: "data-analyzer",
GYXX_NOTIFICATION_RECIPIENTS_JSON: (
'["ou_89bcff110ccbb09a23548dc0fb3d880c"]'
),
GYXX_NOTIFICATION_ROUTE_REVISION: "0" * 64,
GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID: "content.marketing_report.daily",
},
project_root=tmp_path,
data_root=tmp_path / "var",
)
synthetic_id = "script.product_commerce.0123456789ab"
command = adapter.build(
_entry(
workflow_id=synthetic_id,
entry="run_alerts.py",
args=(),
),
context=_context_for(synthetic_id),
)
for variable in (
GYXX_NOTIFICATION_ROUTE_MODE,
GYXX_NOTIFICATION_APP_PROFILE,
GYXX_NOTIFICATION_HERMES_PROFILE,
GYXX_NOTIFICATION_RECIPIENTS_JSON,
GYXX_NOTIFICATION_ROUTE_REVISION,
GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID,
):
assert variable not in command.env
def test_module_adapter_snapshots_declared_route_for_public_manual_command(
tmp_path: Path,
) -> None:
project_root = tmp_path / "project"
config_root = project_root / "config"
config_root.mkdir(parents=True)
shutil.copyfile(
PROJECT_ROOT / "config" / "notification-routing.json",
config_root / "notification-routing.json",
)
module_root = tmp_path / "content_marketing"
module_root.mkdir()
(module_root / "daily.py").write_text("pass", encoding="utf-8")
synthetic_id = "script.content_marketing.0123456789ab"
adapter = ModuleCommandAdapter(
ModuleSourceRoots({"content_marketing": module_root}),
project_root=project_root,
data_root=tmp_path / "data",
)
command = adapter.build(
_entry(
workflow_id=synthetic_id,
module="content_marketing",
entry="daily.py",
args=(),
notification_workflow_id="content.marketing_report.daily",
),
context=_context_for(synthetic_id),
)
assert command.env[GYXX_NOTIFICATION_ROUTE_MODE] == "legacy"
assert (
command.env[GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID]
== "content.marketing_report.daily"
)