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"): 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, ) -> 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", } 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 _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_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"} 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" 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_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( console_settings: Settings, ) -> None: 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 executed = service.trigger( workflow_id, { "business_date": "2026-08-01", "execute": True, "confirmed": True, "shadow": True, }, ) assert executed["mode"] == "execute" assert executed["shadow"] is True with pytest.raises(ConsoleConflictError): service.trigger(workflow_id, {"business_date": "2026-08-01"}) assert len(launcher.calls) == 2 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"}]}), 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) 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"}]}), 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["GYXX_WORKFLOW_ACCEPTANCE"] = "1" with pytest.raises(ConsoleRequestError, match="验收隔离"): console_module._validate_product_production_runtime(alert, 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" 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, ) assert first["operation_id"] != second["operation_id"] 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", ] 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) 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) 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 "执行流程" 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 '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 "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 "节点错误详情" in decoded_asset assert "naturalMonthRange" in decoded_asset assert "聚水潭查询范围" in decoded_asset assert 'periodInput.type = monthMode ? "month" : "date"' in decoded_asset assert 'const businessDate = monthMode ? `${selectedPeriod}-01`' 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 "initCustomSelects" in decoded_asset assert 'data-action="stop"' in decoded_asset assert 'method: "DELETE"' 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 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_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_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]