mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: 支持客户端多时间定时推送 (#1731)
* feat: support runtime multi-time scheduling * fix(review-feedback-1731): 修复 and add the missing workers value here, likely None to preserve the * fix: refine scheduler settings UI * fix: compact scheduler time inputs * fix(review-feedback-1731): Refresh scheduler status after saving changes * fix(review-feedback-1731): Refresh scheduler status after env imports * fix(review-feedback-1731): Do not mark swallowed analysis failures as success * fix: align serve scheduler ownership * fix: preserve runtime scheduler startup semantics * fix: ignore immediate run when cli owns schedule * fix(review-feedback-1731): main.py + api/app.py:--serve-only 下虽然 runtime schedule requested 被置为 * fix(review-feedback-1731): apps/dsa-desktop/main.js / main.py / api/app.py / src/services/runtime * fix: preserve runtime scheduler ownership semantics * fix(review-feedback-1731): 修正 Desktop 启动会自动分析/调度,以及 Web 调度开关在 runtime 状态与持久配置不一致时无法触发 reconcile 的问题 * fix: remove duplicate scheduler save diff variable * fix: allow empty schedule times config * fix: reject busy scheduler run-now requests * fix(review-feedback-1731): 确认并修正 runtime scheduler 重建时可能重复注册 AGENT EVENT MONITOR ENABLED 后台任务的问题 * fix: keep runtime event monitor idempotent * fix: preserve runtime scheduler CLI flags * fix(review-feedback-1731): preserve schedule CLI flags in runtime runs * fix(review-feedback-1731): 修正 --serve --schedule / --serve + SCHEDULE ENABLED=true 下 API * fix(review-feedback-1731): 修复当前 diff 引入的运行时并发风险和 Uvicorn 兼容风险
This commit is contained in:
@@ -711,6 +711,8 @@ BACKTEST_NEUTRAL_BAND_PCT=2.0
|
||||
SCHEDULE_ENABLED=false
|
||||
# 每日执行时间(HH:MM 格式,24小时制)
|
||||
SCHEDULE_TIME=18:00
|
||||
# 多时间执行列表(逗号分隔,留空时使用 SCHEDULE_TIME)
|
||||
SCHEDULE_TIMES=
|
||||
# 定时模式启动时是否立即执行一次分析(true/false)
|
||||
# 若未显式设置,定时模式会沿用 RUN_IMMEDIATELY 的运行时覆盖语义以兼容旧配置
|
||||
SCHEDULE_RUN_IMMEDIATELY=true
|
||||
|
||||
80
api/app.py
80
api/app.py
@@ -16,6 +16,7 @@ FastAPI 应用工厂模块
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -139,6 +140,14 @@ from api.v1.schemas.common import HealthResponse
|
||||
from src.auth import is_auth_enabled
|
||||
from src.data.stock_index_loader import find_existing_stock_index_path
|
||||
from src.services.system_config_service import SystemConfigService
|
||||
from src.services.runtime_scheduler import (
|
||||
CLI_SCHEDULER_OWNER_ENV,
|
||||
RUNTIME_SCHEDULER_ARGS_ENV,
|
||||
RUNTIME_SCHEDULER_FORCE_ENABLED_ENV,
|
||||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||||
RUNTIME_SCHEDULER_SUPPRESS_START_ENV,
|
||||
RuntimeSchedulerService,
|
||||
)
|
||||
from src.services.stock_index_remote_service import (
|
||||
get_remote_stock_index_cache_path,
|
||||
refresh_remote_stock_index_cache,
|
||||
@@ -180,10 +189,75 @@ def _schedule_stock_index_background_refresh(app: FastAPI, reason: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _load_runtime_scheduler_args() -> dict:
|
||||
raw_value = os.getenv(RUNTIME_SCHEDULER_ARGS_ENV)
|
||||
if not raw_value:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw_value)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Invalid %s payload; runtime scheduler uses default args", RUNTIME_SCHEDULER_ARGS_ENV)
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("%s payload is not an object; runtime scheduler uses default args", RUNTIME_SCHEDULER_ARGS_ENV)
|
||||
return {}
|
||||
return parsed
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(app: FastAPI):
|
||||
"""Initialize and release shared services for the app lifecycle."""
|
||||
app.state.system_config_service = SystemConfigService()
|
||||
runtime_owns_schedule = os.getenv(CLI_SCHEDULER_OWNER_ENV, "").strip().lower() not in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
runtime_force_enabled = os.getenv(RUNTIME_SCHEDULER_FORCE_ENABLED_ENV, "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
runtime_suppress_start = os.getenv(RUNTIME_SCHEDULER_SUPPRESS_START_ENV, "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
runtime_run_immediately_override = os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV)
|
||||
if runtime_suppress_start or not runtime_owns_schedule:
|
||||
runtime_run_immediately = False
|
||||
elif runtime_run_immediately_override is None:
|
||||
from src.config import get_config
|
||||
|
||||
runtime_run_immediately = bool(getattr(get_config(), "schedule_run_immediately", False))
|
||||
else:
|
||||
runtime_run_immediately = runtime_run_immediately_override.strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
runtime_scheduler_args = _load_runtime_scheduler_args()
|
||||
os.environ.pop(RUNTIME_SCHEDULER_FORCE_ENABLED_ENV, None)
|
||||
os.environ.pop(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV, None)
|
||||
os.environ.pop(RUNTIME_SCHEDULER_SUPPRESS_START_ENV, None)
|
||||
os.environ.pop(RUNTIME_SCHEDULER_ARGS_ENV, None)
|
||||
runtime_scheduler_service = RuntimeSchedulerService(
|
||||
owns_schedule=runtime_owns_schedule,
|
||||
force_enabled=runtime_force_enabled,
|
||||
run_immediately_in_background=True,
|
||||
schedule_args_overrides=runtime_scheduler_args,
|
||||
)
|
||||
app.state.runtime_scheduler_service = runtime_scheduler_service
|
||||
if not runtime_suppress_start:
|
||||
app.state.runtime_scheduler_service.reconcile_from_config(
|
||||
run_immediately=runtime_run_immediately,
|
||||
)
|
||||
app.state.system_config_service = SystemConfigService(
|
||||
runtime_scheduler=app.state.runtime_scheduler_service,
|
||||
)
|
||||
_schedule_stock_index_background_refresh(app, "startup")
|
||||
try:
|
||||
yield
|
||||
@@ -195,6 +269,10 @@ async def app_lifespan(app: FastAPI):
|
||||
await refresh_task
|
||||
if hasattr(app.state, "system_config_service"):
|
||||
delattr(app.state, "system_config_service")
|
||||
runtime_scheduler = getattr(app.state, "runtime_scheduler_service", None)
|
||||
if runtime_scheduler is not None:
|
||||
runtime_scheduler.stop()
|
||||
delattr(app.state, "runtime_scheduler_service")
|
||||
|
||||
|
||||
def create_app(static_dir: Optional[Path] = None) -> FastAPI:
|
||||
|
||||
10
api/deps.py
10
api/deps.py
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
||||
from src.storage import DatabaseManager
|
||||
from src.config import get_config, Config
|
||||
from src.services.system_config_service import SystemConfigService
|
||||
from src.services.runtime_scheduler import RuntimeSchedulerService
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
@@ -69,3 +70,12 @@ def get_system_config_service(request: Request) -> SystemConfigService:
|
||||
service = SystemConfigService()
|
||||
request.app.state.system_config_service = service
|
||||
return service
|
||||
|
||||
|
||||
def get_runtime_scheduler_service(request: Request) -> RuntimeSchedulerService:
|
||||
"""Get app-lifecycle shared RuntimeSchedulerService instance."""
|
||||
service = getattr(request.app.state, "runtime_scheduler_service", None)
|
||||
if service is None:
|
||||
service = RuntimeSchedulerService()
|
||||
request.app.state.runtime_scheduler_service = service
|
||||
return service
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from api.deps import get_system_config_service
|
||||
from api.deps import get_runtime_scheduler_service, get_system_config_service
|
||||
from api.v1.schemas.common import ErrorResponse
|
||||
from api.v1.schemas.system_config import (
|
||||
DiscoverLLMChannelModelsRequest,
|
||||
@@ -35,12 +35,47 @@ from src.services.system_config_service import (
|
||||
ConfigValidationError,
|
||||
SystemConfigService,
|
||||
)
|
||||
from src.services.runtime_scheduler import RuntimeSchedulerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/scheduler/status",
|
||||
summary="Get runtime scheduler status",
|
||||
description="Return status for the in-process Web/API/Desktop scheduler.",
|
||||
)
|
||||
def get_scheduler_status(
|
||||
scheduler: RuntimeSchedulerService = Depends(get_runtime_scheduler_service),
|
||||
) -> dict:
|
||||
"""Return runtime scheduler status."""
|
||||
return scheduler.status()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scheduler/run-now",
|
||||
summary="Run scheduled analysis now",
|
||||
description="Trigger one scheduled analysis run in the current process.",
|
||||
)
|
||||
def run_scheduler_now(
|
||||
scheduler: RuntimeSchedulerService = Depends(get_runtime_scheduler_service),
|
||||
) -> dict:
|
||||
"""Trigger one runtime scheduled analysis run."""
|
||||
result = scheduler.run_now()
|
||||
if not result.get("accepted", False):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "scheduler_busy",
|
||||
"message": "A scheduled analysis is already running",
|
||||
"reason": result.get("reason", "analysis_already_running"),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class EnvBackupAccessDenied(Exception):
|
||||
"""Raised when raw `.env` backup access is not allowed for this request."""
|
||||
|
||||
|
||||
@@ -928,7 +928,6 @@ function startBackend({ port, envFile, dbPath, logDir }) {
|
||||
LOG_DIR: logDir,
|
||||
PYTHONUTF8: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
SCHEDULE_ENABLED: 'false',
|
||||
WEBUI_ENABLED: 'false',
|
||||
BOT_ENABLED: 'false',
|
||||
DINGTALK_STREAM_ENABLED: 'false',
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
DiscoverLLMChannelModelsResponse,
|
||||
ExportSystemConfigResponse,
|
||||
ImportSystemConfigRequest,
|
||||
SchedulerRunNowResponse,
|
||||
SchedulerStatusResponse,
|
||||
SetupStatusResponse,
|
||||
SystemConfigConflictResponse,
|
||||
SystemConfigResponse,
|
||||
@@ -154,6 +156,16 @@ export const systemConfigApi = {
|
||||
return toCamelCase<SetupStatusResponse>(response.data);
|
||||
},
|
||||
|
||||
async getSchedulerStatus(): Promise<SchedulerStatusResponse> {
|
||||
const response = await apiClient.get<Record<string, unknown>>('/api/v1/system/scheduler/status');
|
||||
return toCamelCase<SchedulerStatusResponse>(response.data);
|
||||
},
|
||||
|
||||
async runSchedulerNow(): Promise<SchedulerRunNowResponse> {
|
||||
const response = await apiClient.post<Record<string, unknown>>('/api/v1/system/scheduler/run-now');
|
||||
return toCamelCase<SchedulerRunNowResponse>(response.data);
|
||||
},
|
||||
|
||||
async validate(payload: ValidateSystemConfigRequest): Promise<ValidateSystemConfigResponse> {
|
||||
const response = await apiClient.post<Record<string, unknown>>(
|
||||
'/api/v1/system/config/validate',
|
||||
|
||||
@@ -288,8 +288,16 @@ export function useSystemConfig() {
|
||||
});
|
||||
}, [dirtyKeys, draftValues, serverItemByKey]);
|
||||
|
||||
const save = useCallback(async (): Promise<SaveResult> => {
|
||||
if (!hasDirty) {
|
||||
const save = useCallback(async (changedItems?: SystemConfigUpdateItem[]): Promise<SaveResult> => {
|
||||
const explicitItems = changedItems ?? [];
|
||||
const resolvedChangedItems = explicitItems.length > 0 ? explicitItems : getChangedItems();
|
||||
|
||||
if (!explicitItems.length && !hasDirty) {
|
||||
setToast({ type: 'success', message: '当前没有可保存的修改。' });
|
||||
return { success: true, message: '当前没有可保存的修改' };
|
||||
}
|
||||
|
||||
if (!resolvedChangedItems.length) {
|
||||
setToast({ type: 'success', message: '当前没有可保存的修改。' });
|
||||
return { success: true, message: '当前没有可保存的修改' };
|
||||
}
|
||||
@@ -298,10 +306,8 @@ export function useSystemConfig() {
|
||||
setSaveError(null);
|
||||
setRetryAction(null);
|
||||
|
||||
const changedItems = getChangedItems();
|
||||
|
||||
try {
|
||||
const validateResult = await systemConfigApi.validate({ items: changedItems });
|
||||
const validateResult = await systemConfigApi.validate({ items: resolvedChangedItems });
|
||||
setValidationIssues(validateResult.issues || []);
|
||||
|
||||
if (!validateResult.valid) {
|
||||
@@ -323,7 +329,7 @@ export function useSystemConfig() {
|
||||
configVersion,
|
||||
maskToken,
|
||||
reloadNow: true,
|
||||
items: changedItems,
|
||||
items: resolvedChangedItems,
|
||||
});
|
||||
|
||||
const refreshed = await systemConfigApi.getConfig(true);
|
||||
|
||||
@@ -578,6 +578,27 @@ const zh = {
|
||||
'settings.disablingAlphaSift': '关闭中...',
|
||||
'settings.enabledAlphaSiftSuccess': '已开启 AlphaSift 选股。',
|
||||
'settings.disabledAlphaSiftSuccess': '已关闭 AlphaSift 选股。',
|
||||
'settings.schedulerAddTime': '添加时间',
|
||||
'settings.schedulerDescription': '配置自动分析的每日执行时间,保存后长运行的 Web/API/Desktop 进程会按新配置生效。',
|
||||
'settings.schedulerDisabled': '未启用',
|
||||
'settings.schedulerEffectiveTimes': '生效时间',
|
||||
'settings.schedulerEnable': '启用定时任务',
|
||||
'settings.schedulerEnabled': '已启用',
|
||||
'settings.schedulerEnableDescription': '开启后会按下方时间自动执行分析任务。',
|
||||
'settings.schedulerLastError': '最近错误',
|
||||
'settings.schedulerLastSuccess': '上次成功',
|
||||
'settings.schedulerNextRun': '下次执行',
|
||||
'settings.schedulerRefresh': '刷新状态',
|
||||
'settings.schedulerRefreshing': '刷新中...',
|
||||
'settings.schedulerRemoveTime': '删除时间',
|
||||
'settings.schedulerRunAccepted': '已提交执行请求。',
|
||||
'settings.schedulerRunNow': '立即执行一次',
|
||||
'settings.schedulerRunning': '运行中',
|
||||
'settings.schedulerRunningNow': '执行中...',
|
||||
'settings.schedulerStatus': '当前状态',
|
||||
'settings.schedulerTimeInputAria': '定时执行时间 {index}',
|
||||
'settings.schedulerTimes': '定时执行时间',
|
||||
'settings.schedulerTitle': '定时任务',
|
||||
'settings.envExported': '已导出当前已保存的 .env 备份。',
|
||||
'settings.envImported': '已导入 .env 备份并重新加载配置。',
|
||||
'settings.envImportedRefreshFailedMessage': '备份已导入,但重新加载配置失败,请手动重载页面。',
|
||||
@@ -1245,6 +1266,27 @@ const en: Record<UiTextKey, string> = {
|
||||
'settings.disablingAlphaSift': 'Disabling...',
|
||||
'settings.enabledAlphaSiftSuccess': 'AlphaSift screening enabled.',
|
||||
'settings.disabledAlphaSiftSuccess': 'AlphaSift screening disabled.',
|
||||
'settings.schedulerAddTime': 'Add time',
|
||||
'settings.schedulerDescription': 'Configure the daily automatic analysis times. After saving, long-running Web/API/Desktop processes apply the new schedule.',
|
||||
'settings.schedulerDisabled': 'Disabled',
|
||||
'settings.schedulerEffectiveTimes': 'Active times',
|
||||
'settings.schedulerEnable': 'Enable scheduled task',
|
||||
'settings.schedulerEnabled': 'Enabled',
|
||||
'settings.schedulerEnableDescription': 'When enabled, analysis runs automatically at the times below.',
|
||||
'settings.schedulerLastError': 'Last error',
|
||||
'settings.schedulerLastSuccess': 'Last success',
|
||||
'settings.schedulerNextRun': 'Next run',
|
||||
'settings.schedulerRefresh': 'Refresh status',
|
||||
'settings.schedulerRefreshing': 'Refreshing...',
|
||||
'settings.schedulerRemoveTime': 'Remove time',
|
||||
'settings.schedulerRunAccepted': 'Run request submitted.',
|
||||
'settings.schedulerRunNow': 'Run once now',
|
||||
'settings.schedulerRunning': 'Running',
|
||||
'settings.schedulerRunningNow': 'Running...',
|
||||
'settings.schedulerStatus': 'Current status',
|
||||
'settings.schedulerTimeInputAria': 'Scheduled time {index}',
|
||||
'settings.schedulerTimes': 'Scheduled times',
|
||||
'settings.schedulerTitle': 'Scheduled task',
|
||||
'settings.envExported': 'Saved .env backup exported.',
|
||||
'settings.envImported': '.env backup imported and configuration reloaded.',
|
||||
'settings.envImportedRefreshFailedMessage': 'The backup was imported, but configuration reload failed. Reload the page manually.',
|
||||
|
||||
@@ -530,16 +530,16 @@ const settingsHelpZhCN: SettingsHelpMap = {
|
||||
'settings.system.schedule': {
|
||||
title: '定时任务',
|
||||
summary: '控制是否启用每日定时分析以及启动时是否立即执行一次。',
|
||||
usage: 'SCHEDULE_TIME 使用 HH:MM 24 小时格式;SCHEDULE_ENABLED 和 SCHEDULE_RUN_IMMEDIATELY 控制定时模式启动行为。',
|
||||
usage: 'SCHEDULE_TIME 使用 HH:MM 24 小时格式;SCHEDULE_TIMES 可配置逗号分隔的多个 HH:MM 时间点;SCHEDULE_ENABLED 控制 runtime scheduler 是否启用。',
|
||||
valueNotes: [
|
||||
'已运行的 schedule 模式会在下一轮调度检查中读取新的 SCHEDULE_TIME 并重建 daily job。',
|
||||
'SCHEDULE_ENABLED 和 SCHEDULE_RUN_IMMEDIATELY 属于启动期行为,保存后不会启动、停止或重建当前 scheduler。',
|
||||
'已运行的 schedule 模式会在下一轮调度检查中读取新的 SCHEDULE_TIME / SCHEDULE_TIMES 并重建 daily jobs。',
|
||||
'WebUI/API/Desktop 长运行进程保存 SCHEDULE_ENABLED、SCHEDULE_TIME 或 SCHEDULE_TIMES 后会按新配置启停或重建 runtime scheduler。',
|
||||
'定时任务触发时会读取当前保存的 STOCK_LIST。',
|
||||
],
|
||||
impact: ['影响 schedule 模式下自动分析频率、启动行为和通知推送时间。'],
|
||||
notes: [
|
||||
'注意运行环境时区,容器和服务器时区可能与本地不同。',
|
||||
'若当前进程未以 schedule 模式启动,保存这些字段不会自动创建调度器。',
|
||||
'SCHEDULE_RUN_IMMEDIATELY 仍是启动期行为;保存后不会立即触发一次分析。',
|
||||
],
|
||||
},
|
||||
'settings.system.RUN_IMMEDIATELY': {
|
||||
@@ -1515,16 +1515,16 @@ const settingsHelpEnUS: SettingsHelpMap = {
|
||||
'settings.system.schedule': {
|
||||
title: 'Schedule',
|
||||
summary: 'Controls daily scheduled analysis and whether startup runs immediately.',
|
||||
usage: 'SCHEDULE_TIME uses HH:MM 24-hour format. SCHEDULE_ENABLED and SCHEDULE_RUN_IMMEDIATELY control schedule-mode startup behavior.',
|
||||
usage: 'SCHEDULE_TIME uses HH:MM 24-hour format. SCHEDULE_TIMES accepts comma-separated HH:MM values. SCHEDULE_ENABLED controls whether the runtime scheduler is enabled.',
|
||||
valueNotes: [
|
||||
'An already-running schedule mode reads a new SCHEDULE_TIME on the next scheduler check and rebuilds the daily job.',
|
||||
'SCHEDULE_ENABLED and SCHEDULE_RUN_IMMEDIATELY are startup-time settings; saving them does not start, stop, or rebuild the current scheduler.',
|
||||
'An already-running schedule mode reads new SCHEDULE_TIME / SCHEDULE_TIMES values on the next scheduler check and rebuilds the daily jobs.',
|
||||
'Long-running WebUI/API/Desktop processes start, stop, or rebuild the runtime scheduler after saving SCHEDULE_ENABLED, SCHEDULE_TIME, or SCHEDULE_TIMES.',
|
||||
'Scheduled runs read the currently saved STOCK_LIST.',
|
||||
],
|
||||
impact: ['Affects automatic analysis frequency, startup behavior, and notification timing in schedule mode.'],
|
||||
notes: [
|
||||
'Check the runtime timezone, especially in containers and servers.',
|
||||
'If the current process was not started in schedule mode, saving these fields will not create a scheduler.',
|
||||
'SCHEDULE_RUN_IMMEDIATELY remains a startup-time setting; saving it does not trigger an immediate analysis run.',
|
||||
],
|
||||
},
|
||||
'settings.system.RUN_IMMEDIATELY': {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Clock, Play, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useAuth, useSystemConfig } from '../hooks';
|
||||
import { useUiLanguage } from '../contexts/UiLanguageContext';
|
||||
import { createParsedApiError, getParsedApiError, type ParsedApiError } from '../api/error';
|
||||
@@ -21,8 +22,14 @@ import {
|
||||
} from '../components/settings';
|
||||
import { WEB_BUILD_INFO } from '../utils/constants';
|
||||
import { getCategoryDescription } from '../utils/systemConfigI18n';
|
||||
import type { SystemConfigCategory } from '../types/systemConfig';
|
||||
import type { UiTextKey } from '../i18n/uiText';
|
||||
import type {
|
||||
ConfigValidationIssue,
|
||||
SchedulerStatusResponse,
|
||||
SystemConfigCategory,
|
||||
SystemConfigItem,
|
||||
SystemConfigUpdateItem,
|
||||
} from '../types/systemConfig';
|
||||
import type { UiLanguage, UiTextKey } from '../i18n/uiText';
|
||||
|
||||
type DesktopWindow = Window & {
|
||||
dsaDesktop?: {
|
||||
@@ -209,6 +216,350 @@ function formatEnvBackupFilename(isDesktopRuntime: boolean) {
|
||||
return `${isDesktopRuntime ? 'dsa-desktop-env' : 'dsa-env'}_${date}_${time}.env`;
|
||||
}
|
||||
|
||||
const SCHEDULE_TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
||||
const SCHEDULER_DEFAULT_TIME = '18:00';
|
||||
const SCHEDULER_SETTING_KEYS = new Set([
|
||||
'SCHEDULE_ENABLED',
|
||||
'SCHEDULE_TIME',
|
||||
'SCHEDULE_TIMES',
|
||||
'SCHEDULE_RUN_IMMEDIATELY',
|
||||
]);
|
||||
|
||||
function getConfigItem(items: SystemConfigItem[], key: string) {
|
||||
return items.find((item) => item.key === key);
|
||||
}
|
||||
|
||||
function isEnabledConfigValue(value: unknown) {
|
||||
return String(value ?? '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function parseScheduleTimes(scheduleTimesValue?: string, fallbackValue?: string) {
|
||||
const values = String(scheduleTimesValue ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (values.length > 0) {
|
||||
return values;
|
||||
}
|
||||
|
||||
const fallback = String(fallbackValue ?? '').trim();
|
||||
return fallback ? [fallback] : [SCHEDULER_DEFAULT_TIME];
|
||||
}
|
||||
|
||||
function serializeScheduleTimes(times: string[]) {
|
||||
return times.map((time) => time.trim()).filter(Boolean).join(',');
|
||||
}
|
||||
|
||||
function formatSchedulerTimestamp(value: string | null | undefined, language: UiLanguage) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(language === 'en' ? 'en-US' : 'zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
type SchedulerSettingsCardProps = {
|
||||
items: SystemConfigItem[];
|
||||
disabled: boolean;
|
||||
issueByKey: Record<string, ConfigValidationIssue[]>;
|
||||
statusRefreshToken: number;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onSchedulerStateChange?: (payload: {
|
||||
runtimeEnabled: boolean | null;
|
||||
overrideEnabled: boolean | null;
|
||||
}) => void;
|
||||
t: (key: UiTextKey, params?: Record<string, string | number>) => string;
|
||||
language: UiLanguage;
|
||||
};
|
||||
|
||||
const SchedulerSettingsCard: React.FC<SchedulerSettingsCardProps> = ({
|
||||
items,
|
||||
disabled,
|
||||
issueByKey,
|
||||
statusRefreshToken,
|
||||
onChange,
|
||||
onSchedulerStateChange,
|
||||
t,
|
||||
language,
|
||||
}) => {
|
||||
const scheduleEnabledItem = getConfigItem(items, 'SCHEDULE_ENABLED');
|
||||
const scheduleTimesItem = getConfigItem(items, 'SCHEDULE_TIMES');
|
||||
const scheduleTimeItem = getConfigItem(items, 'SCHEDULE_TIME');
|
||||
const hasSchedulerSettings = Boolean(scheduleEnabledItem || scheduleTimesItem || scheduleTimeItem);
|
||||
const [status, setStatus] = useState<SchedulerStatusResponse | null>(null);
|
||||
const [isRefreshingStatus, setIsRefreshingStatus] = useState(false);
|
||||
const [isRunningNow, setIsRunningNow] = useState(false);
|
||||
const [statusError, setStatusError] = useState<ParsedApiError | null>(null);
|
||||
const [runNowError, setRunNowError] = useState<ParsedApiError | null>(null);
|
||||
const [runNowSuccess, setRunNowSuccess] = useState('');
|
||||
const [scheduleEnabledOverride, setScheduleEnabledOverride] = useState<boolean | null>(null);
|
||||
|
||||
const refreshSchedulerStatus = useCallback(async () => {
|
||||
setStatusError(null);
|
||||
setIsRefreshingStatus(true);
|
||||
try {
|
||||
const payload = await systemConfigApi.getSchedulerStatus();
|
||||
setStatus(payload);
|
||||
} catch (error: unknown) {
|
||||
setStatusError(getParsedApiError(error));
|
||||
} finally {
|
||||
setIsRefreshingStatus(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSchedulerSettings) {
|
||||
return;
|
||||
}
|
||||
void refreshSchedulerStatus();
|
||||
}, [hasSchedulerSettings, refreshSchedulerStatus, statusRefreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const isRuntimeDerived = isEnabledConfigValue(scheduleEnabledItem?.value) === status?.enabled;
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleEnabledOverride === null && isRuntimeDerived) {
|
||||
setScheduleEnabledOverride(null);
|
||||
}
|
||||
}, [scheduleEnabledItem?.value, scheduleEnabledOverride, statusRefreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSchedulerStateChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeEnabled = status?.enabled ?? null;
|
||||
onSchedulerStateChange({
|
||||
runtimeEnabled,
|
||||
overrideEnabled: scheduleEnabledOverride,
|
||||
});
|
||||
}, [onSchedulerStateChange, status?.enabled, scheduleEnabledOverride]);
|
||||
|
||||
if (!hasSchedulerSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleEnabled = isEnabledConfigValue(scheduleEnabledItem?.value);
|
||||
const scheduleTimes = parseScheduleTimes(
|
||||
String(scheduleTimesItem?.value ?? ''),
|
||||
String(scheduleTimeItem?.value ?? ''),
|
||||
);
|
||||
const timeTargetKey = scheduleTimesItem ? 'SCHEDULE_TIMES' : 'SCHEDULE_TIME';
|
||||
const statusEnabled = status?.enabled ?? scheduleEnabled;
|
||||
const displayedScheduleEnabled = scheduleEnabledOverride ?? statusEnabled;
|
||||
const effectiveStatusTimes = status?.scheduleTimes?.length ? status.scheduleTimes : scheduleTimes.filter(Boolean);
|
||||
const validationIssues = [
|
||||
...(issueByKey.SCHEDULE_ENABLED || []),
|
||||
...(issueByKey.SCHEDULE_TIMES || []),
|
||||
...(issueByKey.SCHEDULE_TIME || []),
|
||||
];
|
||||
|
||||
const updateScheduleTimes = (nextTimes: string[]) => {
|
||||
if (timeTargetKey === 'SCHEDULE_TIME') {
|
||||
onChange(timeTargetKey, nextTimes[0] || '');
|
||||
return;
|
||||
}
|
||||
onChange(timeTargetKey, serializeScheduleTimes(nextTimes));
|
||||
};
|
||||
|
||||
const runSchedulerNow = async () => {
|
||||
setRunNowError(null);
|
||||
setRunNowSuccess('');
|
||||
setIsRunningNow(true);
|
||||
try {
|
||||
await systemConfigApi.runSchedulerNow();
|
||||
setRunNowSuccess(t('settings.schedulerRunAccepted'));
|
||||
await refreshSchedulerStatus();
|
||||
} catch (error: unknown) {
|
||||
setRunNowError(getParsedApiError(error));
|
||||
} finally {
|
||||
setIsRunningNow(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSectionCard
|
||||
title={t('settings.schedulerTitle')}
|
||||
description={t('settings.schedulerDescription')}
|
||||
>
|
||||
<div data-testid="scheduler-settings-card" className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(300px,360px)]">
|
||||
<div className="space-y-4 rounded-2xl border settings-border bg-background/35 px-4 py-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-border text-cyan focus:ring-cyan/20"
|
||||
checked={displayedScheduleEnabled}
|
||||
data-testid="scheduler-enabled-checkbox"
|
||||
disabled={disabled || !scheduleEnabledItem?.schema?.isEditable}
|
||||
onChange={(event) => {
|
||||
const nextEnabled = Boolean(event.target.checked);
|
||||
setScheduleEnabledOverride(nextEnabled);
|
||||
onChange('SCHEDULE_ENABLED', nextEnabled ? 'true' : 'false');
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-foreground">{t('settings.schedulerEnable')}</span>
|
||||
<span className="block text-xs leading-6 text-muted-text">{t('settings.schedulerEnableDescription')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Clock className="h-4 w-4" aria-hidden="true" />
|
||||
{t('settings.schedulerTimes')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{scheduleTimes.map((time, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex h-11 shrink-0 items-center gap-1 rounded-xl border settings-border bg-card/90 p-1 shadow-inner"
|
||||
>
|
||||
<input
|
||||
data-testid={`scheduler-time-input-${index}`}
|
||||
type="time"
|
||||
value={SCHEDULE_TIME_PATTERN.test(time) ? time : ''}
|
||||
aria-label={t('settings.schedulerTimeInputAria', { index: index + 1 })}
|
||||
className="h-9 w-[8.75rem] rounded-lg border-none bg-transparent px-2 text-sm font-medium text-foreground outline-none transition focus:bg-background/60 focus:ring-2 focus:ring-cyan/20"
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const nextTimes = scheduleTimes.map((currentTime, currentIndex) => (
|
||||
currentIndex === index ? event.target.value : currentTime
|
||||
));
|
||||
updateScheduleTimes(nextTimes);
|
||||
}}
|
||||
/>
|
||||
{scheduleTimes.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="settings-secondary"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-lg px-0"
|
||||
aria-label={t('settings.schedulerRemoveTime')}
|
||||
title={t('settings.schedulerRemoveTime')}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
updateScheduleTimes(scheduleTimes.filter((_, currentIndex) => currentIndex !== index));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="settings-secondary"
|
||||
size="sm"
|
||||
className="h-11 shrink-0"
|
||||
data-testid="scheduler-add-time-button"
|
||||
disabled={disabled}
|
||||
onClick={() => updateScheduleTimes([...scheduleTimes, SCHEDULER_DEFAULT_TIME])}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
{t('settings.schedulerAddTime')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border settings-border bg-background/35 px-4 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">{t('settings.schedulerStatus')}</p>
|
||||
<p className="mt-1 text-xs leading-6 text-muted-text">
|
||||
{status?.running
|
||||
? t('settings.schedulerRunning')
|
||||
: statusEnabled
|
||||
? t('settings.schedulerEnabled')
|
||||
: t('settings.schedulerDisabled')}
|
||||
</p>
|
||||
</div>
|
||||
<dl className="grid grid-cols-1 gap-2 text-xs">
|
||||
<div className="rounded-xl border settings-border bg-card/60 px-3 py-2">
|
||||
<dt className="text-muted-text">{t('settings.schedulerEffectiveTimes')}</dt>
|
||||
<dd className="mt-1 font-medium text-foreground">{effectiveStatusTimes.join(', ') || '-'}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border settings-border bg-card/60 px-3 py-2">
|
||||
<dt className="text-muted-text">{t('settings.schedulerNextRun')}</dt>
|
||||
<dd className="mt-1 font-medium text-foreground">
|
||||
{formatSchedulerTimestamp(status?.nextRunAt, language)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border settings-border bg-card/60 px-3 py-2">
|
||||
<dt className="text-muted-text">{t('settings.schedulerLastSuccess')}</dt>
|
||||
<dd data-testid="scheduler-last-success" className="mt-1 font-medium text-foreground">
|
||||
{formatSchedulerTimestamp(status?.lastSuccessAt, language)}
|
||||
</dd>
|
||||
</div>
|
||||
{status?.lastError ? (
|
||||
<div className="rounded-xl border border-danger/40 bg-danger/10 px-3 py-2">
|
||||
<dt className="text-danger">{t('settings.schedulerLastError')}</dt>
|
||||
<dd data-testid="scheduler-last-error" className="mt-1 break-words text-danger">{status.lastError}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="settings-secondary"
|
||||
size="sm"
|
||||
data-testid="scheduler-refresh-status-button"
|
||||
disabled={disabled || isRefreshingStatus}
|
||||
isLoading={isRefreshingStatus}
|
||||
loadingText={t('settings.schedulerRefreshing')}
|
||||
onClick={() => void refreshSchedulerStatus()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
{t('settings.schedulerRefresh')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="settings-primary"
|
||||
size="sm"
|
||||
data-testid="scheduler-run-now-button"
|
||||
disabled={disabled || isRunningNow}
|
||||
isLoading={isRunningNow}
|
||||
loadingText={t('settings.schedulerRunningNow')}
|
||||
onClick={() => void runSchedulerNow()}
|
||||
>
|
||||
<Play className="h-4 w-4" aria-hidden="true" />
|
||||
{t('settings.schedulerRunNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationIssues.length ? (
|
||||
<div className="space-y-1 text-xs text-danger">
|
||||
{validationIssues.map((issue) => (
|
||||
<p key={`${issue.key}-${issue.code}`}>{issue.message}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{statusError ? <ApiErrorAlert error={statusError} /> : null}
|
||||
{runNowError ? <ApiErrorAlert error={runNowError} /> : null}
|
||||
{!runNowError && runNowSuccess ? (
|
||||
<SettingsAlert title={t('settings.actionSuccess')} message={runNowSuccess} variant="success" />
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { authEnabled, passwordChangeable } = useAuth();
|
||||
const { language: uiLanguage, t } = useUiLanguage();
|
||||
@@ -222,6 +573,9 @@ const SettingsPage: React.FC = () => {
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
|
||||
const [isCheckingDesktopUpdate, setIsCheckingDesktopUpdate] = useState(false);
|
||||
const [schedulerStatusRefreshToken, setSchedulerStatusRefreshToken] = useState(0);
|
||||
const [schedulerRuntimeEnabled, setSchedulerRuntimeEnabled] = useState<boolean | null>(null);
|
||||
const [schedulerOverrideFromUi, setSchedulerOverrideFromUi] = useState<boolean | null>(null);
|
||||
const envBackupImportRef = useRef<HTMLInputElement | null>(null);
|
||||
const desktopRuntimeApi = getDesktopRuntimeApi();
|
||||
const isDesktopRuntime = Boolean(desktopRuntimeApi);
|
||||
@@ -262,6 +616,8 @@ const SettingsPage: React.FC = () => {
|
||||
maskToken,
|
||||
} = useSystemConfig();
|
||||
|
||||
const currentChangedItems = getChangedItems();
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -330,6 +686,22 @@ const SettingsPage: React.FC = () => {
|
||||
const alphasiftEnabled = String(alphasiftItem?.value ?? '').trim().toLowerCase() === 'true';
|
||||
const hasConfiguredChannels = Boolean((rawActiveItemMap.get('LLM_CHANNELS') || '').trim());
|
||||
const hasLitellmConfig = Boolean((rawActiveItemMap.get('LITELLM_CONFIG') || '').trim());
|
||||
const hasRuntimeSchedulerMismatch =
|
||||
schedulerRuntimeEnabled !== null
|
||||
&& schedulerOverrideFromUi !== null
|
||||
&& schedulerOverrideFromUi !== schedulerRuntimeEnabled;
|
||||
const hasRuntimeSchedulerMismatchInDraft = hasRuntimeSchedulerMismatch
|
||||
&& !currentChangedItems.some((item) => item.key === 'SCHEDULE_ENABLED');
|
||||
const effectiveHasDirty = hasDirty || hasRuntimeSchedulerMismatchInDraft;
|
||||
const effectiveDirtyCount = dirtyCount + (hasRuntimeSchedulerMismatchInDraft ? 1 : 0);
|
||||
|
||||
const handleSchedulerRuntimeStateChange = useCallback(({ runtimeEnabled, overrideEnabled }: {
|
||||
runtimeEnabled: boolean | null;
|
||||
overrideEnabled: boolean | null;
|
||||
}) => {
|
||||
setSchedulerRuntimeEnabled(runtimeEnabled);
|
||||
setSchedulerOverrideFromUi(overrideEnabled);
|
||||
}, []);
|
||||
|
||||
// UI rendering rule only: hide channel-managed and legacy provider-specific
|
||||
// LLM keys from generic fields when channel mode is active. This does not
|
||||
@@ -364,6 +736,7 @@ const SettingsPage: React.FC = () => {
|
||||
]);
|
||||
const SYSTEM_HIDDEN_KEYS = new Set([
|
||||
'ADMIN_AUTH_ENABLED',
|
||||
...SCHEDULER_SETTING_KEYS,
|
||||
]);
|
||||
const DATA_SOURCE_HIDDEN_KEYS = new Set([
|
||||
'ALPHASIFT_ENABLED',
|
||||
@@ -436,7 +809,7 @@ const SettingsPage: React.FC = () => {
|
||||
setIsImportingEnv(true);
|
||||
try {
|
||||
const content = await file.text();
|
||||
await systemConfigApi.importEnv({
|
||||
const importResult = await systemConfigApi.importEnv({
|
||||
configVersion,
|
||||
content,
|
||||
reloadNow: true,
|
||||
@@ -451,6 +824,9 @@ const SettingsPage: React.FC = () => {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (importResult.updatedKeys.some((key) => SCHEDULER_SETTING_KEYS.has(key))) {
|
||||
setSchedulerStatusRefreshToken((current) => current + 1);
|
||||
}
|
||||
notifySystemConfigChanged();
|
||||
setEnvBackupActionSuccess(t('settings.envImported'));
|
||||
} catch (error: unknown) {
|
||||
@@ -516,12 +892,25 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
const changedItems = getChangedItems();
|
||||
const syncRuntimeSchedulerState =
|
||||
schedulerOverrideFromUi !== null
|
||||
&& schedulerRuntimeEnabled !== null
|
||||
&& schedulerOverrideFromUi !== schedulerRuntimeEnabled
|
||||
&& !changedItems.some((item) => item.key === 'SCHEDULE_ENABLED');
|
||||
const schedulerSyncItem: SystemConfigUpdateItem[] = syncRuntimeSchedulerState
|
||||
? [{ key: 'SCHEDULE_ENABLED', value: schedulerOverrideFromUi ? 'true' : 'false' }]
|
||||
: [];
|
||||
const changedItemsToSave = [...changedItems, ...schedulerSyncItem];
|
||||
const changedAlphaSiftItem = changedItems.find((item) => item.key === 'ALPHASIFT_ENABLED');
|
||||
const result = await save();
|
||||
const changedSchedulerSettings = changedItemsToSave.some((item) => SCHEDULER_SETTING_KEYS.has(item.key));
|
||||
const result = await save(changedItemsToSave);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
notifySystemConfigChanged();
|
||||
if (changedSchedulerSettings) {
|
||||
setSchedulerStatusRefreshToken((current) => current + 1);
|
||||
}
|
||||
if (!changedAlphaSiftItem) {
|
||||
return;
|
||||
}
|
||||
@@ -631,20 +1020,20 @@ const SettingsPage: React.FC = () => {
|
||||
>
|
||||
{t('settings.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="settings-primary"
|
||||
onClick={() => void handleSaveConfig()}
|
||||
disabled={!hasDirty || isSaving || isLoading}
|
||||
isLoading={isSaving}
|
||||
loadingText={t('settings.saving')}
|
||||
>
|
||||
{isSaving
|
||||
? t('settings.saving')
|
||||
: dirtyCount
|
||||
? t('settings.saveConfigWithCount', { count: dirtyCount })
|
||||
: t('settings.saveConfig')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="settings-primary"
|
||||
onClick={() => void handleSaveConfig()}
|
||||
disabled={!effectiveHasDirty || isSaving || isLoading}
|
||||
isLoading={isSaving}
|
||||
loadingText={t('settings.saving')}
|
||||
>
|
||||
{isSaving
|
||||
? t('settings.saving')
|
||||
: effectiveDirtyCount
|
||||
? t('settings.saveConfigWithCount', { count: effectiveDirtyCount })
|
||||
: t('settings.saveConfig')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -731,6 +1120,18 @@ const SettingsPage: React.FC = () => {
|
||||
</SettingsSectionCard>
|
||||
) : null}
|
||||
{activeCategory === 'system' ? <AuthSettingsCard /> : null}
|
||||
{activeCategory === 'system' ? (
|
||||
<SchedulerSettingsCard
|
||||
items={rawActiveItems}
|
||||
disabled={isSaving || isLoading}
|
||||
issueByKey={issueByKey}
|
||||
statusRefreshToken={schedulerStatusRefreshToken}
|
||||
onSchedulerStateChange={handleSchedulerRuntimeStateChange}
|
||||
onChange={setDraftValue}
|
||||
t={t}
|
||||
language={uiLanguage}
|
||||
/>
|
||||
) : null}
|
||||
{activeCategory === 'system' ? (
|
||||
<SettingsSectionCard
|
||||
title={t('settings.versionInfo')}
|
||||
|
||||
@@ -6,7 +6,9 @@ import SettingsPage from '../SettingsPage';
|
||||
|
||||
const {
|
||||
exportEnv,
|
||||
getSchedulerStatus,
|
||||
importEnv,
|
||||
runSchedulerNow,
|
||||
updateSystemConfig,
|
||||
alphasiftEnable,
|
||||
alphasiftInstall,
|
||||
@@ -33,7 +35,9 @@ const {
|
||||
webBuildInfoMock,
|
||||
} = vi.hoisted(() => ({
|
||||
exportEnv: vi.fn(),
|
||||
getSchedulerStatus: vi.fn(),
|
||||
importEnv: vi.fn(),
|
||||
runSchedulerNow: vi.fn(),
|
||||
updateSystemConfig: vi.fn(),
|
||||
alphasiftEnable: vi.fn(),
|
||||
alphasiftInstall: vi.fn(),
|
||||
@@ -76,7 +80,9 @@ vi.mock('../../hooks', () => ({
|
||||
vi.mock('../../api/systemConfig', () => ({
|
||||
systemConfigApi: {
|
||||
exportEnv: (...args: unknown[]) => exportEnv(...args),
|
||||
getSchedulerStatus: (...args: unknown[]) => getSchedulerStatus(...args),
|
||||
importEnv: (...args: unknown[]) => importEnv(...args),
|
||||
runSchedulerNow: (...args: unknown[]) => runSchedulerNow(...args),
|
||||
update: (...args: unknown[]) => updateSystemConfig(...args),
|
||||
},
|
||||
}));
|
||||
@@ -421,6 +427,19 @@ describe('SettingsPage', () => {
|
||||
configVersion: 'v1',
|
||||
updatedAt: '2026-03-21T00:00:00Z',
|
||||
});
|
||||
getSchedulerStatus.mockResolvedValue({
|
||||
enabled: true,
|
||||
running: false,
|
||||
scheduleTimes: ['09:20', '15:10'],
|
||||
nextRunAt: '2026-06-21T09:20:00+08:00',
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
runSchedulerNow.mockResolvedValue({
|
||||
accepted: true,
|
||||
running: true,
|
||||
});
|
||||
importEnv.mockResolvedValue({
|
||||
success: true,
|
||||
configVersion: 'v2',
|
||||
@@ -1013,6 +1032,614 @@ describe('SettingsPage', () => {
|
||||
expect(screen.getByTestId('settings-field-ALPHASIFT_INSTALL_SPEC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maps schedule settings to the scheduler card instead of generic raw fields', async () => {
|
||||
const configState = buildSystemConfigState();
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'true',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIME',
|
||||
value: '18:00',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIME',
|
||||
category: 'system',
|
||||
dataType: 'time',
|
||||
uiControl: 'time',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '09:20,15:10',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_RUN_IMMEDIATELY',
|
||||
value: 'false',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_RUN_IMMEDIATELY',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'LOG_LEVEL',
|
||||
value: 'INFO',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'LOG_LEVEL',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'select',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: ['INFO', 'DEBUG'],
|
||||
validation: {},
|
||||
displayOrder: 50,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(await screen.findByTestId('scheduler-settings-card')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('settings-field-SCHEDULE_ENABLED')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('settings-field-SCHEDULE_TIME')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('settings-field-SCHEDULE_TIMES')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('settings-field-SCHEDULE_RUN_IMMEDIATELY')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('settings-field-LOG_LEVEL')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByTestId('scheduler-time-input-0'), {
|
||||
target: { value: '10:30' },
|
||||
});
|
||||
|
||||
expect(setDraftValue).toHaveBeenCalledWith('SCHEDULE_TIMES', '10:30,15:10');
|
||||
|
||||
fireEvent.click(screen.getByTestId('scheduler-run-now-button'));
|
||||
|
||||
await waitFor(() => expect(runSchedulerNow).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('shows an error when run-now is rejected because analysis is already running', async () => {
|
||||
runSchedulerNow.mockRejectedValueOnce(new Error('A scheduled analysis is already running'));
|
||||
const configState = buildSystemConfigState();
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'true',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '18:00',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('scheduler-run-now-button'));
|
||||
|
||||
await waitFor(() => expect(runSchedulerNow).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText(/A scheduled analysis is already running/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show a failed run as the last successful scheduler run', async () => {
|
||||
const configState = buildSystemConfigState();
|
||||
getSchedulerStatus.mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
running: false,
|
||||
scheduleTimes: ['18:00'],
|
||||
nextRunAt: null,
|
||||
lastRunAt: '2026-06-21T17:00:00+08:00',
|
||||
lastSuccessAt: null,
|
||||
lastError: 'analysis failed',
|
||||
});
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'true',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '18:00',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(await screen.findByTestId('scheduler-last-success')).toHaveTextContent('-');
|
||||
expect(screen.getByTestId('scheduler-last-error')).toHaveTextContent('analysis failed');
|
||||
});
|
||||
|
||||
it('shows active runtime scheduler state even when saved schedule flag is false', async () => {
|
||||
const configState = buildSystemConfigState();
|
||||
getSchedulerStatus.mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
running: false,
|
||||
scheduleTimes: ['18:00'],
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'false',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '18:00',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
const enabledCheckbox = await screen.findByTestId('scheduler-enabled-checkbox');
|
||||
expect(enabledCheckbox).toBeChecked();
|
||||
|
||||
fireEvent.click(enabledCheckbox);
|
||||
|
||||
expect(setDraftValue).toHaveBeenCalledWith('SCHEDULE_ENABLED', 'false');
|
||||
await waitFor(() => expect(enabledCheckbox).not.toBeChecked());
|
||||
});
|
||||
|
||||
it('keeps local scheduler toggle edits when runtime and saved states are initially consistent', async () => {
|
||||
const configState = buildSystemConfigState();
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'true',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '18:00',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
render(<SettingsPage />);
|
||||
|
||||
const enabledCheckbox = await screen.findByTestId('scheduler-enabled-checkbox');
|
||||
expect(enabledCheckbox).toBeChecked();
|
||||
|
||||
fireEvent.click(enabledCheckbox);
|
||||
|
||||
expect(setDraftValue).toHaveBeenCalledWith('SCHEDULE_ENABLED', 'false');
|
||||
await waitFor(() => expect(enabledCheckbox).not.toBeChecked());
|
||||
|
||||
const refreshButton = screen.getByTestId('scheduler-refresh-status-button');
|
||||
fireEvent.click(refreshButton);
|
||||
await waitFor(() => expect(enabledCheckbox).not.toBeChecked());
|
||||
});
|
||||
|
||||
it('can reconcile runtime scheduler state when runtime is enabled but saved value is disabled', async () => {
|
||||
save.mockResolvedValue({ success: true });
|
||||
getChangedItems.mockReturnValue([]);
|
||||
const configState = buildSystemConfigState();
|
||||
getSchedulerStatus.mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
running: false,
|
||||
scheduleTimes: ['18:00'],
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
hasDirty: false,
|
||||
dirtyCount: 0,
|
||||
getChangedItems: () => [],
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'false',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '18:00',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /保存配置/ });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const enabledCheckbox = await screen.findByTestId('scheduler-enabled-checkbox');
|
||||
expect(enabledCheckbox).toBeChecked();
|
||||
fireEvent.click(enabledCheckbox);
|
||||
|
||||
await waitFor(() => expect(enabledCheckbox).not.toBeChecked());
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
await waitFor(() => expect(saveButton).toHaveTextContent('保存配置 (1)'));
|
||||
|
||||
fireEvent.click(saveButton);
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith([{ key: 'SCHEDULE_ENABLED', value: 'false' }]));
|
||||
});
|
||||
|
||||
it('can reconcile runtime scheduler state when runtime is disabled but saved value is enabled', async () => {
|
||||
save.mockResolvedValue({ success: true });
|
||||
getChangedItems.mockReturnValue([]);
|
||||
const configState = buildSystemConfigState();
|
||||
getSchedulerStatus.mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
running: false,
|
||||
scheduleTimes: ['18:00'],
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
hasDirty: false,
|
||||
dirtyCount: 0,
|
||||
getChangedItems: () => [],
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'true',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '18:00',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /保存配置/ });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const enabledCheckbox = await screen.findByTestId('scheduler-enabled-checkbox');
|
||||
expect(enabledCheckbox).not.toBeChecked();
|
||||
fireEvent.click(enabledCheckbox);
|
||||
|
||||
await waitFor(() => expect(enabledCheckbox).toBeChecked());
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
await waitFor(() => expect(saveButton).toHaveTextContent('保存配置 (1)'));
|
||||
|
||||
fireEvent.click(saveButton);
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith([{ key: 'SCHEDULE_ENABLED', value: 'true' }]));
|
||||
});
|
||||
|
||||
it('refreshes scheduler status after saving scheduler settings', async () => {
|
||||
const configState = buildSystemConfigState();
|
||||
getSchedulerStatus
|
||||
.mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
running: false,
|
||||
scheduleTimes: [],
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
running: false,
|
||||
scheduleTimes: ['09:20', '15:10'],
|
||||
nextRunAt: '2026-06-21T09:20:00+08:00',
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
save.mockResolvedValue({ success: true });
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
hasDirty: true,
|
||||
dirtyCount: 1,
|
||||
getChangedItems: () => [{ key: 'SCHEDULE_ENABLED', value: 'true' }],
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'false',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '09:20,15:10',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(await screen.findByText('未启用')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存配置 (1)' }));
|
||||
|
||||
await waitFor(() => expect(getSchedulerStatus).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByText('已启用')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes AlphaSift state when the enable flow fails', async () => {
|
||||
const configState = buildSystemConfigState();
|
||||
alphasiftEnable.mockRejectedValueOnce(new Error('config update failed'));
|
||||
@@ -1310,6 +1937,105 @@ describe('SettingsPage', () => {
|
||||
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('refreshes scheduler status after successful env import updates scheduler settings', async () => {
|
||||
(window as { dsaDesktop?: unknown }).dsaDesktop = { version: '3.12.0' };
|
||||
const configState = buildSystemConfigState();
|
||||
getSchedulerStatus
|
||||
.mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
running: false,
|
||||
scheduleTimes: ['18:00'],
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
running: false,
|
||||
scheduleTimes: ['09:20', '15:10'],
|
||||
nextRunAt: '2026-06-21T09:20:00+08:00',
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
importEnv.mockResolvedValueOnce({
|
||||
success: true,
|
||||
configVersion: 'v2',
|
||||
appliedCount: 2,
|
||||
skippedMaskedCount: 0,
|
||||
reloadTriggered: true,
|
||||
updatedKeys: ['SCHEDULE_ENABLED', 'SCHEDULE_TIMES'],
|
||||
warnings: [],
|
||||
});
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'system',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
system: [
|
||||
...configState.itemsByCategory.system,
|
||||
{
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
value: 'false',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_ENABLED',
|
||||
category: 'system',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCHEDULE_TIMES',
|
||||
value: '18:00',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCHEDULE_TIMES',
|
||||
category: 'system',
|
||||
dataType: 'string',
|
||||
uiControl: 'text',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 11,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const { container } = render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => expect(getSchedulerStatus).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('未启用')).toBeInTheDocument();
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [new File(['SCHEDULE_ENABLED=true\nSCHEDULE_TIMES=09:20,15:10\n'], 'desktop-backup.env', { type: 'text/plain' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(importEnv).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(getSchedulerStatus).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('已启用')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error when env import succeeds but reload fails', async () => {
|
||||
(window as { dsaDesktop?: unknown }).dsaDesktop = { version: '3.12.0' };
|
||||
load.mockResolvedValue(false);
|
||||
|
||||
@@ -154,6 +154,24 @@ export interface ValidateSystemConfigResponse {
|
||||
issues: ConfigValidationIssue[];
|
||||
}
|
||||
|
||||
export interface SchedulerStatusResponse {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
scheduleTimes: string[];
|
||||
nextRunAt?: string | null;
|
||||
lastRunAt?: string | null;
|
||||
lastSuccessAt?: string | null;
|
||||
lastError?: string | null;
|
||||
lastSkippedAt?: string | null;
|
||||
lastSkipReason?: string | null;
|
||||
}
|
||||
|
||||
export interface SchedulerRunNowResponse {
|
||||
accepted: boolean;
|
||||
running: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface TestLLMChannelRequest {
|
||||
name: string;
|
||||
protocol: string;
|
||||
|
||||
@@ -149,7 +149,8 @@ const fieldTitleMap: Record<string, string> = {
|
||||
REPORT_SHOW_LLM_MODEL: '显示分析模型',
|
||||
MAX_WORKERS: '最大并发线程数',
|
||||
SCHEDULE_ENABLED: '启用定时任务',
|
||||
SCHEDULE_RUN_IMMEDIATELY: '保存后立即执行定时任务',
|
||||
SCHEDULE_TIMES: '定时执行时间',
|
||||
SCHEDULE_RUN_IMMEDIATELY: '启动定时模式后立即执行',
|
||||
TRADING_DAY_CHECK_ENABLED: '启用交易日检查',
|
||||
WEBUI_HOST: 'WebUI 监听地址',
|
||||
ADMIN_AUTH_ENABLED: '启用后台登录鉴权',
|
||||
@@ -299,7 +300,8 @@ const fieldDescriptionMap: Record<string, string> = {
|
||||
REPORT_SHOW_LLM_MODEL: '在通知报告底部显示本次分析使用的 LLM 模型名称;关闭后隐藏运行时模型信息。仅影响展示,不会影响 provider/model/Base URL、运行时模型保存、迁移或清理。',
|
||||
MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',
|
||||
SCHEDULE_ENABLED: '启用每日定时分析任务。',
|
||||
SCHEDULE_RUN_IMMEDIATELY: '保存定时配置后立即触发一次任务。',
|
||||
SCHEDULE_TIMES: '多个每日定时任务执行时间,使用逗号分隔;Web 设置页会以时间控件维护。',
|
||||
SCHEDULE_RUN_IMMEDIATELY: '仅影响 schedule 模式启动时是否立即触发一次任务;保存 Web 设置不会触发执行。',
|
||||
TRADING_DAY_CHECK_ENABLED: '启用交易日校验,非交易日自动跳过定时分析。',
|
||||
WEBUI_HOST: 'WebUI 服务监听地址(默认通常为 0.0.0.0)。',
|
||||
ADMIN_AUTH_ENABLED: '启用 Web 管理端账号密码登录校验。',
|
||||
|
||||
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
|
||||
- [修复] 修复日股/韩股历史列表重建市场阶段摘要时将 non_trading 等结果阶段误传为 analysis_phase 导致列表查询失败的问题。
|
||||
- [新功能] 支持 SCHEDULE_TIMES 多时间定时推送,并让 Web/API/Desktop 长运行进程保存调度配置后热启停或重建 runtime scheduler。
|
||||
- [改进] Web 设置页以“定时任务”面板维护多时间定时推送、状态刷新和立即执行一次,不再向用户直接暴露 SCHEDULE_TIMES 等内部配置键。
|
||||
- [修复] 修复 `--serve --schedule` 下 CLI 调度器与 Web/API runtime scheduler 状态脱节的问题,并避免设置页把失败执行时间显示为上次成功。
|
||||
- [修复] runtime scheduler 的立即执行接口在已有分析运行时返回 409 忙碌状态,Web 设置页不再把未排队的请求提示为已提交。
|
||||
- [修复] 避免 runtime scheduler 重建定时任务时重复立即运行事件监控,减少重复告警和后台任务状态丢失。
|
||||
- [修复] Web/API runtime scheduler 接管 `--serve --schedule` 后保留 `--dry-run`、`--no-notify` 等启动参数语义。
|
||||
- [改进] Web 历史报告详情不再内嵌展示 AI 建议卡片,结构化决策信号集中在 AI 建议页查询,并保留按来源报告 ID 筛选或 URL 参数精确定位入口。
|
||||
|
||||
## [3.23.0] - 2026-06-20
|
||||
|
||||
@@ -97,12 +97,12 @@
|
||||
2. **Docker 部署 / WebUI 系统设置**:
|
||||
- `--env-file .env` / Compose `env_file` 只会把宿主机 `.env` 作为启动环境变量注入容器,不会自动创建或回写容器内 `/app/.env`
|
||||
- WebUI 设置页会在当前活跃 `.env` 文件缺少某些键时展示启动注入的同名环境变量作为兜底;但“导出 `.env`”仍只导出当前活跃配置文件内容
|
||||
- WebUI 保存后的 `STOCK_LIST`、`SCHEDULE_ENABLED`、`SCHEDULE_TIME`、`SCHEDULE_RUN_IMMEDIATELY`、`RUN_IMMEDIATELY` 会写回容器内的 `.env`
|
||||
- WebUI 保存后的 `STOCK_LIST`、`SCHEDULE_ENABLED`、`SCHEDULE_TIME`、`SCHEDULE_TIMES`、`SCHEDULE_RUN_IMMEDIATELY`、`RUN_IMMEDIATELY` 会写回容器内的 `.env`
|
||||
- WebUI 保存后会触发当前进程的配置重载;运行中的读取路径会同步使用最新写回的 `.env`,例如定时任务会继续热读取保存后的 `STOCK_LIST`
|
||||
- 如果容器启动命令里传入了这些同名环境变量(如 `--env-file .env`、`docker run -e ...` 或 Compose `environment:`),后续重启时仍可能以启动环境变量为准;要让 WebUI 保存值接管,请同步更新或移除这些同名 override
|
||||
- 如需持久化 WebUI 保存的配置,请将 `ENV_FILE` 指向 `/app/data/runtime.env` 等可写数据卷文件,不要把宿主机 `.env` 单文件挂载到 `/app/.env`
|
||||
- 其中 `SCHEDULE_*` 与 `RUN_IMMEDIATELY` 属于**启动期调度配置**,保存后不会立即触发一次分析,也不会热重建当前进程里的 scheduler
|
||||
- 如需让调度开关立刻接管当前容器,请重启容器,并确保以 schedule 模式启动
|
||||
- `SCHEDULE_ENABLED`、`SCHEDULE_TIME`、`SCHEDULE_TIMES` 保存后会让 WebUI/API/Desktop 长运行进程按新配置启停或重建 runtime scheduler
|
||||
- `SCHEDULE_RUN_IMMEDIATELY` 与 `RUN_IMMEDIATELY` 仍属于启动期/一次性运行配置,保存后不会立即触发一次分析
|
||||
3. **Docker 手工改 `.env` 后**:修改后仍建议重启容器
|
||||
```bash
|
||||
docker-compose down && docker-compose up -d
|
||||
|
||||
@@ -95,12 +95,12 @@ This document compiles common issues encountered by users and their solutions.
|
||||
2. **Docker deployment / WebUI Settings**:
|
||||
- `--env-file .env` / Compose `env_file` only injects the host `.env` as startup environment variables; it does not create or write back to `/app/.env` inside the container
|
||||
- When the active `.env` file does not contain a key, the WebUI Settings page falls back to showing the same key from startup-injected environment variables; the raw `.env` export still contains only the active config file content
|
||||
- WebUI saves `STOCK_LIST`, `SCHEDULE_ENABLED`, `SCHEDULE_TIME`, `SCHEDULE_RUN_IMMEDIATELY`, and `RUN_IMMEDIATELY` back into the container's `.env`
|
||||
- WebUI saves `STOCK_LIST`, `SCHEDULE_ENABLED`, `SCHEDULE_TIME`, `SCHEDULE_TIMES`, `SCHEDULE_RUN_IMMEDIATELY`, and `RUN_IMMEDIATELY` back into the container's `.env`
|
||||
- Saving from WebUI triggers a config reload for the current process, and runtime reads continue from the latest persisted `.env`; for example, scheduled runs keep hot-reading the saved `STOCK_LIST`
|
||||
- If you pass the same keys as startup env vars (`--env-file .env`, `docker run -e ...`, or Compose `environment:`), those startup values can still win on later restarts; update or remove the same-name overrides if you want the WebUI-saved `.env` values to take over
|
||||
- To persist WebUI-saved config, point `ENV_FILE` at a writable data-volume file such as `/app/data/runtime.env`; do not bind-mount the host `.env` as a single file over `/app/.env`
|
||||
- `SCHEDULE_*` and `RUN_IMMEDIATELY` are still **startup-time scheduling settings**: saving them does not immediately trigger an analysis run and does not hot-rebuild the scheduler inside the current process
|
||||
- To make schedule changes take over the current container, restart it and make sure the process is started in schedule mode
|
||||
- Saving `SCHEDULE_ENABLED`, `SCHEDULE_TIME`, or `SCHEDULE_TIMES` starts, stops, or rebuilds the runtime scheduler in long-running WebUI/API/Desktop processes
|
||||
- `SCHEDULE_RUN_IMMEDIATELY` and `RUN_IMMEDIATELY` remain startup/one-shot settings; saving them does not immediately trigger an analysis run
|
||||
3. **Manual `.env` edits in Docker**: Restart the container after changes
|
||||
```bash
|
||||
docker-compose down && docker-compose up -d
|
||||
|
||||
@@ -428,6 +428,7 @@ daily_stock_analysis/
|
||||
| `TRADING_DAY_CHECK_ENABLED` | 交易日检查:默认 `true`,非交易日跳过执行;设为 `false` 或使用 `--force-run` 可强制执行(Issue #373) | `true` |
|
||||
| `SCHEDULE_ENABLED` | 启用定时任务 | `false` |
|
||||
| `SCHEDULE_TIME` | 定时执行时间 | `18:00` |
|
||||
| `SCHEDULE_TIMES` | 多个定时执行时间,逗号分隔;为空时使用 `SCHEDULE_TIME` | 空 |
|
||||
| `LOG_DIR` | 日志目录 | `./logs` |
|
||||
| `SAVE_CONTEXT_SNAPSHOT` | 保存分析历史 `context_snapshot`;设为 `false` 时新历史不保存 enhanced_context、market_phase_summary、AnalysisContextPack overview 或诊断快照,但不关闭当次 Prompt 低敏摘要 | `true` |
|
||||
|
||||
@@ -705,7 +706,9 @@ python main.py --schedule --no-run-immediately
|
||||
|
||||
> 说明:定时模式每次触发前都会重新读取当前保存的 `STOCK_LIST`。如果同时传入 `--stocks`,该参数不会锁定后续计划执行的股票列表;需要临时只跑指定股票时,请使用非定时的单次运行命令。
|
||||
>
|
||||
> 从 `python main.py --schedule`、`python main.py --serve --schedule` 或等价内置调度模式启动后,WebUI 保存新的 `SCHEDULE_TIME` 会在下一轮调度检查内自动重绑 daily job,无需重启进程;旧的执行时间不会继续保留。
|
||||
> 从 `python main.py --schedule` 或等价纯 CLI 调度模式启动后,WebUI 保存新的 `SCHEDULE_TIME` / `SCHEDULE_TIMES` 会在下一轮调度检查内自动重绑 daily jobs,无需重启进程;旧的执行时间不会继续保留。`python main.py --serve --schedule` 会由 Web/API runtime scheduler 接管定时任务,WebUI/API/Desktop 长运行进程保存 `SCHEDULE_ENABLED`、`SCHEDULE_TIME` 或 `SCHEDULE_TIMES` 后会按当前配置启停或重建 runtime scheduler。
|
||||
>
|
||||
> Web/API runtime scheduler 的立即执行入口只会在没有分析任务运行时接受请求;如果已有分析在执行,会返回忙碌状态而不是假装排队成功。
|
||||
|
||||
#### 环境变量方式
|
||||
|
||||
@@ -715,6 +718,7 @@ python main.py --schedule --no-run-immediately
|
||||
|--------|------|:-------:|:-----:|
|
||||
| `SCHEDULE_ENABLED` | 是否启用定时任务 | `false` | `true` |
|
||||
| `SCHEDULE_TIME` | 每日执行时间 (HH:MM) | `18:00` | `09:30` |
|
||||
| `SCHEDULE_TIMES` | 多个每日执行时间,逗号分隔;为空时使用 `SCHEDULE_TIME` | 空 | `09:20,12:30,15:10,18:00` |
|
||||
| `SCHEDULE_RUN_IMMEDIATELY` | 定时模式启动时是否立即运行一次;未显式设置时沿用 `RUN_IMMEDIATELY` 的运行时覆盖语义 | `true` | `false` |
|
||||
| `RUN_IMMEDIATELY` | 非定时模式启动时是否立即运行一次;同时作为未显式设置 `SCHEDULE_RUN_IMMEDIATELY` 时的 legacy 回退 | `true` | `false` |
|
||||
| `TRADING_DAY_CHECK_ENABLED` | 交易日检查:非交易日跳过执行;设为 `false` 可强制执行 | `true` | `false` |
|
||||
|
||||
@@ -353,6 +353,7 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat
|
||||
| `MARKET_REVIEW_COLOR_SCHEME` | Index change color style in market reviews: `green_up` = green gains/red losses (default), `red_up` = red gains/green losses | `green_up` |
|
||||
| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |
|
||||
| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |
|
||||
| `SCHEDULE_TIMES` | Multiple scheduled execution times, comma-separated; falls back to `SCHEDULE_TIME` when empty | empty |
|
||||
| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |
|
||||
| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |
|
||||
| `LOG_DIR` | Log directory | `./logs` |
|
||||
@@ -610,7 +611,9 @@ crontab -e
|
||||
|
||||
> Note: Scheduled mode reloads the saved `STOCK_LIST` before each run. If you also pass `--stocks`, it will not pin future scheduled executions to the startup snapshot; use a normal one-off run when you want to analyze a temporary stock list.
|
||||
>
|
||||
> When the built-in scheduler is started via `python main.py --schedule`, `python main.py --serve --schedule`, or an equivalent local mode, saving a new `SCHEDULE_TIME` from the WebUI will rebind the daily job on the next scheduler poll without restarting the process. The previous trigger time is removed instead of being kept alongside the new one.
|
||||
> When the built-in scheduler is started via `python main.py --schedule` or an equivalent CLI-only mode, saving a new `SCHEDULE_TIME` / `SCHEDULE_TIMES` from the WebUI will rebind the daily jobs on the next scheduler poll without restarting the process. The previous trigger times are removed instead of being kept alongside the new ones. `python main.py --serve --schedule` is owned by the Web/API runtime scheduler, so long-running WebUI/API/Desktop processes start, stop, or rebuild the runtime scheduler after saving `SCHEDULE_ENABLED`, `SCHEDULE_TIME`, or `SCHEDULE_TIMES`.
|
||||
>
|
||||
> The Web/API runtime scheduler run-now endpoint only accepts a request when no analysis is already running; if an analysis is in progress, it returns a busy response instead of reporting a queued run.
|
||||
|
||||
### Market Phase Baseline (Issue #1386 P0)
|
||||
|
||||
|
||||
@@ -93,5 +93,8 @@ Issue #1512 收口后,Web 设置页只展示后端配置注册表中的正式
|
||||
|
||||
- `WEBUI_HOST`、`WEBUI_PORT`:监听地址和端口只在进程启动时绑定,保存后必须重启当前进程、Docker 容器或服务管理器才会生效。
|
||||
- `RUN_IMMEDIATELY`:非 schedule 模式启动期单次运行配置,保存后不会让已运行的 WebUI/API 进程立即触发分析。
|
||||
- `SCHEDULE_ENABLED`、`SCHEDULE_RUN_IMMEDIATELY`:schedule 模式启动行为,保存后不会启动、停止或重建当前 scheduler,需要以 schedule 模式重启后生效。
|
||||
- `SCHEDULE_TIME`:不是重启必需项。已运行的 schedule 模式会在下一轮调度检查中读取新时间并重建 daily job;但如果当前进程未以 schedule 模式启动,保存该字段不会自动创建 scheduler。
|
||||
- Web 设置页不直接暴露 `SCHEDULE_TIME` / `SCHEDULE_TIMES` / `SCHEDULE_RUN_IMMEDIATELY` 等内部键;用户通过“定时任务”卡片维护启用状态、多个执行时间和立即执行一次。
|
||||
- `SCHEDULE_ENABLED`:WebUI/API/Desktop 长运行进程(包括 `python main.py --serve --schedule`)会在保存后按新值启动或停止 runtime scheduler;纯 CLI schedule 模式(`python main.py --schedule`)仍按启动时参数和配置运行。
|
||||
- `SCHEDULE_TIME`、`SCHEDULE_TIMES`:不是重启必需项。`SCHEDULE_TIMES` 为空时使用 `SCHEDULE_TIME`;已运行的 scheduler 会按新时间重建 daily jobs。
|
||||
- `SCHEDULE_RUN_IMMEDIATELY`:schedule 模式启动行为,保存后不会让当前进程立即执行一次分析;手动执行请使用 runtime scheduler 的 run-now API。
|
||||
- runtime scheduler 的 run-now API 只会在没有分析任务运行时接受请求;如果已有分析在执行,会返回忙碌状态,Web 设置页会提示稍后重试。
|
||||
|
||||
212
main.py
212
main.py
@@ -23,6 +23,7 @@ A股自选股智能分析系统 - 主调度程序
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -638,8 +639,10 @@ def _save_reused_market_review_report(
|
||||
def run_full_analysis(
|
||||
config: Config,
|
||||
args: argparse.Namespace,
|
||||
stock_codes: Optional[List[str]] = None
|
||||
):
|
||||
stock_codes: Optional[List[str]] = None,
|
||||
*,
|
||||
raise_errors: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
执行完整的分析流程(个股 + 大盘复盘)
|
||||
|
||||
@@ -666,7 +669,7 @@ def run_full_analysis(
|
||||
logger.info(
|
||||
"今日所有相关市场均为非交易日,跳过执行。可使用 --force-run 强制执行。"
|
||||
)
|
||||
return
|
||||
return True
|
||||
if set(filtered_codes) != set(effective_codes):
|
||||
skipped = set(effective_codes) - set(filtered_codes)
|
||||
logger.info("今日休市股票已跳过: %s", skipped)
|
||||
@@ -964,8 +967,41 @@ def run_full_analysis(
|
||||
except Exception as e:
|
||||
logger.warning(f"自动回测失败(已忽略): {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"分析流程执行失败: {e}")
|
||||
if raise_errors:
|
||||
raise
|
||||
return False
|
||||
|
||||
|
||||
def run_scheduled_analysis(
|
||||
config: Config,
|
||||
args: argparse.Namespace,
|
||||
stock_codes: Optional[List[str]] = None,
|
||||
) -> bool:
|
||||
"""Run scheduled analysis with failures propagated to the scheduler."""
|
||||
return run_full_analysis(config, args, stock_codes, raise_errors=True)
|
||||
|
||||
|
||||
def _run_analysis_with_runtime_scheduler_lock(
|
||||
config: Config,
|
||||
args: argparse.Namespace,
|
||||
stock_codes: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
from src.services.runtime_scheduler import run_with_global_analysis_lock
|
||||
|
||||
# Keep startup/triggered analysis in sync with API runtime scheduler and
|
||||
# run-now entrypoint. Blocking is expected here because startup paths should
|
||||
# wait for an in-flight job before returning a response.
|
||||
run_with_global_analysis_lock(
|
||||
task_runner=run_full_analysis,
|
||||
config=config,
|
||||
args=args,
|
||||
stock_codes=stock_codes,
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
def start_api_server(host: str, port: int, config: Config) -> None:
|
||||
@@ -989,19 +1025,69 @@ def start_api_server(host: str, port: int, config: Config) -> None:
|
||||
finally:
|
||||
probe.close()
|
||||
|
||||
def run_server():
|
||||
level_name = (config.log_level or "INFO").lower()
|
||||
uvicorn.run(
|
||||
level_name = (config.log_level or "INFO").lower()
|
||||
use_config_signal_handlers = True
|
||||
uvicorn_kwargs = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"log_level": level_name,
|
||||
"log_config": None,
|
||||
}
|
||||
try:
|
||||
uvicorn_config = uvicorn.Config(
|
||||
"api.app:app",
|
||||
host=host,
|
||||
port=port,
|
||||
log_level=level_name,
|
||||
log_config=None,
|
||||
install_signal_handlers=False,
|
||||
**uvicorn_kwargs,
|
||||
)
|
||||
except TypeError:
|
||||
# Older uvicorn versions do not accept install_signal_handlers in
|
||||
# Config; fall back and only disable signal handling via Server attribute
|
||||
# when it's a boolean flag.
|
||||
use_config_signal_handlers = False
|
||||
uvicorn_config = uvicorn.Config(
|
||||
"api.app:app",
|
||||
**uvicorn_kwargs,
|
||||
)
|
||||
uvicorn_server = uvicorn.Server(config=uvicorn_config)
|
||||
if not use_config_signal_handlers:
|
||||
install_signal_handlers = getattr(uvicorn_server, "install_signal_handlers", None)
|
||||
if isinstance(install_signal_handlers, bool):
|
||||
uvicorn_server.install_signal_handlers = False
|
||||
|
||||
startup_error: list[BaseException] = []
|
||||
|
||||
def run_server():
|
||||
try:
|
||||
uvicorn_server.run()
|
||||
except Exception as exc: # noqa: BLE001 - surface startup issues to caller promptly
|
||||
startup_error.append(exc)
|
||||
|
||||
thread = threading.Thread(target=run_server, daemon=True)
|
||||
thread.start()
|
||||
logger.info(f"FastAPI 服务已启动: http://{host}:{port}")
|
||||
|
||||
timeout_seconds = 3.0
|
||||
wait_deadline = time.time() + timeout_seconds
|
||||
while time.time() < wait_deadline:
|
||||
if startup_error:
|
||||
raise RuntimeError(
|
||||
f"FastAPI server failed to start: {host}:{port}; {startup_error[0]}"
|
||||
)
|
||||
if uvicorn_server.started:
|
||||
logger.info(f"FastAPI 服务已启动: http://{host}:{port}")
|
||||
return
|
||||
if not thread.is_alive():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if startup_error:
|
||||
raise RuntimeError(f"FastAPI server failed to start: {host}:{port}; {startup_error[0]}")
|
||||
if uvicorn_server.started:
|
||||
logger.info(f"FastAPI 服务已启动: http://{host}:{port}")
|
||||
return
|
||||
if not thread.is_alive():
|
||||
raise RuntimeError(f"FastAPI 服务器启动后立即退出: {host}:{port}")
|
||||
|
||||
raise RuntimeError(f"FastAPI 服务在 {timeout_seconds:.1f}s 内未完成启动: {host}:{port}")
|
||||
|
||||
|
||||
def _is_truthy_env(var_name: str, default: str = "true") -> bool:
|
||||
@@ -1086,6 +1172,36 @@ def _build_schedule_time_provider(default_schedule_time: str):
|
||||
return _provider
|
||||
|
||||
|
||||
def _build_schedule_times_provider(default_schedule_time: str):
|
||||
"""Read the latest SCHEDULE_TIMES with SCHEDULE_TIME fallback."""
|
||||
from src.core.config_manager import ConfigManager
|
||||
from src.scheduler import normalize_schedule_times
|
||||
|
||||
_SYSTEM_DEFAULT_SCHEDULE_TIME = "18:00"
|
||||
manager = ConfigManager()
|
||||
|
||||
def _provider():
|
||||
if "SCHEDULE_TIMES" in _INITIAL_PROCESS_ENV:
|
||||
return normalize_schedule_times(
|
||||
os.getenv("SCHEDULE_TIMES", ""),
|
||||
fallback_time=os.getenv("SCHEDULE_TIME", default_schedule_time),
|
||||
)
|
||||
if "SCHEDULE_TIME" in _INITIAL_PROCESS_ENV:
|
||||
return normalize_schedule_times(
|
||||
os.getenv("SCHEDULE_TIMES", ""),
|
||||
fallback_time=os.getenv("SCHEDULE_TIME", default_schedule_time),
|
||||
)
|
||||
|
||||
config_map = manager.read_config_map()
|
||||
schedule_time = (config_map.get("SCHEDULE_TIME", "") or "").strip() or _SYSTEM_DEFAULT_SCHEDULE_TIME
|
||||
return normalize_schedule_times(
|
||||
config_map.get("SCHEDULE_TIMES", ""),
|
||||
fallback_time=schedule_time,
|
||||
)
|
||||
|
||||
return _provider
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
主入口函数
|
||||
@@ -1174,6 +1290,47 @@ def main() -> int:
|
||||
|
||||
bot_clients_started = False
|
||||
if start_serve:
|
||||
from src.services.runtime_scheduler import (
|
||||
CLI_SCHEDULER_OWNER_ENV,
|
||||
RUNTIME_SCHEDULER_ARGS_ENV,
|
||||
RUNTIME_SCHEDULER_FORCE_ENABLED_ENV,
|
||||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||||
RUNTIME_SCHEDULER_SUPPRESS_START_ENV,
|
||||
)
|
||||
|
||||
# The API runtime scheduler owns schedules once the Web/API service starts.
|
||||
# This keeps Web settings, status, and run-now actions attached to the real
|
||||
# scheduler instead of a separate CLI loop.
|
||||
os.environ.pop(CLI_SCHEDULER_OWNER_ENV, None)
|
||||
if args.serve_only:
|
||||
os.environ[RUNTIME_SCHEDULER_SUPPRESS_START_ENV] = "true"
|
||||
else:
|
||||
os.environ.pop(RUNTIME_SCHEDULER_SUPPRESS_START_ENV, None)
|
||||
runtime_schedule_requested = not args.serve_only and (
|
||||
args.schedule or config.schedule_enabled
|
||||
)
|
||||
if not args.serve_only and args.schedule:
|
||||
os.environ[RUNTIME_SCHEDULER_FORCE_ENABLED_ENV] = "true"
|
||||
else:
|
||||
os.environ.pop(RUNTIME_SCHEDULER_FORCE_ENABLED_ENV, None)
|
||||
if runtime_schedule_requested:
|
||||
runtime_run_immediately = config.schedule_run_immediately
|
||||
if getattr(args, 'no_run_immediately', False):
|
||||
runtime_run_immediately = False
|
||||
os.environ[RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV] = (
|
||||
"true" if runtime_run_immediately else "false"
|
||||
)
|
||||
else:
|
||||
os.environ.pop(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV, None)
|
||||
os.environ[RUNTIME_SCHEDULER_ARGS_ENV] = json.dumps({
|
||||
"no_notify": bool(getattr(args, "no_notify", False)),
|
||||
"no_market_review": bool(getattr(args, "no_market_review", False)),
|
||||
"dry_run": bool(getattr(args, "dry_run", False)),
|
||||
"force_run": bool(getattr(args, "force_run", False)),
|
||||
"single_notify": bool(getattr(args, "single_notify", False)),
|
||||
"no_context_snapshot": bool(getattr(args, "no_context_snapshot", False)),
|
||||
"workers": getattr(args, "workers", None),
|
||||
})
|
||||
if not prepare_webui_frontend_assets():
|
||||
logger.warning("前端静态资源未就绪,继续启动 FastAPI 服务(Web 页面可能不可用)")
|
||||
try:
|
||||
@@ -1257,6 +1414,18 @@ def main() -> int:
|
||||
|
||||
# 模式2: 定时任务模式
|
||||
if args.schedule or config.schedule_enabled:
|
||||
if start_serve:
|
||||
logger.info("模式: Web/API runtime scheduler")
|
||||
logger.info(f"Web 服务运行中: http://{args.host}:{args.port}")
|
||||
logger.info("Web/API runtime scheduler 已接管定时任务,保存设置会作用于当前进程")
|
||||
logger.info("按 Ctrl+C 退出...")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("\n用户中断,程序退出")
|
||||
return 0
|
||||
|
||||
logger.info("模式: 定时任务")
|
||||
logger.info(f"每日执行时间: {config.schedule_time}")
|
||||
|
||||
@@ -1272,6 +1441,7 @@ def main() -> int:
|
||||
from src.scheduler import run_with_schedule
|
||||
scheduled_stock_codes = _resolve_scheduled_stock_codes(stock_codes)
|
||||
schedule_time_provider = _build_schedule_time_provider(config.schedule_time)
|
||||
schedule_times_provider = _build_schedule_times_provider(config.schedule_time)
|
||||
|
||||
def scheduled_task():
|
||||
runtime_config = _reload_runtime_config()
|
||||
@@ -1297,18 +1467,22 @@ def main() -> int:
|
||||
"name": "agent_event_monitor",
|
||||
})
|
||||
|
||||
run_with_schedule(
|
||||
task=scheduled_task,
|
||||
schedule_time=config.schedule_time,
|
||||
run_immediately=should_run_immediately,
|
||||
background_tasks=background_tasks,
|
||||
schedule_time_provider=schedule_time_provider,
|
||||
)
|
||||
schedule_kwargs = {
|
||||
"task": scheduled_task,
|
||||
"schedule_time": config.schedule_time,
|
||||
"run_immediately": should_run_immediately,
|
||||
"background_tasks": background_tasks,
|
||||
"schedule_time_provider": schedule_time_provider,
|
||||
}
|
||||
if hasattr(config, "schedule_times"):
|
||||
schedule_kwargs["schedule_times"] = config.schedule_times
|
||||
schedule_kwargs["schedule_times_provider"] = schedule_times_provider
|
||||
run_with_schedule(**schedule_kwargs)
|
||||
return 0
|
||||
|
||||
# 模式3: 正常单次运行
|
||||
if config.run_immediately:
|
||||
run_full_analysis(config, args, stock_codes)
|
||||
_run_analysis_with_runtime_scheduler_lock(config, args, stock_codes)
|
||||
else:
|
||||
logger.info("配置为不立即运行分析 (RUN_IMMEDIATELY=false)")
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from src.notification_contracts import (
|
||||
is_feishu_static_configured,
|
||||
)
|
||||
from src.llm import generation_params as llm_generation_params
|
||||
from src.scheduler import normalize_schedule_times
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -898,6 +899,7 @@ class Config:
|
||||
# === 定时任务配置 ===
|
||||
schedule_enabled: bool = False # 是否启用定时任务
|
||||
schedule_time: str = "18:00" # 每日推送时间(HH:MM 格式)
|
||||
schedule_times: List[str] = field(default_factory=lambda: ["18:00"])
|
||||
schedule_run_immediately: bool = True # 启动时是否立即执行一次
|
||||
run_immediately: bool = True # 启动时是否立即执行一次(非定时模式)
|
||||
market_review_enabled: bool = True # 是否启用大盘复盘
|
||||
@@ -1013,6 +1015,7 @@ class Config:
|
||||
"RUN_IMMEDIATELY",
|
||||
"SCHEDULE_ENABLED",
|
||||
"SCHEDULE_TIME",
|
||||
"SCHEDULE_TIMES",
|
||||
"SCHEDULE_RUN_IMMEDIATELY",
|
||||
}
|
||||
)
|
||||
@@ -1422,6 +1425,11 @@ class Config:
|
||||
default='18:00',
|
||||
prefer_env_file=True,
|
||||
)
|
||||
schedule_times_value = cls._resolve_env_value(
|
||||
'SCHEDULE_TIMES',
|
||||
default='',
|
||||
prefer_env_file=True,
|
||||
)
|
||||
|
||||
report_language_raw = cls._resolve_report_language_env_value(
|
||||
preexisting_report_language
|
||||
@@ -1725,6 +1733,10 @@ class Config:
|
||||
prefer_env_file=True,
|
||||
).lower() == 'true',
|
||||
schedule_time=(schedule_time_value or '18:00').strip() or '18:00',
|
||||
schedule_times=normalize_schedule_times(
|
||||
schedule_times_value,
|
||||
fallback_time=(schedule_time_value or '18:00').strip() or '18:00',
|
||||
),
|
||||
schedule_run_immediately=schedule_run_immediately,
|
||||
run_immediately=legacy_run_immediately,
|
||||
market_review_enabled=os.getenv('MARKET_REVIEW_ENABLED', 'true').lower() == 'true',
|
||||
|
||||
@@ -2654,6 +2654,33 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
],
|
||||
"warning_codes": ["local_timezone"],
|
||||
},
|
||||
"SCHEDULE_TIMES": {
|
||||
"title": "Schedule Times",
|
||||
"description": "Comma-separated daily schedule times in HH:MM format. Falls back to SCHEDULE_TIME when empty.",
|
||||
"category": "system",
|
||||
"data_type": "string",
|
||||
"ui_control": "text",
|
||||
"is_sensitive": False,
|
||||
"is_required": False,
|
||||
"is_editable": True,
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"validation": {"pattern": r"^\s*(?:(?:[01]\d|2[0-3]):[0-5]\d\s*(?:,\s*(?:[01]\d|2[0-3]):[0-5]\d\s*)*)?$"},
|
||||
"display_order": 11,
|
||||
"help_key": "settings.system.schedule",
|
||||
"examples": [
|
||||
"SCHEDULE_TIMES=09:20,12:30,15:10,18:00",
|
||||
"SCHEDULE_TIME=18:00",
|
||||
"SCHEDULE_ENABLED=true",
|
||||
],
|
||||
"docs": [
|
||||
{
|
||||
"label": "Full guide: configuration",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/full-guide.md#其他配置",
|
||||
},
|
||||
],
|
||||
"warning_codes": ["local_timezone"],
|
||||
},
|
||||
"HTTP_PROXY": {
|
||||
"title": "HTTP Proxy",
|
||||
"description": "Optional HTTP proxy endpoint.",
|
||||
|
||||
146
src/scheduler.py
146
src/scheduler.py
@@ -19,11 +19,35 @@ import signal
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_schedule_times(
|
||||
schedule_times: Optional[Union[Sequence[str], str]],
|
||||
*,
|
||||
fallback_time: str = "18:00",
|
||||
) -> List[str]:
|
||||
"""Return sorted unique HH:MM schedule times with SCHEDULE_TIME fallback."""
|
||||
if isinstance(schedule_times, str):
|
||||
raw_items = [item.strip() for item in schedule_times.split(",")]
|
||||
elif schedule_times is None:
|
||||
raw_items = []
|
||||
else:
|
||||
raw_items = [str(item).strip() for item in schedule_times]
|
||||
|
||||
valid = {
|
||||
item
|
||||
for item in raw_items
|
||||
if item and re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", item)
|
||||
}
|
||||
if not valid:
|
||||
fallback = (fallback_time or "18:00").strip() or "18:00"
|
||||
valid.add(fallback if re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", fallback) else "18:00")
|
||||
return sorted(valid)
|
||||
|
||||
|
||||
class GracefulShutdown:
|
||||
"""
|
||||
优雅退出处理器
|
||||
@@ -31,9 +55,11 @@ class GracefulShutdown:
|
||||
捕获 SIGTERM/SIGINT 信号,确保任务完成后再退出
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, register_signals: bool = True):
|
||||
self.shutdown_requested = False
|
||||
self._lock = threading.Lock()
|
||||
if not register_signals:
|
||||
return
|
||||
|
||||
# 注册信号处理器
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
@@ -67,6 +93,9 @@ class Scheduler:
|
||||
self,
|
||||
schedule_time: str = "18:00",
|
||||
schedule_time_provider: Optional[Callable[[], str]] = None,
|
||||
schedule_times: Optional[Sequence[str]] = None,
|
||||
schedule_times_provider: Optional[Callable[[], Union[Sequence[str], str]]] = None,
|
||||
register_signals: bool = True,
|
||||
):
|
||||
"""
|
||||
初始化调度器
|
||||
@@ -82,10 +111,17 @@ class Scheduler:
|
||||
raise ImportError("请安装 schedule 库: pip install schedule")
|
||||
|
||||
self.schedule_time = schedule_time
|
||||
self.schedule_times = (
|
||||
normalize_schedule_times(schedule_times, fallback_time=schedule_time)
|
||||
if schedule_times is not None
|
||||
else [(schedule_time or "").strip()]
|
||||
)
|
||||
self._schedule_time_provider = schedule_time_provider
|
||||
self.shutdown_handler = GracefulShutdown()
|
||||
self._schedule_times_provider = schedule_times_provider
|
||||
self.shutdown_handler = GracefulShutdown(register_signals=register_signals)
|
||||
self._task_callback: Optional[Callable] = None
|
||||
self._daily_job: Optional[Any] = None
|
||||
self._daily_jobs: List[Any] = []
|
||||
self._background_tasks: List[Dict[str, Any]] = []
|
||||
self._running = False
|
||||
|
||||
@@ -98,7 +134,7 @@ class Scheduler:
|
||||
run_immediately: 是否在设置后立即执行一次
|
||||
"""
|
||||
self._task_callback = task
|
||||
if not self._configure_daily_task(self.schedule_time):
|
||||
if not self._configure_daily_tasks(self.schedule_times):
|
||||
raise ValueError(f"无效的定时执行时间: {self.schedule_time!r}")
|
||||
|
||||
if run_immediately:
|
||||
@@ -115,17 +151,21 @@ class Scheduler:
|
||||
|
||||
def _cancel_daily_job(self) -> None:
|
||||
"""Remove the currently registered daily job if one exists."""
|
||||
if self._daily_job is None:
|
||||
if self._daily_job is None and not self._daily_jobs:
|
||||
return
|
||||
|
||||
if hasattr(self.schedule, "cancel_job"):
|
||||
self.schedule.cancel_job(self._daily_job)
|
||||
else: # pragma: no cover - compatibility fallback
|
||||
jobs = getattr(self.schedule, "jobs", None)
|
||||
if isinstance(jobs, list) and self._daily_job in jobs:
|
||||
jobs.remove(self._daily_job)
|
||||
for job in list(self._daily_jobs or [self._daily_job]):
|
||||
if job is None:
|
||||
continue
|
||||
if hasattr(self.schedule, "cancel_job"):
|
||||
self.schedule.cancel_job(job)
|
||||
else: # pragma: no cover - compatibility fallback
|
||||
jobs = getattr(self.schedule, "jobs", None)
|
||||
if isinstance(jobs, list) and job in jobs:
|
||||
jobs.remove(job)
|
||||
|
||||
self._daily_job = None
|
||||
self._daily_jobs = []
|
||||
|
||||
def _configure_daily_task(self, schedule_time: str) -> bool:
|
||||
"""(Re)register the daily job at the requested time."""
|
||||
@@ -170,6 +210,74 @@ class Scheduler:
|
||||
if self._configure_daily_task(latest_schedule_time):
|
||||
logger.info("更新后的下次执行时间: %s", self._get_next_run_time())
|
||||
|
||||
def _configure_daily_tasks(self, schedule_times: Union[Sequence[str], str]) -> bool:
|
||||
"""(Re)register daily jobs at the requested times."""
|
||||
raw_items = (
|
||||
[item.strip() for item in schedule_times.split(",")]
|
||||
if isinstance(schedule_times, str)
|
||||
else [str(item).strip() for item in schedule_times]
|
||||
)
|
||||
invalid_items = [item for item in raw_items if item and not self._is_valid_schedule_time(item)]
|
||||
if invalid_items:
|
||||
logger.warning(
|
||||
"Invalid schedule time values %r; keeping current times %s",
|
||||
invalid_items,
|
||||
",".join(self.schedule_times),
|
||||
)
|
||||
return False
|
||||
|
||||
candidates = normalize_schedule_times(raw_items, fallback_time=self.schedule_time)
|
||||
previous_times = list(self.schedule_times)
|
||||
self._cancel_daily_job()
|
||||
self._daily_jobs = [
|
||||
self.schedule.every().day.at(candidate).do(self._safe_run_task)
|
||||
for candidate in candidates
|
||||
]
|
||||
self._daily_job = self._daily_jobs[0] if self._daily_jobs else None
|
||||
self.schedule_times = candidates
|
||||
self.schedule_time = candidates[0] if candidates else "18:00"
|
||||
|
||||
if previous_times == candidates:
|
||||
logger.info("Daily scheduled jobs configured at: %s", ",".join(self.schedule_times))
|
||||
else:
|
||||
logger.info(
|
||||
"Schedule times changed from %s to %s",
|
||||
",".join(previous_times),
|
||||
",".join(self.schedule_times),
|
||||
)
|
||||
return True
|
||||
|
||||
def _refresh_daily_schedule_if_needed(self) -> None:
|
||||
"""Reload daily schedule times from the latest runtime config if needed."""
|
||||
if self._task_callback is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if self._schedule_times_provider is not None:
|
||||
latest_schedule_times = self._schedule_times_provider()
|
||||
elif self._schedule_time_provider is not None:
|
||||
latest_schedule_times = [(self._schedule_time_provider() or "").strip()]
|
||||
else:
|
||||
return
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.warning(
|
||||
"Failed to read latest schedule times; keeping %s: %s",
|
||||
",".join(self.schedule_times),
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
latest = normalize_schedule_times(latest_schedule_times, fallback_time=self.schedule_time)
|
||||
if latest == self.schedule_times:
|
||||
return
|
||||
|
||||
if self._configure_daily_tasks(latest):
|
||||
logger.info("Schedule refreshed; next run: %s", self._get_next_run_time())
|
||||
|
||||
def refresh_daily_schedule_if_needed(self) -> None:
|
||||
"""Public wrapper for runtime scheduler reconciliation."""
|
||||
self._refresh_daily_schedule_if_needed()
|
||||
|
||||
def _safe_run_task(self):
|
||||
"""安全执行任务(带异常捕获)"""
|
||||
if self._task_callback is None:
|
||||
@@ -303,6 +411,7 @@ class Scheduler:
|
||||
def stop(self):
|
||||
"""停止调度器"""
|
||||
self._running = False
|
||||
self._cancel_daily_job()
|
||||
|
||||
|
||||
def run_with_schedule(
|
||||
@@ -311,6 +420,8 @@ def run_with_schedule(
|
||||
run_immediately: bool = True,
|
||||
background_tasks: Optional[List[Dict[str, Any]]] = None,
|
||||
schedule_time_provider: Optional[Callable[[], str]] = None,
|
||||
schedule_times: Optional[Sequence[str]] = None,
|
||||
schedule_times_provider: Optional[Callable[[], Union[Sequence[str], str]]] = None,
|
||||
):
|
||||
"""
|
||||
便捷函数:使用定时调度运行任务
|
||||
@@ -325,10 +436,15 @@ def run_with_schedule(
|
||||
schedule_time_provider: 可选的时间提供器;调度器每轮检查前会读取,
|
||||
当返回值变化时自动重建 daily job。
|
||||
"""
|
||||
scheduler = Scheduler(
|
||||
schedule_time=schedule_time,
|
||||
schedule_time_provider=schedule_time_provider,
|
||||
)
|
||||
scheduler_kwargs: Dict[str, Any] = {
|
||||
"schedule_time": schedule_time,
|
||||
"schedule_time_provider": schedule_time_provider,
|
||||
}
|
||||
if schedule_times is not None:
|
||||
scheduler_kwargs["schedule_times"] = schedule_times
|
||||
if schedule_times_provider is not None:
|
||||
scheduler_kwargs["schedule_times_provider"] = schedule_times_provider
|
||||
scheduler = Scheduler(**scheduler_kwargs)
|
||||
for entry in background_tasks or []:
|
||||
scheduler.add_background_task(
|
||||
task=entry["task"],
|
||||
|
||||
384
src/services/runtime_scheduler.py
Normal file
384
src/services/runtime_scheduler.py
Normal file
@@ -0,0 +1,384 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Runtime scheduler service for long-lived API/Web/Desktop processes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import _thread
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from src.config import Config, get_config
|
||||
from src.scheduler import Scheduler, normalize_schedule_times
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CLI_SCHEDULER_OWNER_ENV = "DSA_CLI_SCHEDULER_OWNS_SCHEDULE"
|
||||
RUNTIME_SCHEDULER_FORCE_ENABLED_ENV = "DSA_RUNTIME_SCHEDULER_FORCE_ENABLED"
|
||||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV = "DSA_RUNTIME_SCHEDULER_RUN_IMMEDIATELY"
|
||||
RUNTIME_SCHEDULER_SUPPRESS_START_ENV = "DSA_RUNTIME_SCHEDULER_SUPPRESS_START"
|
||||
RUNTIME_SCHEDULER_ARGS_ENV = "DSA_RUNTIME_SCHEDULER_ARGS"
|
||||
_RUNTIME_ANALYSIS_LOCK = threading.Lock()
|
||||
SCHEDULE_ARGS_OVERRIDE_KEYS = {
|
||||
"no_notify",
|
||||
"no_market_review",
|
||||
"dry_run",
|
||||
"force_run",
|
||||
"single_notify",
|
||||
"no_context_snapshot",
|
||||
"workers",
|
||||
}
|
||||
|
||||
|
||||
def run_with_global_analysis_lock(
|
||||
task_runner: Callable[[Config, Any, Optional[List[str]]], Any],
|
||||
config: Config,
|
||||
args: Any,
|
||||
stock_codes: Optional[List[str]] = None,
|
||||
*,
|
||||
blocking: bool = True,
|
||||
) -> bool:
|
||||
"""Execute a task while holding the shared runtime analysis lock."""
|
||||
if not _RUNTIME_ANALYSIS_LOCK.acquire(blocking=blocking):
|
||||
return False
|
||||
try:
|
||||
task_runner(config, args, stock_codes)
|
||||
finally:
|
||||
_RUNTIME_ANALYSIS_LOCK.release()
|
||||
return True
|
||||
|
||||
|
||||
def _agent_event_monitor_interval_seconds(config: Config) -> int:
|
||||
"""Return the validated Event Monitor polling interval in seconds."""
|
||||
interval_minutes = getattr(config, "agent_event_monitor_interval_minutes", 5)
|
||||
try:
|
||||
interval_minutes = max(1, int(interval_minutes))
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive branch
|
||||
logger.warning(
|
||||
"Invalid AGENT_EVENT_MONITOR_INTERVAL_MINUTES=%r; use fallback 5",
|
||||
interval_minutes,
|
||||
)
|
||||
interval_minutes = 5
|
||||
return interval_minutes * 60
|
||||
|
||||
|
||||
def build_agent_event_monitor_background_tasks(
|
||||
config: Config,
|
||||
*,
|
||||
config_provider: Callable[[], Config],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build scheduler background tasks used by the runtime scheduler."""
|
||||
if not getattr(config, "agent_event_monitor_enabled", False):
|
||||
return []
|
||||
|
||||
from src.services.alert_worker import AlertWorker
|
||||
|
||||
interval_seconds = _agent_event_monitor_interval_seconds(config)
|
||||
try:
|
||||
alert_worker = AlertWorker(config_provider=config_provider)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.warning("Failed to initialize AlertWorker for event monitor: %s", exc)
|
||||
return []
|
||||
|
||||
def event_monitor_task() -> None:
|
||||
stats = alert_worker.run_once()
|
||||
triggered_count = stats.get("triggered", 0)
|
||||
if triggered_count:
|
||||
logger.info("[EventMonitor] triggered %d alert(s)", triggered_count)
|
||||
|
||||
return [{
|
||||
"task": event_monitor_task,
|
||||
"interval_seconds": interval_seconds,
|
||||
"run_immediately": True,
|
||||
"name": "agent_event_monitor",
|
||||
}]
|
||||
|
||||
|
||||
class RuntimeSchedulerService:
|
||||
"""Manage scheduled analysis inside the current API/Web/Desktop process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_provider: Callable[[], Config] = get_config,
|
||||
task_runner: Optional[Callable[[Config, Any, Optional[List[str]]], Any]] = None,
|
||||
owns_schedule: Optional[bool] = None,
|
||||
force_enabled: bool = False,
|
||||
run_immediately_in_background: bool = False,
|
||||
background_tasks_provider: Optional[Callable[[Config], List[Dict[str, Any]]]] = None,
|
||||
schedule_args_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self._config_provider = config_provider
|
||||
self._task_runner = task_runner
|
||||
if owns_schedule is None:
|
||||
owns_schedule = os.getenv(CLI_SCHEDULER_OWNER_ENV, "").strip().lower() not in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
self._owns_schedule = owns_schedule
|
||||
self._force_enabled = force_enabled
|
||||
self._run_immediately_in_background = run_immediately_in_background
|
||||
self._background_tasks_provider = background_tasks_provider
|
||||
self._schedule_args_overrides = {
|
||||
key: value
|
||||
for key, value in (schedule_args_overrides or {}).items()
|
||||
if key in SCHEDULE_ARGS_OVERRIDE_KEYS
|
||||
}
|
||||
self._background_task_cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._background_task_registered_names: Set[str] = set()
|
||||
self._lock = threading.RLock()
|
||||
self._run_lock = _RUNTIME_ANALYSIS_LOCK
|
||||
self._scheduler: Optional[Scheduler] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._enabled = False
|
||||
self._last_run_at: Optional[str] = None
|
||||
self._last_success_at: Optional[str] = None
|
||||
self._last_error: Optional[str] = None
|
||||
self._last_skipped_at: Optional[str] = None
|
||||
self._last_skip_reason: Optional[str] = None
|
||||
|
||||
def _make_schedule_args(self) -> SimpleNamespace:
|
||||
defaults = {
|
||||
"schedule": True,
|
||||
"no_run_immediately": True,
|
||||
"no_notify": False,
|
||||
"no_market_review": False,
|
||||
"dry_run": False,
|
||||
"force_run": False,
|
||||
"single_notify": False,
|
||||
"no_context_snapshot": False,
|
||||
"market_review": False,
|
||||
"serve": False,
|
||||
"serve_only": True,
|
||||
"stocks": None,
|
||||
"workers": None,
|
||||
}
|
||||
defaults.update(self._schedule_args_overrides)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
def _reload_config(self) -> Config:
|
||||
from main import _reload_runtime_config
|
||||
|
||||
return _reload_runtime_config()
|
||||
|
||||
def _record_analysis_busy_skip(self) -> None:
|
||||
self._last_skipped_at = datetime.now().isoformat()
|
||||
self._last_skip_reason = "analysis_already_running"
|
||||
logger.warning("Runtime scheduler skipped run: analysis already running")
|
||||
|
||||
def _run_analysis_locked(self, stock_codes: Optional[List[str]]) -> None:
|
||||
try:
|
||||
config = self._reload_config()
|
||||
runner = self._task_runner
|
||||
if runner is None:
|
||||
from main import run_scheduled_analysis
|
||||
|
||||
runner = run_scheduled_analysis
|
||||
self._last_run_at = datetime.now().isoformat()
|
||||
result = runner(config, self._make_schedule_args(), stock_codes)
|
||||
if result is False:
|
||||
raise RuntimeError("runtime scheduled analysis reported failure")
|
||||
self._last_success_at = datetime.now().isoformat()
|
||||
self._last_error = None
|
||||
except Exception as exc: # noqa: BLE001 - scheduled runs must not kill API process.
|
||||
self._last_error = str(exc)
|
||||
logger.exception("Runtime scheduled analysis failed: %s", exc)
|
||||
|
||||
def _run_analysis_once(self, stock_codes: Optional[List[str]] = None) -> bool:
|
||||
if not self._run_lock.acquire(blocking=False):
|
||||
self._record_analysis_busy_skip()
|
||||
return False
|
||||
try:
|
||||
self._run_analysis_locked(stock_codes)
|
||||
finally:
|
||||
self._run_lock.release()
|
||||
return True
|
||||
|
||||
def _current_times(self) -> List[str]:
|
||||
config = self._config_provider()
|
||||
return normalize_schedule_times(
|
||||
getattr(config, "schedule_times", None),
|
||||
fallback_time=getattr(config, "schedule_time", "18:00"),
|
||||
)
|
||||
|
||||
def _is_schedule_enabled(self, config: Config) -> bool:
|
||||
return self._force_enabled or bool(getattr(config, "schedule_enabled", False))
|
||||
|
||||
def _current_background_tasks(self, config: Config) -> List[Dict[str, Any]]:
|
||||
if self._background_tasks_provider is not None:
|
||||
return self._background_tasks_provider(config)
|
||||
return self._current_agent_event_monitor_background_tasks(config)
|
||||
|
||||
def _current_agent_event_monitor_background_tasks(self, config: Config) -> List[Dict[str, Any]]:
|
||||
name = "agent_event_monitor"
|
||||
if not getattr(config, "agent_event_monitor_enabled", False):
|
||||
self._background_task_cache.pop(name, None)
|
||||
self._background_task_registered_names.discard(name)
|
||||
return []
|
||||
|
||||
cached = self._background_task_cache.get(name)
|
||||
if cached is None:
|
||||
entries = build_agent_event_monitor_background_tasks(
|
||||
config,
|
||||
config_provider=self._reload_config,
|
||||
)
|
||||
if not entries:
|
||||
self._background_task_cache.pop(name, None)
|
||||
self._background_task_registered_names.discard(name)
|
||||
return []
|
||||
cached = dict(entries[0])
|
||||
cached["name"] = name
|
||||
self._background_task_cache[name] = cached
|
||||
interval_seconds = int(cached["interval_seconds"])
|
||||
else:
|
||||
interval_seconds = _agent_event_monitor_interval_seconds(config)
|
||||
|
||||
run_immediately = (
|
||||
bool(cached.get("run_immediately", False))
|
||||
and name not in self._background_task_registered_names
|
||||
)
|
||||
self._background_task_registered_names.add(name)
|
||||
return [{
|
||||
"task": cached["task"],
|
||||
"interval_seconds": interval_seconds,
|
||||
"run_immediately": run_immediately,
|
||||
"name": name,
|
||||
}]
|
||||
|
||||
@staticmethod
|
||||
def _run_in_background_thread(target: Callable[[], None]) -> None:
|
||||
"""Run a callback in a background thread without blocking startup."""
|
||||
try:
|
||||
_thread.start_new_thread(target, ())
|
||||
return
|
||||
except Exception:
|
||||
# Best-effort fallback for environments where the low-level thread API
|
||||
# is unavailable or restricted.
|
||||
thread = threading.Thread(target=target, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def start(self, *, run_immediately: bool = False) -> None:
|
||||
with self._lock:
|
||||
if not self._owns_schedule:
|
||||
self.stop()
|
||||
return
|
||||
config = self._config_provider()
|
||||
if not self._is_schedule_enabled(config):
|
||||
self.stop()
|
||||
return
|
||||
background_tasks = self._current_background_tasks(config)
|
||||
self.stop()
|
||||
times = normalize_schedule_times(
|
||||
getattr(config, "schedule_times", None),
|
||||
fallback_time=getattr(config, "schedule_time", "18:00"),
|
||||
)
|
||||
scheduler = Scheduler(
|
||||
schedule_time=getattr(config, "schedule_time", "18:00"),
|
||||
schedule_times=times,
|
||||
schedule_times_provider=self._current_times,
|
||||
register_signals=False,
|
||||
)
|
||||
if run_immediately and self._run_immediately_in_background:
|
||||
scheduler.set_daily_task(self._run_analysis_once, run_immediately=False)
|
||||
else:
|
||||
scheduler.set_daily_task(self._run_analysis_once, run_immediately=run_immediately)
|
||||
for entry in background_tasks:
|
||||
scheduler.add_background_task(
|
||||
entry["task"],
|
||||
interval_seconds=entry["interval_seconds"],
|
||||
run_immediately=entry.get("run_immediately", False),
|
||||
name=entry.get("name"),
|
||||
)
|
||||
if run_immediately and self._run_immediately_in_background:
|
||||
self._run_in_background_thread(self._run_analysis_once)
|
||||
thread = threading.Thread(
|
||||
target=scheduler.run,
|
||||
daemon=True,
|
||||
name="runtime-scheduler",
|
||||
)
|
||||
self._scheduler = scheduler
|
||||
self._thread = thread
|
||||
self._enabled = True
|
||||
thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
scheduler = self._scheduler
|
||||
if scheduler is not None:
|
||||
scheduler.stop()
|
||||
self._scheduler = None
|
||||
self._thread = None
|
||||
self._enabled = False
|
||||
|
||||
def reconcile_from_config(
|
||||
self,
|
||||
*,
|
||||
run_immediately: bool = False,
|
||||
clear_enabled_override: bool = False,
|
||||
) -> None:
|
||||
if clear_enabled_override:
|
||||
self._force_enabled = False
|
||||
if not self._owns_schedule:
|
||||
self.stop()
|
||||
return
|
||||
config = self._config_provider()
|
||||
if self._is_schedule_enabled(config):
|
||||
self.start(run_immediately=run_immediately)
|
||||
else:
|
||||
self.stop()
|
||||
|
||||
def run_now(self) -> Dict[str, Any]:
|
||||
if not self._run_lock.acquire(blocking=False):
|
||||
self._record_analysis_busy_skip()
|
||||
return {
|
||||
"accepted": False,
|
||||
"running": True,
|
||||
"reason": "analysis_already_running",
|
||||
}
|
||||
|
||||
def run_and_release() -> None:
|
||||
try:
|
||||
self._run_analysis_locked()
|
||||
finally:
|
||||
self._run_lock.release()
|
||||
|
||||
worker = threading.Thread(
|
||||
target=run_and_release,
|
||||
daemon=True,
|
||||
name="runtime-scheduler-run-now",
|
||||
)
|
||||
try:
|
||||
worker.start()
|
||||
except Exception:
|
||||
self._run_lock.release()
|
||||
raise
|
||||
return {"accepted": True, "running": True}
|
||||
|
||||
def status(self) -> Dict[str, Any]:
|
||||
scheduler = self._scheduler
|
||||
jobs = scheduler.schedule.get_jobs() if scheduler is not None else []
|
||||
next_run = None
|
||||
if jobs:
|
||||
next_run = min(job.next_run for job in jobs).isoformat()
|
||||
if scheduler is not None:
|
||||
schedule_times = list(getattr(scheduler, "schedule_times", []))
|
||||
else:
|
||||
try:
|
||||
schedule_times = self._current_times()
|
||||
except Exception: # pragma: no cover - defensive status fallback
|
||||
schedule_times = []
|
||||
running = self._run_lock.locked()
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"running": running,
|
||||
"schedule_times": schedule_times,
|
||||
"next_run_at": next_run,
|
||||
"last_run_at": self._last_run_at,
|
||||
"last_success_at": self._last_success_at,
|
||||
"last_error": self._last_error,
|
||||
"last_skipped_at": self._last_skipped_at,
|
||||
"last_skip_reason": self._last_skip_reason,
|
||||
}
|
||||
@@ -210,8 +210,9 @@ class SystemConfigService:
|
||||
"astrbot": ("ASTRBOT_URL",),
|
||||
}
|
||||
|
||||
def __init__(self, manager: Optional[ConfigManager] = None):
|
||||
def __init__(self, manager: Optional[ConfigManager] = None, runtime_scheduler: Optional[Any] = None):
|
||||
self._manager = manager or ConfigManager()
|
||||
self._runtime_scheduler = runtime_scheduler
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
"""Return grouped schema metadata for UI rendering."""
|
||||
@@ -1461,6 +1462,18 @@ class SystemConfigService:
|
||||
updates=dict(updates),
|
||||
)
|
||||
)
|
||||
if self._runtime_scheduler is not None and submitted_keys & {
|
||||
"SCHEDULE_ENABLED",
|
||||
"SCHEDULE_TIME",
|
||||
"SCHEDULE_TIMES",
|
||||
}:
|
||||
try:
|
||||
self._runtime_scheduler.reconcile_from_config(
|
||||
clear_enabled_override="SCHEDULE_ENABLED" in submitted_keys,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.error("Runtime scheduler reconcile failed: %s", exc, exc_info=True)
|
||||
warnings.append("Configuration updated but runtime scheduler reconcile failed")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -1539,7 +1552,6 @@ class SystemConfigService:
|
||||
)
|
||||
|
||||
startup_only_schedule_keys = submitted_keys & {
|
||||
"SCHEDULE_ENABLED",
|
||||
"SCHEDULE_RUN_IMMEDIATELY",
|
||||
}
|
||||
if startup_only_schedule_keys:
|
||||
@@ -1551,6 +1563,28 @@ class SystemConfigService:
|
||||
)
|
||||
)
|
||||
|
||||
if "SCHEDULE_ENABLED" in submitted_keys:
|
||||
schedule_enabled = (current_map.get("SCHEDULE_ENABLED", "false") or "false").strip().lower()
|
||||
warnings.append(
|
||||
(
|
||||
f"SCHEDULE_ENABLED={schedule_enabled} 已写入 .env。"
|
||||
"如果当前进程是 WebUI/API/Desktop 长运行进程,runtime scheduler 会按新配置启停;"
|
||||
"CLI schedule 模式仍按启动参数和配置运行。"
|
||||
)
|
||||
)
|
||||
|
||||
if "SCHEDULE_TIMES" in submitted_keys:
|
||||
schedule_times = (current_map.get("SCHEDULE_TIMES", "") or "").strip()
|
||||
schedule_time = (current_map.get("SCHEDULE_TIME", "") or "").strip() or "18:00"
|
||||
effective = schedule_times or schedule_time
|
||||
warnings.append(
|
||||
(
|
||||
f"SCHEDULE_TIMES={effective} 已写入 .env。"
|
||||
"有效时间点会去重、排序;为空时继续使用 SCHEDULE_TIME。"
|
||||
"如果当前进程存在 runtime scheduler,会按新时间重建 daily jobs。"
|
||||
)
|
||||
)
|
||||
|
||||
if "SCHEDULE_TIME" in submitted_keys:
|
||||
schedule_time = (current_map.get("SCHEDULE_TIME", "") or "").strip() or "18:00"
|
||||
warnings.append(
|
||||
|
||||
@@ -68,6 +68,38 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase):
|
||||
self.assertEqual(config.fundamental_stage_timeout_seconds, 8.0)
|
||||
self.assertEqual(config.fundamental_fetch_timeout_seconds, 3.0)
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_schedule_times_parse_dedupe_and_fallback(
|
||||
self, _mock_parse_litellm_yaml, _mock_setup_env
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STOCK_LIST": "600519",
|
||||
"SCHEDULE_TIME": "18:00",
|
||||
"SCHEDULE_TIMES": "15:10,09:20,15:10",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
config = Config._load_from_env()
|
||||
|
||||
self.assertEqual(config.schedule_time, "18:00")
|
||||
self.assertEqual(config.schedule_times, ["09:20", "15:10"])
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STOCK_LIST": "600519",
|
||||
"SCHEDULE_TIME": "09:30",
|
||||
"SCHEDULE_TIMES": "",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
config = Config._load_from_env()
|
||||
|
||||
self.assertEqual(config.schedule_times, [config.schedule_time])
|
||||
|
||||
@patch("src.config.setup_env")
|
||||
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
|
||||
def test_alphasift_install_spec_defaults_only_when_env_missing(
|
||||
|
||||
@@ -180,6 +180,18 @@ class TestLLMUsageHMACFieldsRegistered(unittest.TestCase):
|
||||
self.assertEqual(field["help_key"], "settings.ai_model.LLM_USAGE_HMAC_KEY_VERSION")
|
||||
|
||||
|
||||
class TestScheduleTimesFieldRegistered(unittest.TestCase):
|
||||
def test_schedule_times_pattern_accepts_documented_empty_fallback(self):
|
||||
field = get_field_definition("SCHEDULE_TIMES")
|
||||
pattern = re.compile(field["validation"]["pattern"])
|
||||
|
||||
self.assertIsNotNone(pattern.fullmatch(""))
|
||||
self.assertIsNotNone(pattern.fullmatch(" "))
|
||||
self.assertIsNotNone(pattern.fullmatch("09:20,12:30,15:10"))
|
||||
self.assertIsNone(pattern.fullmatch("09:20,"))
|
||||
self.assertIsNone(pattern.fullmatch("25:70"))
|
||||
|
||||
|
||||
class TestSettingsHelpMetadata(unittest.TestCase):
|
||||
"""Field help metadata should be available for covered settings help slices."""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Regression tests for scheduled mode stock selection behavior."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
@@ -145,6 +146,79 @@ class MainScheduleModeTestCase(unittest.TestCase):
|
||||
self.assertIn("127.0.0.1:8000", str(caught.exception))
|
||||
thread_cls.assert_not_called()
|
||||
|
||||
def test_start_api_server_fails_when_uvicorn_background_startup_fails(self) -> None:
|
||||
config = self._make_config(log_level="INFO")
|
||||
|
||||
class _FakeUvicornServer:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.started = False
|
||||
|
||||
def run(self) -> None:
|
||||
raise RuntimeError("lifespan bootstrap failed")
|
||||
|
||||
class _FakeUvicornConfig:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _FakeUvicornModule:
|
||||
Config = _FakeUvicornConfig
|
||||
|
||||
Server = _FakeUvicornServer
|
||||
|
||||
class _UnusedSocket:
|
||||
def bind(self, address):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("socket.socket", return_value=_UnusedSocket()), \
|
||||
patch.dict("sys.modules", {"uvicorn": _FakeUvicornModule()}):
|
||||
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
main.start_api_server("127.0.0.1", 8000, config)
|
||||
|
||||
self.assertIn("lifespan bootstrap failed", str(caught.exception))
|
||||
|
||||
def test_start_api_server_compatible_with_uvicorn_install_signal_handlers_method(self) -> None:
|
||||
config = self._make_config(log_level="INFO")
|
||||
|
||||
class _CompatServer:
|
||||
instance = None
|
||||
|
||||
def __init__(self, config):
|
||||
type(self).instance = self
|
||||
self.config = config
|
||||
self.started = False
|
||||
self.install_signal_handlers = self._install_signal_handlers
|
||||
|
||||
def _install_signal_handlers(self) -> None:
|
||||
return None
|
||||
|
||||
def run(self) -> None:
|
||||
self.started = True
|
||||
|
||||
class _CompatConfig:
|
||||
def __init__(self, *args, **kwargs):
|
||||
if "install_signal_handlers" in kwargs:
|
||||
raise TypeError("install_signal_handlers is unsupported")
|
||||
|
||||
class _UnusedSocket:
|
||||
def bind(self, address):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("socket.socket", return_value=_UnusedSocket()), \
|
||||
patch.dict("sys.modules", {"uvicorn": SimpleNamespace(Config=_CompatConfig, Server=_CompatServer)}):
|
||||
main.start_api_server("127.0.0.1", 8000, config)
|
||||
|
||||
self.assertIsNotNone(_CompatServer.instance)
|
||||
self.assertTrue(callable(_CompatServer.instance.install_signal_handlers))
|
||||
self.assertTrue(_CompatServer.instance.started)
|
||||
|
||||
def test_schedule_mode_ignores_cli_stock_snapshot(self) -> None:
|
||||
args = self._make_args(schedule=True, stocks="600519,000001")
|
||||
config = self._make_config(schedule_enabled=False)
|
||||
@@ -404,12 +478,14 @@ class MainScheduleModeTestCase(unittest.TestCase):
|
||||
patch("main.start_api_server", side_effect=RuntimeError("port busy")), \
|
||||
patch("main.start_bot_stream_clients") as start_bots, \
|
||||
patch("main.run_full_analysis") as run_full_analysis, \
|
||||
patch("main._run_analysis_with_runtime_scheduler_lock") as run_with_lock, \
|
||||
patch("main.logger.error") as error_log:
|
||||
exit_code = main.main()
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
start_bots.assert_not_called()
|
||||
run_full_analysis.assert_called_once_with(config, args, None)
|
||||
run_with_lock.assert_called_once_with(config, args, None)
|
||||
run_full_analysis.assert_not_called()
|
||||
error_log.assert_called_once()
|
||||
|
||||
def test_serve_schedule_mode_continues_scheduler_when_api_server_start_fails(self) -> None:
|
||||
@@ -450,6 +526,214 @@ class MainScheduleModeTestCase(unittest.TestCase):
|
||||
self.assertEqual(scheduled_call["background_tasks"], [])
|
||||
error_log.assert_called_once()
|
||||
|
||||
def test_serve_with_enabled_schedule_uses_api_runtime_scheduler(self) -> None:
|
||||
from src.services.runtime_scheduler import (
|
||||
CLI_SCHEDULER_OWNER_ENV,
|
||||
RUNTIME_SCHEDULER_ARGS_ENV,
|
||||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||||
)
|
||||
|
||||
args = self._make_args(
|
||||
serve=True,
|
||||
schedule=False,
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
no_notify=True,
|
||||
no_market_review=True,
|
||||
dry_run=True,
|
||||
force_run=True,
|
||||
single_notify=True,
|
||||
no_context_snapshot=True,
|
||||
workers=4,
|
||||
)
|
||||
config = self._make_config(webui_enabled=False, schedule_enabled=True)
|
||||
marker_seen_by_server = []
|
||||
run_immediately_seen_by_server = []
|
||||
runtime_args_seen_by_server = []
|
||||
|
||||
def fake_start_api_server(host, port, config):
|
||||
marker_seen_by_server.append(os.getenv(CLI_SCHEDULER_OWNER_ENV))
|
||||
run_immediately_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||||
runtime_args_seen_by_server.append(json.loads(os.getenv(RUNTIME_SCHEDULER_ARGS_ENV, "{}")))
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"GITHUB_ACTIONS": "false", CLI_SCHEDULER_OWNER_ENV: "true"},
|
||||
clear=False,
|
||||
), \
|
||||
patch("main.parse_arguments", return_value=args), \
|
||||
patch("main.get_config", return_value=config), \
|
||||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||||
patch("main.start_bot_stream_clients") as start_bots, \
|
||||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||||
patch("src.scheduler.run_with_schedule") as run_with_schedule:
|
||||
exit_code = main.main()
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(marker_seen_by_server, [None])
|
||||
self.assertEqual(run_immediately_seen_by_server, ["true"])
|
||||
self.assertEqual(runtime_args_seen_by_server, [{
|
||||
"no_notify": True,
|
||||
"no_market_review": True,
|
||||
"dry_run": True,
|
||||
"force_run": True,
|
||||
"single_notify": True,
|
||||
"no_context_snapshot": True,
|
||||
"workers": 4,
|
||||
}])
|
||||
start_bots.assert_called_once_with(config)
|
||||
run_with_schedule.assert_not_called()
|
||||
|
||||
def test_serve_mode_uses_shared_analysis_lock_for_immediate_run_full_analysis(self) -> None:
|
||||
args = self._make_args(serve=True, schedule=False, host="127.0.0.1", port=8000)
|
||||
config = self._make_config(webui_enabled=False, run_immediately=True)
|
||||
|
||||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||||
patch("main.parse_arguments", return_value=args), \
|
||||
patch("main.get_config", return_value=config), \
|
||||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||||
patch("main.start_api_server"), \
|
||||
patch("main.start_bot_stream_clients") as start_bots, \
|
||||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||||
patch("main.run_full_analysis") as run_full_analysis, \
|
||||
patch("main._run_analysis_with_runtime_scheduler_lock") as run_with_lock:
|
||||
exit_code = main.main()
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(run_with_lock.call_count, 1)
|
||||
run_with_lock.assert_called_once_with(config, args, None)
|
||||
run_full_analysis.assert_not_called()
|
||||
start_bots.assert_called_once_with(config)
|
||||
|
||||
def test_serve_schedule_flag_enables_api_runtime_scheduler(self) -> None:
|
||||
from src.services.runtime_scheduler import (
|
||||
CLI_SCHEDULER_OWNER_ENV,
|
||||
RUNTIME_SCHEDULER_ARGS_ENV,
|
||||
RUNTIME_SCHEDULER_FORCE_ENABLED_ENV,
|
||||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||||
)
|
||||
|
||||
args = self._make_args(
|
||||
serve=True,
|
||||
schedule=True,
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
no_notify=True,
|
||||
no_market_review=True,
|
||||
dry_run=True,
|
||||
force_run=True,
|
||||
single_notify=True,
|
||||
no_context_snapshot=True,
|
||||
workers=4,
|
||||
)
|
||||
config = self._make_config(webui_enabled=False, schedule_enabled=False)
|
||||
marker_seen_by_server = []
|
||||
force_enabled_seen_by_server = []
|
||||
run_immediately_seen_by_server = []
|
||||
runtime_args_seen_by_server = []
|
||||
|
||||
def fake_start_api_server(host, port, config):
|
||||
marker_seen_by_server.append(os.getenv(CLI_SCHEDULER_OWNER_ENV))
|
||||
force_enabled_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_FORCE_ENABLED_ENV))
|
||||
run_immediately_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||||
runtime_args_seen_by_server.append(json.loads(os.getenv(RUNTIME_SCHEDULER_ARGS_ENV, "{}")))
|
||||
|
||||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||||
patch("main.parse_arguments", return_value=args), \
|
||||
patch("main.get_config", return_value=config), \
|
||||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||||
patch("main.start_bot_stream_clients"), \
|
||||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||||
patch("src.scheduler.run_with_schedule") as run_with_schedule:
|
||||
exit_code = main.main()
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(marker_seen_by_server, [None])
|
||||
self.assertEqual(force_enabled_seen_by_server, ["true"])
|
||||
self.assertEqual(run_immediately_seen_by_server, ["true"])
|
||||
self.assertEqual(runtime_args_seen_by_server, [{
|
||||
"no_notify": True,
|
||||
"no_market_review": True,
|
||||
"dry_run": True,
|
||||
"force_run": True,
|
||||
"single_notify": True,
|
||||
"no_context_snapshot": True,
|
||||
"workers": 4,
|
||||
}])
|
||||
self.assertFalse(config.schedule_enabled)
|
||||
run_with_schedule.assert_not_called()
|
||||
|
||||
def test_serve_schedule_flag_passes_no_run_immediately_to_runtime_scheduler(self) -> None:
|
||||
from src.services.runtime_scheduler import RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV
|
||||
|
||||
args = self._make_args(
|
||||
serve=True,
|
||||
schedule=True,
|
||||
no_run_immediately=True,
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
)
|
||||
config = self._make_config(webui_enabled=False, schedule_enabled=False)
|
||||
run_immediately_seen_by_server = []
|
||||
|
||||
def fake_start_api_server(host, port, config):
|
||||
run_immediately_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||||
|
||||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||||
patch("main.parse_arguments", return_value=args), \
|
||||
patch("main.get_config", return_value=config), \
|
||||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||||
patch("main.start_bot_stream_clients"), \
|
||||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||||
patch("src.scheduler.run_with_schedule") as run_with_schedule:
|
||||
exit_code = main.main()
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(run_immediately_seen_by_server, ["false"])
|
||||
run_with_schedule.assert_not_called()
|
||||
|
||||
def test_serve_only_suppresses_startup_scheduler_without_disabling_runtime_owner(self) -> None:
|
||||
from src.services.runtime_scheduler import (
|
||||
CLI_SCHEDULER_OWNER_ENV,
|
||||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||||
RUNTIME_SCHEDULER_SUPPRESS_START_ENV,
|
||||
)
|
||||
|
||||
args = self._make_args(serve_only=True, host="127.0.0.1", port=8000)
|
||||
config = self._make_config(webui_enabled=False, schedule_enabled=True)
|
||||
marker_seen_by_server = []
|
||||
suppress_seen_by_server = []
|
||||
run_immediately_seen_by_server = []
|
||||
|
||||
def fake_start_api_server(host, port, config):
|
||||
marker_seen_by_server.append(os.getenv(CLI_SCHEDULER_OWNER_ENV))
|
||||
suppress_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_SUPPRESS_START_ENV))
|
||||
run_immediately_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"GITHUB_ACTIONS": "false", CLI_SCHEDULER_OWNER_ENV: "true"},
|
||||
clear=False,
|
||||
), \
|
||||
patch("main.parse_arguments", return_value=args), \
|
||||
patch("main.get_config", return_value=config), \
|
||||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||||
patch("main.start_bot_stream_clients") as start_bots, \
|
||||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||||
patch("src.scheduler.run_with_schedule") as run_with_schedule:
|
||||
exit_code = main.main()
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(marker_seen_by_server, [None])
|
||||
self.assertEqual(suppress_seen_by_server, ["true"])
|
||||
self.assertEqual(run_immediately_seen_by_server, [None])
|
||||
start_bots.assert_called_once_with(config)
|
||||
run_with_schedule.assert_not_called()
|
||||
|
||||
def test_reload_runtime_config_preserves_process_env_overrides(self) -> None:
|
||||
self.env_path.write_text(
|
||||
"OPENAI_API_KEY=stale-file\nSCHEDULE_TIME=09:30\n",
|
||||
|
||||
738
tests/test_runtime_scheduler_service.py
Normal file
738
tests/test_runtime_scheduler_service.py
Normal file
@@ -0,0 +1,738 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Regression tests for RuntimeSchedulerService scheduling ownership."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.services.runtime_scheduler import (
|
||||
CLI_SCHEDULER_OWNER_ENV,
|
||||
RUNTIME_SCHEDULER_ARGS_ENV,
|
||||
RUNTIME_SCHEDULER_FORCE_ENABLED_ENV,
|
||||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||||
RUNTIME_SCHEDULER_SUPPRESS_START_ENV,
|
||||
RuntimeSchedulerService,
|
||||
)
|
||||
|
||||
|
||||
class _FakeJob:
|
||||
def __init__(self, schedule_module):
|
||||
self._schedule_module = schedule_module
|
||||
self.next_run = datetime(2026, 1, 1, 18, 0, 0)
|
||||
self.at_time = None
|
||||
self.job_func = None
|
||||
|
||||
@property
|
||||
def day(self):
|
||||
return self
|
||||
|
||||
def at(self, value):
|
||||
self.at_time = value
|
||||
hour, minute = [int(part) for part in value.split(":")]
|
||||
self.next_run = datetime(2026, 1, 1, hour, minute, 0)
|
||||
return self
|
||||
|
||||
def do(self, fn):
|
||||
self.job_func = fn
|
||||
self._schedule_module.jobs.append(self)
|
||||
return self
|
||||
|
||||
|
||||
class _FakeScheduleModule:
|
||||
def __init__(self):
|
||||
self.jobs = []
|
||||
|
||||
def every(self):
|
||||
return _FakeJob(self)
|
||||
|
||||
def get_jobs(self):
|
||||
return list(self.jobs)
|
||||
|
||||
def run_pending(self):
|
||||
for job in list(self.jobs):
|
||||
job.job_func()
|
||||
|
||||
def cancel_job(self, job):
|
||||
if job in self.jobs:
|
||||
self.jobs.remove(job)
|
||||
|
||||
|
||||
class _NoopThread:
|
||||
def __init__(self, target=None, **kwargs):
|
||||
self.target = target
|
||||
self.kwargs = kwargs
|
||||
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
|
||||
class RuntimeSchedulerServiceTestCase(unittest.TestCase):
|
||||
def test_run_analysis_args_include_workers(self) -> None:
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["18:00"],
|
||||
)
|
||||
seen_args = []
|
||||
|
||||
def runner(config_arg, args, stock_codes):
|
||||
seen_args.append(args)
|
||||
|
||||
service = RuntimeSchedulerService(
|
||||
config_provider=lambda: config,
|
||||
task_runner=runner,
|
||||
)
|
||||
service._reload_config = lambda: config
|
||||
|
||||
service._run_analysis_once()
|
||||
|
||||
self.assertEqual(len(seen_args), 1)
|
||||
self.assertTrue(hasattr(seen_args[0], "workers"))
|
||||
self.assertIsNone(seen_args[0].workers)
|
||||
|
||||
def test_run_analysis_args_preserve_startup_schedule_flags(self) -> None:
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["18:00"],
|
||||
)
|
||||
seen_args = []
|
||||
|
||||
def runner(config_arg, args, stock_codes):
|
||||
seen_args.append(args)
|
||||
|
||||
service = RuntimeSchedulerService(
|
||||
config_provider=lambda: config,
|
||||
task_runner=runner,
|
||||
schedule_args_overrides={
|
||||
"no_notify": True,
|
||||
"no_market_review": True,
|
||||
"dry_run": True,
|
||||
"force_run": True,
|
||||
"single_notify": True,
|
||||
"no_context_snapshot": True,
|
||||
"workers": 3,
|
||||
"serve": True,
|
||||
},
|
||||
)
|
||||
service._reload_config = lambda: config
|
||||
|
||||
service._run_analysis_once()
|
||||
|
||||
self.assertEqual(len(seen_args), 1)
|
||||
self.assertTrue(seen_args[0].no_notify)
|
||||
self.assertTrue(seen_args[0].no_market_review)
|
||||
self.assertTrue(seen_args[0].dry_run)
|
||||
self.assertTrue(seen_args[0].force_run)
|
||||
self.assertTrue(seen_args[0].single_notify)
|
||||
self.assertTrue(seen_args[0].no_context_snapshot)
|
||||
self.assertEqual(seen_args[0].workers, 3)
|
||||
self.assertFalse(seen_args[0].serve)
|
||||
self.assertTrue(seen_args[0].serve_only)
|
||||
|
||||
def test_default_runner_does_not_mark_failed_analysis_return_success(self) -> None:
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["18:00"],
|
||||
)
|
||||
service = RuntimeSchedulerService(config_provider=lambda: config)
|
||||
service._reload_config = lambda: config
|
||||
|
||||
with patch("main.run_full_analysis", return_value=False) as run_full_analysis:
|
||||
service._run_analysis_once()
|
||||
|
||||
run_full_analysis.assert_called_once()
|
||||
self.assertTrue(run_full_analysis.call_args.kwargs["raise_errors"])
|
||||
status = service.status()
|
||||
self.assertIsNone(status["last_success_at"])
|
||||
self.assertIn("reported failure", status["last_error"])
|
||||
|
||||
def test_run_now_rejects_when_analysis_is_already_running(self) -> None:
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["18:00"],
|
||||
)
|
||||
service = RuntimeSchedulerService(config_provider=lambda: config)
|
||||
service._run_lock.acquire()
|
||||
try:
|
||||
result = service.run_now()
|
||||
finally:
|
||||
service._run_lock.release()
|
||||
|
||||
self.assertFalse(result["accepted"])
|
||||
self.assertTrue(result["running"])
|
||||
self.assertEqual(result["reason"], "analysis_already_running")
|
||||
status = service.status()
|
||||
self.assertEqual(status["last_skip_reason"], "analysis_already_running")
|
||||
self.assertIsNotNone(status["last_skipped_at"])
|
||||
|
||||
def test_run_now_uses_shared_lock_across_service_instances(self) -> None:
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["18:00"],
|
||||
)
|
||||
primary_service = RuntimeSchedulerService(config_provider=lambda: config)
|
||||
secondary_service = RuntimeSchedulerService(config_provider=lambda: config)
|
||||
|
||||
self.assertIs(primary_service._run_lock, secondary_service._run_lock)
|
||||
|
||||
primary_service._run_lock.acquire()
|
||||
try:
|
||||
result = secondary_service.run_now()
|
||||
finally:
|
||||
primary_service._run_lock.release()
|
||||
|
||||
self.assertFalse(result["accepted"])
|
||||
self.assertEqual(result["running"], True)
|
||||
self.assertEqual(result["reason"], "analysis_already_running")
|
||||
status = secondary_service.status()
|
||||
self.assertEqual(status["last_skip_reason"], "analysis_already_running")
|
||||
self.assertIsNotNone(status["last_skipped_at"])
|
||||
|
||||
def test_run_now_endpoint_returns_conflict_when_scheduler_is_busy(self) -> None:
|
||||
from api.v1.endpoints.system_config import run_scheduler_now
|
||||
|
||||
scheduler = MagicMock()
|
||||
scheduler.run_now.return_value = {
|
||||
"accepted": False,
|
||||
"running": True,
|
||||
"reason": "analysis_already_running",
|
||||
}
|
||||
|
||||
with self.assertRaises(HTTPException) as captured:
|
||||
run_scheduler_now(scheduler=scheduler)
|
||||
|
||||
self.assertEqual(captured.exception.status_code, 409)
|
||||
self.assertEqual(captured.exception.detail["error"], "scheduler_busy")
|
||||
self.assertEqual(captured.exception.detail["reason"], "analysis_already_running")
|
||||
|
||||
def test_reconcile_replaces_daily_jobs_without_triggering_old_jobs(self) -> None:
|
||||
fake_schedule = _FakeScheduleModule()
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["09:20"],
|
||||
)
|
||||
calls = []
|
||||
|
||||
def runner(config_arg, args, stock_codes):
|
||||
calls.append("run")
|
||||
|
||||
service = RuntimeSchedulerService(
|
||||
config_provider=lambda: config,
|
||||
task_runner=runner,
|
||||
)
|
||||
service._reload_config = lambda: config
|
||||
|
||||
with patch.dict(sys.modules, {"schedule": fake_schedule}), patch(
|
||||
"src.services.runtime_scheduler.threading.Thread",
|
||||
_NoopThread,
|
||||
):
|
||||
service.reconcile_from_config()
|
||||
old_jobs = fake_schedule.get_jobs()
|
||||
self.assertEqual([job.at_time for job in old_jobs], ["09:20"])
|
||||
|
||||
config.schedule_times = ["15:10"]
|
||||
service.reconcile_from_config()
|
||||
|
||||
self.assertEqual([job.at_time for job in fake_schedule.get_jobs()], ["15:10"])
|
||||
self.assertNotIn(old_jobs[0], fake_schedule.get_jobs())
|
||||
|
||||
fake_schedule.run_pending()
|
||||
|
||||
self.assertEqual(calls, ["run"])
|
||||
|
||||
def test_initial_reconcile_can_run_immediately_once(self) -> None:
|
||||
fake_schedule = _FakeScheduleModule()
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["09:20"],
|
||||
)
|
||||
calls = []
|
||||
|
||||
def runner(config_arg, args, stock_codes):
|
||||
calls.append("run")
|
||||
|
||||
service = RuntimeSchedulerService(
|
||||
config_provider=lambda: config,
|
||||
task_runner=runner,
|
||||
)
|
||||
service._reload_config = lambda: config
|
||||
|
||||
with patch.dict(sys.modules, {"schedule": fake_schedule}), patch(
|
||||
"src.services.runtime_scheduler.threading.Thread",
|
||||
_NoopThread,
|
||||
):
|
||||
service.reconcile_from_config(run_immediately=True)
|
||||
config.schedule_times = ["15:10"]
|
||||
service.reconcile_from_config()
|
||||
|
||||
self.assertEqual(calls, ["run"])
|
||||
|
||||
def test_start_registers_event_monitor_background_task(self) -> None:
|
||||
class _FakeScheduler:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.background_tasks = []
|
||||
self.daily_task = None
|
||||
self.daily_task_run_immediately = None
|
||||
self._jobs = []
|
||||
|
||||
def set_daily_task(self, task, run_immediately: bool) -> None:
|
||||
self.daily_task = task
|
||||
self.daily_task_run_immediately = run_immediately
|
||||
|
||||
def add_background_task(
|
||||
self,
|
||||
task: callable,
|
||||
interval_seconds: int,
|
||||
run_immediately: bool,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
self.background_tasks.append({
|
||||
"task": task,
|
||||
"interval_seconds": interval_seconds,
|
||||
"run_immediately": run_immediately,
|
||||
"name": name,
|
||||
})
|
||||
|
||||
def run(self) -> None:
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def schedule(self):
|
||||
class _Namespace:
|
||||
@staticmethod
|
||||
def get_jobs():
|
||||
return []
|
||||
|
||||
return _Namespace
|
||||
|
||||
@property
|
||||
def schedule_time(self):
|
||||
return self.kwargs.get("schedule_time")
|
||||
|
||||
fake_worker = MagicMock()
|
||||
fake_worker.run_once.return_value = {"triggered": 2}
|
||||
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["18:00"],
|
||||
agent_event_monitor_enabled=True,
|
||||
agent_event_monitor_interval_minutes=7,
|
||||
)
|
||||
|
||||
service = RuntimeSchedulerService(config_provider=lambda: config)
|
||||
service._reload_config = lambda: config
|
||||
|
||||
with patch(
|
||||
"src.services.runtime_scheduler.Scheduler",
|
||||
_FakeScheduler,
|
||||
), patch(
|
||||
"src.services.runtime_scheduler.threading.Thread",
|
||||
_NoopThread,
|
||||
), patch("src.services.alert_worker.AlertWorker", return_value=fake_worker):
|
||||
service.start()
|
||||
|
||||
scheduler = service._scheduler
|
||||
self.assertIsNotNone(scheduler)
|
||||
self.assertEqual(len(scheduler.background_tasks), 1) # type: ignore[attr-defined]
|
||||
self.assertEqual(scheduler.background_tasks[0]["name"], "agent_event_monitor") # type: ignore[index]
|
||||
self.assertEqual(scheduler.background_tasks[0]["interval_seconds"], 7 * 60) # type: ignore[index]
|
||||
self.assertEqual(scheduler.background_tasks[0]["run_immediately"], True) # type: ignore[index]
|
||||
scheduler.background_tasks[0]["task"]() # type: ignore[index]
|
||||
fake_worker.run_once.assert_called_once()
|
||||
|
||||
def test_rebuild_reuses_event_monitor_without_immediate_rerun(self) -> None:
|
||||
schedulers = []
|
||||
|
||||
class _FakeScheduler:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.background_tasks = []
|
||||
self.daily_task = None
|
||||
self.daily_task_run_immediately = None
|
||||
self._jobs = []
|
||||
schedulers.append(self)
|
||||
|
||||
def set_daily_task(self, task, run_immediately: bool) -> None:
|
||||
self.daily_task = task
|
||||
self.daily_task_run_immediately = run_immediately
|
||||
|
||||
def add_background_task(
|
||||
self,
|
||||
task: callable,
|
||||
interval_seconds: int,
|
||||
run_immediately: bool,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
self.background_tasks.append({
|
||||
"task": task,
|
||||
"interval_seconds": interval_seconds,
|
||||
"run_immediately": run_immediately,
|
||||
"name": name,
|
||||
})
|
||||
|
||||
def run(self) -> None:
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def schedule(self):
|
||||
class _Namespace:
|
||||
@staticmethod
|
||||
def get_jobs():
|
||||
return []
|
||||
|
||||
return _Namespace
|
||||
|
||||
@property
|
||||
def schedule_time(self):
|
||||
return self.kwargs.get("schedule_time")
|
||||
|
||||
fake_worker = MagicMock()
|
||||
fake_worker.run_once.return_value = {"triggered": 0}
|
||||
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=True,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["18:00"],
|
||||
agent_event_monitor_enabled=True,
|
||||
agent_event_monitor_interval_minutes=7,
|
||||
)
|
||||
|
||||
service = RuntimeSchedulerService(config_provider=lambda: config)
|
||||
service._reload_config = lambda: config
|
||||
|
||||
with patch(
|
||||
"src.services.runtime_scheduler.Scheduler",
|
||||
_FakeScheduler,
|
||||
), patch(
|
||||
"src.services.runtime_scheduler.threading.Thread",
|
||||
_NoopThread,
|
||||
), patch("src.services.alert_worker.AlertWorker", return_value=fake_worker) as worker_cls:
|
||||
service.reconcile_from_config()
|
||||
config.schedule_times = ["19:00"]
|
||||
config.agent_event_monitor_interval_minutes = 11
|
||||
service.reconcile_from_config()
|
||||
config.schedule_times = ["20:00"]
|
||||
service.reconcile_from_config()
|
||||
|
||||
self.assertEqual(worker_cls.call_count, 1)
|
||||
self.assertEqual(len(schedulers), 3)
|
||||
first_task = schedulers[0].background_tasks[0]
|
||||
second_task = schedulers[1].background_tasks[0]
|
||||
third_task = schedulers[2].background_tasks[0]
|
||||
self.assertTrue(first_task["run_immediately"])
|
||||
self.assertFalse(second_task["run_immediately"])
|
||||
self.assertFalse(third_task["run_immediately"])
|
||||
self.assertIs(first_task["task"], second_task["task"])
|
||||
self.assertIs(first_task["task"], third_task["task"])
|
||||
self.assertEqual(second_task["interval_seconds"], 11 * 60)
|
||||
self.assertEqual(third_task["interval_seconds"], 11 * 60)
|
||||
|
||||
def test_force_enabled_survives_time_reconcile_until_explicit_enabled_update(self) -> None:
|
||||
fake_schedule = _FakeScheduleModule()
|
||||
config = SimpleNamespace(
|
||||
schedule_enabled=False,
|
||||
schedule_time="18:00",
|
||||
schedule_times=["09:20"],
|
||||
)
|
||||
service = RuntimeSchedulerService(
|
||||
config_provider=lambda: config,
|
||||
force_enabled=True,
|
||||
)
|
||||
|
||||
with patch.dict(sys.modules, {"schedule": fake_schedule}), patch(
|
||||
"src.services.runtime_scheduler.threading.Thread",
|
||||
_NoopThread,
|
||||
):
|
||||
service.reconcile_from_config()
|
||||
self.assertTrue(service.status()["enabled"])
|
||||
|
||||
config.schedule_times = ["15:10"]
|
||||
service.reconcile_from_config()
|
||||
self.assertTrue(service.status()["enabled"])
|
||||
self.assertEqual([job.at_time for job in fake_schedule.get_jobs()], ["15:10"])
|
||||
|
||||
service.reconcile_from_config(clear_enabled_override=True)
|
||||
self.assertFalse(service.status()["enabled"])
|
||||
self.assertEqual(fake_schedule.get_jobs(), [])
|
||||
|
||||
def test_lifespan_disables_runtime_scheduler_when_cli_owns_schedule(self) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
events = []
|
||||
|
||||
class FakeRuntimeSchedulerService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
owns_schedule=True,
|
||||
force_enabled=False,
|
||||
run_immediately_in_background=False,
|
||||
schedule_args_overrides=None,
|
||||
):
|
||||
self.owns_schedule = owns_schedule
|
||||
self.force_enabled = force_enabled
|
||||
events.append(("init", owns_schedule, force_enabled, run_immediately_in_background))
|
||||
|
||||
def reconcile_from_config(self, *, run_immediately=False, clear_enabled_override=False):
|
||||
events.append((
|
||||
"reconcile",
|
||||
self.owns_schedule,
|
||||
run_immediately,
|
||||
clear_enabled_override,
|
||||
))
|
||||
|
||||
def stop(self):
|
||||
events.append(("stop", self.owns_schedule))
|
||||
|
||||
class FakeSystemConfigService:
|
||||
def __init__(self, runtime_scheduler=None):
|
||||
self.runtime_scheduler = runtime_scheduler
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir, patch.dict(
|
||||
os.environ,
|
||||
{CLI_SCHEDULER_OWNER_ENV: "true"},
|
||||
clear=False,
|
||||
), patch(
|
||||
"src.config.get_config",
|
||||
return_value=SimpleNamespace(schedule_run_immediately=True),
|
||||
), patch("api.app.RuntimeSchedulerService", FakeRuntimeSchedulerService), patch(
|
||||
"api.app.SystemConfigService",
|
||||
FakeSystemConfigService,
|
||||
), patch("api.app._schedule_stock_index_background_refresh"):
|
||||
app = create_app(static_dir=Path(temp_dir))
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
self.assertEqual(events, [
|
||||
("init", False, False, True),
|
||||
("reconcile", False, False, False),
|
||||
("stop", False),
|
||||
])
|
||||
|
||||
def test_lifespan_passes_runtime_scheduler_start_flags(self) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
events = []
|
||||
|
||||
class FakeRuntimeSchedulerService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
owns_schedule=True,
|
||||
force_enabled=False,
|
||||
run_immediately_in_background=False,
|
||||
schedule_args_overrides=None,
|
||||
):
|
||||
events.append(("init", owns_schedule, force_enabled, run_immediately_in_background))
|
||||
|
||||
def reconcile_from_config(self, *, run_immediately=False, clear_enabled_override=False):
|
||||
events.append(("reconcile", run_immediately, clear_enabled_override))
|
||||
|
||||
def stop(self):
|
||||
events.append(("stop",))
|
||||
|
||||
class FakeSystemConfigService:
|
||||
def __init__(self, runtime_scheduler=None):
|
||||
self.runtime_scheduler = runtime_scheduler
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir, patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
RUNTIME_SCHEDULER_FORCE_ENABLED_ENV: "true",
|
||||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV: "true",
|
||||
},
|
||||
clear=False,
|
||||
), patch("api.app.RuntimeSchedulerService", FakeRuntimeSchedulerService), patch(
|
||||
"api.app.SystemConfigService",
|
||||
FakeSystemConfigService,
|
||||
), patch("api.app._schedule_stock_index_background_refresh"):
|
||||
app = create_app(static_dir=Path(temp_dir))
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
self.assertEqual(events, [
|
||||
("init", True, True, True),
|
||||
("reconcile", True, False),
|
||||
("stop",),
|
||||
])
|
||||
self.assertIsNone(os.getenv(RUNTIME_SCHEDULER_FORCE_ENABLED_ENV))
|
||||
self.assertIsNone(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||||
|
||||
def test_lifespan_suppresses_initial_start_without_losing_runtime_ownership(self) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
events = []
|
||||
|
||||
class FakeRuntimeSchedulerService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
owns_schedule=True,
|
||||
force_enabled=False,
|
||||
run_immediately_in_background=False,
|
||||
schedule_args_overrides=None,
|
||||
):
|
||||
events.append(("init", owns_schedule, force_enabled, run_immediately_in_background))
|
||||
|
||||
def reconcile_from_config(self, *, run_immediately=False, clear_enabled_override=False):
|
||||
events.append(("reconcile", run_immediately, clear_enabled_override))
|
||||
|
||||
def stop(self):
|
||||
events.append(("stop",))
|
||||
|
||||
class FakeSystemConfigService:
|
||||
def __init__(self, runtime_scheduler=None):
|
||||
self.runtime_scheduler = runtime_scheduler
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir, patch.dict(
|
||||
os.environ,
|
||||
{RUNTIME_SCHEDULER_SUPPRESS_START_ENV: "true"},
|
||||
clear=False,
|
||||
), patch(
|
||||
"src.config.get_config",
|
||||
return_value=SimpleNamespace(schedule_run_immediately=True),
|
||||
), patch("api.app.RuntimeSchedulerService", FakeRuntimeSchedulerService), patch(
|
||||
"api.app.SystemConfigService",
|
||||
FakeSystemConfigService,
|
||||
), patch("api.app._schedule_stock_index_background_refresh"):
|
||||
app = create_app(static_dir=Path(temp_dir))
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
self.assertEqual(events, [
|
||||
("init", True, False, True),
|
||||
("stop",),
|
||||
])
|
||||
self.assertIsNone(os.getenv(RUNTIME_SCHEDULER_SUPPRESS_START_ENV))
|
||||
|
||||
def test_lifespan_passes_runtime_scheduler_args_overrides(self) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
events = []
|
||||
runtime_args = {
|
||||
"no_notify": True,
|
||||
"no_market_review": True,
|
||||
"dry_run": True,
|
||||
"force_run": True,
|
||||
"single_notify": True,
|
||||
"no_context_snapshot": True,
|
||||
"workers": 4,
|
||||
}
|
||||
|
||||
class FakeRuntimeSchedulerService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
owns_schedule=True,
|
||||
force_enabled=False,
|
||||
run_immediately_in_background=False,
|
||||
schedule_args_overrides=None,
|
||||
):
|
||||
events.append(("init_args", schedule_args_overrides))
|
||||
|
||||
def reconcile_from_config(self, *, run_immediately=False, clear_enabled_override=False):
|
||||
events.append(("reconcile", run_immediately, clear_enabled_override))
|
||||
|
||||
def stop(self):
|
||||
events.append(("stop",))
|
||||
|
||||
class FakeSystemConfigService:
|
||||
def __init__(self, runtime_scheduler=None):
|
||||
self.runtime_scheduler = runtime_scheduler
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir, patch.dict(
|
||||
os.environ,
|
||||
{RUNTIME_SCHEDULER_ARGS_ENV: json.dumps(runtime_args)},
|
||||
clear=False,
|
||||
), patch(
|
||||
"src.config.get_config",
|
||||
return_value=SimpleNamespace(schedule_run_immediately=True),
|
||||
), patch("api.app.RuntimeSchedulerService", FakeRuntimeSchedulerService), patch(
|
||||
"api.app.SystemConfigService",
|
||||
FakeSystemConfigService,
|
||||
), patch("api.app._schedule_stock_index_background_refresh"):
|
||||
app = create_app(static_dir=Path(temp_dir))
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
self.assertEqual(events[0], ("init_args", runtime_args))
|
||||
self.assertIsNone(os.getenv(RUNTIME_SCHEDULER_ARGS_ENV))
|
||||
|
||||
def test_lifespan_uses_configured_run_immediately_without_override(self) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
events = []
|
||||
|
||||
class FakeRuntimeSchedulerService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
owns_schedule=True,
|
||||
force_enabled=False,
|
||||
run_immediately_in_background=False,
|
||||
schedule_args_overrides=None,
|
||||
):
|
||||
events.append(("init", owns_schedule, force_enabled, run_immediately_in_background))
|
||||
|
||||
def reconcile_from_config(self, *, run_immediately=False, clear_enabled_override=False):
|
||||
events.append(("reconcile", run_immediately, clear_enabled_override))
|
||||
|
||||
def stop(self):
|
||||
events.append(("stop",))
|
||||
|
||||
class FakeSystemConfigService:
|
||||
def __init__(self, runtime_scheduler=None):
|
||||
self.runtime_scheduler = runtime_scheduler
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir, patch.dict(os.environ, {}, clear=False), patch(
|
||||
"src.config.get_config",
|
||||
return_value=SimpleNamespace(schedule_run_immediately=True),
|
||||
), patch("api.app.RuntimeSchedulerService", FakeRuntimeSchedulerService), patch(
|
||||
"api.app.SystemConfigService",
|
||||
FakeSystemConfigService,
|
||||
), patch("api.app._schedule_stock_index_background_refresh"):
|
||||
os.environ.pop(CLI_SCHEDULER_OWNER_ENV, None)
|
||||
os.environ.pop(RUNTIME_SCHEDULER_FORCE_ENABLED_ENV, None)
|
||||
os.environ.pop(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV, None)
|
||||
|
||||
app = create_app(static_dir=Path(temp_dir))
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
self.assertEqual(events, [
|
||||
("init", True, False, True),
|
||||
("reconcile", True, False),
|
||||
("stop",),
|
||||
])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -135,6 +135,38 @@ class SchedulerBackgroundTaskTestCase(unittest.TestCase):
|
||||
self.assertEqual(fake_schedule.jobs[0].at_time, "09:30")
|
||||
self.assertEqual(scheduler.schedule_time, "09:30")
|
||||
|
||||
def test_scheduler_registers_multiple_daily_jobs(self):
|
||||
fake_schedule = _FakeScheduleModule()
|
||||
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
||||
from src.scheduler import Scheduler
|
||||
|
||||
scheduler = Scheduler(
|
||||
schedule_time="18:00",
|
||||
schedule_times=["15:10", "09:20", "15:10"],
|
||||
)
|
||||
scheduler.set_daily_task(lambda: None, run_immediately=False)
|
||||
|
||||
self.assertEqual([job.at_time for job in fake_schedule.jobs], ["09:20", "15:10"])
|
||||
self.assertEqual(scheduler.schedule_times, ["09:20", "15:10"])
|
||||
|
||||
def test_scheduler_stop_cancels_registered_daily_jobs(self):
|
||||
fake_schedule = _FakeScheduleModule()
|
||||
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
||||
from src.scheduler import Scheduler
|
||||
|
||||
scheduler = Scheduler(
|
||||
schedule_time="18:00",
|
||||
schedule_times=["09:20", "15:10"],
|
||||
)
|
||||
scheduler.set_daily_task(lambda: None, run_immediately=False)
|
||||
self.assertEqual(len(fake_schedule.jobs), 2)
|
||||
|
||||
scheduler.stop()
|
||||
|
||||
self.assertEqual(fake_schedule.jobs, [])
|
||||
self.assertEqual(scheduler._daily_jobs, [])
|
||||
self.assertIsNone(scheduler._daily_job)
|
||||
|
||||
def test_scheduler_keeps_existing_daily_job_when_schedule_time_invalid(self):
|
||||
fake_schedule = _FakeScheduleModule()
|
||||
with patch.dict(sys.modules, {"schedule": fake_schedule}):
|
||||
|
||||
@@ -795,6 +795,11 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertFalse(validation["valid"])
|
||||
self.assertTrue(any(issue["code"] == "invalid_format" for issue in validation["issues"]))
|
||||
|
||||
def test_validate_accepts_empty_schedule_times_fallback(self) -> None:
|
||||
validation = self.service.validate(items=[{"key": "SCHEDULE_TIMES", "value": ""}])
|
||||
self.assertTrue(validation["valid"])
|
||||
self.assertEqual(validation["issues"], [])
|
||||
|
||||
def test_validate_reports_invalid_searxng_url(self) -> None:
|
||||
validation = self.service.validate(items=[{"key": "SEARXNG_BASE_URLS", "value": "searx.local,https://ok.example"}])
|
||||
self.assertFalse(validation["valid"])
|
||||
@@ -2665,13 +2670,20 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
for warning in response["warnings"]
|
||||
if "SCHEDULE_ENABLED" in warning
|
||||
)
|
||||
schedule_run_warning = next(
|
||||
warning
|
||||
for warning in response["warnings"]
|
||||
if "SCHEDULE_RUN_IMMEDIATELY" in warning
|
||||
)
|
||||
|
||||
self.assertIn("非 schedule 模式", run_warning)
|
||||
self.assertNotIn("以 schedule 模式", run_warning)
|
||||
self.assertIn("SCHEDULE_RUN_IMMEDIATELY", schedule_warning)
|
||||
self.assertIn("不会因为本次保存启动、停止或重建 scheduler", schedule_warning)
|
||||
self.assertIn("以 schedule 模式重新启动后生效", schedule_warning)
|
||||
self.assertNotIn("它属于启动期单次运行配置", schedule_warning)
|
||||
self.assertIn("runtime scheduler", schedule_warning)
|
||||
self.assertIn("CLI schedule", schedule_warning)
|
||||
self.assertIn("SCHEDULE_RUN_IMMEDIATELY", schedule_run_warning)
|
||||
self.assertIn("不会因为本次保存启动、停止或重建 scheduler", schedule_run_warning)
|
||||
self.assertIn("以 schedule 模式重新启动后生效", schedule_run_warning)
|
||||
self.assertNotIn("它属于启动期单次运行配置", schedule_run_warning)
|
||||
|
||||
def test_update_appends_schedule_time_runtime_rebind_warning(self) -> None:
|
||||
response = self.service.update(
|
||||
|
||||
Reference in New Issue
Block a user