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 '' in page assert '' in page assert '' 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]