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