feat: complete production workflow migration

This commit is contained in:
2026-08-06 14:29:57 +08:00
parent 7f215e79c4
commit 8df5266abb
448 changed files with 56937 additions and 14619 deletions
@@ -0,0 +1,723 @@
from __future__ import annotations
import json
import os
import shutil
import sqlite3
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.supply_chain import run as supply_run
from gyxx_flow.modules.supply_chain.orchestrator import config as supply_config
from gyxx_flow.modules.supply_chain.orchestrator import (
mcp_workflow,
pg_writer,
runner,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SUPPLY_WRAPPERS = (
PROJECT_ROOT
/ "src"
/ "gyxx_flow"
/ "modules"
/ "supply_chain"
/ "orchestrator"
/ "scripts"
)
@pytest.fixture
def acceptance_environment(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> Path:
evidence = tmp_path / "evidence.jsonl"
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
monkeypatch.setenv("GYXX_DATA_ROOT", str(tmp_path / "data"))
return evidence
def _forbidden(message: str):
def fail(*_args, **_kwargs):
pytest.fail(message)
return fail
def test_frontend_supply_run_uses_stable_business_day_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
assert mcp_workflow._resolve_workflow_id("purchase-confirmation", None) == (
"purchase-confirmation_20260803"
)
assert mcp_workflow._resolve_workflow_id(
"purchase-confirmation", "explicit-run"
) == "explicit-run"
def test_invalid_business_date_keeps_unique_run_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026/08/03")
first = mcp_workflow._resolve_workflow_id("purchase-confirmation", None)
second = mcp_workflow._resolve_workflow_id("purchase-confirmation", None)
assert first.startswith("purchase-confirmation_")
assert first != second
def test_local_hermes_profile_api_key_is_used_without_copying_secret(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = tmp_path / "profiles" / "data-analyzer"
profile.mkdir(parents=True)
(profile / ".env").write_text(
"API_SERVER_PORT=8642\nAPI_SERVER_KEY=local-only-key\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("GYXX_SUPPLY_HERMES_TOKEN", raising=False)
monkeypatch.delenv("GYXX_HERMES_API_KEY", raising=False)
monkeypatch.delenv("HERMES_API_KEY", raising=False)
assert supply_config._hermes_profile_api_key("data-analyzer") == (
"local-only-key"
)
def test_stable_run_manifest_accepts_only_current_attempt_files(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
run_dir = tmp_path / "purchase-confirmation" / "run_id=stable-run"
run_dir.mkdir(parents=True)
old_file = run_dir / "old.csv"
current_file = run_dir / "current.csv"
old_file.write_text("old", encoding="utf-8")
current_file.write_text("current", encoding="utf-8")
(run_dir / "_run_manifest.json").write_text(
json.dumps(
{
"run_id": "stable-run",
"files": [old_file.name, current_file.name],
}
),
encoding="utf-8",
)
monkeypatch.setattr(mcp_workflow, "SHARED_DIR", tmp_path)
assert mcp_workflow._validate_run_manifest(
"purchase-confirmation", "stable-run", [str(current_file)]
) == (True, "")
assert set(
mcp_workflow._reusable_collected_files(
"purchase-confirmation", "stable-run"
)
) == {str(old_file), str(current_file)}
def test_replenishment_batch_uses_stable_business_day_ids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from gyxx_flow.modules.supply_chain.orchestrator.scripts import batch_process
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
assert batch_process._stable_run_id("replenishment") == (
"replenishment_20260803"
)
assert batch_process._stable_run_id("replenishment-alert") == (
"replenishment-alert_20260803"
)
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment-alert")
assert batch_process._is_alert_only_target()
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment")
assert not batch_process._is_alert_only_target()
def test_alert_batch_stops_before_replenishment_side_effects(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from gyxx_flow.modules.supply_chain.orchestrator.scripts import batch_process
threshold_file = tmp_path / "threshold_alert_data.json"
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment-alert")
monkeypatch.setattr(batch_process, "DATA_DIR", tmp_path)
monkeypatch.setattr(batch_process, "THRESHOLD_ALERT_FILE", threshold_file)
monkeypatch.setattr(batch_process, "_PG_IMPORT_OK", False)
monkeypatch.setattr(batch_process, "_run_product_replenishment", lambda: None)
monkeypatch.setattr(
batch_process,
"_ensure_threshold_alert_file",
lambda: {"alert_count": 1, "alerts": [{"sku": "sku-1"}]},
)
monkeypatch.setattr(
batch_process,
"_pick_latest_xlsx",
_forbidden("alert workflow must not enter replenishment persistence"),
)
assert batch_process.main() == 0
payload = json.loads(threshold_file.read_text(encoding="utf-8"))
assert payload["run_id"] == "replenishment-alert_20260803"
assert payload["pg_ok"] is False
def _forbid_gateway_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
mcp_workflow,
"_acquire_workflow_lock",
_forbidden("cookie preflight must run before workflow locking"),
)
monkeypatch.setattr(
mcp_workflow,
"_send_collector_heartbeat",
_forbidden("cookie preflight must run before Feishu heartbeats"),
)
monkeypatch.setattr(
mcp_workflow,
"call_hermes",
_forbidden("cookie preflight must run before Hermes"),
)
monkeypatch.setattr(
mcp_workflow,
"_run_local_collection_script",
_forbidden("cookie preflight must not start a browser collector"),
)
monkeypatch.setattr(
mcp_workflow.requests,
"Session",
_forbidden("cookie preflight must not create an HTTP session"),
)
monkeypatch.setattr(
mcp_workflow.requests,
"post",
_forbidden("cookie preflight must not send an HTTP request"),
)
@pytest.mark.parametrize(
("workflow_name", "expected_script_id"),
[
(
"purchase-confirmation",
"supply_chain:orchestrator/scripts/PurchaseConfirmation.py",
),
(
"replenishment-alert",
"supply_chain:orchestrator/scripts/ProductReplenishment.py",
),
(
"replenishment",
"supply_chain:orchestrator/scripts/ProductReplenishment.py",
),
],
)
def test_gateway_skips_missing_cookie_before_all_external_effects(
workflow_name: str,
expected_script_id: str,
acceptance_environment: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
_forbid_gateway_side_effects(monkeypatch)
result = mcp_workflow.run_mcp_workflow(workflow_name, "acceptance-run")
assert result["status"] == "skipped_cookie"
assert result["workflow_id"] == "acceptance-run"
assert result["cookie_preflight"]["script_id"] == expected_script_id
assert result["cookie_preflight"]["status"] == "SKIPPED_COOKIE"
assert result["reason"] == "browser state is missing"
events = [
json.loads(line)
for line in acceptance_environment.read_text(encoding="utf-8").splitlines()
]
gateway_event = next(
item for item in events if item["event"] == "supply_gateway_cookie_skipped"
)
assert gateway_event["operation"] == f"supply.{workflow_name}.cookie_preflight"
assert gateway_event["details"]["script_id"] == expected_script_id
assert gateway_event["details"]["workflow_id"] == "acceptance-run"
def test_profile_without_required_platform_cookie_is_still_skipped(
acceptance_environment: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
binding = mcp_workflow._acceptance_binding_for_workflow("purchase-confirmation")
cookie_db = binding.profile_dir / "Default" / "Network" / "Cookies"
cookie_db.parent.mkdir(parents=True)
with sqlite3.connect(cookie_db) as connection:
connection.execute("CREATE TABLE cookies (expires_utc INTEGER)")
connection.execute(
"INSERT INTO cookies (expires_utc) VALUES (?)",
(999_999_999_999_999_999,),
)
(binding.profile_dir / "Local State").write_text("{}", encoding="utf-8")
_forbid_gateway_side_effects(monkeypatch)
result = mcp_workflow.run_mcp_workflow(
"purchase-confirmation",
"nonempty-profile-run",
)
assert result["status"] == "skipped_cookie"
assert result["cookie_preflight"]["script_id"].endswith(
"/PurchaseConfirmation.py"
)
assert result["reason"] == "browser state is missing"
events = [
json.loads(line)
for line in acceptance_environment.read_text(encoding="utf-8").splitlines()
]
assert events[-1]["event"] == "supply_gateway_cookie_skipped"
assert events[-1]["details"]["reason"] == "browser state is missing"
def test_supply_credentials_allow_preflight_without_cookie_or_live_cdp(
acceptance_environment: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
del acceptance_environment
monkeypatch.setenv("GYXX_SUPPLY_ERP_PASSWORD", "configured-for-test")
lock_calls: list[tuple[str, str]] = []
def reject_lock(workflow_name: str, workflow_id: str) -> tuple[bool, str]:
lock_calls.append((workflow_name, workflow_id))
return False, "stop-after-preflight"
monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", reject_lock)
result = mcp_workflow.run_mcp_workflow(
"purchase-confirmation",
"credential-fallback-run",
)
assert result["status"] == "failed"
assert result["error"] == "stop-after-preflight"
assert lock_calls == [("purchase-confirmation", "credential-fallback-run")]
def test_analyzer_facts_include_authoritative_absolute_collected_files(
tmp_path: Path,
) -> None:
collected_file = tmp_path / "purchase_confirmation.csv"
facts = mcp_workflow._build_analyze_facts(
"purchase-confirmation",
[str(collected_file)],
)
assert facts == [
"AUTHORITATIVE COLLECTED FILES (absolute paths): "
f"{collected_file.resolve()}"
]
def test_close_workflow_browser_terminates_only_bound_cdp_tree(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeProcess:
def __init__(
self,
pid: int,
cmdline: list[str],
children: list[FakeProcess] | None = None,
) -> None:
self.pid = pid
self.info = {"pid": pid, "cmdline": cmdline}
self._children = children or []
self.terminated = False
self.killed = False
def children(self, *, recursive: bool) -> list[FakeProcess]:
assert recursive is True
return self._children
def terminate(self) -> None:
self.terminated = True
def kill(self) -> None:
self.killed = True
child = FakeProcess(102, ["chrome.exe", "--type=renderer"])
bound = FakeProcess(
101,
["chrome.exe", "--remote-debugging-port=22116"],
[child],
)
unrelated = FakeProcess(
201,
["chrome.exe", "--remote-debugging-port=22115"],
)
waited: list[tuple[int, ...]] = []
monkeypatch.setattr(
mcp_workflow,
"_acceptance_binding_for_workflow",
lambda _workflow_name: SimpleNamespace(cdp_port=22116),
)
monkeypatch.setattr(
mcp_workflow.psutil,
"process_iter",
lambda _attrs: [bound, unrelated],
)
def fake_wait_procs(processes, *, timeout: int):
assert timeout == 5
waited.append(tuple(process.pid for process in processes))
return list(processes), []
monkeypatch.setattr(mcp_workflow.psutil, "wait_procs", fake_wait_procs)
closed = mcp_workflow._close_workflow_browser("purchase-confirmation")
assert closed == [101, 102]
assert bound.terminated is True
assert child.terminated is True
assert unrelated.terminated is False
assert unrelated.killed is False
assert waited == [(102, 101)]
def test_close_workflow_browser_ignores_other_workflows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
mcp_workflow,
"_acceptance_binding_for_workflow",
_forbidden("unmanaged workflow must not resolve a browser binding"),
)
assert mcp_workflow._close_workflow_browser("purchase-order-update") == []
def test_purchase_order_update_block_precedes_cookie_preflight(
acceptance_environment: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
mcp_workflow,
"_acceptance_cookie_preflight",
_forbidden("ERP mutation block must take precedence over cookie inspection"),
)
monkeypatch.setattr(
mcp_workflow,
"_acquire_workflow_lock",
_forbidden("blocked ERP workflow must not acquire a lock"),
)
monkeypatch.setattr(
mcp_workflow,
"_send_collector_heartbeat",
_forbidden("blocked ERP workflow must not send a heartbeat"),
)
result = mcp_workflow.run_mcp_workflow(
"purchase-order-update",
"blocked-erp-run",
)
assert result["status"] == "blocked_business_mutation"
assert "business_mutation_blocked" in acceptance_environment.read_text(
encoding="utf-8"
)
def test_analyzer_prompt_does_not_rely_on_two_hop_acceptance_environment(
acceptance_environment: Path,
) -> None:
prompt = mcp_workflow._build_analyze_prompt("replenishment")
assert "Hermes 网关不会继承编排进程的验收环境变量" in prompt
assert "不得调用 insert_replenishment_bitable.py" in prompt
assert prompt.rfind("【验收最终覆盖规则】") > prompt.rfind(
"多维表写入统一走 Python 脚本"
)
assert mcp_workflow.ANALYZER_OWNER_OPEN_ID in prompt
assert "ou_8ee224968aa26a74c7d30ba27fed5eeb" not in prompt
@pytest.mark.parametrize(
"response_text",
[
"没有向业务用户发送任何过程或进度消息,仅投递最终业务通知。",
"未向目标用户推送额外进度更新,只发送最终结果。",
(
"No additional process/progress messages were sent to users; "
"final business notification delivered."
),
],
)
def test_notification_policy_allows_explicitly_negated_process_messages(
response_text: str,
) -> None:
assert not mcp_workflow._contains_process_notification_violation(response_text)
@pytest.mark.parametrize(
"response_text",
[
"已向业务用户发送进度通知。",
"Progress notification sent to the target user.",
],
)
def test_notification_policy_rejects_process_messages_sent_to_users(
response_text: str,
) -> None:
assert mcp_workflow._contains_process_notification_violation(response_text)
def test_notification_receipt_rejects_empty_message_ids() -> None:
with pytest.raises(ValueError, match="non-empty message_id"):
pg_writer.mark_notification_sent("replenishment", "run-1", [])
def test_notification_receipt_requires_persisted_message_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(pg_writer, "notification_already_sent", lambda *_args: False)
assert not mcp_workflow._notification_receipt_exists("replenishment", "run-1")
monkeypatch.setattr(pg_writer, "notification_already_sent", lambda *_args: True)
assert mcp_workflow._notification_receipt_exists("replenishment", "run-1")
def test_notification_receipt_falls_back_to_zero_row_workflow_batch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeCursor:
def __init__(self) -> None:
self.fetches = iter((None, None, (1,)))
def __enter__(self):
return self
def __exit__(self, *_args: object) -> None:
return None
def execute(self, _sql: str, _params: object) -> None:
return None
def fetchone(self):
return next(self.fetches)
class FakeConnection:
def __init__(self) -> None:
self.cursor_instance = FakeCursor()
def cursor(self) -> FakeCursor:
return self.cursor_instance
def close(self) -> None:
return None
monkeypatch.setattr(pg_writer, "_conn", FakeConnection)
assert pg_writer.notification_already_sent(
"purchase-confirmation", "zero-row-run"
)
def test_production_mode_preserves_the_existing_lock_first_flow(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
monkeypatch.setattr(
mcp_workflow,
"_acceptance_cookie_preflight",
_forbidden("production mode must not run the acceptance preflight"),
)
lock_calls: list[tuple[str, str]] = []
def reject_lock(workflow_name: str, workflow_id: str) -> tuple[bool, str]:
lock_calls.append((workflow_name, workflow_id))
return False, "already running"
monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", reject_lock)
result = mcp_workflow.run_mcp_workflow("replenishment", "production-run")
assert result["status"] == "failed"
assert result["error"] == "already running"
assert lock_calls == [("replenishment", "production-run")]
@pytest.mark.parametrize(
("status", "expected"),
[
("completed", 0),
("skipped_cookie", COOKIE_SKIP_EXIT_CODE),
("blocked_business_mutation", runner.BLOCKED_EXIT_CODE),
("failed", 1),
],
)
def test_both_supply_clis_propagate_structured_result_exit_codes(
status: str,
expected: int,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
result = {"workflow": "replenishment", "status": status}
monkeypatch.setattr(runner, "run_mcp", lambda _workflow: result)
monkeypatch.setattr(
sys,
"argv",
["runner.py", "mcp", "replenishment"],
)
assert runner.main() == expected
monkeypatch.setattr(
sys,
"argv",
["run.py", "mcp-run", "replenishment"],
)
assert supply_run.main() == expected
capsys.readouterr()
def test_batch_exit_code_uses_failure_then_block_then_cookie_precedence() -> None:
assert runner.result_exit_code(
{
"results": [
{"status": "completed"},
{"status": "skipped_cookie"},
]
}
) == COOKIE_SKIP_EXIT_CODE
assert runner.result_exit_code(
{
"results": [
{"status": "skipped_cookie"},
{"status": "blocked_business_mutation"},
]
}
) == runner.BLOCKED_EXIT_CODE
assert runner.result_exit_code(
{
"results": [
{"status": "blocked_business_mutation"},
{"status": "failed"},
]
}
) == 1
@pytest.mark.parametrize(
"script_name",
["collect_confirmation.ps1", "collect_replenishment.ps1"],
)
def test_direct_collection_wrappers_gate_before_browser_start(
script_name: str,
) -> None:
source = (SUPPLY_WRAPPERS / script_name).read_text(encoding="utf-8-sig")
acceptance_position = source.index('if ($env:GYXX_WORKFLOW_ACCEPTANCE -eq "1")')
gate_position = source.index("[SKIPPED_COOKIE]")
browser_start_position = source.index("Start-Process -FilePath $chromePath")
assert gate_position < browser_start_position
assert "exit 75" in source[gate_position:browser_start_position]
assert '$forceFreshBrowser = $false' in source[gate_position - 200:browser_start_position]
assert "$env:GYXX_SUPPLY_ERP_PASSWORD" in source[
acceptance_position:browser_start_position
]
assert "starting the target-owned profile for credential fallback" in source
def test_direct_purchase_order_wrapper_blocks_before_task_and_browser_access() -> None:
source = (SUPPLY_WRAPPERS / "collect_purchase_order_update.ps1").read_text(
encoding="utf-8-sig"
)
gate_position = source.index("[BLOCKED_BUSINESS_MUTATION]")
assert gate_position < source.index("$taskFile =")
assert gate_position < source.index("Start-Process -FilePath $chromePath")
assert "exit 77" in source[gate_position:source.index("$scriptDir =")]
@pytest.mark.parametrize(
("script_name", "expected_code", "expected_marker"),
[
("collect_confirmation.ps1", COOKIE_SKIP_EXIT_CODE, "SKIPPED_COOKIE"),
("collect_replenishment.ps1", COOKIE_SKIP_EXIT_CODE, "SKIPPED_COOKIE"),
(
"collect_purchase_order_update.ps1",
runner.BLOCKED_EXIT_CODE,
"BLOCKED_BUSINESS_MUTATION",
),
],
)
def test_direct_wrapper_process_never_calls_start_process_when_gated(
script_name: str,
expected_code: int,
expected_marker: str,
tmp_path: Path,
) -> None:
powershell = shutil.which("powershell.exe")
if powershell is None:
pytest.skip("Windows PowerShell is unavailable")
target = SUPPLY_WRAPPERS / script_name
escaped_target = str(target).replace("'", "''")
command = "\n".join(
[
"function global:Get-NetTCPConnection {",
" param([int]$LocalPort, [string]$State)",
" return $null",
"}",
"function global:Start-Process { throw 'START_PROCESS_CALLED' }",
f"& '{escaped_target}'",
"$code = $LASTEXITCODE",
"exit $code",
]
)
environment = os.environ.copy()
environment.update(
{
"GYXX_WORKFLOW_ACCEPTANCE": "1",
"GYXX_PROJECT_ROOT": str(PROJECT_ROOT),
"GYXX_DATA_ROOT": str(tmp_path / "data"),
}
)
completed = subprocess.run(
[
powershell,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
command,
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
check=False,
)
output = completed.stdout + completed.stderr
assert completed.returncode == expected_code, output
assert expected_marker in output
assert "START_PROCESS_CALLED" not in output