Files
gyxx-flow/tests/test_console.py
T

3274 lines
110 KiB
Python

from __future__ import annotations
import http.client
import json
import re
import shutil
import subprocess
import threading
from contextlib import contextmanager
from datetime import date, datetime, timezone
from http import HTTPStatus
from pathlib import Path
from typing import Any, Iterator, TextIO
import psutil
import pytest
import gyxx_flow.console as console_module
from gyxx_flow.catalog import ScheduleEntry, WorkflowCatalog, WorkflowEntry
from gyxx_flow.console import (
MAX_REQUEST_BYTES,
ConsoleConflictError,
ConsoleNotFoundError,
ConsolePreconditionError,
ConsoleRequestError,
ScheduleConfigStore,
SubprocessConsoleRunLauncher,
WorkflowConsoleService,
create_console_server,
)
from gyxx_flow.core.config import Settings
from gyxx_flow.core.context import RunContext
from gyxx_flow.core.layout import DataLayout
from gyxx_flow.core.records import RunJournal
from gyxx_flow.ops import RunIndex
from gyxx_flow.scheduler_service import PythonScheduler
PROJECT_ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def console_settings(tmp_path: Path) -> Settings:
project_root = tmp_path / "project"
config_dir = project_root / "config"
config_dir.mkdir(parents=True)
for name in (
"workflows.json",
"schedules.json",
"notification-routing.json",
):
shutil.copyfile(PROJECT_ROOT / "config" / name, config_dir / name)
return Settings(project_root=project_root, data_root=tmp_path / "data")
class FakeConsoleLauncher:
def __init__(self) -> None:
self.calls: list[dict[str, object]] = []
self._active: dict[str, dict[str, object]] = {}
self._cancelled: dict[str, dict[str, object]] = {}
def launch(
self,
workflow_id: str,
business_date: date,
*,
execute: bool,
shadow: bool,
video_upload_store: str | None = None,
tmall_topic_keyword: str | None = None,
force_refresh: bool = False,
tmall_baibu_import_mode: str | None = None,
) -> dict[str, object]:
if workflow_id in self._active:
raise ConsoleConflictError("already active")
operation = {
"operation_id": f"op-{len(self.calls) + 1:016x}",
"workflow_id": workflow_id,
"business_date": business_date.isoformat(),
"mode": "execute" if execute else "dry_run",
"shadow": shadow,
"started_at": "2026-08-01T00:00:00+00:00",
"status": "accepted",
}
if video_upload_store is not None:
operation["store"] = video_upload_store
if tmall_topic_keyword is not None:
operation["topic_keyword"] = tmall_topic_keyword
if force_refresh:
operation["force_refresh"] = True
if tmall_baibu_import_mode is not None:
operation["tmall_baibu_import_mode"] = tmall_baibu_import_mode
self.calls.append(operation)
self._active[workflow_id] = operation
return dict(operation)
def active(self) -> tuple[dict[str, object], ...]:
return tuple(dict(item) for item in self._active.values())
def cancel(
self,
workflow_id: str,
operation_id: str,
) -> dict[str, object]:
operation = self._active.get(workflow_id)
if operation is None:
previous = self._cancelled.get(operation_id)
if previous is not None and previous["workflow_id"] == workflow_id:
return {**previous, "already_cancelled": True}
raise ConsoleConflictError("not active")
if operation["operation_id"] != operation_id:
raise ConsoleConflictError("not active")
del self._active[workflow_id]
operation.update(
{
"status": "cancelled",
"already_cancelled": False,
"ended_at": "2026-08-01T00:01:00+00:00",
}
)
self._cancelled[operation_id] = dict(operation)
return dict(operation)
def complete(self, workflow_id: str) -> None:
self._active.pop(workflow_id, None)
def _read_config(settings: Settings, name: str) -> dict[str, Any]:
return json.loads(
(settings.project_root / "config" / name).read_text(encoding="utf-8")
)
def _write_config(settings: Settings, name: str, payload: dict[str, Any]) -> None:
(settings.project_root / "config" / name).write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _write_scheduler_state(settings: Settings, payload: dict[str, Any]) -> None:
path = settings.data_root / "state" / "scheduler" / "state.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
def _append_non_scheduled_workflow(
settings: Settings,
*,
workflow_id: str,
module: str,
trigger: str,
) -> None:
workflows = _read_config(settings, "workflows.json")
workflows["workflows"].append(
{
"id": workflow_id,
"module": module,
"trigger": trigger,
"execution": {"entry": "console_probe.py"},
"provenance": {"source_project": "console-test"},
}
)
_write_config(settings, "workflows.json", workflows)
def _schedule_row(settings: Settings, workflow_id: str) -> dict[str, Any]:
payload = _read_config(settings, "schedules.json")
return next(
item for item in payload["schedules"] if item["workflow_id"] == workflow_id
)
def _index_failed_run(settings: Settings, workflow_id: str) -> str:
context = RunContext.create(
workflow_id,
"2026-08-01",
now=datetime(2026, 8, 1, 1, 2, 3, tzinfo=timezone.utc),
random_suffix="console1",
)
journal = RunJournal.create(
DataLayout(settings.data_root),
context,
mode="execute",
)
journal.finalize("failed", error="critical steps failed: collect")
RunIndex(settings.data_root).index_journal(journal)
return context.run_id
def _index_parallel_main_image_run(settings: Settings) -> str:
context = RunContext.create(
"product.main_image.weekly",
"2026-08-02",
now=datetime(2026, 8, 2, 1, 2, 3, tzinfo=timezone.utc),
random_suffix="parallel1",
)
journal = RunJournal.create(DataLayout(settings.data_root), context)
journal.start_step("jd.attempt-1", attempt=1)
journal.finish_step(
"jd.attempt-1",
status="failed",
exit_code=9,
error="JD browser failed " + "pass" + "word=" + "private" + "-value",
)
journal.start_step("tmall.attempt-1", attempt=1)
journal.finish_step(
"tmall.attempt-1",
status="success",
exit_code=0,
)
journal.finalize("failed", error="critical steps failed: jd")
RunIndex(settings.data_root).index_journal(journal)
return context.run_id
def _index_running_metrics_run(settings: Settings) -> RunJournal:
context = RunContext.create(
"content.metrics.daily",
"2026-08-02",
now=datetime(2026, 8, 2, 2, 3, 4, tzinfo=timezone.utc),
random_suffix="running1",
)
journal = RunJournal.create(DataLayout(settings.data_root), context)
RunIndex(settings.data_root).index_journal(journal)
journal.start_step("collect_collaborators.attempt-1", attempt=1)
return journal
def test_error_sanitizer_redacts_structured_and_url_credentials() -> None:
key_a = "pass" + "word"
key_b = "access_" + "token"
key_c = "to" + "ken"
value_a = "hunter" + "2"
value_b = "abc" + "123"
value_c = "p4" + "ss"
value_d = "query-" + "private"
cases = (
(json.dumps({key_a: value_a}), value_a),
(repr({key_b: value_b}), value_b),
(f"postgresql://alice:{value_c}@db.local/app", value_c),
(f"https://service.local/run?{key_c}={value_d}&mode=1", value_d),
)
for raw_error, sensitive_value in cases:
sanitized = console_module._sanitize_error(raw_error)
assert sanitized is not None
assert sensitive_value not in sanitized
assert "[REDACTED]" in sanitized
def test_error_sanitizer_preserves_context_and_root_cause_tail() -> None:
raw_error = (
"collector startup context\n"
+ "progress line\n" * 1_000
+ "ROOT CAUSE AT LOG TAIL"
)
sanitized = console_module._sanitize_error(raw_error)
assert sanitized is not None
assert sanitized.startswith("collector startup context")
assert "[middle output omitted]" in sanitized
assert sanitized.endswith("ROOT CAUSE AT LOG TAIL")
assert len(sanitized) <= console_module.MAX_ERROR_CHARS
def test_notification_mobile_response_is_app_scoped_and_deduplicated() -> None:
payload = {
"ok": True,
"data": {
"user_list": [
{
"mobile": "+86 138-0013-8000",
"user_id": "ou_89bcff110ccbb09a23548dc0fb3d880c",
},
{
"mobile": "+8613800138000",
"open_id": "ou_89bcff110ccbb09a23548dc0fb3d880c",
},
]
},
}
assert console_module._normalize_mobile("+86 138-0013-8000") == (
"+8613800138000"
)
assert console_module._open_ids_from_mobile_response(
payload,
"+8613800138000",
) == ["ou_89bcff110ccbb09a23548dc0fb3d880c"]
@pytest.mark.parametrize("mobile", ["", "123", "not-a-phone", "+86 138*0013"])
def test_notification_mobile_rejects_unsafe_values(mobile: str) -> None:
with pytest.raises(ConsoleRequestError, match="手机号格式无效"):
console_module._normalize_mobile(mobile)
def test_notification_config_persists_app_scoped_multi_recipient_routes(
console_settings: Settings,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
initial = service.notification_config()
assert len(initial["people"]) == 16
assert initial["available_app_profiles"] == ["hermes-analyzer"]
assert [app["profile"] for app in initial["apps"]] == ["hermes-analyzer"]
wang = next(person for person in initial["people"] if person["id"] == "wang_yunlong")
assert wang["bindings"]["collector"]["open_id"] == (
"ou_8ee224968aa26a74c7d30ba27fed5eeb"
)
assert wang["bindings"]["hermes-analyzer"]["open_id"] == (
"ou_7ad5fc8012e2f741afc5346e05ffd447"
)
assert all(route["configured"] is False for route in initial["routes"])
routes = [dict(route) for route in initial["routes"]]
daily = next(
route
for route in routes
if route["workflow_id"] == "content.marketing_report.daily"
)
daily.update(
configured=True,
enabled=True,
person_ids=["he_yingwei", "huang_shaoji"],
)
body_routes = [
{
"workflow_id": route["workflow_id"],
"configured": route["configured"],
"enabled": route["enabled"],
"person_ids": route["person_ids"],
}
for route in routes
]
updated = service.update_notification_config(
{
"app_profile": initial["app_profile"],
"people": initial["people"],
"routes": body_routes,
},
expected_revision=str(initial["revision"]),
)
updated_daily = next(
route
for route in updated["routes"]
if route["workflow_id"] == "content.marketing_report.daily"
)
assert updated_daily["enabled"] is True
assert updated_daily["person_ids"] == ["he_yingwei", "huang_shaoji"]
assert updated["revision"] != initial["revision"]
assert (
console_settings.data_root
/ "state"
/ "notifications"
/ "routing.json"
).is_file()
with pytest.raises(ConsoleConflictError, match="refresh|刷新"):
service.update_notification_config(
{
"app_profile": initial["app_profile"],
"people": initial["people"],
"routes": body_routes,
},
expected_revision=str(initial["revision"]),
)
def test_notification_phone_binding_uses_selected_profile_without_persisting(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
observed: dict[str, object] = {}
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
initial = service.notification_config()
initial_revision = initial["revision"]
monkeypatch.setattr(
console_module,
"_configured_lark_apps",
lambda _environment: {
"hermes-analyzer": "cli_aa8c4fc918b85cce",
},
)
def fake_resolve(
profile: str,
mobile: str,
*,
environment: object,
) -> str:
observed.update(
profile=profile,
mobile=mobile,
environment=environment,
)
return "ou_c1faf3d3498201d27cd73c388acde067"
monkeypatch.setattr(
console_module,
"_resolve_feishu_open_id_by_mobile",
fake_resolve,
)
resolved = service.resolve_notification_recipient(
{
"person_id": "feng_renyun",
"mobile": "13800138000",
"app_profile": "hermes-analyzer",
}
)
assert resolved == {
"person_id": "feng_renyun",
"mobile": "+8613800138000",
"app_profile": "hermes-analyzer",
"open_id": "ou_c1faf3d3498201d27cd73c388acde067",
}
assert observed["profile"] == "hermes-analyzer"
assert observed["mobile"] == "+8613800138000"
assert service.notification_config()["revision"] == initial_revision
people = initial["people"]
feng = next(person for person in people if person["id"] == "feng_renyun")
feng["mobile"] = resolved["mobile"]
feng["bindings"]["hermes-analyzer"] = {
"open_id": resolved["open_id"],
"verified": True,
"source": "mobile_lookup",
}
updated = service.update_notification_config(
{
"app_profile": initial["app_profile"],
"people": people,
"routes": [
{
"workflow_id": route["workflow_id"],
"configured": route["configured"],
"enabled": route["enabled"],
"person_ids": route["person_ids"],
}
for route in initial["routes"]
],
},
expected_revision=str(initial_revision),
)
saved_feng = next(
person for person in updated["people"] if person["id"] == "feng_renyun"
)
assert saved_feng["bindings"]["hermes-analyzer"]["verified"] is True
def test_notification_phone_binding_rejects_profile_with_wrong_app_id(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
monkeypatch.setattr(
console_module,
"_configured_lark_apps",
lambda _environment: {"hermes-analyzer": "cli_wrong_application"},
)
with pytest.raises(ConsoleRequestError, match="App ID"):
service.resolve_notification_recipient(
{
"person_id": "feng_renyun",
"mobile": "13800138000",
"app_profile": "hermes-analyzer",
}
)
def test_notification_update_rejects_forged_mobile_lookup_verification(
console_settings: Settings,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
initial = service.notification_config()
he = next(person for person in initial["people"] if person["id"] == "he_yingwei")
he["mobile"] = "+8613800138000"
he["bindings"]["hermes-analyzer"] = {
"open_id": "ou_89bcff110ccbb09a23548dc0fb3d880c",
"verified": True,
"source": "mobile_lookup",
}
with pytest.raises(ConsoleRequestError, match="重新通过手机号绑定"):
service.update_notification_config(
{
"app_profile": initial["app_profile"],
"people": initial["people"],
"routes": [
{
"workflow_id": route["workflow_id"],
"configured": route["configured"],
"enabled": route["enabled"],
"person_ids": route["person_ids"],
}
for route in initial["routes"]
],
},
expected_revision=str(initial["revision"]),
)
def test_overview_tracks_the_current_complete_catalog_without_leaking_paths(
console_settings: Settings,
) -> None:
launcher = FakeConsoleLauncher()
run_id = _index_failed_run(console_settings, "content.metrics.daily")
service = WorkflowConsoleService(console_settings, launcher=launcher)
initial = service.overview()
initial_ids = {item["id"] for item in initial["workflows"]}
catalog = WorkflowCatalog.load(console_settings.project_root / "config")
assert initial_ids == {item.workflow_id for item in catalog.workflows}
assert initial["summary"]["total"] == len(catalog.workflows)
assert {module["id"] for module in initial["modules"]} == {
"content_marketing",
"product_commerce",
"shop_intelligence",
"supply_chain",
}
assert sum(module["count"] for module in initial["modules"]) == len(
catalog.workflows
)
failed = next(
item for item in initial["workflows"] if item["id"] == "content.metrics.daily"
)
assert failed["last_run"]["run_id"] == run_id
assert failed["last_run"]["mode"] == "execute"
assert failed["last_run"]["error"] == "critical steps failed: collect"
assert all(step["name"] and step["description"] for step in failed["steps"])
assert {step["replay_policy"] for step in failed["steps"]} == {"guarded"}
notes_master = next(
item
for item in initial["workflows"]
if item["id"] == "content.notes_master.daily"
)
assert notes_master["name"] == "笔记清单与合作同步"
assert notes_master["registered"] is True
assert notes_master["schedule"]["at"] == "09:00"
shop = next(
item for item in initial["workflows"] if item["id"] == "shop.metrics.weekly"
)
assert {step["replay_policy"] for step in shop["steps"]} == {"idempotent"}
price_appeal = next(
item
for item in initial["workflows"]
if item["id"] == "shop.douyin_price_appeal"
)
assert price_appeal["name"] == "抖音待改价申诉"
assert price_appeal["trigger"] == "scheduled"
assert price_appeal["registered"] is True
assert price_appeal["schedule"]["at"] == ["08:00", "16:00", "22:00"]
assert price_appeal["steps"][0]["replay_policy"] == "idempotent"
assert price_appeal["steps"][0]["timeout_seconds"] == 1_800
jd_self_operated = next(
item
for item in initial["workflows"]
if item["id"] == "shop.jd_self_operated.daily"
)
assert [step["id"] for step in jd_self_operated["steps"]] == [
"brand",
"product",
]
assert [
step["timeout_seconds"] for step in jd_self_operated["steps"]
] == [600, 1_800]
monthly_sales = next(
item
for item in initial["workflows"]
if item["id"] == "product.sales_sheet.daily"
)
assert monthly_sales["run_date_mode"] == "month"
all_shop_daily = next(
item
for item in initial["workflows"]
if item["id"] == "product.erp_all_shop_daily"
)
assert all_shop_daily["run_date_mode"] == "date"
assert all_shop_daily["schedule"]["at"] == "09:00"
assert all_shop_daily["schedule"]["business_date_offset_days"] == -1
assert failed["run_date_mode"] == "date"
report = next(
item
for item in initial["workflows"]
if item["id"] == "content.marketing_report.daily"
)
report_flow = report["steps"][0]["data_flow"]
assert [source["system"] for source in report_flow["sources"]] == [
"PostgreSQL",
"飞书",
]
assert report_flow["processing"] == [
"聚合业务事实与款式表现",
"调用 Hermes 生成营销分析",
"生成图表和 Markdown 日报",
]
assert report_flow["destinations"][1]["condition"] == (
"正式执行且启用 --send"
)
assert "journal_path" not in json.dumps(initial, ensure_ascii=False)
workflows = _read_config(console_settings, "workflows.json")
workflows["workflows"].append(
{
"id": "content.console_probe",
"module": "content_marketing",
"trigger": "manual",
"execution": {"entry": "console_probe.py"},
"provenance": {"source_project": "content"},
}
)
_write_config(console_settings, "workflows.json", workflows)
refreshed = service.overview()
assert refreshed["summary"]["total"] == len(catalog.workflows) + 1
assert "content.console_probe" in {
item["id"] for item in refreshed["workflows"]
}
probe = next(
item for item in refreshed["workflows"] if item["id"] == "content.console_probe"
)
assert probe["registered"] is False
assert probe["schedule"] is None
def test_start_scheduler_launches_resident_process_with_console_environment(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
tmp_path: Path,
) -> None:
env_file = tmp_path / "runtime.env"
env_file.write_text("PG_PASSWORD=redacted-for-test\n", encoding="utf-8")
process = type(
"FakeSchedulerProcess",
(),
{"pid": 43210, "poll": lambda self: None},
)()
calls: list[tuple[list[str], dict[str, object]]] = []
def fake_popen(argv: list[str], **kwargs: object) -> object:
calls.append((argv, kwargs))
return process
process_info = console_module._SchedulerProcessInfo(
pid=43210,
started_at="2026-08-31T01:00:00+00:00",
)
lock_checks = iter((None, process_info))
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
monkeypatch.setattr(
console_module,
"_scheduler_process_from_lock",
lambda _settings: next(lock_checks),
)
result = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
scheduler_env_files=[env_file],
).start_scheduler()
assert result == {
"status": "running",
"service_running": True,
"pid": 43210,
"started_at": "2026-08-31T01:00:00+00:00",
}
assert len(calls) == 1
argv, kwargs = calls[0]
assert argv[1:5] == ["-m", "gyxx_flow", "schedule", "run"]
assert argv[5:] == ["--env-file", str(env_file.resolve())]
assert kwargs["cwd"] == console_settings.project_root
assert kwargs["stdin"] is subprocess.DEVNULL
assert isinstance(kwargs["env"], dict)
assert kwargs["env"]["GYXX_PROJECT_ROOT"] == str(
console_settings.project_root
)
assert kwargs["env"]["GYXX_DATA_ROOT"] == str(console_settings.data_root)
def test_workflow_display_name_is_persistent_revision_safe_and_resettable(
console_settings: Settings,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
workflow_id = "content.marketing_report.daily"
workflows_before = (console_settings.project_root / "config" / "workflows.json").read_bytes()
initial = service.overview()
workflow = next(item for item in initial["workflows"] if item["id"] == workflow_id)
assert workflow["name"] == "营销日报生成与发送"
assert workflow["default_name"] == workflow["name"]
assert workflow["name_customized"] is False
renamed = service.update_display_name(
workflow_id,
{"display_name": " 每日内容营销简报 "},
expected_revision=initial["workflow_names_revision"],
)
assert renamed["name"] == "每日内容营销简报"
assert renamed["name_customized"] is True
restarted = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
).overview()
persisted = next(
item for item in restarted["workflows"] if item["id"] == workflow_id
)
assert persisted["name"] == "每日内容营销简报"
assert persisted["default_name"] == "营销日报生成与发送"
assert persisted["name_customized"] is True
assert (
console_settings.project_root / "config" / "workflows.json"
).read_bytes() == workflows_before
state_path = (
console_settings.data_root
/ "state"
/ "console"
/ "workflow-display-names.json"
)
state_before_conflict = state_path.read_bytes()
with pytest.raises(ConsoleConflictError, match="已被更新"):
service.update_display_name(
workflow_id,
{"display_name": "过期窗口中的名称"},
expected_revision=initial["workflow_names_revision"],
)
assert state_path.read_bytes() == state_before_conflict
reset = service.update_display_name(
workflow_id,
{"display_name": None},
expected_revision=renamed["workflow_names_revision"],
)
assert reset["name"] == "营销日报生成与发送"
assert reset["name_customized"] is False
@pytest.mark.parametrize(
"display_name",
[" ", "x" * 65, "包含\u200b隐藏字符"],
)
def test_workflow_display_name_rejects_unsafe_values(
console_settings: Settings,
display_name: str,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
revision = service.overview()["workflow_names_revision"]
with pytest.raises(ConsoleRequestError):
service.update_display_name(
"content.marketing_report.daily",
{"display_name": display_name},
expected_revision=revision,
)
def test_workflow_display_name_rejects_invalid_request_boundaries(
console_settings: Settings,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
revision = service.overview()["workflow_names_revision"]
with pytest.raises(ConsolePreconditionError):
service.update_display_name(
"content.marketing_report.daily",
{"display_name": "新名称"},
expected_revision=None,
)
with pytest.raises(ConsoleRequestError, match="只允许"):
service.update_display_name(
"content.marketing_report.daily",
{"display_name": "新名称", "id": "other.workflow"},
expected_revision=revision,
)
with pytest.raises(ConsoleNotFoundError):
service.update_display_name(
"content.unknown",
{"display_name": "新名称"},
expected_revision=revision,
)
def test_overview_falls_back_when_workflow_display_names_are_corrupt(
console_settings: Settings,
) -> None:
state_path = (
console_settings.data_root
/ "state"
/ "console"
/ "workflow-display-names.json"
)
state_path.parent.mkdir(parents=True)
state_path.write_text("{broken", encoding="utf-8")
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
overview = service.overview()
workflow = next(
item
for item in overview["workflows"]
if item["id"] == "content.marketing_report.daily"
)
assert workflow["name"] == "营销日报生成与发送"
assert workflow["name_customized"] is False
assert overview["warnings"] == [
"自定义工作流名称配置无效,当前显示默认名称"
]
before = state_path.read_bytes()
with pytest.raises(ConsoleRequestError, match="配置无效"):
service.update_display_name(
workflow["id"],
{"display_name": "不会覆盖损坏配置"},
expected_revision=overview["workflow_names_revision"],
)
assert state_path.read_bytes() == before
def test_overview_includes_sanitized_parallel_step_details(
console_settings: Settings,
) -> None:
run_id = _index_parallel_main_image_run(console_settings)
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
overview = service.overview()
workflow = next(
item
for item in overview["workflows"]
if item["id"] == "product.main_image.weekly"
)
last_run = workflow["last_run"]
assert last_run["run_id"] == run_id
assert [step["id"] for step in last_run["steps"]] == [
"jd.attempt-1",
"tmall.attempt-1",
]
assert last_run["steps"][0]["status"] == "failed"
assert last_run["steps"][0]["exit_code"] == 9
assert "private-value" not in last_run["steps"][0]["error"]
assert "[REDACTED]" in last_run["steps"][0]["error"]
assert last_run["steps"][1]["status"] == "success"
history = service.recent_runs("product.main_image.weekly", limit=1)
assert history["runs"][0]["steps"] == last_run["steps"]
assert history["runs"][0]["mode"] == "unknown"
def test_overview_reads_live_step_progress_from_a_running_journal(
console_settings: Settings,
) -> None:
journal = _index_running_metrics_run(console_settings)
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
workflow = next(
item
for item in service.overview()["workflows"]
if item["id"] == "content.metrics.daily"
)
assert workflow["last_run"]["status"] == "running"
assert workflow["last_run"]["step_counts"] == {
"success": 0,
"failed": 0,
"skipped": 0,
"running": 1,
}
assert workflow["last_run"]["steps"][0]["id"] == (
"collect_collaborators.attempt-1"
)
assert workflow["last_run"]["steps"][0]["status"] == "running"
journal.finish_step(
"collect_collaborators.attempt-1",
status="success",
exit_code=0,
)
journal.start_step("refresh_self_mapping.attempt-1", attempt=1)
refreshed = next(
item
for item in service.overview()["workflows"]
if item["id"] == "content.metrics.daily"
)
assert refreshed["last_run"]["step_counts"] == {
"success": 1,
"failed": 0,
"skipped": 0,
"running": 1,
}
assert [step["status"] for step in refreshed["last_run"]["steps"]] == [
"success",
"running",
]
def test_overview_keeps_a_new_active_launch_separate_from_the_previous_run(
console_settings: Settings,
) -> None:
previous_run_id = _index_parallel_main_image_run(console_settings)
launcher = FakeConsoleLauncher()
launcher.launch(
"product.main_image.weekly",
date(2026, 8, 3),
execute=True,
shadow=False,
)
service = WorkflowConsoleService(console_settings, launcher=launcher)
workflow = next(
item
for item in service.overview()["workflows"]
if item["id"] == "product.main_image.weekly"
)
assert workflow["active_run"]["business_date"] == "2026-08-03"
assert workflow["active_run"]["mode"] == "execute"
assert workflow["last_run"]["run_id"] == previous_run_id
assert workflow["last_run"]["business_date"] == "2026-08-02"
assert workflow["last_run"]["status"] == "failed"
class RecordingLockManager:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
self.active = False
@contextmanager
def acquire(
self,
resource: str,
*,
owner: str,
timeout_seconds: float = 30.0,
poll_seconds: float = 0.1,
) -> Iterator[object]:
del timeout_seconds, poll_seconds
self.calls.append((resource, owner))
self.active = True
try:
yield object()
finally:
self.active = False
def test_schedule_update_uses_revision_and_named_lock_for_atomic_replacement(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
store = ScheduleConfigStore(console_settings)
revision = store.revision()
locks = RecordingLockManager()
store._locks = locks # type: ignore[assignment]
real_atomic_write = console_module.atomic_write_json
def checked_atomic_write(path: Path | str, payload: Any) -> Path:
if Path(path) == store.path:
assert locks.active is True
return real_atomic_write(path, payload)
monkeypatch.setattr(console_module, "atomic_write_json", checked_atomic_write)
schedule, updated_revision = store.update(
"content.metrics.daily",
{
"kind": "daily",
"at": "06:15",
"enabled": False,
"business_date_offset_days": -1,
},
expected_revision=f'"{revision}"',
)
assert locks.calls and locks.calls[0][0] == "config:schedules"
assert locks.calls[0][1].startswith("console-")
assert locks.active is False
assert updated_revision != revision
assert schedule.at == "06:15"
assert schedule.enabled is False
assert console_module._schedule_payload(schedule)["at"] == "06:15"
assert _schedule_row(console_settings, "content.metrics.daily") == {
"workflow_id": "content.metrics.daily",
"kind": "daily",
"at": "06:15",
"enabled": False,
"business_date_offset_days": -1,
}
def test_schedule_update_accepts_and_returns_multiple_start_times(
console_settings: Settings,
) -> None:
store = ScheduleConfigStore(console_settings)
schedule, _revision = store.update(
"content.metrics.daily",
{
"kind": "daily",
"at": ["08:00", "16:00", "22:00"],
"enabled": True,
"business_date_offset_days": 0,
},
expected_revision=store.revision(),
)
assert schedule.effective_times == ("08:00", "16:00", "22:00")
assert console_module._schedule_payload(schedule)["at"] == [
"08:00",
"16:00",
"22:00",
]
assert _schedule_row(console_settings, "content.metrics.daily")["at"] == [
"08:00",
"16:00",
"22:00",
]
@pytest.mark.parametrize(
("schedule", "now", "expected"),
[
(
ScheduleEntry(
workflow_id="test.daily",
kind="daily",
at="08:00",
at_times=("08:00", "16:00", "22:00"),
),
datetime(2026, 8, 6, 16, 30, tzinfo=timezone.utc),
"2026-08-06T22:00:00+00:00",
),
(
ScheduleEntry(
workflow_id="test.weekly",
kind="weekly",
at="08:00",
at_times=("08:00", "16:00", "22:00"),
days=("Thursday",),
),
datetime(2026, 8, 6, 22, 30, tzinfo=timezone.utc),
"2026-08-13T08:00:00+00:00",
),
(
ScheduleEntry(
workflow_id="test.monthly",
kind="monthly",
at="08:00",
at_times=("08:00", "16:00", "22:00"),
day_of_month=6,
),
datetime(2026, 8, 6, 16, 30, tzinfo=timezone.utc),
"2026-08-06T22:00:00+00:00",
),
(
ScheduleEntry(
workflow_id="test.interval",
kind="interval_days",
at="08:00",
at_times=("08:00", "16:00", "22:00"),
every_days=3,
anchor_date="2026-08-06",
),
datetime(2026, 8, 6, 22, 30, tzinfo=timezone.utc),
"2026-08-09T08:00:00+00:00",
),
],
)
def test_next_run_uses_the_nearest_slot_across_all_start_times(
schedule: ScheduleEntry,
now: datetime,
expected: str,
) -> None:
assert console_module._next_run_at(schedule, now) == expected
def test_schedule_update_rejects_stale_revision_without_changing_file(
console_settings: Settings,
) -> None:
store = ScheduleConfigStore(console_settings)
before = store.path.read_bytes()
with pytest.raises(ConsoleConflictError) as raised:
store.update(
"content.metrics.daily",
{"kind": "daily", "at": "06:15"},
expected_revision="0" * 64,
)
assert raised.value.status == HTTPStatus.CONFLICT
assert store.path.read_bytes() == before
@pytest.mark.parametrize(
("workflow_id", "module", "trigger", "expected_status"),
[
(
"content.console_manual",
"content_marketing",
"manual",
HTTPStatus.BAD_REQUEST,
),
(
"product.console_unavailable",
"product_commerce",
"unavailable",
HTTPStatus.BAD_REQUEST,
),
("missing.workflow", None, None, HTTPStatus.NOT_FOUND),
],
)
def test_schedule_update_rejects_non_scheduled_or_unknown_workflows(
console_settings: Settings,
workflow_id: str,
module: str | None,
trigger: str | None,
expected_status: HTTPStatus,
) -> None:
if module is not None and trigger is not None:
_append_non_scheduled_workflow(
console_settings,
workflow_id=workflow_id,
module=module,
trigger=trigger,
)
store = ScheduleConfigStore(console_settings)
before = store.path.read_bytes()
with pytest.raises(ConsoleRequestError) as raised:
store.update(
workflow_id,
{"kind": "daily", "at": "06:15"},
expected_revision=store.revision(),
)
assert raised.value.status == expected_status
assert store.path.read_bytes() == before
def test_schedule_kind_switch_removes_fields_owned_by_the_previous_kind(
console_settings: Settings,
) -> None:
store = ScheduleConfigStore(console_settings)
updated, _revision = store.update(
"content.comments.weekly",
{
"kind": "daily",
"at": "07:45",
"enabled": True,
"business_date_offset_days": 0,
"days": ["Sunday"],
"day_of_month": 31,
"every_days": 3,
"anchor_date": "2026-08-01",
},
expected_revision=store.revision(),
)
assert updated.kind == "daily"
assert updated.days == ()
assert updated.day_of_month is None
assert updated.every_days is None
assert updated.anchor_date is None
assert _schedule_row(console_settings, "content.comments.weekly") == {
"workflow_id": "content.comments.weekly",
"kind": "daily",
"at": "07:45",
"enabled": True,
"business_date_offset_days": 0,
}
@pytest.mark.parametrize(
"payload",
[
{"kind": "daily", "at": "9:00"},
{"kind": "daily", "at": "09:00:30"},
{"kind": "daily", "at": []},
{"kind": "daily", "at": ["09:00", "09:00"]},
{"kind": "daily", "at": ["09:00", "9:30"]},
{
"kind": "interval_days",
"at": "09:00",
"every_days": 3,
"anchor_date": "not-a-date",
},
{
"kind": "interval_days",
"at": "09:00",
"every_days": True,
"anchor_date": "2026-08-01",
},
{"kind": "monthly", "at": "09:00", "day_of_month": True},
{
"kind": "daily",
"at": "09:00",
"business_date_offset_days": True,
},
],
)
def test_invalid_schedule_values_leave_the_original_file_unchanged(
console_settings: Settings,
payload: dict[str, Any],
) -> None:
store = ScheduleConfigStore(console_settings)
before = store.path.read_bytes()
with pytest.raises(ConsoleRequestError):
store.update(
"content.metrics.daily",
payload,
expected_revision=store.revision(),
)
assert store.path.read_bytes() == before
def test_service_trigger_supports_dry_run_confirmed_execute_and_conflict(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
for name, value in {
"PG_HOST": "database.example",
"PG_PORT": "5432",
"PG_DB": "business",
"PG_USER": "runtime",
"PG_PASSWORD": "placeholder",
}.items():
monkeypatch.setenv(name, value)
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
monkeypatch.delenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", raising=False)
launcher = FakeConsoleLauncher()
service = WorkflowConsoleService(console_settings, launcher=launcher)
workflow_id = "content.metrics.daily"
dry_run = service.trigger(workflow_id, {"business_date": "2026-08-01"})
assert dry_run["mode"] == "dry_run"
assert launcher.calls[-1]["mode"] == "dry_run"
launcher.complete(workflow_id)
with pytest.raises(ConsoleRequestError, match="确认"):
service.trigger(
workflow_id,
{"business_date": "2026-08-01", "execute": True},
)
assert len(launcher.calls) == 1
with pytest.raises(ConsoleRequestError, match="强制重新采集"):
service.trigger(
workflow_id,
{"business_date": "2026-08-01", "force_refresh": "yes"},
)
with pytest.raises(ConsoleRequestError, match="必须选择真实执行"):
service.trigger(
workflow_id,
{"business_date": "2026-08-01", "force_refresh": True},
)
executed = service.trigger(
workflow_id,
{
"business_date": "2026-08-01",
"execute": True,
"confirmed": True,
"shadow": True,
"force_refresh": True,
},
)
assert executed["mode"] == "execute"
assert executed["shadow"] is True
assert executed["force_refresh"] is True
with pytest.raises(ConsoleConflictError):
service.trigger(workflow_id, {"business_date": "2026-08-01"})
assert len(launcher.calls) == 2
def test_tmall_baibu_trigger_defaults_to_no_hyperlinks_and_accepts_all_mode(
console_settings: Settings,
) -> None:
launcher = FakeConsoleLauncher()
service = WorkflowConsoleService(console_settings, launcher=launcher)
overview = service.overview()
baibu = next(
item for item in overview["workflows"] if item["id"] == "product.tmall_baibu_apply"
)
assert baibu["tmall_baibu_import"]["default_mode"] == "without_hyperlinks"
assert [option["step_count"] for option in baibu["tmall_baibu_import"]["options"]] == [3, 6]
default_run = service.trigger(
"product.tmall_baibu_apply",
{"business_date": "2026-08-27"},
)
assert default_run["tmall_baibu_import_mode"] == "without_hyperlinks"
assert launcher.calls[-1]["tmall_baibu_import_mode"] == "without_hyperlinks"
launcher.complete("product.tmall_baibu_apply")
all_run = service.trigger(
"product.tmall_baibu_apply",
{
"business_date": "2026-08-27",
"tmall_baibu_import_mode": "all",
},
)
assert all_run["tmall_baibu_import_mode"] == "all"
assert launcher.calls[-1]["tmall_baibu_import_mode"] == "all"
with pytest.raises(ConsoleRequestError, match="导入范围"):
service.trigger(
"product.tmall_baibu_apply",
{
"business_date": "2026-08-27",
"tmall_baibu_import_mode": "invalid",
},
)
def test_tmall_baibu_force_trigger_does_not_read_business_date_receipts(
console_settings: Settings,
) -> None:
from gyxx_flow.ops import EffectLedger
ledger = EffectLedger(console_settings.data_root)
claim = ledger.begin(
workflow_id="product.tmall_baibu_apply",
business_date="2026-08-27",
step_id="old_all_bag",
run_id="run-original",
)
ledger.mark_ambiguous(claim, reason="page-result-needs-reconciliation")
launcher = FakeConsoleLauncher()
service = WorkflowConsoleService(console_settings, launcher=launcher)
result = service.trigger(
"product.tmall_baibu_apply",
{
"business_date": "2026-08-27",
"execute": True,
"confirmed": True,
"force_refresh": True,
"tmall_baibu_import_mode": "without_hyperlinks",
},
)
assert result["status"] == "accepted"
assert launcher.calls[-1]["workflow_id"] == "product.tmall_baibu_apply"
def test_overview_exposes_only_verified_scheduled_runs_as_cancellable(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
_write_scheduler_state(
console_settings,
{
"schema_version": 1,
"updated_at": "2026-08-14T02:00:00+00:00",
"jobs": {
"content.relogin.weekly": {
"status": "running",
"pid": 4310,
"business_date": "2026-08-14",
"started_at": "2026-08-14T02:00:00+00:00",
},
"content.metrics.daily": {
"status": "running",
"pid": 4311,
"business_date": "2026-08-14",
"started_at": "2026-08-14T02:00:00+00:00",
},
},
},
)
class VerifiedProcess:
pid = 4310
def fake_process(
workflow_id: str,
_job: dict[str, Any],
) -> tuple[bool, Any, float | None]:
if workflow_id == "content.relogin.weekly":
return True, VerifiedProcess(), 1_800_000_000.0
return False, None, None
monkeypatch.setattr(console_module, "_scheduler_job_process", fake_process)
overview = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
).overview()
workflows = {item["id"]: item for item in overview["workflows"]}
verified = workflows["content.relogin.weekly"]["scheduler_job"]
assert verified["status"] == "running"
assert verified["cancellable"] is True
assert "pid" not in verified
stale = workflows["content.metrics.daily"]["scheduler_job"]
assert stale["status"] == "interrupted"
assert stale["cancellable"] is False
assert overview["summary"]["running"] == 1
def test_service_cancels_verified_scheduled_run_and_finalizes_journal(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
workflow_id = "content.relogin.weekly"
_write_scheduler_state(
console_settings,
{
"schema_version": 1,
"updated_at": "2026-08-14T02:00:00+00:00",
"jobs": {
workflow_id: {
"status": "running",
"pid": 4321,
"business_date": "2026-08-14",
"started_at": "2026-08-14T02:00:00+00:00",
}
},
},
)
class VerifiedProcess:
pid = 4321
process = VerifiedProcess()
terminated: list[tuple[Any, float]] = []
monkeypatch.setattr(
console_module,
"_scheduler_job_process",
lambda _workflow_id, _job: (True, process, 1_800_000_000.0),
)
monkeypatch.setattr(
console_module,
"_scheduler_run_journal_path",
lambda *_args: console_settings.data_root / "runs" / "run.json",
)
monkeypatch.setattr(
console_module,
"_terminate_scheduler_process_tree",
lambda value, started_at: terminated.append((value, started_at)),
)
monkeypatch.setattr(
console_module,
"_finalize_scheduler_cancelled_journal",
lambda *_args: ("cancelled", "scheduled-run-id"),
)
result = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
).cancel_scheduled(workflow_id)
assert result["status"] == "cancelled"
assert result["source"] == "scheduler"
assert result["run_id"] == "scheduled-run-id"
assert terminated == [(process, 1_800_000_000.0)]
def test_tmall_video_trigger_routes_only_the_selected_store(
console_settings: Settings,
) -> None:
launcher = FakeConsoleLauncher()
service = WorkflowConsoleService(console_settings, launcher=launcher)
overview = service.overview()
video_workflow = next(
item for item in overview["workflows"] if item["id"] == "product.video_upload"
)
assert video_workflow["topic_config"]["default_keyword"] == "我的夏日焕新清单"
luggage = service.trigger(
"product.video_upload",
{
"business_date": "2026-08-11",
"store": "luggage",
"tmall_topic_keyword": "秋上新",
},
)
assert luggage["store"] == "luggage"
assert luggage["topic_keyword"] == "秋上新"
assert launcher.calls[-1]["store"] == "luggage"
assert launcher.calls[-1]["topic_keyword"] == "秋上新"
launcher.complete("product.video_upload")
flagship = service.trigger(
"product.video_upload",
{"business_date": "2026-08-11", "store": "flagship"},
)
assert flagship["store"] == "flagship"
assert flagship["topic_keyword"] == "我的夏日焕新清单"
assert launcher.calls[-1]["store"] == "flagship"
@pytest.mark.parametrize("value", [123, True, "x" * 121])
def test_tmall_video_trigger_rejects_invalid_topic_keyword(
console_settings: Settings,
value: object,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
with pytest.raises(ConsoleRequestError, match="话题关键词"):
service.trigger(
"product.video_upload",
{"business_date": "2026-08-11", "tmall_topic_keyword": value},
)
def test_video_trigger_execute_uses_direct_model_preflight(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
monkeypatch.setenv("PG_HOST", "database.example")
monkeypatch.setenv("PG_PORT", "5432")
monkeypatch.setenv("PG_DB", "business")
monkeypatch.setenv("PG_USER", "runtime")
monkeypatch.setenv("PG_PASSWORD", "placeholder")
monkeypatch.setenv(
"STYLE_ANALYSIS_LLM_BASE_URL",
"https://api.minimaxi.com/v1",
)
monkeypatch.setenv("STYLE_ANALYSIS_LLM_API_KEY", "test-only-secret")
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
monkeypatch.delenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", raising=False)
monkeypatch.delenv("GYXX_HERMES_API_KEY", raising=False)
monkeypatch.delenv("HERMES_API_KEY", raising=False)
launcher = FakeConsoleLauncher()
result = WorkflowConsoleService(
console_settings,
launcher=launcher,
).trigger(
"product.video_upload",
{
"business_date": "2026-08-11",
"store": "flagship",
"execute": True,
"confirmed": True,
},
)
assert result["mode"] == "execute"
assert launcher.calls[-1]["store"] == "flagship"
@pytest.mark.parametrize("store", ["all", "unknown", "", None, 1])
def test_tmall_video_trigger_rejects_invalid_frontend_store(
console_settings: Settings,
store: object,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
with pytest.raises(ConsoleRequestError, match="店铺参数无效"):
service.trigger(
"product.video_upload",
{"business_date": "2026-08-11", "store": store},
)
def test_other_workflow_rejects_tmall_store_parameter(
console_settings: Settings,
) -> None:
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
with pytest.raises(ConsoleRequestError, match="不支持的字段"):
service.trigger(
"content.metrics.daily",
{"business_date": "2026-08-11", "store": "flagship"},
)
@pytest.mark.parametrize(
"workflow_id",
[
"content.notes_master.daily",
"content.metrics.daily",
"content.metrics.backfill",
"content.marketing_report.daily",
"content.creator_report.monthly",
"content.summary.monthly",
"content.comments.weekly",
"content.summary.weekly",
],
)
def test_content_production_preflight_accepts_complete_cloud_postgres(
workflow_id: str,
) -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
entry = next(
item for item in catalog.workflows if item.workflow_id == workflow_id
)
environment = {
"PG_HOST": "database.example",
"PG_PORT": "5432",
"PG_DB": "business",
"PG_USER": "runtime",
"PG_PASSWORD": "placeholder",
}
if workflow_id in console_module._CONTENT_DIRECT_ANALYSIS_WORKFLOW_IDS:
environment["CONTENT_ANALYSIS_LLM_API_KEY"] = "test-only-secret"
console_module._validate_content_production_runtime(entry, environment)
@pytest.mark.parametrize(
"workflow_id",
["content.summary.monthly", "content.summary.weekly"],
)
def test_content_summary_preflight_requires_direct_model_key(
workflow_id: str,
) -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
entry = next(
item for item in catalog.workflows if item.workflow_id == workflow_id
)
with pytest.raises(ConsoleRequestError, match="直连 MiniMax"):
console_module._validate_content_production_runtime(
entry,
{
"PG_HOST": "database.example",
"PG_PORT": "5432",
"PG_DB": "business",
"PG_USER": "runtime",
"PG_PASSWORD": "placeholder",
},
)
def test_content_summary_child_does_not_inherit_hermes_credentials() -> None:
resolved = console_module._console_runtime_environment(
{
"GYXX_HERMES_API_KEY": "legacy-secret",
"HERMES_API_KEY": "legacy-secret",
"HERMES_ANALYZER_TOKEN": "legacy-secret",
"GYXX_SUPPLY_HERMES_TOKEN": "legacy-secret",
"CONTENT_ANALYSIS_LLM_API_KEY": "direct-secret",
},
workflow_id="content.summary.weekly",
)
assert "GYXX_HERMES_API_KEY" not in resolved
assert "HERMES_API_KEY" not in resolved
assert "HERMES_ANALYZER_TOKEN" not in resolved
assert "GYXX_SUPPLY_HERMES_TOKEN" not in resolved
assert resolved["CONTENT_ANALYSIS_LLM_API_KEY"] == "direct-secret"
@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "[::1]"])
def test_content_production_preflight_rejects_loopback_postgres(host: str) -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
entry = next(
item
for item in catalog.workflows
if item.workflow_id == "content.metrics.daily"
)
with pytest.raises(ConsoleRequestError, match="云端 PostgreSQL.*loopback"):
console_module._validate_content_production_runtime(
entry,
{
"PG_HOST": host,
"PG_PORT": "5432",
"PG_DB": "business",
"PG_USER": "runtime",
"PG_PASSWORD": "placeholder",
},
)
@pytest.mark.parametrize(
("flag", "message"),
[
("GYXX_WORKFLOW_ACCEPTANCE", "验收隔离"),
("GYXX_FEISHU_TABLE_WRITE_DISABLED", "禁用真实写入"),
],
)
def test_content_production_preflight_rejects_non_production_modes(
flag: str,
message: str,
) -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
relogin = next(
item
for item in catalog.workflows
if item.workflow_id == "content.relogin.weekly"
)
with pytest.raises(ConsoleRequestError, match=message):
console_module._validate_content_production_runtime(relogin, {flag: "1"})
def test_content_relogin_preflight_does_not_require_postgres() -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
relogin = next(
item
for item in catalog.workflows
if item.workflow_id == "content.relogin.weekly"
)
console_module._validate_content_production_runtime(relogin, {})
@pytest.mark.parametrize(
"workflow_id",
["content.notes_master.daily", "content.metrics.daily"],
)
def test_content_execute_missing_database_fails_before_popen(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
workflow_id: str,
) -> None:
for name in ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD"):
monkeypatch.delenv(name, raising=False)
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
monkeypatch.delenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", raising=False)
popen_called = False
def forbidden_popen(*_args: object, **_kwargs: object) -> None:
nonlocal popen_called
popen_called = True
pytest.fail("Popen must not run when content production preflight fails")
monkeypatch.setattr(console_module.subprocess, "Popen", forbidden_popen)
service = WorkflowConsoleService(console_settings)
with pytest.raises(
ConsoleRequestError,
match="PostgreSQL.*PG_HOST.*PG_PASSWORD.*--env-file",
):
service.trigger(
workflow_id,
{
"business_date": "2026-08-01",
"execute": True,
"confirmed": True,
},
)
assert popen_called is False
def test_product_production_preflight_requires_real_database_and_lark_profile(
tmp_path: Path,
) -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
daily = next(
item for item in catalog.workflows if item.workflow_id == "product.daily"
)
config_dir = tmp_path / ".lark-cli"
config_dir.mkdir()
(config_dir / "config.json").write_text(
json.dumps(
{
"apps": [
{
"name": "hermes-analyzer",
"appId": "cli_aa8c4fc918b85cce",
}
]
}
),
encoding="utf-8",
)
environment = {
"PG_HOST": "database.example",
"PG_PORT": "5432",
"PG_DB": "business",
"PG_USER": "runtime",
"PG_PASSWORD": "placeholder",
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
}
console_module._validate_product_production_runtime(daily, environment)
without_database = {
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
}
with pytest.raises(ConsoleRequestError, match="PostgreSQL"):
console_module._validate_product_production_runtime(daily, without_database)
empty_config_dir = tmp_path / "empty-lark-cli"
empty_config_dir.mkdir()
without_lark = {
**environment,
"LARKSUITE_CLI_CONFIG_DIR": str(empty_config_dir),
}
with pytest.raises(ConsoleRequestError, match="hermes-analyzer"):
console_module._validate_product_production_runtime(daily, without_lark)
wrong_app_dir = tmp_path / "wrong-lark-app"
wrong_app_dir.mkdir()
(wrong_app_dir / "config.json").write_text(
json.dumps(
{
"apps": [
{
"name": "hermes-analyzer",
"appId": "cli_wrong_application",
}
]
}
),
encoding="utf-8",
)
with pytest.raises(ConsoleRequestError, match="hermes-analyzer"):
console_module._validate_product_production_runtime(
daily,
{**environment, "LARKSUITE_CLI_CONFIG_DIR": str(wrong_app_dir)},
)
def test_monthly_sales_preflight_does_not_require_postgres_or_hermes() -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
monthly_sales = next(
item
for item in catalog.workflows
if item.workflow_id == "product.sales_sheet.daily"
)
console_module._validate_product_production_runtime(monthly_sales, {})
with pytest.raises(ConsoleRequestError, match="验收隔离"):
console_module._validate_product_production_runtime(
monthly_sales, {"GYXX_WORKFLOW_ACCEPTANCE": "1"}
)
def test_product_production_preflight_rejects_acceptance_and_missing_owner(
tmp_path: Path,
) -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
alert = next(
item
for item in catalog.workflows
if item.workflow_id == "product.alert.daily"
)
config_dir = tmp_path / ".lark-cli"
config_dir.mkdir()
(config_dir / "config.json").write_text(
json.dumps(
{
"apps": [
{
"name": "hermes-analyzer",
"appId": "cli_aa8c4fc918b85cce",
}
]
}
),
encoding="utf-8",
)
environment = {
"GYXX_POSTGRES_DSN": "postgresql://runtime:${PG_PASSWORD}@database.example/business",
"GYXX_HERMES_API_KEY": "placeholder",
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
}
with pytest.raises(ConsoleRequestError, match="负责人"):
console_module._validate_product_production_runtime(alert, environment)
environment["GYXX_NOTIFICATION_RECIPIENT_OPEN_ID"] = "configured-owner"
environment.pop("GYXX_HERMES_API_KEY")
console_module._validate_product_production_runtime(alert, environment)
environment["GYXX_WORKFLOW_ACCEPTANCE"] = "1"
with pytest.raises(ConsoleRequestError, match="验收隔离"):
console_module._validate_product_production_runtime(alert, environment)
def test_disabled_product_alert_preflight_requires_only_postgres_output_sink(
console_settings: Settings,
) -> None:
catalog = WorkflowCatalog.load(console_settings.project_root / "config")
alert = next(
item
for item in catalog.workflows
if item.workflow_id == "product.alert.daily"
)
service = WorkflowConsoleService(
console_settings,
launcher=FakeConsoleLauncher(),
)
routing, revision = service.notifications.snapshot()
routing["routes"]["product.alert.daily"] = {
"enabled": False,
"app_profile": "hermes-analyzer",
"person_ids": [],
}
service.notifications.update(routing, expected_revision=revision)
console_module._validate_product_production_runtime(
alert,
{
"GYXX_POSTGRES_DSN": "postgresql://runtime:secret@database.example/business",
"GYXX_PROJECT_ROOT": str(console_settings.project_root),
"GYXX_DATA_ROOT": str(console_settings.data_root),
},
)
def test_product_production_preflight_resolves_local_hermes_profile(
tmp_path: Path,
) -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
style_analysis = next(
item
for item in catalog.workflows
if item.workflow_id == "product.style_analysis.interval"
)
config_dir = tmp_path / ".lark-cli"
config_dir.mkdir()
(config_dir / "config.json").write_text(
json.dumps(
{
"apps": [
{
"name": "hermes-analyzer",
"appId": "cli_aa8c4fc918b85cce",
}
]
}
),
encoding="utf-8",
)
hermes_home = tmp_path / ".hermes"
profile_dir = hermes_home / "profiles" / "data-analyzer"
profile_dir.mkdir(parents=True)
(profile_dir / ".env").write_text(
"API_SERVER_KEY=test-profile-key\n",
encoding="utf-8",
)
environment = {
"PG_HOST": "database.example",
"PG_PORT": "5432",
"PG_DB": "business",
"PG_USER": "runtime",
"PG_PASSWORD": "placeholder",
"HERMES_HOME": str(hermes_home),
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
}
console_module._validate_product_production_runtime(
style_analysis,
environment,
)
resolved = console_module._console_runtime_environment(environment)
assert resolved["GYXX_HERMES_API_KEY"] == "test-profile-key"
assert resolved["HERMES_API_KEY"] == "test-profile-key"
@pytest.mark.parametrize(
"workflow_id",
("product.video_upload", "product.jd_video_upload"),
)
def test_video_production_preflight_uses_direct_model_without_hermes(
monkeypatch: pytest.MonkeyPatch,
workflow_id: str,
) -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
entry = next(item for item in catalog.workflows if item.workflow_id == workflow_id)
environment = {
"PG_HOST": "database.example",
"PG_PORT": "5432",
"PG_DB": "business",
"PG_USER": "runtime",
"PG_PASSWORD": "placeholder",
"STYLE_ANALYSIS_LLM_BASE_URL": "https://api.minimaxi.com/v1",
"STYLE_ANALYSIS_LLM_API_KEY": "test-only-secret",
"STYLE_ANALYSIS_LLM_MODEL": "MiniMax-M3",
"GYXX_HERMES_API_KEY": "legacy-secret",
"HERMES_API_KEY": "legacy-secret",
}
def forbidden_hermes_resolver(*_args: object, **_kwargs: object) -> str:
pytest.fail("video console execution must not resolve Hermes credentials")
monkeypatch.setattr(
console_module,
"resolve_hermes_profile_api_key",
forbidden_hermes_resolver,
)
console_module._validate_product_production_runtime(entry, environment)
resolved = console_module._console_runtime_environment(
environment,
workflow_id=workflow_id,
)
assert resolved["STYLE_ANALYSIS_LLM_BASE_URL"] == "https://api.minimaxi.com/v1"
assert "GYXX_HERMES_API_KEY" not in resolved
assert "HERMES_API_KEY" not in resolved
def test_video_production_preflight_rejects_missing_direct_model_config() -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
entry = next(
item
for item in catalog.workflows
if item.workflow_id == "product.video_upload"
)
environment = {
"PG_HOST": "database.example",
"PG_PORT": "5432",
"PG_DB": "business",
"PG_USER": "runtime",
"PG_PASSWORD": "placeholder",
}
with pytest.raises(ConsoleRequestError, match="直连大模型配置无效"):
console_module._validate_product_production_runtime(entry, environment)
class StubProcess:
def __init__(self, pid: int) -> None:
self.pid = pid
self.return_code: int | None = None
def poll(self) -> int | None:
return self.return_code
class StubTreeProcess:
def __init__(
self,
pid: int,
*,
started_at: float,
popen: StubProcess | None = None,
ignore_terminate: bool = False,
) -> None:
self.pid = pid
self.started_at = started_at
self.popen = popen
self.ignore_terminate = ignore_terminate
self.alive = True
self.terminated = False
self.killed = False
self.descendants: list[StubTreeProcess] = []
def create_time(self) -> float:
return self.started_at
def children(self, *, recursive: bool) -> list[StubTreeProcess]:
assert recursive is True
return list(self.descendants)
def terminate(self) -> None:
self.terminated = True
if not self.ignore_terminate:
self.alive = False
if self.popen is not None:
self.popen.return_code = -15
def kill(self) -> None:
self.killed = True
self.alive = False
if self.popen is not None:
self.popen.return_code = -9
def is_running(self) -> bool:
return self.alive
def status(self) -> str:
return psutil.STATUS_RUNNING if self.alive else psutil.STATUS_ZOMBIE
def wait(self, timeout: float | None = None) -> int:
if self.alive:
raise psutil.TimeoutExpired(timeout or 0, pid=self.pid)
return -9 if self.killed else -15
def test_subprocess_launcher_uses_fixed_argv_environment_and_unique_operations(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
settings = Settings(
project_root=tmp_path / "project",
data_root=tmp_path / "data",
)
python = tmp_path / "runtime" / "python.exe"
hermes_home = tmp_path / ".hermes"
profile_dir = hermes_home / "profiles" / "data-analyzer"
profile_dir.mkdir(parents=True)
(profile_dir / ".env").write_text(
"API_SERVER_KEY=test-launcher-key\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("GYXX_HERMES_API_KEY", raising=False)
monkeypatch.delenv("HERMES_API_KEY", raising=False)
calls: list[tuple[list[str], dict[str, Any], StubProcess]] = []
def fake_popen(argv: list[str], **kwargs: Any) -> StubProcess:
process = StubProcess(9000 + len(calls))
calls.append((argv, kwargs, process))
return process
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
launcher = SubprocessConsoleRunLauncher(settings, python_executable=python)
first = launcher.launch(
"content.metrics.daily",
date(2026, 8, 1),
execute=True,
shadow=True,
)
second = launcher.launch(
"product.daily",
date(2026, 7, 31),
execute=False,
shadow=False,
)
third = launcher.launch(
"product.video_upload",
date(2026, 8, 11),
execute=True,
shadow=False,
video_upload_store="luggage",
tmall_topic_keyword="秋上新",
force_refresh=True,
)
fourth = launcher.launch(
"product.tmall_baibu_apply",
date(2026, 8, 27),
execute=False,
shadow=False,
tmall_baibu_import_mode="without_hyperlinks",
)
assert first["operation_id"] != second["operation_id"]
assert third["store"] == "luggage"
assert fourth["tmall_baibu_import_mode"] == "without_hyperlinks"
assert re.fullmatch(r"op-[0-9a-f]{16}", str(first["operation_id"]))
assert calls[0][0] == [
str(python.resolve()),
"-m",
"gyxx_flow",
"run",
"content.metrics.daily",
"--date",
"2026-08-01",
"--execute",
"--shadow",
]
assert calls[1][0] == [
str(python.resolve()),
"-m",
"gyxx_flow",
"run",
"product.daily",
"--date",
"2026-07-31",
]
assert calls[2][0] == [
str(python.resolve()),
"-m",
"gyxx_flow",
"run",
"product.video_upload",
"--date",
"2026-08-11",
"--execute",
]
assert calls[2][1]["env"]["GYXX_VIDEO_UPLOAD_STORE"] == "luggage"
assert calls[2][1]["env"]["GYXX_TMALL_TOPIC_KEYWORD"] == "秋上新"
assert calls[2][1]["env"]["GYXX_FORCE_REFRESH"] == "true"
assert calls[3][0][-2:] == ["--tmall-baibu-import-mode", "without_hyperlinks"]
for argv, kwargs, _process in calls:
assert kwargs["cwd"] == settings.project_root
assert kwargs["shell"] is False
assert kwargs["stdin"] is subprocess.DEVNULL
assert kwargs["stderr"] is subprocess.STDOUT
assert kwargs["env"]["GYXX_PROJECT_ROOT"] == str(settings.project_root)
assert kwargs["env"]["GYXX_DATA_ROOT"] == str(settings.data_root)
if "product.video_upload" in argv:
assert "GYXX_HERMES_API_KEY" not in kwargs["env"]
assert "HERMES_API_KEY" not in kwargs["env"]
else:
assert kwargs["env"]["GYXX_HERMES_API_KEY"] == "test-launcher-key"
assert kwargs["env"]["HERMES_API_KEY"] == "test-launcher-key"
assert str(kwargs["stdout"].name).endswith(".log")
assert str(first["operation_id"]) in str(calls[0][1]["stdout"].name)
assert str(second["operation_id"]) in str(calls[1][1]["stdout"].name)
assert str(third["operation_id"]) in str(calls[2][1]["stdout"].name)
for _argv, _kwargs, process in calls:
process.return_code = 0
assert launcher.active() == ()
assert all(kwargs["stdout"].closed for _argv, kwargs, _process in calls)
def test_subprocess_launcher_cancels_exact_operation_tree_and_journal(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
calls: list[tuple[dict[str, Any], StubProcess]] = []
roots: dict[int, StubTreeProcess] = {}
children: dict[int, StubTreeProcess] = {}
grandchildren: dict[int, StubTreeProcess] = {}
real_psutil_process = console_module.psutil.Process
def fake_popen(_argv: list[str], **kwargs: Any) -> StubProcess:
pid = 9100 + len(calls)
popen = StubProcess(pid)
root = StubTreeProcess(pid, started_at=1_800_000_000.0, popen=popen)
child = StubTreeProcess(
pid + 100,
started_at=1_800_000_001.0,
ignore_terminate=True,
)
grandchild = StubTreeProcess(
pid + 200,
started_at=1_800_000_002.0,
ignore_terminate=True,
)
child.descendants.append(grandchild)
root.descendants.extend((child, grandchild))
roots[pid] = root
children[pid] = child
grandchildren[pid] = grandchild
calls.append((kwargs, popen))
return popen
def fake_wait_procs(
processes: list[StubTreeProcess],
*,
timeout: float,
) -> tuple[list[StubTreeProcess], list[StubTreeProcess]]:
assert timeout > 0
gone = [process for process in processes if not process.alive]
alive = [process for process in processes if process.alive]
return gone, alive
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
monkeypatch.setattr(
console_module.psutil,
"Process",
lambda pid: roots.get(pid) or real_psutil_process(pid),
)
monkeypatch.setattr(console_module.psutil, "wait_procs", fake_wait_procs)
launcher = SubprocessConsoleRunLauncher(console_settings)
workflow_id = "content.metrics.daily"
started = launcher.launch(
workflow_id,
date(2026, 8, 2),
execute=True,
shadow=False,
)
operation_id = str(started["operation_id"])
popen = calls[0][1]
context = RunContext.create(
workflow_id,
"2026-08-02",
random_suffix="cancel1",
)
journal = RunJournal.create(DataLayout(console_settings.data_root), context)
RunIndex(console_settings.data_root).index_journal(journal)
journal.start_step("collect.attempt-1", attempt=1)
lock_root = console_settings.data_root / "state" / "locks"
lock_root.mkdir(parents=True, exist_ok=True)
(lock_root / "workflow.lock").write_text(
json.dumps(
{
"resource": f"workflow:{workflow_id}",
"owner": context.run_id,
"pid": popen.pid,
"process_started_at": roots[popen.pid].started_at,
}
),
encoding="utf-8",
)
with pytest.raises(ConsoleConflictError, match="刷新"):
launcher.cancel(workflow_id, "op-0000000000000000")
assert roots[popen.pid].terminated is False
cancelled = launcher.cancel(workflow_id, operation_id)
assert cancelled["status"] == "cancelled"
assert cancelled["journal_status"] == "cancelled"
assert cancelled["run_id"] == context.run_id
assert roots[popen.pid].terminated is True
assert children[popen.pid].terminated is True
assert children[popen.pid].killed is True
assert grandchildren[popen.pid].terminated is True
assert grandchildren[popen.pid].killed is True
assert popen.poll() is not None
assert calls[0][0]["stdout"].closed is True
assert launcher.active() == ()
indexed = RunIndex(console_settings.data_root).get(context.run_id)
assert indexed is not None
assert indexed.status == "cancelled"
assert indexed.step_counts == {
"success": 0,
"failed": 0,
"skipped": 1,
"running": 0,
}
journal_payload = json.loads(journal.path.read_text(encoding="utf-8"))
assert journal_payload["status"] == "cancelled"
assert journal_payload["steps"]["collect.attempt-1"]["status"] == "skipped"
assert journal_payload["steps"]["collect.attempt-1"]["ended_at"] is not None
repeated = launcher.cancel(workflow_id, operation_id)
assert repeated["status"] == "cancelled"
assert repeated["already_cancelled"] is True
cancellation_path = (
console_settings.data_root
/ "state"
/ "ops"
/ "console-cancellations"
/ f"{operation_id}.json"
)
assert json.loads(cancellation_path.read_text(encoding="utf-8"))[
"journal_status"
] == "cancelled"
immediate = launcher.launch(
"product.daily",
date(2026, 8, 2),
execute=True,
shadow=False,
)
immediate_result = launcher.cancel(
"product.daily",
str(immediate["operation_id"]),
)
assert immediate_result["journal_status"] == "synthetic_cancelled"
synthetic = RunIndex(console_settings.data_root).get(
str(immediate_result["run_id"])
)
assert synthetic is not None
assert synthetic.status == "cancelled"
overview = WorkflowConsoleService(
console_settings,
launcher=launcher,
).overview()
product = next(
item for item in overview["workflows"] if item["id"] == "product.daily"
)
assert product["active_run"] is None
assert product["last_run"]["status"] == "cancelled"
def test_subprocess_launcher_refuses_pid_identity_change(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
roots: dict[int, StubTreeProcess] = {}
streams: list[TextIO] = []
real_psutil_process = console_module.psutil.Process
def fake_popen(_argv: list[str], **kwargs: Any) -> StubProcess:
process = StubProcess(9200)
roots[process.pid] = StubTreeProcess(
process.pid,
started_at=1_800_000_000.0,
popen=process,
)
streams.append(kwargs["stdout"])
return process
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
monkeypatch.setattr(
console_module.psutil,
"Process",
lambda pid: roots.get(pid) or real_psutil_process(pid),
)
launcher = SubprocessConsoleRunLauncher(console_settings)
launched = launcher.launch(
"content.metrics.daily",
date(2026, 8, 2),
execute=False,
shadow=False,
)
roots[9200].started_at += 10
with pytest.raises(ConsoleConflictError, match="身份已变化"):
launcher.cancel(
"content.metrics.daily",
str(launched["operation_id"]),
)
assert roots[9200].terminated is False
assert streams[0].closed is False
assert len(launcher.active()) == 1
assert roots[9200].popen is not None
roots[9200].popen.return_code = 0
roots[9200].alive = False
assert launcher.active() == ()
assert streams[0].closed is True
def test_subprocess_launcher_recovers_active_operation_after_console_restart(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
calls: list[tuple[dict[str, Any], StubProcess]] = []
roots: dict[int, StubTreeProcess] = {}
real_psutil_process = console_module.psutil.Process
def fake_popen(_argv: list[str], **kwargs: Any) -> StubProcess:
process = StubProcess(9300)
roots[process.pid] = StubTreeProcess(
process.pid,
started_at=1_800_000_000.0,
popen=process,
)
calls.append((kwargs, process))
return process
def fake_wait_procs(
processes: list[StubTreeProcess],
*,
timeout: float,
) -> tuple[list[StubTreeProcess], list[StubTreeProcess]]:
assert timeout > 0
return (
[process for process in processes if not process.alive],
[process for process in processes if process.alive],
)
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
monkeypatch.setattr(
console_module.psutil,
"Process",
lambda pid: roots.get(pid) or real_psutil_process(pid),
)
monkeypatch.setattr(console_module.psutil, "wait_procs", fake_wait_procs)
first_launcher = SubprocessConsoleRunLauncher(console_settings)
started = first_launcher.launch(
"content.metrics.daily",
date(2026, 8, 2),
execute=True,
shadow=False,
)
operation_id = str(started["operation_id"])
active_path = (
console_settings.data_root
/ "state"
/ "ops"
/ "console-active"
/ f"{operation_id}.json"
)
assert active_path.exists()
calls[0][0]["stdout"].close()
recovered_launcher = SubprocessConsoleRunLauncher(console_settings)
assert recovered_launcher.active() == (
{
"operation_id": operation_id,
"workflow_id": "content.metrics.daily",
"business_date": "2026-08-02",
"mode": "execute",
"shadow": False,
"started_at": started["started_at"],
"status": "running",
},
)
cancelled = recovered_launcher.cancel("content.metrics.daily", operation_id)
assert cancelled["status"] == "cancelled"
assert cancelled["journal_status"] == "synthetic_cancelled"
assert active_path.exists() is False
assert roots[9300].terminated is True
def test_subprocess_launcher_discards_stale_recovery_record_without_terminating(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
streams: list[TextIO] = []
roots: dict[int, StubTreeProcess] = {}
real_psutil_process = console_module.psutil.Process
def fake_popen(_argv: list[str], **kwargs: Any) -> StubProcess:
process = StubProcess(9400)
roots[process.pid] = StubTreeProcess(
process.pid,
started_at=1_800_000_000.0,
popen=process,
)
streams.append(kwargs["stdout"])
return process
monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen)
monkeypatch.setattr(
console_module.psutil,
"Process",
lambda pid: roots.get(pid) or real_psutil_process(pid),
)
first_launcher = SubprocessConsoleRunLauncher(console_settings)
started = first_launcher.launch(
"content.metrics.daily",
date(2026, 8, 2),
execute=False,
shadow=False,
)
operation_id = str(started["operation_id"])
active_path = (
console_settings.data_root
/ "state"
/ "ops"
/ "console-active"
/ f"{operation_id}.json"
)
streams[0].close()
roots[9400].started_at += 30
recovered_launcher = SubprocessConsoleRunLauncher(console_settings)
assert recovered_launcher.active() == ()
assert active_path.exists() is False
assert roots[9400].terminated is False
@contextmanager
def _running_server(
settings: Settings,
launcher: FakeConsoleLauncher,
*,
token: str | None = None,
) -> Iterator[console_module.WorkflowConsoleHTTPServer]:
server = create_console_server(
settings,
host="127.0.0.1",
port=0,
token=token,
launcher=launcher,
)
thread = threading.Thread(
target=server.serve_forever,
kwargs={"poll_interval": 0.01},
daemon=True,
)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _http_request(
server: console_module.WorkflowConsoleHTTPServer,
method: str,
path: str,
*,
body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> tuple[int, dict[str, str], bytes]:
connection = http.client.HTTPConnection(
"127.0.0.1", server.server_port, timeout=5
)
try:
connection.request(method, path, body=body, headers=headers or {})
response = connection.getresponse()
content = response.read()
return response.status, dict(response.getheaders()), content
finally:
connection.close()
def _assert_security_headers(headers: dict[str, str]) -> None:
assert "default-src 'self'" in headers["Content-Security-Policy"]
assert headers["X-Content-Type-Options"] == "nosniff"
assert headers["X-Frame-Options"] == "DENY"
assert headers["Referrer-Policy"] == "no-referrer"
assert "Access-Control-Allow-Origin" not in headers
def test_http_home_assets_and_overview_have_security_headers(
console_settings: Settings,
) -> None:
with _running_server(console_settings, FakeConsoleLauncher()) as server:
status, headers, content = _http_request(server, "GET", "/")
assert status == HTTPStatus.OK
_assert_security_headers(headers)
page = content.decode("utf-8")
assert "/assets/app.css" in page
assert "/assets/app.js" in page
assert page.count("data-custom-select") == 2
assert 'data-description="显示所有触发类型"' in page
assert 'data-description="显示全部运行结果"' in page
assert 'id="drawer-stop-button"' in page
assert 'id="stop-overlay"' in page
assert 'role="alertdialog"' in page
assert 'id="drawer-rename-button"' in page
assert 'id="drawer-name-form"' in page
assert 'id="schedule-distribution"' in page
assert 'id="schedule-day-filter"' in page
assert 'id="service-pill"' in page
assert 'id="scheduler-start-button"' in page
assert "启动调度器" in page
assert 'id="run-store-field"' in page
assert 'id="run-tmall-baibu-field"' in page
assert 'id="run-tmall-baibu-mode"' in page
assert '<option value="without_hyperlinks">导入去掉超链的</option>' in page
assert '<option value="all">导入全部' in page
assert 'id="run-force-row"' in page
assert 'id="run-force"' in page
assert "强制重跑:忽略幂等跳过与已有结果,重新采集并正式执行" in page
assert '<option value="flagship">光影行星旗舰店</option>' in page
assert '<option value="luggage">光影行星鑫华达专卖店:龙虾仔</option>' in page
assert 'id="run-tmall-topic-field"' in page
assert 'id="run-tmall-topic-keyword"' in page
assert "存在未上传附件时才登录该店铺账号" in page
assert 'data-schedule-day="Monday"' in page
assert "工作流时间分布" in page
assert 'id="hidden-workflows-toggle"' in page
assert 'id="drawer-force-run-button"' in page
assert "执行流程" in page
for path, content_type in (
("/assets/app.css", "text/css"),
("/assets/app.js", "text/javascript"),
):
status, asset_headers, asset = _http_request(server, "GET", path)
assert status == HTTPStatus.OK
assert asset
assert asset_headers["Content-Type"].startswith(content_type)
_assert_security_headers(asset_headers)
decoded_asset = asset.decode("utf-8")
if path.endswith("app.js"):
assert "并行执行" in decoded_asset
assert "节点间连线只表示真实执行依赖" in decoded_asset
assert "技术实现与运行规则" in decoded_asset
assert "数据来源" in decoded_asset
assert "节点内处理" in decoded_asset
assert "结果去向" in decoded_asset
assert "renderStepBusinessFlow" in decoded_asset
assert "persistDrawerDisplayName" in decoded_asset
assert "HIDDEN_WORKFLOWS_STORAGE_KEY" in decoded_asset
assert "setWorkflowHidden" in decoded_asset
assert 'data-action="${hidden ? "unhide" : "hide"}"' in decoded_asset
assert 'method: "PUT"' in decoded_asset
assert "display-name" in decoded_asset
assert "跳过原因" in decoded_asset
assert "topology-node__diagnostic" in decoded_asset
assert "正在启动工作流" in decoded_asset
assert "手动触发" in decoded_asset
assert "path.dataset.edgeState = edgeState" in decoded_asset
assert "预演完成" in decoded_asset
assert "预演完成(全部跳过)" in decoded_asset
assert "正式执行成功" in decoded_asset
assert "正式执行完成(全部跳过)" in decoded_asset
assert "历史模式未知" in decoded_asset
assert "allStepsSkipped" in decoded_asset
assert "最近成功" not in decoded_asset
assert "scheduleOverviewPoll" in decoded_asset
assert "function scheduleTimes(schedule)" in decoded_asset
assert "多个时间用逗号分隔" in decoded_asset
assert 'times.length === 1 ? times[0] : times' in decoded_asset
assert "replaceHtmlIfChanged" in decoded_asset
assert 'workflow.id === "product.erp_all_shop_daily"' in decoded_asset
assert '"补采日期"' in decoded_asset
assert '"确认并补采该日"' in decoded_asset
assert "默认选择昨天" in decoded_asset
assert "HISTORY_CACHE_TTL_MS" in decoded_asset
assert 'id="topology-refreshed-at"' in decoded_asset
assert 'data-workflow-id="${esc(workflow.id)}"' in decoded_asset
assert 'openDrawer(workflow.id, "steps")' in decoded_asset
assert "state.data?.scheduler?.service_running" in decoded_asset
assert '"/api/scheduler/start"' in decoded_asset
assert "function startScheduler()" in decoded_asset
assert "schedulerStartTimer" in decoded_asset
assert "节点错误详情" in decoded_asset
assert "naturalMonthRange" in decoded_asset
assert "聚水潭查询范围" in decoded_asset
assert 'periodInput.type = monthMode ? "month" : "date"' in decoded_asset
assert 'const businessDate = repeatableTmallBaibu ? todayForTimezone(0) : monthMode ? `${selectedPeriod}-01` : selectedPeriod;' in decoded_asset
assert 'workflow.id === "product.video_upload"' in decoded_asset
assert 'workflow?.id === "content.notes_master.daily"' in decoded_asset
assert 'return isNotesMasterWorkflow(workflow) ? "手动同步" : "立即运行"' in decoded_asset
assert "function runWorkflowNow(workflowId, { forceRefresh = false } = {})" in decoded_asset
assert "force_refresh: forceRefresh" in decoded_asset
assert "tmall_baibu_import_mode" in decoded_asset
assert "syncTmallBaibuImportField" in decoded_asset
assert 'data-action="force-run"' in decoded_asset
assert 'openRun(id, { forceRefresh: true })' in decoded_asset
assert "当天已处理(幂等跳过)" in decoded_asset
assert 'openRun(workflowId, { forceRefresh })' in decoded_asset
assert 'byId("run-force").checked' in decoded_asset
assert 'byId("run-force-row").hidden = !execute' in decoded_asset
assert 'confirmed: execute && byId("run-confirmed").checked' in decoded_asset
assert 'shadow: byId("run-shadow").checked' in decoded_asset
assert "state.launchingWorkflows.add(workflow.id)" in decoded_asset
assert 'data-running="${running ? "true" : "false"}"' in decoded_asset
assert "有工作流正在运行" in decoded_asset
assert '"确认并正式同步"' in decoded_asset
assert '"已提交笔记清单同步"' in decoded_asset
assert "...(store ? { store } : {})" in decoded_asset
assert "tmall_topic_keyword" in decoded_asset
assert "syncTmallTopicField" in decoded_asset
assert "光影行星鑫华达专卖店:龙虾仔" in decoded_asset
assert "renderCardLiveState" in decoded_asset
assert 'data-live-progress="${completed}/${steps.length}"' in decoded_asset
assert 'data-current-step="${esc(currentNames.join("、"))}"' in decoded_asset
assert 'data-step-id="${esc(step.id)}"' in decoded_asset
assert 'data-action="steps"' in decoded_asset
assert "已处理 ${completed} / ${steps.length}" in decoded_asset
assert "topologyDisplayRun(workflow)" in decoded_asset
assert "const displayRun = busy ? topologyDisplayRun(workflow) : last;" in decoded_asset
assert 'aria-haspopup="listbox"' in decoded_asset
assert 'role="option"' in decoded_asset
assert "closeOpenCustomSelect" in decoded_asset
assert "function renderScheduleDistribution()" in decoded_asset
assert "scheduleCadenceLabel" in decoded_asset
assert "runningScheduleTime" in decoded_asset
assert "scheduleRuntimeMeta" in decoded_asset
assert "centerSelectedSchedulePoint" in decoded_asset
assert "positionScheduleChartPoints" in decoded_asset
assert 'data-schedule-point="${esc(node.time)}"' in decoded_asset
assert 'data-running="${node.running ? "true" : "false"}"' in decoded_asset
assert 'data-action="schedule"' in decoded_asset
assert "scheduleMatchesDay" in decoded_asset
assert "个启动项" in decoded_asset
assert "initCustomSelects" in decoded_asset
assert 'data-action="stop"' in decoded_asset
assert 'method: "DELETE"' in decoded_asset
assert "scheduled-run" in decoded_asset
assert 'request.source === "scheduler"' in decoded_asset
assert "body: {}" in decoded_asset
assert "正在停止…" in decoded_asset
if path.endswith("app.css"):
assert ".workflow-topology" in decoded_asset
assert ".topology-edge--continue" in decoded_asset
assert ".topology-edge--active" in decoded_asset
assert "@keyframes topology-node-breathe" in decoded_asset
assert '.topology-node[data-state="queued"]' in decoded_asset
assert ".run-step-detail" in decoded_asset
assert ".card-live__step[data-state=\"running\"]" in decoded_asset
assert "@keyframes card-live-flow" in decoded_asset
assert ".custom-select__menu" in decoded_asset
assert "@keyframes custom-select-in" in decoded_asset
assert ".card-action--stop" in decoded_asset
assert ".stop-modal" in decoded_asset
assert ".topology-node__business-flow" in decoded_asset
assert ".business-flow__destination" in decoded_asset
assert ".drawer-name-editor" in decoded_asset
assert ".hidden-workflows-toggle" in decoded_asset
assert ".card-action--visibility" in decoded_asset
assert ".schedule-distribution" in decoded_asset
assert ".schedule-chart__line" in decoded_asset
assert ".schedule-chart__point" in decoded_asset
assert ".schedule-node-detail" in decoded_asset
assert ".schedule-node-workflow" in decoded_asset
assert ".scheduler-control" in decoded_asset
assert ".scheduler-start-button" in decoded_asset
assert "background: linear-gradient(180deg, #fbfcff, #f5f8ff)" in decoded_asset
assert "@keyframes schedule-running-pulse" in decoded_asset
assert "@keyframes schedule-running-sweep" in decoded_asset
assert '.module-nav__item[data-running="true"]' in decoded_asset
assert "@keyframes module-nav-running-breathe" in decoded_asset
assert "@keyframes module-nav-running-sweep" in decoded_asset
status, api_headers, body = _http_request(server, "GET", "/api/overview")
assert status == HTTPStatus.OK
_assert_security_headers(api_headers)
payload = json.loads(body)
assert payload["summary"]["total"] == len(payload["workflows"])
assert {module["id"] for module in payload["modules"]} == {
"content_marketing",
"product_commerce",
"shop_intelligence",
"supply_chain",
}
def test_http_scheduler_start_requires_same_origin_and_returns_service_state(
monkeypatch: pytest.MonkeyPatch,
console_settings: Settings,
) -> None:
result = {
"status": "running",
"service_running": True,
"pid": 43210,
}
with _running_server(console_settings, FakeConsoleLauncher()) as server:
monkeypatch.setattr(
server.console_service,
"start_scheduler",
lambda: dict(result),
)
origin = f"http://127.0.0.1:{server.server_port}"
status, headers, body = _http_request(
server,
"POST",
"/api/scheduler/start",
body=b"{}",
headers={
"Content-Type": "application/json; charset=utf-8",
"X-GYXX-Console": "1",
"Origin": origin,
},
)
assert status == HTTPStatus.ACCEPTED
_assert_security_headers(headers)
assert json.loads(body) == result
def test_http_token_protects_api_and_never_relaxes_security_headers(
console_settings: Settings,
) -> None:
token = "console-token-that-is-long-enough"
with _running_server(
console_settings, FakeConsoleLauncher(), token=token
) as server:
status, headers, _body = _http_request(server, "GET", "/api/overview")
assert status == HTTPStatus.UNAUTHORIZED
assert headers["WWW-Authenticate"].startswith("Bearer")
_assert_security_headers(headers)
status, _headers, _body = _http_request(
server,
"GET",
"/api/overview",
headers={"Authorization": "Bearer wrong"},
)
assert status == HTTPStatus.UNAUTHORIZED
status, headers, body = _http_request(
server,
"GET",
"/api/overview",
headers={"Authorization": f"Bearer {token}"},
)
assert status == HTTPStatus.OK
_assert_security_headers(headers)
assert json.loads(body)["summary"]["total"] > 0
def test_http_mutations_require_json_marker_same_origin_and_bounded_body(
console_settings: Settings,
) -> None:
launcher = FakeConsoleLauncher()
body = json.dumps({"business_date": "2026-08-01"}).encode("utf-8")
path = "/api/workflows/content.metrics.daily/runs"
with _running_server(console_settings, launcher) as server:
origin = f"http://127.0.0.1:{server.server_port}"
status, _headers, _body = _http_request(
server,
"POST",
path,
body=body,
headers={"Content-Type": "application/json"},
)
assert status == HTTPStatus.FORBIDDEN
status, _headers, _body = _http_request(
server,
"POST",
path,
body=body,
headers={"Content-Type": "text/plain", "X-GYXX-Console": "1"},
)
assert status == HTTPStatus.UNSUPPORTED_MEDIA_TYPE
status, _headers, _body = _http_request(
server,
"POST",
path,
body=body,
headers={
"Content-Type": "application/json",
"X-GYXX-Console": "1",
"Origin": "https://evil.example",
},
)
assert status == HTTPStatus.FORBIDDEN
oversized = b"{" + b"x" * MAX_REQUEST_BYTES
status, _headers, _body = _http_request(
server,
"POST",
path,
body=oversized,
headers={
"Content-Type": "application/json",
"X-GYXX-Console": "1",
"Origin": origin,
},
)
assert status == HTTPStatus.BAD_REQUEST
status, headers, accepted = _http_request(
server,
"POST",
path,
body=body,
headers={
"Content-Type": "application/json; charset=utf-8",
"X-GYXX-Console": "1",
"Origin": origin,
},
)
assert status == HTTPStatus.ACCEPTED
_assert_security_headers(headers)
accepted_payload = json.loads(accepted)
assert accepted_payload["mode"] == "dry_run"
assert len(launcher.calls) == 1
cancel_path = f"{path}/{accepted_payload['operation_id']}"
status, _headers, _body = _http_request(
server,
"DELETE",
cancel_path,
headers={"Content-Type": "application/json"},
)
assert status == HTTPStatus.FORBIDDEN
status, _headers, _body = _http_request(
server,
"DELETE",
cancel_path,
headers={
"Content-Type": "application/json",
"X-GYXX-Console": "1",
"Origin": "https://evil.example",
},
)
assert status == HTTPStatus.FORBIDDEN
mutation_headers = {
"Content-Type": "application/json",
"X-GYXX-Console": "1",
"Origin": origin,
}
connection = http.client.HTTPConnection(
"127.0.0.1", server.server_port, timeout=5
)
try:
connection.request(
"DELETE",
f"{path}/op-0000000000000000",
body=b"{}",
headers=mutation_headers,
)
conflict = connection.getresponse()
assert conflict.status == HTTPStatus.CONFLICT
conflict.read()
connection.request("GET", "/api/overview")
overview = connection.getresponse()
assert overview.status == HTTPStatus.OK
assert json.loads(overview.read())["summary"]["running"] == 1
finally:
connection.close()
status, cancel_headers, cancelled = _http_request(
server,
"DELETE",
cancel_path,
body=b"{}",
headers=mutation_headers,
)
assert status == HTTPStatus.OK
_assert_security_headers(cancel_headers)
assert json.loads(cancelled)["status"] == "cancelled"
assert launcher.active() == ()
status, _headers, repeated = _http_request(
server,
"DELETE",
cancel_path,
body=b"{}",
headers=mutation_headers,
)
assert status == HTTPStatus.OK
assert json.loads(repeated)["already_cancelled"] is True
def test_http_updates_and_resets_workflow_display_name(
console_settings: Settings,
) -> None:
launcher = FakeConsoleLauncher()
workflow_id = "content.marketing_report.daily"
path = f"/api/workflows/{workflow_id}/display-name"
with _running_server(console_settings, launcher) as server:
origin = f"http://127.0.0.1:{server.server_port}"
status, _headers, overview_body = _http_request(
server,
"GET",
"/api/overview",
)
assert status == HTTPStatus.OK
revision = json.loads(overview_body)["workflow_names_revision"]
headers = {
"Content-Type": "application/json",
"X-GYXX-Console": "1",
"Origin": origin,
}
body = json.dumps(
{"display_name": "每日内容营销简报"},
ensure_ascii=False,
).encode("utf-8")
status, _headers, _body = _http_request(
server,
"PUT",
path,
body=body,
headers=headers,
)
assert status == HTTPStatus.PRECONDITION_REQUIRED
status, response_headers, renamed_body = _http_request(
server,
"PUT",
path,
body=body,
headers={**headers, "If-Match": revision},
)
assert status == HTTPStatus.OK
_assert_security_headers(response_headers)
renamed = json.loads(renamed_body)
assert renamed["name"] == "每日内容营销简报"
assert renamed["name_customized"] is True
status, _headers, _body = _http_request(
server,
"PUT",
path,
body=body,
headers={**headers, "If-Match": revision},
)
assert status == HTTPStatus.CONFLICT
status, _headers, refreshed_body = _http_request(
server,
"GET",
"/api/overview",
)
assert status == HTTPStatus.OK
refreshed = json.loads(refreshed_body)
workflow = next(
item for item in refreshed["workflows"] if item["id"] == workflow_id
)
assert workflow["name"] == "每日内容营销简报"
assert workflow["default_name"] == "营销日报生成与发送"
reset_body = json.dumps({"display_name": None}).encode("utf-8")
status, _headers, reset_response = _http_request(
server,
"PUT",
path,
body=reset_body,
headers={
**headers,
"If-Match": refreshed["workflow_names_revision"],
},
)
assert status == HTTPStatus.OK
assert json.loads(reset_response)["name"] == "营销日报生成与发送"
def test_http_notification_config_requires_revision_and_supports_multi_recipient(
console_settings: Settings,
) -> None:
launcher = FakeConsoleLauncher()
with _running_server(console_settings, launcher) as server:
origin = f"http://127.0.0.1:{server.server_port}"
status, response_headers, body = _http_request(
server,
"GET",
"/api/notifications",
)
assert status == HTTPStatus.OK
_assert_security_headers(response_headers)
config = json.loads(body)
assert len(config["people"]) == 16
assert {
route["workflow_id"] for route in config["routes"]
} == set(console_module.NOTIFICATION_CAPABILITIES)
for route in config["routes"]:
if route["workflow_id"] == "content.marketing_report.daily":
route.update(
configured=True,
enabled=True,
person_ids=["he_yingwei", "li_jingxian"],
)
request = {
"app_profile": config["app_profile"],
"people": config["people"],
"routes": [
{
"workflow_id": route["workflow_id"],
"configured": route["configured"],
"enabled": route["enabled"],
"person_ids": route["person_ids"],
}
for route in config["routes"]
],
}
encoded = json.dumps(request, ensure_ascii=False).encode("utf-8")
headers = {
"Content-Type": "application/json",
"X-GYXX-Console": "1",
"Origin": origin,
}
status, _response_headers, _body = _http_request(
server,
"PUT",
"/api/notifications",
body=encoded,
headers=headers,
)
assert status == HTTPStatus.PRECONDITION_REQUIRED
status, response_headers, updated_body = _http_request(
server,
"PUT",
"/api/notifications",
body=encoded,
headers={**headers, "If-Match": config["revision"]},
)
assert status == HTTPStatus.OK
_assert_security_headers(response_headers)
updated = json.loads(updated_body)
daily = next(
route
for route in updated["routes"]
if route["workflow_id"] == "content.marketing_report.daily"
)
assert daily["person_ids"] == ["he_yingwei", "li_jingxian"]
status, _response_headers, _body = _http_request(
server,
"PUT",
"/api/notifications",
body=encoded,
headers={**headers, "If-Match": config["revision"]},
)
assert status == HTTPStatus.CONFLICT
def test_non_loopback_binding_requires_a_long_token(
console_settings: Settings,
) -> None:
with pytest.raises(ValueError, match="requires GYXX_CONSOLE_TOKEN"):
create_console_server(
console_settings,
host="0.0.0.0",
port=0,
launcher=FakeConsoleLauncher(),
)
class NeverLaunchSchedulerWorkflows:
def launch(
self,
workflow_id: str,
slot: datetime,
business_date: date,
) -> object:
raise AssertionError(
f"dry-run scheduler unexpectedly launched {workflow_id} {slot} {business_date}"
)
def close(self, process: object) -> None:
raise AssertionError(f"unexpected close: {process}")
def _single_workflow_catalog(*, at: str, enabled: bool) -> WorkflowCatalog:
workflow = WorkflowEntry(
workflow_id="content.console_schedule",
module="content_marketing",
trigger="scheduled",
entry="run.py",
source_project="content",
source_task_name="console-schedule",
)
schedule = ScheduleEntry(
workflow_id=workflow.workflow_id,
kind="daily",
at=at,
enabled=enabled,
)
return WorkflowCatalog(
timezone="Asia/Shanghai",
workflows=(workflow,),
schedules=(schedule,),
)
def test_scheduler_reload_catalog_runs_before_each_tick(tmp_path: Path) -> None:
initial = _single_workflow_catalog(at="09:00", enabled=True)
due_catalog = _single_workflow_catalog(at="10:00", enabled=True)
disabled_catalog = _single_workflow_catalog(at="10:00", enabled=False)
loaded = iter((due_catalog, disabled_catalog))
load_calls: list[WorkflowCatalog] = []
def load_catalog() -> WorkflowCatalog:
catalog = next(loaded)
load_calls.append(catalog)
return catalog
scheduler = PythonScheduler(
initial,
tmp_path,
launcher=NeverLaunchSchedulerWorkflows(),
misfire_grace_seconds=600,
dry_run=True,
catalog_loader=load_catalog,
)
now = datetime.fromisoformat("2026-08-01T10:05:00+08:00")
assert scheduler.tick(now) == ["content.console_schedule"]
assert scheduler.catalog is due_catalog
assert scheduler.tick(now) == []
assert scheduler.catalog is disabled_catalog
assert load_calls == [due_catalog, disabled_catalog]