Files
gyxx-flow/src/gyxx_flow/console.py
T
wangyunlong 01218b2907 feat: add deepseek-harness workbench plugin and console diagnosis API
- 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)
2026-09-04 11:10:16 +08:00

3956 lines
150 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Operator-safe workflow web console and HTTP API."""
from __future__ import annotations
import calendar
import hashlib
import hmac
import ipaddress
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import unicodedata
import uuid
from contextlib import suppress
from dataclasses import asdict, dataclass
from datetime import date, datetime, timedelta, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import resources
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence, TextIO, cast
from urllib.parse import parse_qs, unquote, urlsplit
from zoneinfo import ZoneInfo
import psutil
from gyxx_flow.adapters import (
ContentLLMConfigurationError,
load_content_llm_config,
resolve_hermes_profile_api_key,
)
from gyxx_flow.adapters.workflow_config import (
PostgresWorkflowConfigStore,
WorkflowConfigConflictError,
WorkflowConfigError,
WorkflowConfigNotFoundError,
WorkflowConfigRepository,
WorkflowConfigUnavailableError,
WorkflowConfigValidationError,
)
from gyxx_flow.catalog import (
CatalogError,
ScheduleEntry,
WorkflowCatalog,
WorkflowEntry,
)
from gyxx_flow.core.artifacts import atomic_write_json, sha256_file
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.locks import LockManager
from gyxx_flow.core.records import RunJournal
from gyxx_flow.core.text import bounded_head_tail
from gyxx_flow.daily_summary import (
DailySummaryAnalyzer,
DailySummaryBuilder,
)
from gyxx_flow.modules import create_default_registry as create_module_registry
from gyxx_flow.modules.product_commerce import (
TMALL_BAIBU_DEFAULT_IMPORT_MODE,
TMALL_BAIBU_IMPORT_MODE_ALL,
TMALL_BAIBU_IMPORT_MODE_WITHOUT_HYPERLINKS,
TMALL_BAIBU_WORKFLOW_ID,
tmall_baibu_import_counts,
validate_tmall_baibu_import_mode,
)
from gyxx_flow.modules.product_commerce.direct_llm import (
DirectLLMConfigurationError,
load_direct_llm_config,
)
from gyxx_flow.notification_routing import (
ANALYZER_HERMES_PROFILE,
ANALYZER_NOTIFICATION_APP_ID,
NOTIFICATION_CAPABILITIES,
NotificationRoutingConflictError,
NotificationRoutingError,
NotificationRoutingPreconditionError,
NotificationRoutingStore,
resolve_notification_route,
)
from gyxx_flow.ops import RunIndex, RunRecord
CONSOLE_TOKEN_ENV = "GYXX_CONSOLE_TOKEN"
MAX_REQUEST_BYTES = 64 * 1024
MAX_ERROR_CHARS = 4_000
MAX_DIAGNOSIS_LOG_FILES = 5
MAX_DIAGNOSIS_LOG_READ_BYTES = 262_144
MAX_ACTIVE_RUNS = 4
MAX_TRACKED_OPERATIONS = 128
PROCESS_TERMINATE_TIMEOUT_SECONDS = 5.0
SCHEDULER_START_TIMEOUT_SECONDS = 3.0
MAX_WORKFLOW_DISPLAY_NAME_CHARS = 64
MAX_WORKFLOW_DISPLAY_NAME_BYTES = 256
MAX_WORKFLOW_DISPLAY_NAMES_FILE_BYTES = 64 * 1024
EMPTY_WORKFLOW_DISPLAY_NAMES_REVISION = "0" * 64
MAX_NOTIFICATION_PHONE_CHARS = 24
MAX_NOTIFICATION_LOOKUP_PROOFS = 1024
_OPERATION_ID = re.compile(r"^op-[0-9a-f]{16}$")
_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_SAFE_APP_PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
_FEISHU_OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]{8,128}$")
_FEISHU_APP_ID = re.compile(r"^cli_[A-Za-z0-9_-]{6,128}$")
_PROCESS_START_TOLERANCE_SECONDS = 1.0
_CANCELLED_ERROR = "cancelled by console operator"
_CONTENT_POSTGRES_WORKFLOW_IDS = frozenset(
{
"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",
}
)
_CONTENT_DIRECT_ANALYSIS_WORKFLOW_IDS = frozenset(
{
"content.summary.monthly",
"content.summary.weekly",
}
)
_DIRECT_VIDEO_WORKFLOW_IDS = frozenset(
{
"product.video_upload",
"product.jd_video_upload",
}
)
_MODULES = (
{
"id": "content_marketing",
"name": "内容营销",
"description": "达人、评论、内容指标与营销报告",
"accent": "violet",
},
{
"id": "product_commerce",
"name": "商品经营",
"description": "商品、画像、排行与经营分析",
"accent": "blue",
},
{
"id": "shop_intelligence",
"name": "店铺洞察",
"description": "店铺、竞店与自营业绩采集",
"accent": "amber",
},
{
"id": "supply_chain",
"name": "供应链",
"description": "采购、补货与库存预警",
"accent": "green",
},
)
_WORKFLOW_NAMES = {
"content.notes_master.daily": "笔记清单与合作同步",
"content.metrics.daily": "内容指标日报采集",
"content.marketing_report.daily": "营销日报生成与发送",
"content.relogin.weekly": "内容平台登录态刷新",
"content.creator_report.monthly": "达人月报生成",
"content.summary.monthly": "内容月度汇总",
"content.comments.weekly": "多平台评论周采集",
"content.summary.weekly": "内容周度汇总",
"content.mapping.refresh": "内容与款式映射刷新",
"content.retry_failed": "失败内容任务重试",
"content.metrics.backfill": "内容指标补采",
"product.persona.daily": "商品人群画像日采集",
"product.daily": "商品经营日报采集",
"product.alert.daily": "商品销量异常告警",
"product.ecommerce_costs.daily": "三平台电商费用采集",
"product.style_analysis.interval": "款式周期分析",
"product.main_image.weekly": "京东与天猫主图周采集",
"product.sales_sheet.daily": "商品月度销量表",
"product.erp_all_shop_daily": "聚水潭全店铺款式日报",
"product.backfill": "商品数据补采",
"product.market_rank": "市场排行周采集",
"product.tmall_baibu_apply": "天猫百亿补贴批量报名",
"product.review_collection": "商品评价采集",
"product.weekly_aggregate.documented_missing": "商品周聚合(待接入)",
"shop.metrics.weekly": "店铺经营指标周采集",
"shop.competitor.weekly": "竞店指标周采集",
"shop.jd_self_operated.daily": "京东自营业绩日采集",
"shop.douyin_price_appeal": "抖音待改价申诉",
"shop.jd_self_operated.history": "京东自营业绩单日回采",
"supply.purchase_confirmation.daily": "采购确认日处理",
"supply.replenishment.weekly": "补货建议周处理",
"supply.replenishment_alert.daily": "补货预警日处理",
"supply.purchase_order_update": "采购单更新",
}
_SECRET_VALUE = re.compile(
r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|token|password|secret)"
r"[\"']?\s*[=:]\s*)"
r'(?:"[^"\r\n]*"|\'[^\'\r\n]*\'|[^\s,;}\]]+)'
)
_URL_SECRET = re.compile(
r"(?i)([?&](?:api[_-]?key|access[_-]?token|token|password|secret)=)"
r"[^&\s\"'},;\]]+"
)
_URL_USERINFO = re.compile(
r"(?i)(\b[a-z][a-z0-9+.-]*://)[^/@\s]+@"
)
_BEARER = re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+")
class ConsoleRequestError(ValueError):
"""Safe client-visible console request error."""
status = HTTPStatus.BAD_REQUEST
class ConsoleNotFoundError(ConsoleRequestError):
status = HTTPStatus.NOT_FOUND
class ConsoleConflictError(ConsoleRequestError):
status = HTTPStatus.CONFLICT
class ConsolePreconditionError(ConsoleRequestError):
status = HTTPStatus.PRECONDITION_REQUIRED
class ConsoleServiceUnavailableError(ConsoleRequestError):
status = HTTPStatus.SERVICE_UNAVAILABLE
class RunLauncher(Protocol):
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]: ...
def active(self) -> tuple[dict[str, object], ...]: ...
def cancel(
self,
workflow_id: str,
operation_id: str,
) -> dict[str, object]: ...
class _ManagedProcess(Protocol):
pid: int
def poll(self) -> int | None: ...
def wait(self, timeout: float | None = None) -> int | None: ...
@dataclass(frozen=True, slots=True)
class _RecoveredProcess:
"""A narrow Popen-compatible handle for a process that survived console restart."""
pid: int
process_started_at: float
def _live_process(self) -> psutil.Process | None:
try:
process = psutil.Process(self.pid)
if (
abs(process.create_time() - self.process_started_at)
> _PROCESS_START_TOLERANCE_SECONDS
):
return None
if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
return None
return process
except (psutil.Error, OSError):
return None
def poll(self) -> int | None:
return None if self._live_process() is not None else 0
def wait(self, timeout: float | None = None) -> int | None:
process = self._live_process()
if process is None:
return 0
try:
return process.wait(timeout=timeout)
except psutil.TimeoutExpired as exc:
raise subprocess.TimeoutExpired(str(self.pid), timeout) from exc
except (psutil.Error, OSError):
return 0
@dataclass(frozen=True, slots=True)
class _SchedulerProcessInfo:
pid: int
started_at: str | None
@dataclass(slots=True)
class _ActiveConsoleRun:
operation_id: str
workflow_id: str
business_date: str
mode: str
shadow: bool
started_at: str
process: _ManagedProcess
stream: TextIO
process_started_at: float | None
known_run_ids: frozenset[str]
class SubprocessConsoleRunLauncher:
"""Launch fixed, registered workflow CLI commands without blocking HTTP."""
def __init__(
self,
settings: Settings,
*,
python_executable: Path | None = None,
max_active: int = MAX_ACTIVE_RUNS,
) -> None:
self.settings = settings
self.python_executable = Path(python_executable or sys.executable).resolve()
self.max_active = max_active
self.log_root = settings.data_root / "logs" / "console"
self.active_root = settings.data_root / "state" / "ops" / "console-active"
self._runs: dict[str, _ActiveConsoleRun] = {}
self._finished_operations: dict[str, dict[str, object]] = {}
self._lock = threading.RLock()
self._recover_active_runs()
def _reap_locked(self) -> None:
for workflow_id, active in list(self._runs.items()):
exit_code = active.process.poll()
if exit_code is None:
continue
self._complete_locked(active, exit_code=exit_code)
def _complete_locked(
self,
active: _ActiveConsoleRun,
*,
exit_code: int | None,
) -> dict[str, object]:
active.stream.close()
self._remove_active_record(active.operation_id)
if self._runs.get(active.workflow_id) is active:
del self._runs[active.workflow_id]
result: dict[str, object] = {
"operation_id": active.operation_id,
"workflow_id": active.workflow_id,
"business_date": active.business_date,
"mode": active.mode,
"shadow": active.shadow,
"started_at": active.started_at,
"ended_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"status": "completed",
"exit_code": exit_code,
}
self._remember_finished_locked(result)
return result
def _remember_finished_locked(self, result: dict[str, object]) -> None:
operation_id = str(result["operation_id"])
self._finished_operations.pop(operation_id, None)
self._finished_operations[operation_id] = dict(result)
while len(self._finished_operations) > MAX_TRACKED_OPERATIONS:
oldest = next(iter(self._finished_operations))
del self._finished_operations[oldest]
def active(self) -> tuple[dict[str, object], ...]:
with self._lock:
self._reap_locked()
return tuple(
{
"operation_id": item.operation_id,
"workflow_id": item.workflow_id,
"business_date": item.business_date,
"mode": item.mode,
"shadow": item.shadow,
"started_at": item.started_at,
"status": "running",
}
for item in self._runs.values()
)
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]:
with self._lock:
self._reap_locked()
if workflow_id in self._runs:
raise ConsoleConflictError("该工作流已从控制台启动,正在执行中")
if len(self._runs) >= self.max_active:
raise ConsoleConflictError("控制台并发执行已达到上限,请稍后再试")
operation_id = f"op-{uuid.uuid4().hex[:16]}"
started_at = datetime.now(timezone.utc).isoformat(timespec="microseconds")
known_run_ids = self._journal_run_ids(workflow_id, business_date)
self.log_root.mkdir(parents=True, exist_ok=True)
log_path = self.log_root / f"{workflow_id}-{operation_id}.log"
stream = log_path.open("a", encoding="utf-8", newline="\n")
argv = [
str(self.python_executable),
"-m",
"gyxx_flow",
"run",
workflow_id,
"--date",
business_date.isoformat(),
]
if execute:
argv.append("--execute")
if shadow:
argv.append("--shadow")
if tmall_baibu_import_mode is not None:
argv.extend(
[
"--tmall-baibu-import-mode",
tmall_baibu_import_mode,
]
)
environment = _console_runtime_environment(
{
**os.environ,
"GYXX_PROJECT_ROOT": str(self.settings.project_root),
"GYXX_DATA_ROOT": str(self.settings.data_root),
},
workflow_id=workflow_id,
)
if video_upload_store is not None:
environment["GYXX_VIDEO_UPLOAD_STORE"] = video_upload_store
if tmall_topic_keyword is not None:
environment["GYXX_TMALL_TOPIC_KEYWORD"] = tmall_topic_keyword
if force_refresh:
environment["GYXX_FORCE_REFRESH"] = "true"
else:
environment.pop("GYXX_FORCE_REFRESH", None)
environment.pop(CONSOLE_TOKEN_ENV, None)
kwargs: dict[str, object] = {}
if os.name == "nt":
kwargs["creationflags"] = (
subprocess.CREATE_NO_WINDOW
| subprocess.CREATE_NEW_PROCESS_GROUP
)
else:
kwargs["start_new_session"] = True
try:
process = subprocess.Popen(
argv,
cwd=self.settings.project_root,
env=environment,
stdin=subprocess.DEVNULL,
stdout=stream,
stderr=subprocess.STDOUT,
shell=False,
**kwargs,
)
except Exception as exc:
stream.close()
raise ConsoleRequestError("工作流进程启动失败") from exc
try:
process_started_at = psutil.Process(process.pid).create_time()
except (psutil.Error, OSError):
process_started_at = None
active = _ActiveConsoleRun(
operation_id=operation_id,
workflow_id=workflow_id,
business_date=business_date.isoformat(),
mode="execute" if execute else "dry_run",
shadow=shadow,
started_at=started_at,
process=process,
stream=stream,
process_started_at=process_started_at,
known_run_ids=known_run_ids,
)
self._runs[workflow_id] = active
self._write_active_record(active)
result: dict[str, object] = {
"operation_id": operation_id,
"workflow_id": workflow_id,
"business_date": active.business_date,
"mode": active.mode,
"shadow": shadow,
"started_at": started_at,
"status": "accepted",
}
if video_upload_store is not None:
result["store"] = video_upload_store
if tmall_topic_keyword is not None:
result["topic_keyword"] = tmall_topic_keyword
if force_refresh:
result["force_refresh"] = True
if tmall_baibu_import_mode is not None:
result["tmall_baibu_import_mode"] = tmall_baibu_import_mode
return result
def cancel(
self,
workflow_id: str,
operation_id: str,
) -> dict[str, object]:
if not _OPERATION_ID.fullmatch(operation_id):
raise ConsoleNotFoundError("运行操作不存在")
with self._lock:
self._reap_locked()
active = self._runs.get(workflow_id)
if active is None:
return self._cancelled_or_raise_locked(workflow_id, operation_id)
if active.operation_id != operation_id:
previous = self._finished_operations.get(operation_id)
if previous is not None and previous.get("workflow_id") == workflow_id:
return self._cancelled_or_raise_locked(workflow_id, operation_id)
raise ConsoleConflictError("运行操作已更新,请刷新后再停止")
run_id = self._locked_run_id(active)
if not self._terminate_process_tree(active):
self._complete_locked(active, exit_code=active.process.poll())
raise ConsoleConflictError("工作流已经执行结束,无法停止")
journal_status, run_id = self._finalize_cancelled_journal(
active,
run_id=run_id,
)
if journal_status in {"success", "failed"}:
self._complete_locked(active, exit_code=active.process.poll())
raise ConsoleConflictError("工作流已经执行结束,无法停止")
active.stream.close()
self._remove_active_record(active.operation_id)
if self._runs.get(workflow_id) is active:
del self._runs[workflow_id]
result: dict[str, object] = {
"operation_id": operation_id,
"workflow_id": workflow_id,
"business_date": active.business_date,
"mode": active.mode,
"shadow": active.shadow,
"started_at": active.started_at,
"ended_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"status": "cancelled",
"already_cancelled": False,
"journal_status": journal_status,
}
if run_id is not None:
result["run_id"] = run_id
self._write_cancellation_record(result)
self._remember_finished_locked(result)
return dict(result)
def _cancelled_or_raise_locked(
self,
workflow_id: str,
operation_id: str,
) -> dict[str, object]:
previous = self._finished_operations.get(operation_id)
if previous is None:
previous = self._read_cancellation_record(operation_id)
if previous is not None:
self._remember_finished_locked(previous)
if previous is None or previous.get("workflow_id") != workflow_id:
raise ConsoleNotFoundError("运行操作不存在")
if previous.get("status") != "cancelled":
raise ConsoleConflictError("工作流已经执行结束,无法停止")
return {**previous, "already_cancelled": True}
def _active_path(self, operation_id: str) -> Path:
if not _OPERATION_ID.fullmatch(operation_id):
raise ConsoleNotFoundError("运行操作不存在")
return self.active_root / f"{operation_id}.json"
def _write_active_record(self, active: _ActiveConsoleRun) -> None:
if active.process_started_at is None:
return
log_path = self.log_root / f"{active.workflow_id}-{active.operation_id}.log"
payload = {
"schema_version": 1,
"operation_id": active.operation_id,
"workflow_id": active.workflow_id,
"business_date": active.business_date,
"mode": active.mode,
"shadow": active.shadow,
"started_at": active.started_at,
"pid": active.process.pid,
"process_started_at": active.process_started_at,
"log_path": log_path.relative_to(self.settings.data_root).as_posix(),
"known_run_ids": sorted(active.known_run_ids),
}
try:
atomic_write_json(self._active_path(active.operation_id), payload)
except OSError:
# The in-memory handle still supports cancellation for this server lifetime.
return
def _remove_active_record(self, operation_id: str) -> None:
try:
self._active_path(operation_id).unlink(missing_ok=True)
except (OSError, ConsoleRequestError):
return
def _recover_active_runs(self) -> None:
if not self.active_root.exists():
return
for path in sorted(self.active_root.glob("op-*.json")):
active = self._active_from_record(path)
if active is None or active.workflow_id in self._runs:
with suppress(OSError):
path.unlink(missing_ok=True)
continue
self._runs[active.workflow_id] = active
def _active_from_record(self, path: Path) -> _ActiveConsoleRun | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
operation_id = payload["operation_id"]
workflow_id = payload["workflow_id"]
business_date = payload["business_date"]
mode = payload["mode"]
shadow = payload["shadow"]
started_at = payload["started_at"]
pid = payload["pid"]
process_started_at = payload["process_started_at"]
log_reference = payload["log_path"]
known_run_ids = payload.get("known_run_ids", [])
if payload.get("schema_version") != 1:
return None
if not isinstance(operation_id, str) or not _OPERATION_ID.fullmatch(operation_id):
return None
if path.name != f"{operation_id}.json":
return None
if not isinstance(workflow_id, str) or not _SAFE_RUN_ID.fullmatch(workflow_id):
return None
parsed_date = date.fromisoformat(business_date)
if parsed_date.isoformat() != business_date:
return None
if mode not in {"execute", "dry_run"} or not isinstance(shadow, bool):
return None
if not isinstance(started_at, str):
return None
datetime.fromisoformat(started_at)
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
return None
if (
not isinstance(process_started_at, (int, float))
or isinstance(process_started_at, bool)
):
return None
if not isinstance(log_reference, str):
return None
expected_log = self.log_root / f"{workflow_id}-{operation_id}.log"
log_path = (self.settings.data_root / log_reference).resolve()
if log_path != expected_log.resolve():
return None
if not isinstance(known_run_ids, list) or any(
not isinstance(value, str) or not _SAFE_RUN_ID.fullmatch(value)
for value in known_run_ids
):
return None
process = _RecoveredProcess(pid, float(process_started_at))
if process.poll() is not None:
return None
self.log_root.mkdir(parents=True, exist_ok=True)
stream = log_path.open("a", encoding="utf-8", newline="\n")
return _ActiveConsoleRun(
operation_id=operation_id,
workflow_id=workflow_id,
business_date=business_date,
mode=mode,
shadow=shadow,
started_at=started_at,
process=process,
stream=stream,
process_started_at=float(process_started_at),
known_run_ids=frozenset(known_run_ids),
)
except (
KeyError,
OSError,
TypeError,
ValueError,
UnicodeError,
json.JSONDecodeError,
):
return None
def _journal_run_root(self, workflow_id: str, business_date: str) -> Path:
if not _SAFE_RUN_ID.fullmatch(workflow_id):
raise ConsoleRequestError("工作流标识无效")
year, month, day = business_date.split("-")
return (
self.settings.data_root
/ "runs"
/ workflow_id
/ year
/ month
/ day
)
def _journal_run_ids(
self,
workflow_id: str,
business_date: date,
) -> frozenset[str]:
root = self._journal_run_root(workflow_id, business_date.isoformat())
return frozenset(path.parent.name for path in root.glob("*/run.json"))
def _locked_run_id(self, active: _ActiveConsoleRun) -> str | None:
lock_root = self.settings.data_root / "state" / "locks"
for path in lock_root.glob("*.lock"):
try:
metadata = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
continue
if not isinstance(metadata, dict):
continue
if metadata.get("resource") != f"workflow:{active.workflow_id}":
continue
if metadata.get("pid") != active.process.pid:
continue
recorded_start = metadata.get("process_started_at")
if (
active.process_started_at is not None
and isinstance(recorded_start, (int, float))
and not isinstance(recorded_start, bool)
and abs(float(recorded_start) - active.process_started_at)
> _PROCESS_START_TOLERANCE_SECONDS
):
continue
owner = metadata.get("owner")
if isinstance(owner, str) and _SAFE_RUN_ID.fullmatch(owner):
return owner
return None
def _candidate_journals(self, active: _ActiveConsoleRun) -> list[Path]:
root = self._journal_run_root(active.workflow_id, active.business_date)
candidates: list[Path] = []
for path in root.glob("*/run.json"):
if path.parent.name in active.known_run_ids:
continue
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
continue
if not isinstance(payload, dict):
continue
if (
payload.get("workflow_id") == active.workflow_id
and payload.get("business_date") == active.business_date
and payload.get("shadow") is active.shadow
and payload.get("status") == "running"
):
candidates.append(path)
return candidates
def _finalize_cancelled_journal(
self,
active: _ActiveConsoleRun,
*,
run_id: str | None,
) -> tuple[str, str | None]:
path: Path | None = None
if run_id is not None:
candidate = (
self._journal_run_root(active.workflow_id, active.business_date)
/ run_id
/ "run.json"
)
if candidate.exists():
path = candidate
if path is None:
candidates = self._candidate_journals(active)
if len(candidates) == 1:
path = candidates[0]
run_id = path.parent.name
elif candidates:
return self._create_synthetic_cancelled_journal(
active,
reason="ambiguous execution journal",
)
else:
return self._create_synthetic_cancelled_journal(
active,
reason="execution journal was not created before cancellation",
)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
status = payload.get("status")
if status != "running":
return str(status), run_id
journal = RunJournal(path=path)
steps = payload.get("steps", {})
if isinstance(steps, dict):
for step_id, step in steps.items():
if (
isinstance(step_id, str)
and isinstance(step, dict)
and step.get("status") == "running"
):
journal.finish_step(
step_id,
status="skipped",
error=_CANCELLED_ERROR,
)
journal.finalize("cancelled", error=_CANCELLED_ERROR)
RunIndex(self.settings.data_root).index_journal(journal)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
return self._create_synthetic_cancelled_journal(
active,
reason="execution journal could not be finalized",
)
return "cancelled", run_id
def _create_synthetic_cancelled_journal(
self,
active: _ActiveConsoleRun,
*,
reason: str,
) -> tuple[str, str | None]:
try:
context = RunContext.create(
active.workflow_id,
active.business_date,
shadow=active.shadow,
now=datetime.now(timezone.utc),
random_suffix=active.operation_id.removeprefix("op-"),
)
journal = RunJournal.create(
DataLayout(self.settings.data_root),
context,
mode=active.mode,
)
journal.finalize(
"cancelled",
error=f"{_CANCELLED_ERROR}; {reason}",
)
RunIndex(self.settings.data_root).index_journal(journal)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
return "unresolved", None
return "synthetic_cancelled", context.run_id
def _terminate_process_tree(self, active: _ActiveConsoleRun) -> bool:
if active.process.poll() is not None:
return False
try:
root = psutil.Process(active.process.pid)
actual_started_at = root.create_time()
except psutil.NoSuchProcess:
return False
except (psutil.Error, OSError) as exc:
raise ConsoleConflictError("无法确认运行进程身份,未执行停止操作") from exc
if (
active.process_started_at is not None
and abs(actual_started_at - active.process_started_at)
> _PROCESS_START_TOLERANCE_SECONDS
):
raise ConsoleConflictError("运行进程身份已变化,未执行停止操作")
try:
descendants = root.children(recursive=True)
except (psutil.NoSuchProcess, psutil.ZombieProcess):
descendants = []
except psutil.Error as exc:
raise ConsoleConflictError("无法枚举工作流子进程,未执行停止操作") from exc
targets = [*reversed(descendants), root]
for process in targets:
try:
process.terminate()
except (psutil.NoSuchProcess, psutil.ZombieProcess):
continue
except psutil.Error:
continue
_gone, alive = psutil.wait_procs(
targets,
timeout=PROCESS_TERMINATE_TIMEOUT_SECONDS / 2,
)
for process in alive:
try:
process.kill()
except (psutil.NoSuchProcess, psutil.ZombieProcess):
continue
except psutil.Error:
continue
_gone, survivors = psutil.wait_procs(
alive,
timeout=PROCESS_TERMINATE_TIMEOUT_SECONDS / 2,
)
survivors = [process for process in survivors if process.is_running()]
if survivors:
raise ConsoleConflictError("部分工作流子进程未能停止,请检查服务器进程")
if active.process.poll() is None:
try:
active.process.wait(timeout=0.1)
except (subprocess.TimeoutExpired, OSError) as exc:
raise ConsoleConflictError("工作流主进程退出状态无法确认") from exc
if active.process.poll() is None:
raise ConsoleConflictError("工作流主进程退出状态无法确认")
return True
def _cancellation_path(self, operation_id: str) -> Path:
if not _OPERATION_ID.fullmatch(operation_id):
raise ConsoleNotFoundError("运行操作不存在")
return (
self.settings.data_root
/ "state"
/ "ops"
/ "console-cancellations"
/ f"{operation_id}.json"
)
def _write_cancellation_record(self, result: dict[str, object]) -> None:
atomic_write_json(
self._cancellation_path(str(result["operation_id"])),
result,
)
def _read_cancellation_record(
self,
operation_id: str,
) -> dict[str, object] | None:
path = self._cancellation_path(operation_id)
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
if payload.get("operation_id") != operation_id:
return None
return payload
class WorkflowDisplayNameStore:
"""Persist console-only workflow display-name overrides."""
def __init__(self, settings: Settings) -> None:
self.path = DataLayout(settings.data_root).state(
"console",
"workflow-display-names.json",
)
self._locks = LockManager(settings.data_root / "state" / "locks")
def snapshot(self) -> tuple[dict[str, str], str]:
try:
raw = self.path.read_bytes()
except FileNotFoundError:
return {}, EMPTY_WORKFLOW_DISPLAY_NAMES_REVISION
except OSError as exc:
raise ConsoleRequestError("工作流名称配置无法读取") from exc
revision = hashlib.sha256(raw).hexdigest()
if len(raw) > MAX_WORKFLOW_DISPLAY_NAMES_FILE_BYTES:
raise ConsoleRequestError("工作流名称配置过大")
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ConsoleRequestError("工作流名称配置无效") from exc
if (
not isinstance(payload, dict)
or set(payload) != {"schema_version", "overrides"}
or payload.get("schema_version") != 1
or not isinstance(payload.get("overrides"), dict)
):
raise ConsoleRequestError("工作流名称配置无效")
overrides: dict[str, str] = {}
for workflow_id, display_name in payload["overrides"].items():
if not isinstance(workflow_id, str) or not _SAFE_RUN_ID.fullmatch(
workflow_id
):
raise ConsoleRequestError("工作流名称配置无效")
overrides[workflow_id] = _normalize_workflow_display_name(display_name)
return overrides, revision
def revision(self) -> str:
try:
return hashlib.sha256(self.path.read_bytes()).hexdigest()
except FileNotFoundError:
return EMPTY_WORKFLOW_DISPLAY_NAMES_REVISION
except OSError as exc:
raise ConsoleRequestError("工作流名称配置无法读取") from exc
def update(
self,
workflow_id: str,
display_name: object,
*,
default_name: str,
expected_revision: str | None,
) -> tuple[str, bool, str]:
if expected_revision is None:
raise ConsolePreconditionError("缺少名称版本,请刷新页面后重试")
expected_revision = expected_revision.strip().strip('"')
owner = f"console-{uuid.uuid4().hex}"
with self._locks.acquire("console:workflow-display-names", owner=owner):
overrides, current_revision = self.snapshot()
if not hmac.compare_digest(current_revision, expected_revision):
raise ConsoleConflictError("工作流名称已被更新,请刷新后重试")
if display_name is None:
normalized = None
else:
normalized = _normalize_workflow_display_name(display_name)
if normalized is None or normalized == default_name:
overrides.pop(workflow_id, None)
else:
overrides[workflow_id] = normalized
atomic_write_json(
self.path,
{
"schema_version": 1,
"overrides": overrides,
},
)
effective_name = overrides.get(workflow_id, default_name)
return effective_name, workflow_id in overrides, self.revision()
class ScheduleConfigStore:
"""Locked, revision-aware updates to the validated schedule catalog."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.config_dir = settings.project_root / "config"
self.path = self.config_dir / "schedules.json"
self._locks = LockManager(settings.data_root / "state" / "locks")
def revision(self) -> str:
return sha256_file(self.path)
def update(
self,
workflow_id: str,
payload: dict[str, Any],
*,
expected_revision: str | None,
) -> tuple[ScheduleEntry, str]:
if expected_revision is None:
raise ConsolePreconditionError("缺少配置版本,请刷新页面后重试")
expected_revision = expected_revision.strip().strip('"')
owner = f"console-{uuid.uuid4().hex}"
with self._locks.acquire("config:schedules", owner=owner):
current_revision = self.revision()
if not hmac.compare_digest(current_revision, expected_revision):
raise ConsoleConflictError("定时配置已被更新,请刷新后重试")
catalog = WorkflowCatalog.load(self.config_dir)
workflow = next(
(item for item in catalog.workflows if item.workflow_id == workflow_id),
None,
)
if workflow is None:
raise ConsoleNotFoundError("工作流不存在")
if workflow.trigger != "scheduled":
raise ConsoleRequestError("该工作流不是定时工作流,不能配置启动时间")
schedule_item = _schedule_item(workflow_id, payload)
raw = _read_json_object(self.path)
schedules = raw.get("schedules")
if not isinstance(schedules, list):
raise ConsoleRequestError("定时配置结构无效")
replacement_index = next(
(
index
for index, item in enumerate(schedules)
if isinstance(item, dict)
and item.get("workflow_id") == workflow_id
),
None,
)
if replacement_index is None:
raise ConsoleRequestError("工作流缺少现有定时配置")
schedules[replacement_index] = schedule_item
_validate_catalog_candidate(self.settings, raw)
atomic_write_json(self.path, raw)
updated_catalog = WorkflowCatalog.load(self.config_dir)
return updated_catalog.schedule_for(workflow_id), self.revision()
class WorkflowConsoleService:
"""Safe projection and mutation boundary used by the HTTP handler."""
def __init__(
self,
settings: Settings,
*,
launcher: RunLauncher | None = None,
daily_summary_analyzer: DailySummaryAnalyzer | None = None,
dynamic_configs: WorkflowConfigRepository | None = None,
scheduler_env_files: Sequence[Path] | None = None,
) -> None:
self.settings = settings
self.config_dir = settings.project_root / "config"
self.launcher = launcher or SubprocessConsoleRunLauncher(settings)
self.scheduler_env_files = tuple(
Path(path).expanduser().resolve()
for path in (scheduler_env_files or ())
)
self.schedules = ScheduleConfigStore(settings)
self.display_names = WorkflowDisplayNameStore(settings)
self.notifications = NotificationRoutingStore(settings)
self.dynamic_configs = dynamic_configs or PostgresWorkflowConfigStore(
os.environ
)
self.daily_summaries = DailySummaryBuilder(
project_root=settings.project_root,
data_root=settings.data_root,
analyzer=daily_summary_analyzer,
workflow_name=_workflow_name,
)
self._cache_lock = threading.RLock()
self._catalog_signature: tuple[tuple[int, int], tuple[int, int]] | None = None
self._catalog_cache: WorkflowCatalog | None = None
self._registered_cache: frozenset[str] = frozenset()
self._notification_lookup_proofs: set[tuple[str, str, str, str]] = set()
self._scheduler_start_lock = threading.Lock()
def start_scheduler(self) -> dict[str, object]:
"""Start the single resident scheduler without blocking the console."""
with self._scheduler_start_lock:
running = _scheduler_process_from_lock(self.settings)
if running is not None:
return {
"status": "running",
"service_running": True,
"pid": running.pid,
"started_at": running.started_at,
}
occupied_pid = _scheduler_lock_owner_pid(self.settings)
if occupied_pid is not None:
raise ConsoleConflictError(
"调度锁已被其他进程占用,无法确认调度器身份,请先检查服务进程"
)
missing_env_files = [
path for path in self.scheduler_env_files if not path.is_file()
]
if missing_env_files:
raise ConsoleServiceUnavailableError(
"启动调度器所需的运行环境文件不可用"
)
log_root = self.settings.data_root / "logs" / "scheduler-service"
try:
log_root.mkdir(parents=True, exist_ok=True)
log_path = log_root / (
"console-start-"
+ datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ ".log"
)
environment = {
**os.environ,
"GYXX_PROJECT_ROOT": str(self.settings.project_root),
"GYXX_DATA_ROOT": str(self.settings.data_root),
}
environment.pop(CONSOLE_TOKEN_ENV, None)
argv = [
str(Path(sys.executable).resolve()),
"-m",
"gyxx_flow",
"schedule",
"run",
]
for env_file in self.scheduler_env_files:
argv.extend(("--env-file", str(env_file)))
kwargs: dict[str, object] = {}
if os.name == "nt":
kwargs["creationflags"] = (
subprocess.CREATE_NO_WINDOW
| subprocess.CREATE_NEW_PROCESS_GROUP
)
else:
kwargs["start_new_session"] = True
with log_path.open("a", encoding="utf-8", newline="\n") as stream:
process = subprocess.Popen(
argv,
cwd=self.settings.project_root,
env=environment,
stdin=subprocess.DEVNULL,
stdout=stream,
stderr=subprocess.STDOUT,
shell=False,
**kwargs,
)
except OSError as exc:
raise ConsoleServiceUnavailableError(
"调度器进程启动失败,请检查调度服务日志"
) from exc
deadline = time.monotonic() + SCHEDULER_START_TIMEOUT_SECONDS
while time.monotonic() < deadline:
if process.poll() is not None:
raise ConsoleServiceUnavailableError(
"调度器启动后立即退出,请检查调度服务日志"
)
running = _scheduler_process_from_lock(self.settings)
if running is not None:
return {
"status": "running",
"service_running": True,
"pid": running.pid,
"started_at": running.started_at,
}
time.sleep(0.05)
return {
"status": "starting",
"service_running": False,
"pid": process.pid,
}
def dynamic_config_snapshot(
self,
*,
search: str = "",
platform: str = "",
enabled: bool | None = None,
) -> dict[str, Any]:
if len(search) > 120 or len(platform) > 80:
raise ConsoleRequestError("动态配置筛选条件过长")
try:
return self.dynamic_configs.snapshot(
search=search,
platform=platform,
enabled=enabled,
)
except WorkflowConfigUnavailableError as exc:
raise ConsoleServiceUnavailableError(str(exc)) from exc
except WorkflowConfigError as exc:
raise ConsoleRequestError(str(exc)) from exc
def create_dynamic_config(self, payload: dict[str, Any]) -> dict[str, Any]:
try:
return self.dynamic_configs.create(payload)
except WorkflowConfigValidationError as exc:
raise ConsoleRequestError(str(exc)) from exc
except WorkflowConfigUnavailableError as exc:
raise ConsoleServiceUnavailableError(str(exc)) from exc
except WorkflowConfigError as exc:
raise ConsoleRequestError(str(exc)) from exc
def update_dynamic_config(
self,
config_id: int,
payload: dict[str, Any],
*,
expected_revision: int | None,
) -> dict[str, Any]:
if expected_revision is None:
raise ConsolePreconditionError("缺少配置版本,请刷新页面后重试")
try:
return self.dynamic_configs.update(
config_id,
payload,
expected_revision=expected_revision,
)
except WorkflowConfigNotFoundError as exc:
raise ConsoleNotFoundError(str(exc)) from exc
except WorkflowConfigConflictError as exc:
raise ConsoleConflictError(str(exc)) from exc
except WorkflowConfigValidationError as exc:
raise ConsoleRequestError(str(exc)) from exc
except WorkflowConfigUnavailableError as exc:
raise ConsoleServiceUnavailableError(str(exc)) from exc
except WorkflowConfigError as exc:
raise ConsoleRequestError(str(exc)) from exc
def delete_dynamic_config(
self,
config_id: int,
*,
expected_revision: int | None,
) -> dict[str, Any]:
if expected_revision is None:
raise ConsolePreconditionError("缺少配置版本,请刷新页面后重试")
try:
self.dynamic_configs.delete(
config_id,
expected_revision=expected_revision,
)
except WorkflowConfigNotFoundError as exc:
raise ConsoleNotFoundError(str(exc)) from exc
except WorkflowConfigConflictError as exc:
raise ConsoleConflictError(str(exc)) from exc
except WorkflowConfigUnavailableError as exc:
raise ConsoleServiceUnavailableError(str(exc)) from exc
except WorkflowConfigError as exc:
raise ConsoleRequestError(str(exc)) from exc
return {"deleted": True, "id": config_id}
def _catalog(self) -> tuple[WorkflowCatalog, frozenset[str]]:
workflow_path = self.config_dir / "workflows.json"
schedule_path = self.config_dir / "schedules.json"
workflow_stat = workflow_path.stat()
schedule_stat = schedule_path.stat()
signature = (
(workflow_stat.st_mtime_ns, workflow_stat.st_size),
(schedule_stat.st_mtime_ns, schedule_stat.st_size),
)
with self._cache_lock:
if signature != self._catalog_signature:
catalog = WorkflowCatalog.load(self.config_dir)
registry = create_module_registry(catalog=catalog)
self._catalog_cache = catalog
self._registered_cache = frozenset(registry.workflow_ids)
self._catalog_signature = signature
if self._catalog_cache is None:
raise ConsoleRequestError("工作流目录尚未加载")
return self._catalog_cache, self._registered_cache
def overview(self) -> dict[str, object]:
catalog, registered_ids = self._catalog()
warnings: list[str] = []
try:
display_name_overrides, display_names_revision = (
self.display_names.snapshot()
)
except ConsoleRequestError:
display_name_overrides = {}
display_names_revision = self.display_names.revision()
warnings.append("自定义工作流名称配置无效,当前显示默认名称")
try:
records = RunIndex(self.settings.data_root).query()
except ValueError:
records = ()
warnings.append("运行索引中存在损坏记录,最近执行信息暂不可用")
latest: dict[str, RunRecord] = {}
for record in records:
latest.setdefault(record.workflow_id, record)
active_runs = {
str(item["workflow_id"]): item for item in self.launcher.active()
}
scheduler = _scheduler_snapshot(self.settings)
scheduler_jobs = scheduler.pop("jobs")
schedules = {item.workflow_id: item for item in catalog.schedules}
now = datetime.now(ZoneInfo(catalog.timezone))
workflows: list[dict[str, object]] = []
for entry in catalog.workflows:
schedule = schedules.get(entry.workflow_id)
record = latest.get(entry.workflow_id)
default_name = _workflow_name(entry.workflow_id)
display_name = display_name_overrides.get(
entry.workflow_id,
default_name,
)
workflows.append(
{
"id": entry.workflow_id,
"name": display_name,
"default_name": default_name,
"name_customized": display_name != default_name,
"module": entry.module,
"trigger": entry.trigger,
"run_date_mode": (
"month"
if entry.workflow_id == "product.sales_sheet.daily"
else "date"
),
"registered": entry.workflow_id in registered_ids,
"note": entry.note,
"source_task_name": entry.source_task_name,
"steps": _workflow_steps(entry),
**(
{
"tmall_baibu_import": _tmall_baibu_import_payload(entry)
}
if entry.workflow_id == TMALL_BAIBU_WORKFLOW_ID
else {}
),
**(
{"topic_config": _workflow_topic_payload(entry)}
if entry.topic_config is not None
else {}
),
"schedule": _schedule_payload(schedule),
"next_run_at": _next_run_at(schedule, now),
"last_run": _run_payload(
record,
data_root=self.settings.data_root,
),
"active_run": active_runs.get(entry.workflow_id),
"scheduler_job": scheduler_jobs.get(entry.workflow_id),
}
)
module_counts = {
module["id"]: sum(
item["module"] == module["id"] for item in workflows
)
for module in _MODULES
}
modules = [
{**module, "count": module_counts[module["id"]]} for module in _MODULES
]
running_ids = {
item["workflow_id"] for item in active_runs.values()
} | {
workflow_id
for workflow_id, job in scheduler_jobs.items()
if job.get("status") == "running"
} | {
workflow_id
for workflow_id, record in latest.items()
if record.status == "running"
and not _scheduler_terminal_matches_record(
scheduler_jobs.get(workflow_id),
record,
)
}
failed = sum(
bool(item["last_run"] and item["last_run"]["status"] == "failed")
for item in workflows
)
return {
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"timezone": catalog.timezone,
"schedule_revision": self.schedules.revision(),
"workflow_names_revision": display_names_revision,
"summary": {
"total": len(workflows),
"scheduled": len(catalog.schedules),
"enabled": sum(item.enabled for item in catalog.schedules),
"running": len(running_ids),
"failed": failed,
"unavailable": len(catalog.unavailable_workflows()),
},
"scheduler": scheduler,
"modules": modules,
"workflows": workflows,
"warnings": warnings,
}
def update_display_name(
self,
workflow_id: str,
payload: dict[str, Any],
*,
expected_revision: str | None,
) -> dict[str, object]:
if set(payload) != {"display_name"}:
raise ConsoleRequestError("名称请求只允许 display_name 字段")
catalog, _registered = self._catalog()
if workflow_id not in {item.workflow_id for item in catalog.workflows}:
raise ConsoleNotFoundError("工作流不存在")
default_name = _workflow_name(workflow_id)
display_name, customized, revision = self.display_names.update(
workflow_id,
payload["display_name"],
default_name=default_name,
expected_revision=expected_revision,
)
return {
"workflow_id": workflow_id,
"name": display_name,
"default_name": default_name,
"name_customized": customized,
"workflow_names_revision": revision,
}
def notification_config(self) -> dict[str, object]:
"""Return the app-scoped people directory and explicit workflow routes."""
try:
payload, revision = self.notifications.snapshot()
except NotificationRoutingError as exc:
raise ConsoleRequestError(str(exc)) from exc
catalog, _registered = self._catalog()
workflows = {item.workflow_id: item for item in catalog.workflows}
configured_routes = payload.get("routes", {})
if not isinstance(configured_routes, dict):
raise ConsoleRequestError("通知路由配置无效")
routes: list[dict[str, object]] = []
for workflow_id, capability in NOTIFICATION_CAPABILITIES.items():
entry = workflows.get(workflow_id)
if entry is None:
continue
configured = configured_routes.get(workflow_id)
route = configured if isinstance(configured, dict) else {}
routes.append(
{
"workflow_id": workflow_id,
"name": _workflow_name(workflow_id),
"module": entry.module,
"condition": _notification_capability_condition(capability),
"configured": isinstance(configured, dict),
"enabled": bool(route.get("enabled", False)),
"app_profile": route.get(
"app_profile",
payload.get("app_profile", "hermes-analyzer"),
),
"person_ids": list(route.get("person_ids", ())),
}
)
app_profile = payload.get("app_profile", "hermes-analyzer")
people_payload = payload.get("people", {})
if not isinstance(people_payload, dict):
raise ConsoleRequestError("通知人员目录无效")
people = [
{"id": person_id, **person}
for person_id, person in people_payload.items()
if isinstance(person, dict)
]
app_profiles = payload.get("app_profiles", {})
if not isinstance(app_profiles, dict):
raise ConsoleRequestError("飞书应用配置无效")
delivery_profiles = {
profile: value
for profile, value in app_profiles.items()
if isinstance(value, dict)
and value.get("hermes_profile") == ANALYZER_HERMES_PROFILE
}
if app_profile not in delivery_profiles:
raise ConsoleRequestError("业务通知必须使用分析端 Hermes 飞书机器人")
installed_apps = _configured_lark_apps(os.environ)
return {
"revision": revision,
"app_profile": app_profile,
"available_app_profiles": sorted(delivery_profiles),
"apps": [
{
"profile": profile,
"label": value.get("label", profile),
"app_id": value.get("app_id"),
"configured": installed_apps.get(profile) == value.get("app_id"),
}
for profile, value in delivery_profiles.items()
],
"people": people,
"routes": routes,
}
def update_notification_config(
self,
payload: dict[str, Any],
*,
expected_revision: str | None,
) -> dict[str, object]:
"""Atomically replace notification routing after relation validation."""
normalized_routes: dict[str, dict[str, object]] = {}
seen_routes: set[str] = set()
raw_routes = payload.get("routes")
if not isinstance(raw_routes, list):
raise ConsoleRequestError("通知路由必须是列表")
for item in raw_routes:
if not isinstance(item, dict):
raise ConsoleRequestError("通知路由条目无效")
allowed = {
"workflow_id",
"configured",
"enabled",
"person_ids",
}
if set(item) != allowed:
raise ConsoleRequestError("通知路由条目字段无效")
workflow_id = item.get("workflow_id")
if (
not isinstance(workflow_id, str)
or workflow_id not in NOTIFICATION_CAPABILITIES
):
raise ConsoleRequestError("工作流不支持业务通知配置")
if workflow_id in seen_routes:
raise ConsoleRequestError("通知路由不能重复")
seen_routes.add(str(workflow_id))
if not isinstance(item.get("configured"), bool):
raise ConsoleRequestError("通知接管状态无效")
if not item["configured"]:
continue
normalized_routes[str(workflow_id)] = {
"enabled": item.get("enabled"),
"app_profile": payload.get("app_profile"),
"person_ids": item.get("person_ids"),
}
if set(payload) != {"app_profile", "people", "routes"}:
raise ConsoleRequestError("通知配置包含不支持的字段")
try:
current, _current_revision = self.notifications.snapshot()
except NotificationRoutingError as exc:
raise ConsoleRequestError(str(exc)) from exc
requested_profile = payload.get("app_profile")
if not isinstance(requested_profile, str) or not _SAFE_APP_PROFILE.fullmatch(
requested_profile
):
raise ConsoleRequestError("飞书应用 profile 无效")
app_profiles = current.get("app_profiles")
if not isinstance(app_profiles, dict):
raise ConsoleRequestError("飞书应用配置无效")
selected_app = app_profiles.get(requested_profile)
if (
not isinstance(selected_app, dict)
or selected_app.get("hermes_profile") != ANALYZER_HERMES_PROFILE
):
raise ConsoleRequestError("业务通知只能使用分析端 Hermes 飞书机器人")
if (
requested_profile != current.get("app_profile")
and _configured_lark_apps(os.environ).get(requested_profile)
!= selected_app.get("app_id")
):
raise ConsoleRequestError("只能切换到服务器已安装且 App ID 匹配的飞书应用")
raw_people = payload.get("people")
if not isinstance(raw_people, list):
raise ConsoleRequestError("通知人员目录必须是列表")
current_people = current.get("people")
if not isinstance(current_people, dict):
raise ConsoleRequestError("通知人员目录无效")
people: dict[str, dict[str, object]] = {}
consumed_lookup_proofs: set[tuple[str, str, str, str]] = set()
for item in raw_people:
if not isinstance(item, dict) or set(item) != {
"id",
"name",
"mobile",
"bindings",
}:
raise ConsoleRequestError("通知人员条目字段无效")
person_id = item.get("id")
if not isinstance(person_id, str) or not _SAFE_RUN_ID.fullmatch(
person_id
):
raise ConsoleRequestError("人员编号无效")
if person_id in people:
raise ConsoleRequestError("人员编号不能重复")
mobile = _normalize_optional_mobile(item.get("mobile"))
raw_bindings = item.get("bindings")
if not isinstance(raw_bindings, dict):
raise ConsoleRequestError("通知人员应用绑定无效")
previous_person = current_people.get(person_id)
previous_bindings = (
previous_person.get("bindings", {})
if isinstance(previous_person, dict)
else {}
)
if not isinstance(previous_bindings, dict):
previous_bindings = {}
bindings: dict[str, object] = {}
for profile, raw_binding in raw_bindings.items():
if not isinstance(profile, str) or not isinstance(raw_binding, dict):
raise ConsoleRequestError("通知人员应用绑定无效")
open_id = raw_binding.get("open_id")
verified = raw_binding.get("verified")
source = raw_binding.get("source")
previous_binding = previous_bindings.get(profile)
previous_mobile = (
previous_person.get("mobile")
if isinstance(previous_person, dict)
else None
)
unchanged = raw_binding == previous_binding and not (
source == "mobile_lookup" and mobile != previous_mobile
)
proof = (
str(person_id),
profile,
mobile or "",
str(open_id or ""),
)
if unchanged:
bindings[profile] = raw_binding
elif verified is True and source == "mobile_lookup":
with self._cache_lock:
has_proof = proof in self._notification_lookup_proofs
if not has_proof:
raise ConsoleRequestError(
"手机号验证状态已失效,请重新通过手机号绑定"
)
bindings[profile] = raw_binding
consumed_lookup_proofs.add(proof)
else:
bindings[profile] = {
"open_id": open_id,
"verified": False,
"source": "manual",
}
people[person_id] = {
"name": item.get("name"),
"mobile": mobile,
"bindings": bindings,
}
candidate = {
"schema_version": 1,
"app_profile": requested_profile,
"app_profiles": current.get("app_profiles"),
"people": people,
"capabilities": current.get("capabilities"),
"routes": normalized_routes,
}
try:
self.notifications.update(
candidate,
expected_revision=expected_revision,
)
except NotificationRoutingPreconditionError as exc:
raise ConsolePreconditionError(str(exc)) from exc
except NotificationRoutingConflictError as exc:
raise ConsoleConflictError(str(exc)) from exc
except NotificationRoutingError as exc:
raise ConsoleRequestError(str(exc)) from exc
with self._cache_lock:
self._notification_lookup_proofs.difference_update(
consumed_lookup_proofs
)
return self.notification_config()
def resolve_notification_recipient(
self,
payload: dict[str, Any],
) -> dict[str, object]:
"""Resolve one mobile number under the selected Feishu app profile."""
if set(payload) != {"person_id", "mobile", "app_profile"}:
raise ConsoleRequestError("手机号绑定请求字段无效")
person_id = payload.get("person_id")
app_profile = payload.get("app_profile")
if not isinstance(person_id, str) or not _SAFE_RUN_ID.fullmatch(person_id):
raise ConsoleRequestError("人员编号无效")
if not isinstance(app_profile, str) or not _SAFE_APP_PROFILE.fullmatch(
app_profile
):
raise ConsoleRequestError("飞书应用 profile 无效")
mobile = _normalize_mobile(payload.get("mobile"))
try:
current, _revision = self.notifications.snapshot()
except NotificationRoutingError as exc:
raise ConsoleRequestError(str(exc)) from exc
app_profiles = current.get("app_profiles")
selected_app = (
app_profiles.get(app_profile) if isinstance(app_profiles, dict) else None
)
if (
not isinstance(selected_app, dict)
or selected_app.get("hermes_profile") != ANALYZER_HERMES_PROFILE
):
raise ConsoleRequestError("手机号只能绑定到分析端 Hermes 飞书机器人")
if (
_configured_lark_apps(os.environ).get(app_profile)
!= selected_app.get("app_id")
):
raise ConsoleRequestError("所选飞书应用未安装或 App ID 不匹配")
open_id = _resolve_feishu_open_id_by_mobile(
app_profile,
mobile,
environment=os.environ,
)
with self._cache_lock:
self._notification_lookup_proofs.add(
(person_id, app_profile, mobile, open_id)
)
while (
len(self._notification_lookup_proofs)
> MAX_NOTIFICATION_LOOKUP_PROOFS
):
self._notification_lookup_proofs.pop()
return {
"person_id": person_id,
"mobile": mobile,
"app_profile": app_profile,
"open_id": open_id,
}
def recent_runs(self, workflow_id: str, *, limit: int) -> dict[str, object]:
catalog, _registered = self._catalog()
if workflow_id not in {item.workflow_id for item in catalog.workflows}:
raise ConsoleNotFoundError("工作流不存在")
if not 1 <= limit <= 20:
raise ConsoleRequestError("运行记录数量必须在 1 到 20 之间")
try:
records = RunIndex(self.settings.data_root).query(
workflow_id=workflow_id
)[:limit]
except ValueError as exc:
raise ConsoleRequestError("运行索引中存在损坏记录") from exc
return {
"workflow_id": workflow_id,
"runs": [
_run_payload(record, data_root=self.settings.data_root)
for record in records
],
}
def workflow_detail(self, workflow_id: str) -> dict[str, object]:
"""Return the overview projection for a single workflow."""
overview = self.overview()
workflow = next(
(
item
for item in overview["workflows"]
if item["id"] == workflow_id
),
None,
)
if workflow is None:
raise ConsoleNotFoundError("工作流不存在")
payload: dict[str, object] = {"workflow": workflow}
schedule_revision = overview.get("schedule_revision")
if isinstance(schedule_revision, str):
payload["schedule_revision"] = schedule_revision
warnings = overview.get("warnings")
if warnings:
payload["warnings"] = warnings
return payload
def run_detail(self, workflow_id: str, run_id: str) -> dict[str, object]:
"""Return one indexed run with its journal step details."""
record = self._run_record_for(workflow_id, run_id)
return {
"workflow_id": workflow_id,
"run": _run_payload(record, data_root=self.settings.data_root),
}
def run_diagnosis(self, workflow_id: str, run_id: str) -> dict[str, object]:
"""Bundle one run's journal, trace, and sanitized log tails for agents."""
record = self._run_record_for(workflow_id, run_id)
journal = _journal_trace_payload(record, data_root=self.settings.data_root)
return {
"workflow_id": workflow_id,
"run": _run_payload(record, data_root=self.settings.data_root),
"journal": journal,
"logs": _diagnosis_logs(
record,
journal,
data_root=self.settings.data_root,
),
}
def _run_record_for(self, workflow_id: str, run_id: str) -> RunRecord:
catalog, _registered = self._catalog()
if workflow_id not in {item.workflow_id for item in catalog.workflows}:
raise ConsoleNotFoundError("工作流不存在")
if not _SAFE_RUN_ID.fullmatch(run_id):
raise ConsoleNotFoundError("运行记录不存在")
try:
record = RunIndex(self.settings.data_root).get(run_id)
except ValueError as exc:
raise ConsoleRequestError("运行索引中存在损坏记录") from exc
if record is None or record.workflow_id != workflow_id:
raise ConsoleNotFoundError("运行记录不存在")
return record
def daily_summary(
self,
report_date: str | None = None,
*,
force_refresh: bool = False,
) -> dict[str, object]:
"""Return the cached or newly analyzed workflow report for one local day."""
catalog, _registered = self._catalog()
if report_date is None:
target = datetime.now(ZoneInfo(catalog.timezone)).date() - timedelta(days=1)
else:
try:
target = date.fromisoformat(report_date)
except (TypeError, ValueError) as exc:
raise ConsoleRequestError(
"date 参数必须使用 YYYY-MM-DD 格式"
) from exc
if target.isoformat() != report_date:
raise ConsoleRequestError("date 参数必须使用 YYYY-MM-DD 格式")
local_today = datetime.now(ZoneInfo(catalog.timezone)).date()
if target > local_today:
raise ConsoleRequestError("不能汇总未来日期")
try:
return self.daily_summaries.build(
target,
force_refresh=force_refresh,
)
except ValueError as exc:
raise ConsoleRequestError("运行索引中存在损坏记录") from exc
def refresh_daily_summary(self, payload: dict[str, Any]) -> dict[str, object]:
if set(payload) - {"date"}:
raise ConsoleRequestError("日汇总刷新请求只允许 date 字段")
report_date = payload.get("date")
if report_date is not None and not isinstance(report_date, str):
raise ConsoleRequestError("date 参数必须使用 YYYY-MM-DD 格式")
return self.daily_summary(report_date, force_refresh=True)
def update_schedule(
self,
workflow_id: str,
payload: dict[str, Any],
*,
expected_revision: str | None,
) -> dict[str, object]:
schedule, revision = self.schedules.update(
workflow_id,
payload,
expected_revision=expected_revision,
)
with self._cache_lock:
self._catalog_signature = None
catalog, _registered = self._catalog()
now = datetime.now(ZoneInfo(catalog.timezone))
return {
"schedule": _schedule_payload(schedule),
"next_run_at": _next_run_at(schedule, now),
"schedule_revision": revision,
"applies_on_next_scheduler_poll": True,
"may_trigger_on_next_scheduler_poll": schedule.enabled,
}
def trigger(
self,
workflow_id: str,
payload: dict[str, Any],
) -> dict[str, object]:
catalog, registered_ids = self._catalog()
entry = next(
(item for item in catalog.workflows if item.workflow_id == workflow_id),
None,
)
if entry is None:
raise ConsoleNotFoundError("工作流不存在")
if workflow_id not in registered_ids or entry.trigger == "unavailable":
raise ConsoleRequestError("该工作流当前没有可执行入口")
allowed = {
"business_date",
"execute",
"shadow",
"confirmed",
"force_refresh",
}
if workflow_id == "product.video_upload":
allowed.add("store")
allowed.add("tmall_topic_keyword")
if workflow_id == TMALL_BAIBU_WORKFLOW_ID:
allowed.add("tmall_baibu_import_mode")
unknown = set(payload) - allowed
if unknown:
raise ConsoleRequestError("运行请求包含不支持的字段")
raw_date = payload.get("business_date")
try:
business_date = date.fromisoformat(raw_date)
except (TypeError, ValueError) as exc:
raise ConsoleRequestError("业务日期必须使用 YYYY-MM-DD 格式") from exc
if business_date.isoformat() != raw_date:
raise ConsoleRequestError("业务日期必须使用 YYYY-MM-DD 格式")
execute = payload.get("execute", False)
shadow = payload.get("shadow", False)
if not isinstance(execute, bool) or not isinstance(shadow, bool):
raise ConsoleRequestError("执行方式参数无效")
force_refresh = payload.get("force_refresh", False)
if not isinstance(force_refresh, bool):
raise ConsoleRequestError("强制重新采集参数无效")
if force_refresh and not execute:
raise ConsoleRequestError("强制重跑必须选择真实执行")
if execute and payload.get("confirmed") is not True:
raise ConsoleRequestError("正式执行前必须确认外部影响")
video_upload_store: str | None = None
tmall_topic_keyword: str | None = None
if workflow_id == "product.video_upload":
raw_store = payload.get("store", "flagship")
if raw_store not in {"flagship", "luggage"}:
raise ConsoleRequestError("天猫视频上传店铺参数无效")
video_upload_store = str(raw_store)
tmall_topic_keyword = _normalize_tmall_topic_keyword(
payload.get("tmall_topic_keyword"),
entry,
)
tmall_baibu_import_mode: str | None = None
if workflow_id == TMALL_BAIBU_WORKFLOW_ID:
raw_import_mode = payload.get(
"tmall_baibu_import_mode",
TMALL_BAIBU_DEFAULT_IMPORT_MODE,
)
try:
tmall_baibu_import_mode = validate_tmall_baibu_import_mode(
raw_import_mode
)
except ValueError as exc:
raise ConsoleRequestError("天猫百亿补贴导入范围参数无效") from exc
if execute:
runtime_environment = {
**os.environ,
"GYXX_PROJECT_ROOT": str(self.settings.project_root),
"GYXX_DATA_ROOT": str(self.settings.data_root),
}
_validate_product_production_runtime(entry, runtime_environment)
_validate_content_production_runtime(entry, runtime_environment)
scheduler = _scheduler_snapshot(self.settings)
scheduler_job = scheduler["jobs"].get(workflow_id)
if (
scheduler["service_running"]
and scheduler_job
and scheduler_job.get("status") == "running"
):
raise ConsoleConflictError("该工作流正由定时调度器执行")
launch_kwargs: dict[str, object] = {
"execute": execute,
"shadow": shadow,
"video_upload_store": video_upload_store,
"tmall_topic_keyword": tmall_topic_keyword,
"force_refresh": force_refresh,
}
if tmall_baibu_import_mode is not None:
launch_kwargs["tmall_baibu_import_mode"] = tmall_baibu_import_mode
return self.launcher.launch(
workflow_id,
business_date,
**launch_kwargs,
)
def cancel(self, workflow_id: str, operation_id: str) -> dict[str, object]:
catalog, _registered_ids = self._catalog()
if workflow_id not in {item.workflow_id for item in catalog.workflows}:
raise ConsoleNotFoundError("工作流不存在")
if not _OPERATION_ID.fullmatch(operation_id):
raise ConsoleNotFoundError("运行操作不存在")
return self.launcher.cancel(workflow_id, operation_id)
def cancel_scheduled(self, workflow_id: str) -> dict[str, object]:
catalog, _registered_ids = self._catalog()
if workflow_id not in {item.workflow_id for item in catalog.workflows}:
raise ConsoleNotFoundError("工作流不存在")
state = _read_scheduler_state(self.settings)
raw_jobs = state.get("jobs") if isinstance(state, dict) else None
job = raw_jobs.get(workflow_id) if isinstance(raw_jobs, dict) else None
if not isinstance(job, dict) or job.get("status") != "running":
raise ConsoleConflictError("该定时执行已经结束,请刷新后查看最新状态")
live, process, process_started_at = _scheduler_job_process(
workflow_id,
job,
)
if not live:
raise ConsoleConflictError("该定时执行进程已经结束,请刷新后查看最新状态")
if process is None or process_started_at is None:
raise ConsoleConflictError("无法确认定时执行进程身份,未执行停止操作")
business_date = job.get("business_date")
if not isinstance(business_date, str):
raise ConsoleConflictError("定时执行缺少业务日期,未执行停止操作")
try:
parsed_date = date.fromisoformat(business_date)
except ValueError as exc:
raise ConsoleConflictError("定时执行的业务日期无效,未执行停止操作") from exc
if parsed_date.isoformat() != business_date:
raise ConsoleConflictError("定时执行的业务日期无效,未执行停止操作")
pid = process.pid
journal_path = _scheduler_run_journal_path(
self.settings,
workflow_id,
business_date,
pid,
job.get("started_at"),
)
_terminate_scheduler_process_tree(process, process_started_at)
journal_status, run_id = _finalize_scheduler_cancelled_journal(
self.settings,
workflow_id,
business_date,
journal_path,
)
if journal_status in {"success", "failed"}:
raise ConsoleConflictError("该定时执行已经结束,无法停止")
result: dict[str, object] = {
"workflow_id": workflow_id,
"business_date": business_date,
"source": "scheduler",
"started_at": job.get("started_at"),
"ended_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"status": "cancelled",
"journal_status": journal_status,
}
if run_id is not None:
result["run_id"] = run_id
return result
def _validate_product_production_runtime(
entry: WorkflowEntry,
environment: Mapping[str, str],
) -> None:
"""Fail before Popen when a product run cannot reach real production sinks."""
if entry.module != "product_commerce":
return
environment = _console_runtime_environment(
environment,
workflow_id=entry.workflow_id,
)
if _environment_flag(environment, "GYXX_WORKFLOW_ACCEPTANCE"):
raise ConsoleRequestError("正式执行不能使用验收隔离模式")
if _environment_flag(environment, "GYXX_FEISHU_TABLE_WRITE_DISABLED"):
raise ConsoleRequestError("正式执行不能禁用飞书写入")
# 月销量表直接读取飞书款式主表并通过聚水潭商品主体分析取数;
# 它不依赖 PostgreSQL 或 Hermes,浏览器登录态由 runtime binding 预检。
# 百补批量报名只下载 Feishu 模板并提交到淘宝报名页面,同样不依赖
# PostgreSQL/Hermes;六个入口各自的账号和浏览器绑定由脚本侧校验。
if entry.workflow_id in {
"product.sales_sheet.daily",
"product.tmall_baibu_apply",
}:
return
has_dsn = any(
environment.get(name, "").strip()
for name in ("GYXX_POSTGRES_DSN", "DATABASE_URL", "DB_URL")
)
has_pg_fields = all(
environment.get(name, "").strip()
for name in ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD")
)
if not has_dsn and not has_pg_fields:
raise ConsoleRequestError(
"商品经营正式执行缺少 PostgreSQL 运行时凭据;请用控制台 --env-file 注入"
)
if entry.workflow_id in _DIRECT_VIDEO_WORKFLOW_IDS:
_validate_direct_video_model_runtime(environment)
return
route_disabled = False
if entry.workflow_id == "product.alert.daily":
route_disabled = _notification_route_is_disabled(
entry.workflow_id,
environment,
legacy_names=(
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID",
"GYXX_ALERT_RECIPIENT_OPEN_ID",
),
)
elif entry.workflow_id == "product.market_rank":
route_disabled = _notification_route_is_disabled(
entry.workflow_id,
environment,
legacy_names=(
"MARKET_RANK_NOTIFY_OPEN_ID",
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID",
),
)
needs_lark_profile = not (
entry.workflow_id == "product.alert.daily" and route_disabled
)
if needs_lark_profile and not _has_lark_analyzer_profile(environment):
raise ConsoleRequestError(
"商品经营正式执行缺少可用的飞书 hermes-analyzer 配置"
)
if entry.workflow_id == "product.style_analysis.interval" and not any(
environment.get(name, "").strip()
for name in ("GYXX_HERMES_API_KEY", "HERMES_API_KEY")
):
raise ConsoleRequestError("该商品工作流正式执行缺少本机 Hermes 运行时凭据")
if entry.workflow_id == "product.alert.daily" and not _notification_route_ready(
entry.workflow_id,
environment,
legacy_names=(
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID",
"GYXX_ALERT_RECIPIENT_OPEN_ID",
),
):
raise ConsoleRequestError("销量告警正式执行缺少飞书负责人配置")
if entry.workflow_id == "product.market_rank" and not _notification_route_ready(
entry.workflow_id,
environment,
legacy_names=(
"MARKET_RANK_NOTIFY_OPEN_ID",
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID",
),
):
raise ConsoleRequestError("市场排行正式执行缺少飞书负责人配置")
def _validate_content_production_runtime(
entry: WorkflowEntry,
environment: Mapping[str, str],
) -> None:
"""Reject content execute requests that cannot use real production data."""
if entry.module != "content_marketing":
return
if _environment_flag(environment, "GYXX_WORKFLOW_ACCEPTANCE"):
raise ConsoleRequestError("内容营销正式执行不能使用验收隔离模式")
if _environment_flag(environment, "GYXX_FEISHU_TABLE_WRITE_DISABLED"):
raise ConsoleRequestError(
"内容营销正式执行不能禁用真实写入(GYXX_FEISHU_TABLE_WRITE_DISABLED"
)
if entry.workflow_id not in _CONTENT_POSTGRES_WORKFLOW_IDS:
return
required = ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD")
missing = [name for name in required if not environment.get(name, "").strip()]
if missing:
raise ConsoleRequestError(
"内容营销正式执行缺少 PostgreSQL 运行时凭据:"
f"{', '.join(missing)};请用控制台 --env-file 注入"
)
if _is_loopback_host(environment["PG_HOST"]):
raise ConsoleRequestError(
"内容营销正式执行必须连接真实云端 PostgreSQL"
"PG_HOST 不能是本机 loopback 地址"
)
if entry.workflow_id in _CONTENT_DIRECT_ANALYSIS_WORKFLOW_IDS:
_validate_content_analysis_model_runtime(environment)
def _notification_route_ready(
workflow_id: str,
environment: Mapping[str, str],
*,
legacy_names: tuple[str, ...],
) -> bool:
defaults = tuple(
environment.get(name, "").strip()
for name in legacy_names
if environment.get(name, "").strip()
)
try:
route = resolve_notification_route(
workflow_id,
defaults,
environment=environment,
)
except NotificationRoutingError as exc:
raise ConsoleRequestError(str(exc)) from exc
# An explicitly disabled route is a valid operator choice. An enabled route
# has already been relation-validated by the routing store.
return route.configured or bool(route.open_ids)
def _notification_route_is_disabled(
workflow_id: str,
environment: Mapping[str, str],
*,
legacy_names: tuple[str, ...],
) -> bool:
defaults = tuple(
environment.get(name, "").strip()
for name in legacy_names
if environment.get(name, "").strip()
)
try:
route = resolve_notification_route(
workflow_id,
defaults,
environment=environment,
)
except NotificationRoutingError as exc:
raise ConsoleRequestError(str(exc)) from exc
return route.configured and not route.enabled
def _console_runtime_environment(
environment: Mapping[str, str],
*,
workflow_id: str | None = None,
) -> dict[str, str]:
"""Resolve local runtime-only credentials for console child processes."""
resolved = dict(environment)
if workflow_id in _DIRECT_VIDEO_WORKFLOW_IDS | _CONTENT_DIRECT_ANALYSIS_WORKFLOW_IDS:
# Direct-provider children must not inherit legacy Hermes credentials,
# even when the console process itself still uses them for other jobs.
for name in (
"GYXX_HERMES_API_KEY",
"HERMES_API_KEY",
"HERMES_ANALYZER_TOKEN",
"GYXX_SUPPLY_HERMES_TOKEN",
):
resolved.pop(name, None)
return resolved
hermes_key = resolve_hermes_profile_api_key("data-analyzer", resolved)
if hermes_key:
resolved.setdefault("GYXX_HERMES_API_KEY", hermes_key)
resolved.setdefault("HERMES_API_KEY", hermes_key)
return resolved
def _validate_direct_video_model_runtime(
environment: Mapping[str, str],
) -> None:
try:
load_direct_llm_config(environment)
except DirectLLMConfigurationError as exc:
raise ConsoleRequestError(
f"视频正式执行直连大模型配置无效:{exc}"
) from exc
def _validate_content_analysis_model_runtime(
environment: Mapping[str, str],
) -> None:
try:
load_content_llm_config(environment)
except ContentLLMConfigurationError as exc:
raise ConsoleRequestError(
f"内容汇总正式执行直连 MiniMax 配置无效:{exc}"
) from exc
def _environment_flag(environment: Mapping[str, str], name: str) -> bool:
return environment.get(name, "").strip().casefold() in {
"1",
"true",
"yes",
"on",
}
def _has_lark_analyzer_profile(environment: Mapping[str, str]) -> bool:
return (
_configured_lark_apps(environment).get("hermes-analyzer")
== ANALYZER_NOTIFICATION_APP_ID
)
def _configured_lark_profiles(environment: Mapping[str, str]) -> set[str]:
"""Return profile names without exposing app IDs or credential references."""
return set(_configured_lark_profile_catalog(environment))
def _configured_lark_apps(environment: Mapping[str, str]) -> dict[str, str]:
"""Return unambiguous profile-to-App-ID bindings from local lark-cli config."""
return {
profile: app_id
for profile, app_id in _configured_lark_profile_catalog(environment).items()
if app_id is not None
}
def _configured_lark_profile_catalog(
environment: Mapping[str, str],
) -> dict[str, str | None]:
"""Read local profile metadata while rejecting conflicting duplicate names."""
configured = environment.get("LARKSUITE_CLI_CONFIG_DIR", "").strip()
config_root = (
Path(configured).expanduser()
if configured
else Path.home() / ".lark-cli"
)
candidates = (config_root / "config.json", config_root / "hermes" / "config.json")
profiles: dict[str, str | None] = {}
for path in candidates:
if not path.is_file():
continue
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
continue
apps = payload.get("apps") if isinstance(payload, dict) else None
if isinstance(apps, list):
for app in apps:
if not isinstance(app, dict) or not isinstance(app.get("name"), str):
continue
profile = str(app["name"])
if not _SAFE_APP_PROFILE.fullmatch(profile):
continue
raw_app_id = app.get("appId", app.get("app_id"))
app_id = (
str(raw_app_id)
if isinstance(raw_app_id, str)
and _FEISHU_APP_ID.fullmatch(raw_app_id)
else None
)
if profile in profiles and profiles[profile] != app_id:
profiles[profile] = None
else:
profiles[profile] = app_id
return profiles
def _notification_capability_condition(capability: object) -> str:
if isinstance(capability, str):
value = capability
elif isinstance(capability, Mapping):
value = capability.get("condition")
else:
value = getattr(capability, "condition", None)
return str(value or "工作流产出满足发送条件时")
def _normalize_mobile(value: object) -> str:
if not isinstance(value, str) or len(value) > MAX_NOTIFICATION_PHONE_CHARS:
raise ConsoleRequestError("手机号格式无效")
raw = value.strip()
normalized = "".join(
character for character in raw if character not in " -()"
)
if re.fullmatch(r"1\d{10}", normalized):
normalized = f"+86{normalized}"
elif re.fullmatch(r"86\d{11}", normalized):
normalized = f"+{normalized}"
if not re.fullmatch(r"\+[1-9]\d{6,14}", normalized):
raise ConsoleRequestError("手机号格式无效")
return normalized
def _normalize_optional_mobile(value: object) -> str | None:
if value is None:
return None
return _normalize_mobile(value)
def _resolve_feishu_open_id_by_mobile(
app_profile: str,
mobile: str,
*,
environment: Mapping[str, str],
) -> str:
"""Use the configured app identity to resolve its app-scoped open_id."""
command = shutil.which("lark-cli.cmd") or shutil.which("lark-cli")
if not command:
raise ConsoleRequestError("服务器未安装 lark-cli,无法通过手机号绑定")
request_body = json.dumps({"mobiles": [mobile]}, ensure_ascii=False)
process_environment = dict(environment)
process_environment.setdefault("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
process_environment.setdefault("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
try:
completed = subprocess.run(
[
command,
"--profile",
app_profile,
"api",
"POST",
"/open-apis/contact/v3/users/batch_get_id",
"--as",
"bot",
"--params",
'{"user_id_type":"open_id"}',
"--data",
"-",
"--format",
"json",
],
input=request_body,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=process_environment,
timeout=30,
shell=False,
)
except (OSError, subprocess.SubprocessError) as exc:
raise ConsoleRequestError("手机号解析失败,请检查飞书应用授权") from exc
try:
response = _first_json_object(completed.stdout or completed.stderr)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise ConsoleRequestError("手机号解析失败,飞书返回无法识别") from exc
if completed.returncode != 0 or response.get("ok") is False:
raise ConsoleRequestError("手机号解析失败,请检查飞书应用权限和登录状态")
open_ids = _open_ids_from_mobile_response(response, mobile)
if not open_ids:
raise ConsoleNotFoundError("该手机号在所选飞书应用中没有匹配人员")
if len(open_ids) > 1:
raise ConsoleConflictError("该手机号匹配到多个人员,无法自动绑定")
return open_ids[0]
def _first_json_object(raw: str) -> dict[str, Any]:
decoder = json.JSONDecoder()
position = 0
while position < len(raw):
start = raw.find("{", position)
if start < 0:
break
try:
value, _end = decoder.raw_decode(raw, start)
except json.JSONDecodeError:
position = start + 1
continue
if isinstance(value, dict):
return value
position = start + 1
raise ValueError("no JSON object")
def _open_ids_from_mobile_response(
payload: object,
mobile: str,
) -> list[str]:
matches: list[str] = []
def visit(value: object) -> None:
if isinstance(value, dict):
user_list = value.get("user_list")
if isinstance(user_list, list):
for user in user_list:
if not isinstance(user, dict):
continue
returned_mobile = user.get("mobile")
if isinstance(returned_mobile, str) and _normalize_mobile(
returned_mobile
) != mobile:
continue
open_id = user.get("user_id") or user.get("open_id")
if isinstance(open_id, str) and _FEISHU_OPEN_ID.fullmatch(open_id):
matches.append(open_id)
for nested in value.values():
visit(nested)
elif isinstance(value, list):
for nested in value:
visit(nested)
visit(payload)
return list(dict.fromkeys(matches))
def _schedule_item(workflow_id: str, payload: dict[str, Any]) -> dict[str, Any]:
allowed = {
"kind",
"at",
"days",
"day_of_month",
"every_days",
"anchor_date",
"enabled",
"business_date_offset_days",
}
if set(payload) - allowed:
raise ConsoleRequestError("定时配置包含不支持的字段")
kind = payload.get("kind")
at = payload.get("at")
enabled = payload.get("enabled", True)
offset = payload.get("business_date_offset_days", 0)
if kind not in {"daily", "weekly", "monthly", "interval_days"}:
raise ConsoleRequestError("定时类型无效")
if isinstance(at, str):
normalized_at: str | list[str] = at
elif isinstance(at, list) and at and all(
isinstance(value, str) for value in at
):
normalized_at = list(at)
else:
raise ConsoleRequestError("启动时间无效")
if not isinstance(enabled, bool):
raise ConsoleRequestError("启用状态无效")
if isinstance(offset, bool) or not isinstance(offset, int):
raise ConsoleRequestError("业务日期偏移无效")
item: dict[str, Any] = {
"workflow_id": workflow_id,
"kind": kind,
"at": normalized_at,
"enabled": enabled,
"business_date_offset_days": offset,
}
if kind == "weekly":
days = payload.get("days")
if not isinstance(days, list) or not all(
isinstance(day, str) for day in days
):
raise ConsoleRequestError("周计划日期无效")
item["days"] = days
elif kind == "monthly":
item["day_of_month"] = payload.get("day_of_month")
elif kind == "interval_days":
item["every_days"] = payload.get("every_days")
item["anchor_date"] = payload.get("anchor_date")
return item
def _validate_catalog_candidate(settings: Settings, schedule_payload: dict[str, Any]) -> None:
temporary_root = settings.data_root / "tmp"
temporary_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(
prefix="console-schedule-", dir=temporary_root
) as temporary:
candidate = Path(temporary)
shutil.copyfile(
settings.project_root / "config" / "workflows.json",
candidate / "workflows.json",
)
atomic_write_json(candidate / "schedules.json", schedule_payload)
try:
WorkflowCatalog.load(candidate)
except CatalogError as exc:
raise ConsoleRequestError(str(exc)) from exc
def _read_json_object(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ConsoleRequestError("配置文件无法读取") from exc
if not isinstance(payload, dict):
raise ConsoleRequestError("配置文件结构无效")
return payload
def _normalize_workflow_display_name(value: object) -> str:
if not isinstance(value, str):
raise ConsoleRequestError("显示名称必须是字符串或 null")
normalized = unicodedata.normalize("NFC", value).strip()
if not normalized:
raise ConsoleRequestError("显示名称不能为空")
if len(normalized) > MAX_WORKFLOW_DISPLAY_NAME_CHARS:
raise ConsoleRequestError(
f"显示名称不能超过 {MAX_WORKFLOW_DISPLAY_NAME_CHARS} 个字符"
)
if len(normalized.encode("utf-8")) > MAX_WORKFLOW_DISPLAY_NAME_BYTES:
raise ConsoleRequestError("显示名称编码后过长")
if any(unicodedata.category(character) in {"Cc", "Cf", "Cs"} for character in normalized):
raise ConsoleRequestError("显示名称不能包含控制字符或隐藏格式字符")
return normalized
def _workflow_name(workflow_id: str) -> str:
if workflow_id in _WORKFLOW_NAMES:
return _WORKFLOW_NAMES[workflow_id]
return " · ".join(part.replace("_", " ").title() for part in workflow_id.split("."))
def _tmall_baibu_import_payload(entry: WorkflowEntry) -> dict[str, object]:
total, non_hyperlink = tmall_baibu_import_counts(entry)
hyperlink = total - non_hyperlink
return {
"default_mode": TMALL_BAIBU_DEFAULT_IMPORT_MODE,
"options": [
{
"value": TMALL_BAIBU_IMPORT_MODE_WITHOUT_HYPERLINKS,
"label": f"导入去掉超链的({non_hyperlink} 个)",
"description": (
f"跳过 {hyperlink} 个带超链入口,只导入 {non_hyperlink} 个普通入口。"
),
"step_count": non_hyperlink,
},
{
"value": TMALL_BAIBU_IMPORT_MODE_ALL,
"label": f"导入全部({total} 个)",
"description": f"导入配置中的全部 {total} 个入口,包括 {hyperlink} 个超链入口。",
"step_count": total,
},
],
}
def _workflow_topic_payload(entry: WorkflowEntry) -> dict[str, str]:
config = entry.topic_config
if config is None:
return {}
return {
"default_keyword": config.default_keyword,
"label": config.label,
"hint": config.hint,
}
def _normalize_tmall_topic_keyword(
value: object,
entry: WorkflowEntry,
) -> str:
config = entry.topic_config
default_keyword = config.default_keyword if config is not None else ""
if value is None:
return default_keyword
if not isinstance(value, str):
raise ConsoleRequestError("天猫话题关键词必须是字符串")
keyword = value.strip()
if not keyword:
return default_keyword
if len(keyword) > 120:
raise ConsoleRequestError("天猫话题关键词不能超过 120 个字符")
if any(unicodedata.category(character) in {"Cc", "Cf", "Cs"} for character in keyword):
raise ConsoleRequestError("天猫话题关键词不能包含控制字符或隐藏格式字符")
return keyword
def _workflow_steps(entry: Any) -> list[dict[str, object]]:
if entry.steps:
return [
{
"id": step.step_id,
"name": step.name,
"description": step.description,
"has_hyperlink": step.has_hyperlink,
"entry": step.entry,
"args": list(step.args),
"depends_on": list(step.depends_on),
"run_after_failure": step.run_after_failure,
"timeout_seconds": step.timeout_seconds,
"replay_policy": (
step.replay_policy
or (
"idempotent"
if entry.module == "shop_intelligence"
else "guarded"
)
),
"data_flow": _workflow_data_flow_payload(step.data_flow),
}
for step in entry.steps
]
return [
{
"id": "run",
"name": None,
"description": None,
"has_hyperlink": False,
"entry": entry.entry,
"args": list(entry.args),
"depends_on": [],
"run_after_failure": False,
"timeout_seconds": None,
"replay_policy": "guarded",
"data_flow": None,
}
]
def _workflow_data_flow_payload(data_flow: Any) -> dict[str, object] | None:
if data_flow is None:
return None
def endpoint_payload(endpoint: Any) -> dict[str, str]:
payload = {"label": endpoint.label}
for field in ("system", "detail", "condition"):
value = getattr(endpoint, field)
if value is not None:
payload[field] = value
return payload
return {
"sources": [endpoint_payload(item) for item in data_flow.sources],
"processing": list(data_flow.processing),
"destinations": [
endpoint_payload(item) for item in data_flow.destinations
],
}
def _schedule_payload(schedule: ScheduleEntry | None) -> dict[str, object] | None:
if schedule is None:
return None
payload = asdict(schedule)
times = list(schedule.effective_times)
payload["at"] = times if schedule.at_times else times[0]
payload.pop("at_times", None)
payload["days"] = list(schedule.days)
return payload
def _next_run_at(schedule: ScheduleEntry | None, now: datetime) -> str | None:
if schedule is None or not schedule.enabled:
return None
times = tuple(
tuple(int(part) for part in wall_clock.split(":"))
for wall_clock in schedule.effective_times
)
def next_at(day: date) -> datetime | None:
candidates = (
datetime(
day.year,
day.month,
day.day,
hour,
minute,
tzinfo=now.tzinfo,
)
for hour, minute in times
)
return min((candidate for candidate in candidates if candidate > now), default=None)
if schedule.kind == "daily":
for offset in range(2):
candidate = next_at(now.date() + timedelta(days=offset))
if candidate is not None:
return candidate.isoformat()
if schedule.kind == "weekly":
for offset in range(8):
day = now.date() + timedelta(days=offset)
if day.strftime("%A") not in schedule.days:
continue
candidate = next_at(day)
if candidate is not None:
return candidate.isoformat()
if schedule.kind == "monthly" and schedule.day_of_month is not None:
year, month = now.year, now.month
for _ in range(24):
if schedule.day_of_month <= calendar.monthrange(year, month)[1]:
day = date(
year,
month,
schedule.day_of_month,
)
candidate = next_at(day)
if candidate is not None:
return candidate.isoformat()
month += 1
if month == 13:
year += 1
month = 1
if (
schedule.kind == "interval_days"
and schedule.anchor_date
and schedule.every_days
):
anchor = date.fromisoformat(schedule.anchor_date)
if anchor > now.date():
run_day = anchor
else:
elapsed = (now.date() - anchor).days
run_day = anchor + timedelta(
days=(elapsed // schedule.every_days) * schedule.every_days
)
candidate = next_at(run_day)
if candidate is None:
candidate = next_at(run_day + timedelta(days=schedule.every_days))
if candidate is not None:
return candidate.isoformat()
return None
def _run_payload(
record: RunRecord | None,
*,
data_root: Path | None = None,
) -> dict[str, object] | None:
if record is None:
return None
payload: dict[str, object] = {
"run_id": record.run_id,
"business_date": record.business_date,
"mode": record.mode,
"shadow": record.shadow,
"status": record.status,
"started_at": record.started_at,
"ended_at": record.ended_at,
"error": _sanitize_error(record.error),
"step_counts": dict(record.step_counts),
"duration_seconds": _duration_seconds(record.started_at, record.ended_at),
}
if data_root is not None:
steps = _journal_steps(data_root, record)
payload["steps"] = steps
if record.status == "running":
live_counts = {name: 0 for name in ("success", "failed", "skipped", "running")}
for step in steps:
status = step.get("status")
if isinstance(status, str) and status in live_counts:
live_counts[status] += 1
payload["step_counts"] = live_counts
return payload
def _journal_steps(data_root: Path, record: RunRecord) -> list[dict[str, object]]:
path = (
DataLayout(data_root).run_dir(
record.workflow_id,
record.business_date,
record.run_id,
)
/ "run.json"
)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return []
raw_steps = payload.get("steps") if isinstance(payload, dict) else None
if not isinstance(raw_steps, dict):
return []
steps: list[dict[str, object]] = []
for step_id, step in raw_steps.items():
if not isinstance(step_id, str) or not isinstance(step, dict):
continue
steps.append(
{
"id": step_id,
"attempt": step.get("attempt"),
"status": step.get("status"),
"started_at": step.get("started_at"),
"ended_at": step.get("ended_at"),
"exit_code": step.get("exit_code"),
"error": _sanitize_error(step.get("error")),
}
)
return steps
def _journal_trace_payload(
record: RunRecord,
*,
data_root: Path,
) -> dict[str, object]:
path = (
DataLayout(data_root).run_dir(
record.workflow_id,
record.business_date,
record.run_id,
)
/ "run.json"
)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return {"path": None, "mode": None, "trace": None}
if not isinstance(payload, dict):
return {"path": None, "mode": None, "trace": None}
trace = payload.get("trace")
return {
"path": path.relative_to(data_root).as_posix(),
"mode": payload.get("mode"),
"trace": trace if isinstance(trace, dict) else None,
}
def _is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
def _read_log_tail(path: Path) -> str | None:
try:
size = path.stat().st_size
with path.open("rb") as stream:
if size > MAX_DIAGNOSIS_LOG_READ_BYTES:
stream.seek(-MAX_DIAGNOSIS_LOG_READ_BYTES, 2)
raw = stream.read()
except OSError:
return None
text = raw.decode("utf-8", errors="replace")
if size > MAX_DIAGNOSIS_LOG_READ_BYTES:
newline = text.find("\n")
if newline != -1:
text = text[newline + 1 :]
if not text.strip():
return None
return _sanitize_error(text)
def _console_logs_in_window(
console_log_root: Path,
record: RunRecord,
) -> list[Path]:
try:
started = datetime.fromisoformat(record.started_at)
except ValueError:
return []
ended: datetime
if record.ended_at is not None:
try:
ended = datetime.fromisoformat(record.ended_at)
except ValueError:
ended = datetime.now(timezone.utc)
else:
ended = datetime.now(timezone.utc)
matches: list[Path] = []
for path in console_log_root.glob(f"{record.workflow_id}-*.log"):
try:
mtime = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
except OSError:
continue
if mtime < started - timedelta(minutes=10):
continue
if mtime > ended + timedelta(minutes=10):
continue
matches.append(path)
matches.sort(key=lambda item: item.stat().st_mtime, reverse=True)
return matches
def _diagnosis_logs(
record: RunRecord,
journal: dict[str, object],
*,
data_root: Path,
) -> list[dict[str, object]]:
root = data_root.resolve()
candidates: list[Path] = []
trace = journal.get("trace")
paths = trace.get("paths") if isinstance(trace, dict) else None
log_rel = paths.get("log") if isinstance(paths, dict) else None
if isinstance(log_rel, str) and log_rel:
log_dir = (root / log_rel).resolve()
if _is_within(log_dir, root) and log_dir.is_dir():
candidates.extend(
sorted(
log_dir.glob("*.log"),
key=lambda item: item.stat().st_mtime,
reverse=True,
)
)
console_log_root = (root / "logs" / "console").resolve()
if console_log_root.is_dir():
candidates.extend(_console_logs_in_window(console_log_root, record))
logs: list[dict[str, object]] = []
seen: set[Path] = set()
for candidate in candidates:
resolved = candidate.resolve()
if resolved in seen or not _is_within(resolved, root):
continue
seen.add(resolved)
tail = _read_log_tail(resolved)
if tail is None:
continue
logs.append(
{
"path": resolved.relative_to(root).as_posix(),
"tail": tail,
}
)
if len(logs) >= MAX_DIAGNOSIS_LOG_FILES:
break
return logs
def _sanitize_error(value: Any) -> str | None:
if value is None:
return None
if not isinstance(value, str):
return "错误信息格式无效"
text = "".join(
character
for character in value
if character in "\n\t" or ord(character) >= 32
)
text = _SECRET_VALUE.sub(r"\1[REDACTED]", text)
text = _URL_SECRET.sub(r"\1[REDACTED]", text)
text = _URL_USERINFO.sub(r"\1[REDACTED]@", text)
text = _BEARER.sub(r"\1[REDACTED]", text)
if len(text) > MAX_ERROR_CHARS:
text = bounded_head_tail(text, MAX_ERROR_CHARS)
return text
def _duration_seconds(started_at: str, ended_at: str | None) -> int | None:
if ended_at is None:
return None
try:
started = datetime.fromisoformat(started_at)
ended = datetime.fromisoformat(ended_at)
except ValueError:
return None
return max(0, int((ended - started).total_seconds()))
def _scheduler_terminal_matches_record(
job: Mapping[str, Any] | None,
record: RunRecord,
) -> bool:
if not job or job.get("status") not in {
"success",
"failed",
"interrupted",
"launch_failed",
}:
return False
business_date = job.get("business_date")
if isinstance(business_date, str) and business_date != record.business_date:
return False
job_started_at = job.get("started_at")
if not isinstance(job_started_at, str):
return False
try:
job_started = datetime.fromisoformat(job_started_at)
record_started = datetime.fromisoformat(record.started_at)
except ValueError:
return False
return record_started.timestamp() + 2 >= job_started.timestamp()
def _read_scheduler_state(settings: Settings) -> dict[str, Any]:
state_path = settings.data_root / "state" / "scheduler" / "state.json"
if not state_path.exists():
return {}
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return state if isinstance(state, dict) else {}
def _read_scheduler_lock(settings: Settings) -> dict[str, Any] | None:
lock_path = settings.data_root / "state" / "scheduler" / "service.lock"
if not lock_path.exists():
return None
try:
payload = json.loads(lock_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
def _scheduler_lock_owner_pid(settings: Settings) -> int | None:
payload = _read_scheduler_lock(settings)
if payload is None:
return None
raw_pid = payload.get("pid")
if isinstance(raw_pid, bool):
return None
try:
pid = int(raw_pid)
except (TypeError, ValueError):
return None
if pid <= 0:
return None
try:
return pid if psutil.pid_exists(pid) else None
except (psutil.Error, OSError):
return None
def _scheduler_process_from_lock(
settings: Settings,
) -> _SchedulerProcessInfo | None:
"""Return the scheduler process only when the lock owner is verified."""
payload = _read_scheduler_lock(settings)
if payload is None:
return None
raw_pid = payload.get("pid")
if isinstance(raw_pid, bool):
return None
try:
pid = int(raw_pid)
except (TypeError, ValueError):
return None
if pid <= 0:
return None
try:
process = psutil.Process(pid)
if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
return None
command = process.cmdline()
process_started_at = process.create_time()
except (psutil.Error, OSError):
return None
expected = ("-m", "gyxx_flow", "schedule", "run")
verified = any(
tuple(command[index : index + len(expected)]) == expected
for index in range(max(0, len(command) - len(expected) + 1))
)
if not verified:
return None
started_at = payload.get("started_at")
if isinstance(started_at, str):
try:
recorded = datetime.fromisoformat(started_at)
if recorded.tzinfo is None:
recorded = recorded.replace(tzinfo=timezone.utc)
if abs(process_started_at - recorded.timestamp()) > 60:
return None
except ValueError:
started_at = None
else:
started_at = None
return _SchedulerProcessInfo(pid=pid, started_at=started_at)
def _scheduler_job_process(
workflow_id: str,
job: Mapping[str, Any],
) -> tuple[bool, psutil.Process | None, float | None]:
"""Return liveness plus a verified scheduler child process when possible."""
raw_pid = job.get("pid")
if not isinstance(raw_pid, int) or isinstance(raw_pid, bool) or raw_pid <= 0:
return False, None, None
try:
process = psutil.Process(raw_pid)
process_started_at = process.create_time()
except psutil.NoSuchProcess:
return False, None, None
except (psutil.AccessDenied, psutil.Error, OSError):
return True, None, None
recorded_started_at = job.get("started_at")
if isinstance(recorded_started_at, str):
try:
recorded = datetime.fromisoformat(recorded_started_at)
if recorded.tzinfo is None:
recorded = recorded.replace(tzinfo=timezone.utc)
delta = process_started_at - recorded.timestamp()
if delta < -_PROCESS_START_TOLERANCE_SECONDS or delta > 60:
return False, None, None
except ValueError:
return True, None, None
else:
return True, None, None
try:
command = process.cmdline()
except psutil.NoSuchProcess:
return False, None, None
except (psutil.AccessDenied, psutil.Error, OSError):
return True, None, None
expected = ("-m", "gyxx_flow", "run", workflow_id)
verified = any(
tuple(command[index : index + len(expected)]) == expected
for index in range(max(0, len(command) - len(expected) + 1))
)
if not verified:
return False, None, None
return True, process, process_started_at
def _terminate_scheduler_process_tree(
root: psutil.Process,
expected_started_at: float,
) -> None:
try:
actual_started_at = root.create_time()
except psutil.NoSuchProcess as exc:
raise ConsoleConflictError("该定时执行进程已经结束,请刷新后查看最新状态") from exc
except (psutil.Error, OSError) as exc:
raise ConsoleConflictError("无法确认定时执行进程身份,未执行停止操作") from exc
if abs(actual_started_at - expected_started_at) > _PROCESS_START_TOLERANCE_SECONDS:
raise ConsoleConflictError("定时执行进程身份已变化,未执行停止操作")
try:
descendants = root.children(recursive=True)
except (psutil.NoSuchProcess, psutil.ZombieProcess):
descendants = []
except psutil.Error as exc:
raise ConsoleConflictError("无法枚举定时执行子进程,未执行停止操作") from exc
targets = [*reversed(descendants), root]
for process in targets:
try:
process.terminate()
except (psutil.NoSuchProcess, psutil.ZombieProcess):
continue
except psutil.Error:
continue
_gone, alive = psutil.wait_procs(
targets,
timeout=PROCESS_TERMINATE_TIMEOUT_SECONDS / 2,
)
for process in alive:
try:
process.kill()
except (psutil.NoSuchProcess, psutil.ZombieProcess):
continue
except psutil.Error:
continue
_gone, survivors = psutil.wait_procs(
alive,
timeout=PROCESS_TERMINATE_TIMEOUT_SECONDS / 2,
)
if any(process.is_running() for process in survivors):
raise ConsoleConflictError("部分定时执行子进程未能停止,请检查服务器进程")
def _scheduler_run_journal_path(
settings: Settings,
workflow_id: str,
business_date: str,
pid: int,
started_at: Any,
) -> Path | None:
lock_root = settings.data_root / "state" / "locks"
for path in lock_root.glob("*.lock"):
try:
metadata = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
continue
if not isinstance(metadata, dict):
continue
if metadata.get("resource") != f"workflow:{workflow_id}":
continue
if metadata.get("pid") != pid:
continue
owner = metadata.get("owner")
if isinstance(owner, str) and _SAFE_RUN_ID.fullmatch(owner):
candidate = DataLayout(settings.data_root).run_dir(
workflow_id,
business_date,
owner,
) / "run.json"
if candidate.exists():
return candidate
year, month, day = business_date.split("-")
run_root = settings.data_root / "runs" / workflow_id / year / month / day
candidates: list[Path] = []
for path in run_root.glob("*/run.json"):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
continue
if not isinstance(payload, dict) or payload.get("status") != "running":
continue
if payload.get("workflow_id") != workflow_id:
continue
if isinstance(started_at, str) and payload.get("started_at") != started_at:
try:
expected = datetime.fromisoformat(started_at)
actual = datetime.fromisoformat(str(payload.get("started_at")))
if expected.tzinfo is None:
expected = expected.replace(tzinfo=timezone.utc)
if actual.tzinfo is None:
actual = actual.replace(tzinfo=timezone.utc)
if abs((actual - expected).total_seconds()) > 60:
continue
except ValueError:
continue
candidates.append(path)
return candidates[0] if len(candidates) == 1 else None
def _finalize_scheduler_cancelled_journal(
settings: Settings,
workflow_id: str,
business_date: str,
path: Path | None,
) -> tuple[str, str | None]:
if path is None:
try:
context = RunContext.create(
workflow_id,
business_date,
now=datetime.now(timezone.utc),
random_suffix=uuid.uuid4().hex[:16],
)
journal = RunJournal.create(
DataLayout(settings.data_root),
context,
mode="execute",
)
journal.finalize("cancelled", error=_CANCELLED_ERROR)
RunIndex(settings.data_root).index_journal(journal)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
return "unresolved", None
return "synthetic_cancelled", context.run_id
try:
payload = json.loads(path.read_text(encoding="utf-8"))
status = payload.get("status")
run_id = payload.get("run_id")
if status != "running":
return str(status), str(run_id) if isinstance(run_id, str) else None
journal = RunJournal(path=path)
steps = payload.get("steps", {})
if isinstance(steps, dict):
for step_id, step in steps.items():
if (
isinstance(step_id, str)
and isinstance(step, dict)
and step.get("status") == "running"
):
journal.finish_step(
step_id,
status="skipped",
error=_CANCELLED_ERROR,
)
journal.finalize("cancelled", error=_CANCELLED_ERROR)
RunIndex(settings.data_root).index_journal(journal)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
return "unresolved", None
return "cancelled", str(run_id) if isinstance(run_id, str) else path.parent.name
def _scheduler_snapshot(settings: Settings) -> dict[str, Any]:
jobs: dict[str, Any] = {}
updated_at = None
state = _read_scheduler_state(settings)
updated_at = state.get("updated_at")
raw_jobs = state.get("jobs")
if isinstance(raw_jobs, dict):
for workflow_id, job in raw_jobs.items():
if not isinstance(workflow_id, str) or not isinstance(job, dict):
continue
public_job = {
key: _sanitize_error(value) if key == "error" else value
for key, value in job.items()
if key
in {
"slot",
"status",
"business_date",
"started_at",
"finished_at",
"exit_code",
"error",
}
}
if job.get("status") == "running":
live, process, _process_started_at = _scheduler_job_process(
workflow_id,
job,
)
if live:
public_job["cancellable"] = process is not None
else:
public_job.update(
{
"status": "interrupted",
"finished_at": datetime.now(timezone.utc).isoformat(
timespec="seconds"
),
"error": "定时执行进程已不存在,控制台已停止显示运行中",
"cancellable": False,
}
)
jobs[workflow_id] = public_job
service_running = _scheduler_process_from_lock(settings) is not None
return {
"service_running": service_running,
"updated_at": updated_at,
"jobs": jobs,
}
class WorkflowConsoleHTTPServer(ThreadingHTTPServer):
"""Threaded HTTP server carrying immutable console security settings."""
allow_reuse_address = True
daemon_threads = True
def __init__(
self,
server_address: tuple[str, int],
service: WorkflowConsoleService,
*,
token: str | None,
bind_host: str,
) -> None:
self.console_service = service
self.console_token = token
self.bind_host = bind_host
super().__init__(server_address, WorkflowConsoleRequestHandler)
def handle_error(
self,
request: object,
client_address: tuple[str, int],
) -> None:
"""Ignore normal client disconnects while retaining real server errors."""
error = sys.exc_info()[1]
if isinstance(error, (BrokenPipeError, ConnectionResetError)):
return
super().handle_error(request, client_address)
class WorkflowConsoleRequestHandler(BaseHTTPRequestHandler):
"""Small same-origin JSON API and packaged static asset handler."""
protocol_version = "HTTP/1.1"
server_version = "GYXX-Console"
sys_version = ""
def log_message(self, _format: str, *_args: object) -> None:
return
@property
def console_server(self) -> WorkflowConsoleHTTPServer:
return cast(WorkflowConsoleHTTPServer, self.server)
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_HEAD(self) -> None: # noqa: N802
self._dispatch("HEAD")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
def do_PUT(self) -> None: # noqa: N802
self._dispatch("PUT")
def do_DELETE(self) -> None: # noqa: N802
self._dispatch("DELETE")
def do_OPTIONS(self) -> None: # noqa: N802
self._json_error(HTTPStatus.METHOD_NOT_ALLOWED, "不支持跨域请求")
def _dispatch(self, method: str) -> None:
try:
parsed = urlsplit(self.path)
path = parsed.path
if method in {"GET", "HEAD"} and path in {"/", "/index.html"}:
self._static("index.html", "text/html; charset=utf-8", head=method == "HEAD")
return
if method in {"GET", "HEAD"} and path == "/assets/app.css":
self._static("app.css", "text/css; charset=utf-8", head=method == "HEAD")
return
if method in {"GET", "HEAD"} and path == "/assets/app.js":
self._static("app.js", "text/javascript; charset=utf-8", head=method == "HEAD")
return
if method == "GET" and path == "/api/overview":
self._require_api_access(mutation=False)
self._json(HTTPStatus.OK, self.console_server.console_service.overview())
return
if method == "POST" and path == "/api/scheduler/start":
self._require_api_access(mutation=True)
payload = self._read_json()
if payload:
raise ConsoleRequestError("启动调度请求正文必须为空对象")
self._json(
HTTPStatus.ACCEPTED,
self.console_server.console_service.start_scheduler(),
)
return
if method == "GET" and path == "/api/daily-summary":
self._require_api_access(mutation=False)
query = parse_qs(parsed.query)
unknown = set(query) - {"date"}
if unknown or len(query.get("date", [])) > 1:
raise ConsoleRequestError("日汇总查询参数无效")
report_date = query.get("date", [None])[0]
self._json(
HTTPStatus.OK,
self.console_server.console_service.daily_summary(report_date),
)
return
if method == "POST" and path == "/api/daily-summary/refresh":
self._require_api_access(mutation=True)
self._json(
HTTPStatus.OK,
self.console_server.console_service.refresh_daily_summary(
self._read_json()
),
)
return
if method == "GET" and path == "/api/notifications":
self._require_api_access(mutation=False)
self._json(
HTTPStatus.OK,
self.console_server.console_service.notification_config(),
)
return
if method == "PUT" and path == "/api/notifications":
self._require_api_access(mutation=True)
payload = self.console_server.console_service.update_notification_config(
self._read_json(),
expected_revision=self.headers.get("If-Match"),
)
self._json(HTTPStatus.OK, payload)
return
if method == "POST" and path == "/api/notifications/resolve-recipient":
self._require_api_access(mutation=True)
payload = self.console_server.console_service.resolve_notification_recipient(
self._read_json()
)
self._json(HTTPStatus.OK, payload)
return
dynamic_config_match = re.fullmatch(
r"/api/dynamic-configs/([1-9][0-9]*)",
path,
)
if method == "GET" and path == "/api/dynamic-configs":
self._require_api_access(mutation=False)
query = parse_qs(parsed.query)
unknown = set(query) - {"search", "platform", "enabled"}
if unknown or any(len(values) > 1 for values in query.values()):
raise ConsoleRequestError("动态配置查询参数无效")
enabled_raw = query.get("enabled", [""])[0].strip().lower()
if enabled_raw not in {"", "true", "false"}:
raise ConsoleRequestError("enabled 查询参数无效")
enabled = None if not enabled_raw else enabled_raw == "true"
payload = self.console_server.console_service.dynamic_config_snapshot(
search=query.get("search", [""])[0],
platform=query.get("platform", [""])[0],
enabled=enabled,
)
self._json(HTTPStatus.OK, payload)
return
if method == "POST" and path == "/api/dynamic-configs":
self._require_api_access(mutation=True)
payload = self.console_server.console_service.create_dynamic_config(
self._read_json()
)
self._json(HTTPStatus.CREATED, payload)
return
if method == "PUT" and dynamic_config_match:
self._require_api_access(mutation=True)
payload = self.console_server.console_service.update_dynamic_config(
int(dynamic_config_match.group(1)),
self._read_json(),
expected_revision=self._expected_integer_revision(),
)
self._json(HTTPStatus.OK, payload)
return
if method == "DELETE" and dynamic_config_match:
self._require_api_access(mutation=True)
payload = self.console_server.console_service.delete_dynamic_config(
int(dynamic_config_match.group(1)),
expected_revision=self._expected_integer_revision(),
)
self._json(HTTPStatus.OK, payload)
return
runs_match = re.fullmatch(r"/api/workflows/([^/]+)/runs", path)
workflow_match = re.fullmatch(r"/api/workflows/([^/]+)", path)
cancel_match = re.fullmatch(
r"/api/workflows/([^/]+)/runs/([^/]+)",
path,
)
diagnosis_match = re.fullmatch(
r"/api/workflows/([^/]+)/runs/([^/]+)/diagnosis",
path,
)
scheduled_cancel_match = re.fullmatch(
r"/api/workflows/([^/]+)/scheduled-run",
path,
)
schedule_match = re.fullmatch(r"/api/workflows/([^/]+)/schedule", path)
display_name_match = re.fullmatch(
r"/api/workflows/([^/]+)/display-name",
path,
)
if method == "GET" and runs_match:
self._require_api_access(mutation=False)
workflow_id = unquote(runs_match.group(1))
query = parse_qs(parsed.query)
try:
limit = int(query.get("limit", ["12"])[0])
except ValueError as exc:
raise ConsoleRequestError("limit 参数无效") from exc
payload = self.console_server.console_service.recent_runs(
workflow_id, limit=limit
)
self._json(HTTPStatus.OK, payload)
return
if method == "GET" and diagnosis_match:
self._require_api_access(mutation=False)
workflow_id = unquote(diagnosis_match.group(1))
run_id = unquote(diagnosis_match.group(2))
payload = self.console_server.console_service.run_diagnosis(
workflow_id,
run_id,
)
self._json(HTTPStatus.OK, payload)
return
if method == "GET" and cancel_match:
self._require_api_access(mutation=False)
workflow_id = unquote(cancel_match.group(1))
run_id = unquote(cancel_match.group(2))
payload = self.console_server.console_service.run_detail(
workflow_id,
run_id,
)
self._json(HTTPStatus.OK, payload)
return
if method == "GET" and workflow_match:
self._require_api_access(mutation=False)
workflow_id = unquote(workflow_match.group(1))
payload = self.console_server.console_service.workflow_detail(
workflow_id,
)
self._json(HTTPStatus.OK, payload)
return
if method == "PUT" and schedule_match:
self._require_api_access(mutation=True)
workflow_id = unquote(schedule_match.group(1))
payload = self.console_server.console_service.update_schedule(
workflow_id,
self._read_json(),
expected_revision=self.headers.get("If-Match"),
)
self._json(HTTPStatus.OK, payload)
return
if method == "PUT" and display_name_match:
self._require_api_access(mutation=True)
workflow_id = unquote(display_name_match.group(1))
payload = self.console_server.console_service.update_display_name(
workflow_id,
self._read_json(),
expected_revision=self.headers.get("If-Match"),
)
self._json(HTTPStatus.OK, payload)
return
if method == "POST" and runs_match:
self._require_api_access(mutation=True)
workflow_id = unquote(runs_match.group(1))
payload = self.console_server.console_service.trigger(
workflow_id, self._read_json()
)
self._json(HTTPStatus.ACCEPTED, payload)
return
if method == "DELETE" and cancel_match:
self._require_api_access(mutation=True)
cancel_payload = self._read_json()
if cancel_payload:
raise ConsoleRequestError("停止请求正文必须为空对象")
workflow_id = unquote(cancel_match.group(1))
operation_id = unquote(cancel_match.group(2))
payload = self.console_server.console_service.cancel(
workflow_id,
operation_id,
)
self._json(HTTPStatus.OK, payload)
return
if method == "DELETE" and scheduled_cancel_match:
self._require_api_access(mutation=True)
cancel_payload = self._read_json()
if cancel_payload:
raise ConsoleRequestError("停止请求正文必须为空对象")
workflow_id = unquote(scheduled_cancel_match.group(1))
payload = self.console_server.console_service.cancel_scheduled(
workflow_id,
)
self._json(HTTPStatus.OK, payload)
return
if path.startswith("/api/"):
self._require_api_access(mutation=method not in {"GET", "HEAD"})
raise ConsoleNotFoundError("页面或接口不存在")
except ConsoleRequestError as exc:
self._json_error(exc.status, str(exc))
except (BrokenPipeError, ConnectionResetError):
return
except Exception:
self._json_error(HTTPStatus.INTERNAL_SERVER_ERROR, "控制台处理请求失败")
def _require_api_access(self, *, mutation: bool) -> None:
token = self.console_server.console_token
if token is not None:
authorization = self.headers.get("Authorization", "")
prefix = "Bearer "
candidate = authorization[len(prefix) :] if authorization.startswith(prefix) else ""
if not candidate or not hmac.compare_digest(candidate, token):
raise _UnauthorizedError("访问令牌无效或缺失")
elif _is_loopback_host(self.console_server.bind_host):
request_host = self.headers.get("Host", "")
if not _is_loopback_host(_hostname(request_host)):
raise _ForbiddenError("请求主机不在本机回环范围内")
if not mutation:
return
if self.headers.get("X-GYXX-Console") != "1":
raise _ForbiddenError("缺少控制台写操作标识")
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if content_type != "application/json":
raise _UnsupportedMediaTypeError("写操作只接受 application/json")
origin = self.headers.get("Origin")
host = self.headers.get("Host", "")
if origin and urlsplit(origin).netloc.lower() != host.lower():
raise _ForbiddenError("跨站写操作已被拒绝")
def _read_json(self) -> dict[str, Any]:
raw_length = self.headers.get("Content-Length")
try:
length = int(raw_length or "0")
except ValueError as exc:
raise ConsoleRequestError("请求长度无效") from exc
if length <= 0 or length > MAX_REQUEST_BYTES:
raise ConsoleRequestError("请求正文为空或过大")
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ConsoleRequestError("请求 JSON 无效") from exc
if not isinstance(payload, dict):
raise ConsoleRequestError("请求 JSON 必须是对象")
return payload
def _expected_integer_revision(self) -> int | None:
value = self.headers.get("If-Match")
if value is None:
return None
normalized = value.strip().strip('"')
if not normalized.isdigit() or int(normalized) < 1:
raise ConsoleRequestError("配置版本无效")
return int(normalized)
def _static(self, name: str, content_type: str, *, head: bool) -> None:
content = resources.files("gyxx_flow.web").joinpath(name).read_bytes()
headers = {
"Content-Type": content_type,
"Cache-Control": "no-cache" if name == "index.html" else "public, max-age=3600",
}
self._send(HTTPStatus.OK, b"" if head else content, headers, content_length=len(content))
def _json(self, status: HTTPStatus, payload: object) -> None:
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self._send(
status,
content,
{"Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store"},
)
def _json_error(self, status: HTTPStatus, message: str) -> None:
if status == HTTPStatus.UNAUTHORIZED:
self.send_response(status)
self._security_headers()
self.send_header("WWW-Authenticate", 'Bearer realm="GYXX Flow Console"')
content = json.dumps({"error": message}, ensure_ascii=False).encode("utf-8")
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
return
self._json(status, {"error": message})
def _send(
self,
status: HTTPStatus,
content: bytes,
headers: dict[str, str],
*,
content_length: int | None = None,
) -> None:
self.send_response(status)
self._security_headers()
for name, value in headers.items():
self.send_header(name, value)
self.send_header("Content-Length", str(content_length if content_length is not None else len(content)))
self.end_headers()
if content:
self.wfile.write(content)
def _security_headers(self) -> None:
self.send_header(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; "
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; "
"base-uri 'none'; form-action 'self'",
)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
class _UnauthorizedError(ConsoleRequestError):
status = HTTPStatus.UNAUTHORIZED
class _ForbiddenError(ConsoleRequestError):
status = HTTPStatus.FORBIDDEN
class _UnsupportedMediaTypeError(ConsoleRequestError):
status = HTTPStatus.UNSUPPORTED_MEDIA_TYPE
def _hostname(host_header: str) -> str:
try:
return urlsplit(f"//{host_header}").hostname or ""
except ValueError:
return ""
def _is_loopback_host(host: str) -> bool:
normalized = host.strip().strip("[]").lower()
if normalized == "localhost":
return True
try:
return ipaddress.ip_address(normalized).is_loopback
except ValueError:
return False
def create_console_server(
settings: Settings,
*,
host: str = "127.0.0.1",
port: int = 8765,
token: str | None = None,
launcher: RunLauncher | None = None,
daily_summary_analyzer: DailySummaryAnalyzer | None = None,
dynamic_configs: WorkflowConfigRepository | None = None,
scheduler_env_files: Sequence[Path] | None = None,
) -> WorkflowConsoleHTTPServer:
token = token.strip() if token else None
if not _is_loopback_host(host) and token is None:
raise ValueError(
f"non-loopback console binding requires {CONSOLE_TOKEN_ENV}"
)
if token is not None and len(token) < 24:
raise ValueError(f"{CONSOLE_TOKEN_ENV} must contain at least 24 characters")
service = WorkflowConsoleService(
settings,
launcher=launcher,
daily_summary_analyzer=daily_summary_analyzer,
dynamic_configs=dynamic_configs,
scheduler_env_files=scheduler_env_files,
)
return WorkflowConsoleHTTPServer(
(host, port), service, token=token, bind_host=host
)
def serve_console(
settings: Settings,
*,
host: str,
port: int,
output: TextIO,
env_files: Sequence[Path] | None = None,
) -> int:
if not 1 <= port <= 65535:
raise ValueError("console port must be between 1 and 65535")
token = os.environ.get(CONSOLE_TOKEN_ENV, "").strip() or None
server = create_console_server(
settings,
host=host,
port=port,
token=token,
scheduler_env_files=env_files,
)
display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
output.write(f"GYXX Flow console: http://{display_host}:{server.server_port}\n")
output.flush()
try:
server.serve_forever(poll_interval=0.5)
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
__all__ = [
"CONSOLE_TOKEN_ENV",
"ConsoleConflictError",
"ConsoleRequestError",
"ScheduleConfigStore",
"SubprocessConsoleRunLauncher",
"WorkflowConsoleService",
"create_console_server",
"serve_console",
]