01218b2907
- console: GET /api/workflows/{id}, /runs/{run_id}, /runs/{run_id}/diagnosis
(journal trace + sanitized bounded log tails, workflow/run pairing enforced)
- workbench/: dsh 宿主插件(7 个工作流工具、中文系统提示词、失败监控器、
本机回环桥接服务)+ 侧边栏面板客户端包(sidebar.footer.action 与
shell.overlay 追加插槽)+ 降级独立面板 + 一键启动脚本
- adapters/browser: 收敛 looks_like_login_url 到共享层,修复
jd_main_image_collector 对 gyxx_flow.accounts 的越层导入
- tests: 新端点覆盖;replay_policy 断言对齐已迁移的 catalog(repeatable)
3956 lines
150 KiB
Python
3956 lines
150 KiB
Python
"""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",
|
||
]
|