feat(all): 任务引擎双阶段解耦、僵尸涡旋修复、动态 CPU 配额与前端详情页重构

将 TLUSTY/SYNSPEC 拆为各自独立的 enabled/policy/strategies 阶段,
以策略链自动弹栈取代单级 seed_step 布尔回退;定向修复 2026-08-02
僵尸任务涡旋事故;新增节点并发配额热调;前端详情页从 1412 行巨型
视图拆为薄控制器 + detail 子模块,并补齐工具层与单测。

引擎与调度(task_engine_decoupling_design.md)
- models.rs: 新增 StagePolicy / EngineStageConfig / TaskSpec 阶段字段、
  normalize_compat() 校正旧版在途消息策略链、failed_stage 归因
- scheduler.rs: resolve_dispatchable_chain 派发门控、
  trigger_strategy_fallback 按 failed_stage 精确弹栈;启动期
  force_recompute/skip_converged(默认)/skip_failed 三策略
- db.rs: tasks 表 +7 列持久化阶段配置;终态守卫
  (mark_grid_point_running 仅 pending/queued→running;
  record_task_report 拒绝迟到失败翻黑 converged);策略弹栈快照

僵尸涡旋修复(runbook-20260802-zombie-vortex-fix.md)
- 全链路跨库活性交叉校验:派发/claim/孤儿回收/回退统一查 MQ 队列活性,
  活则放行、死则清僵尸,结构性消除"每点重复派发"
- stop/重启卫生:清队列同步 delete_tasks_by_ids,杜绝遗留 pending 行
- report_task: 幂等吸收 + 409 区分迟到冗余结果,仅 state_changed 时回退
- MQ: NULL workflow_name 回填 __legacy__、requeue 后迟到上报被 403 竞态修复

动态 CPU 配额(dynamic_cpu_slots_design.md)
- admin.rs: POST /admin/nodes/:id/quota(Option<Option<i32>> 区分
  缺字段/显式 null);nodes 表 +admin_max_slots
- worker.rs: effective_max_slots = min(admin, physical),心跳下发原子生效

科学产物保全(tlusty_result_artifacts.md)
- runner.rs: SYNSPEC 启动前快照 fort.12/fort.14 → .bfac/.emflux 防覆盖
- 半失败点(大气收敛+光谱失败)改判 Failed 并写入 note;仅 SYNSPEC
  场景不再恒判失败;撤销归档 LRU 200 上限改为永久保留
- executor.rs: 透传 synspec_params 数值参数(此前固定 None)

前端(dashboard/)
- workflowDetail.js 1412→328 行,拆出 views/detail/{ctx,overview,
  pointsTable,parSets,pointPanel}.js,AbortController 治理监听/请求生命周期
- 删除 wfActions.js,新增 wfEnginePanel.js(双阶段三维配置编辑面板)
- 新增 utils/{errors,format,icons,polling,yamlStage}.js 纯函数模块
- 路由级动态 import 代码分割;节点配额三点菜单 + Modal 管理
- 首次引入 node:test 单测(format/polling/yamlStage/psCache,644 行)
- 系统性补齐 a11y:skip-link、ARIA、Tab 键盘漫游、toast 关闭、退出动画

文档与工具
- 新增 6 篇设计/调研:引擎解耦、动态配额、涡旋 runbook、
  光谱正确性分析、收敛判断、产物归档
- PIPELINE/design/api/database 等协同重写为分布式 C/S 架构口径
- scripts/fetch_results.sh 跨节点产物备份;import_results 按 cno 升序导入
- workflows/sdB_cno.yaml: 新增 tlusty/synspec_stage 配置块,修正 wstart 笔误
This commit is contained in:
fmq
2026-08-04 23:40:52 +08:00
parent c8fd24b120
commit cd370d88e7
74 changed files with 13386 additions and 3033 deletions
+1
View File
@@ -45,3 +45,4 @@ hosts.ini
# agent # agent
.zcode/ .zcode/
.omc/ .omc/
.claude/
-207
View File
@@ -1,207 +0,0 @@
{
"version": "1.0.0",
"lastScanned": 1785390240373,
"projectRoot": "/home/fmq/program/tlusty/tl208-s54/dcts",
"techStack": {
"languages": [
{
"name": "Rust",
"version": null,
"confidence": "high",
"markers": [
"Cargo.toml"
]
}
],
"frameworks": [
{
"name": "axum",
"version": null,
"category": "backend"
}
],
"packageManager": "cargo",
"runtime": null
},
"build": {
"buildCommand": "cargo build",
"testCommand": "cargo test",
"lintCommand": "cargo clippy",
"devCommand": "cargo run",
"scripts": {}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": null
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [
"assets",
"docs",
"scripts"
],
"gitBranches": {
"defaultBranch": "main",
"branchingStrategy": null
}
},
"customNotes": [],
"directoryMap": {
"assets": {
"path": "assets",
"purpose": "Static assets",
"fileCount": 3,
"lastAccessed": 1785390240368,
"keyFiles": [
"gfVIS99.dat",
"synspec_static",
"tlusty_static"
]
},
"crates": {
"path": "crates",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1785390240369,
"keyFiles": []
},
"dashboard": {
"path": "dashboard",
"purpose": null,
"fileCount": 5,
"lastAccessed": 1785390240369,
"keyFiles": [
"index.html",
"package-lock.json",
"package.json",
"vite.config.js"
]
},
"data": {
"path": "data",
"purpose": "Data files",
"fileCount": 6,
"lastAccessed": 1785390240370,
"keyFiles": [
"dcts.db",
"dcts.db-shm",
"dcts.db-wal",
"dcts_queue.db",
"dcts_queue.db-shm"
]
},
"deploy.env.d": {
"path": "deploy.env.d",
"purpose": null,
"fileCount": 7,
"lastAccessed": 1785390240370,
"keyFiles": [
"node-dckj-linux-01.env",
"node-dckj-win-01.env",
"node-huawei-linux-01.env",
"node-local-docker.env",
"node.env.example"
]
},
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 9,
"lastAccessed": 1785390240370,
"keyFiles": [
"EXPERIENCE.md",
"PIPELINE.md",
"api.md",
"architecture.md",
"contributing.md"
]
},
"scripts": {
"path": "scripts",
"purpose": "Build/utility scripts",
"fileCount": 3,
"lastAccessed": 1785390240370,
"keyFiles": [
"deploy.sh",
"migrate_data_dirs.sh",
"push_import_results.sh"
]
},
"target": {
"path": "target",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1785390240370,
"keyFiles": [
"CACHEDIR.TAG"
]
},
"tools": {
"path": "tools",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1785390240370,
"keyFiles": []
},
"workflows": {
"path": "workflows",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1785390240371,
"keyFiles": [
"sdB_cno.yaml"
]
},
"assets/data": {
"path": "assets/data",
"purpose": "Data files",
"fileCount": 132,
"lastAccessed": 1785390240372,
"keyFiles": [
"CIA_H2H.dat",
"CIA_H2H2.dat",
"CIA_H2He.dat"
]
},
"dashboard/dist": {
"path": "dashboard/dist",
"purpose": "Distribution/build output",
"fileCount": 1,
"lastAccessed": 1785390240372,
"keyFiles": [
"index.html"
]
},
"dashboard/node_modules": {
"path": "dashboard/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1785390240372,
"keyFiles": []
},
"dashboard/src": {
"path": "dashboard/src",
"purpose": "Source code",
"fileCount": 4,
"lastAccessed": 1785390240373,
"keyFiles": [
"api.js",
"main.js",
"state.js"
]
},
"data/seeds": {
"path": "data/seeds",
"purpose": "Database seeds",
"fileCount": 0,
"lastAccessed": 1785390240373,
"keyFiles": []
}
},
"hotPaths": [],
"userDirectives": []
}
@@ -1,8 +0,0 @@
{
"session_id": "865f1115-942b-4cc4-81f6-17a611773eb2",
"ended_at": "2026-07-30T05:44:26.934Z",
"reason": "prompt_input_exit",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}
+48 -5
View File
@@ -1,4 +1,4 @@
use crate::models::GridAxisValue; use crate::models::{EngineStageConfig, GridAxisValue};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -138,6 +138,43 @@ impl GridConfig {
} }
Ok(cfg) Ok(cfg)
} }
/// 解析 TLUSTY 阶段配置。
///
/// 优先级(见 docs/task_engine_decoupling_design.md §3):
/// 1. 新版顶层 `tlusty:` 块(EngineStageConfig)—— 显式覆盖;
/// 2. 旧版 `seed_step_fallback: bool` —— true → `[cold_run, seed_step]`
/// false → `[cold_run]`(不回退种子步进);
/// 3. 兜底 `default_tlusty()`。
///
/// 注:旧版只控制是否回退种子步进,无 enabled/policy 维度,故回退路径固定
/// enabled=true / policy=SkipConverged(与新默认一致)。
pub fn resolve_tlusty_config(&self) -> EngineStageConfig {
if let Some(cfg) = &self.tlusty {
return cfg.clone();
}
let mut cfg = EngineStageConfig::default_tlusty();
if !self.seed_step_fallback {
cfg.strategies = vec!["cold_run".to_string()];
}
cfg
}
/// 解析 SYNSPEC 阶段配置。
///
/// 优先级:
/// 1. 新版顶层 `synspec_stage:` 块(EngineStageConfig)—— 显式覆盖(含 enabled 开关);
/// 2. 兜底 `default_synspec()`enabled=true,保持旧行为:有大气就跑光谱)。
///
/// 注:旧版 `synspec: SynspecConfig`(数值参数)不影响阶段启用/策略——它只携带
/// 波长范围等数值,由调度器透传到 TaskSpec.synspec_params。如需禁用 SYNSPEC
/// 必须用新版 `synspec_stage: { enabled: false }`。
pub fn resolve_synspec_config(&self) -> EngineStageConfig {
if let Some(cfg) = &self.synspec_stage {
return cfg.clone();
}
EngineStageConfig::default_synspec()
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -213,7 +250,6 @@ fn default_abs_cutoff() -> f64 {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GridConfig { pub struct GridConfig {
pub grid: GridAxesConfig, pub grid: GridAxesConfig,
#[serde(default)] #[serde(default)]
@@ -228,8 +264,7 @@ pub struct GridConfig {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub seed_step_fallback: bool, pub seed_step_fallback: bool,
/// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以 /// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以
/// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。仅因 `deny_unknown_fields` /// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。保留以兼容旧 workflow YAML。
/// 必须能解析而保留。workflow YAML 里仍可写(如 `results: data/seeds`)但被忽略。
#[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")] #[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")]
#[serde(default)] #[serde(default)]
pub results: Option<String>, pub results: Option<String>,
@@ -240,6 +275,14 @@ pub struct GridConfig {
pub template: Option<String>, pub template: Option<String>,
pub fort55: Option<String>, pub fort55: Option<String>,
pub linelist: Option<String>, pub linelist: Option<String>,
/// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。
/// 缺省 None → `resolve_tlusty_config()` 据旧 `seed_step_fallback` 推断默认链。
#[serde(default)]
pub tlusty: Option<EngineStageConfig>,
/// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecConfig`
///(光谱合成数值参数)区分。缺省 None → `resolve_synspec_config()` 给默认 `[standard]`。
#[serde(default)]
pub synspec_stage: Option<EngineStageConfig>,
} }
fn default_grid_niter() -> Option<i32> { fn default_grid_niter() -> Option<i32> {
@@ -410,7 +453,7 @@ pub struct NodeConfig {
/// 避免随沙盒删除而丢失。与 server 的 `seeds` 目录区分:此处存的是**完整产物** /// 避免随沙盒删除而丢失。与 server 的 `seeds` 目录区分:此处存的是**完整产物**
/// (光谱/连续谱/各阶段大气快照等),seeds 只存最小种子集(.7+conv.json)。 /// (光谱/连续谱/各阶段大气快照等),seeds 只存最小种子集(.7+conv.json)。
/// 默认 "data/result",可经 DCTS_RESULT_DIR 覆盖(回退读旧 DCTS_ARCHIVE_DIR)。 /// 默认 "data/result",可经 DCTS_RESULT_DIR 覆盖(回退读旧 DCTS_ARCHIVE_DIR)。
/// 超过 MAX_RESULT_MODELS 个网格点子目录时按 LRU 删除最旧的 /// 归档**永久保留**,不做 LRU 淘汰(2026-08-02 撤销旧 MAX_RESULT_MODELS=200 上限)
pub result_dir: String, pub result_dir: String,
pub heartbeat_sec: u64, pub heartbeat_sec: u64,
} }
+6 -7
View File
@@ -34,9 +34,7 @@ fn parse_fortran_float(s: &str) -> Option<f64> {
return Some(v); return Some(v);
} }
// 2. 归一化无-E 记数法:[前导符号?]<尾数>(含小数点或多位数字)[+/-]<指数> // 2. 归一化无-E 记数法:[前导符号?]<尾数>(含小数点或多位数字)[+/-]<指数>
let re = NO_E_EXP_RE.get_or_init(|| { let re = NO_E_EXP_RE.get_or_init(|| Regex::new(r"^([+-]?[\d.]+)([+-]\d+)$").unwrap());
Regex::new(r"^([+-]?[\d.]+)([+-]\d+)$").unwrap()
});
if let Some(caps) = re.captures(s) { if let Some(caps) = re.captures(s) {
let normalized = format!("{}E{}", &caps[1], &caps[2]); let normalized = format!("{}E{}", &caps[1], &caps[2]);
if let Ok(v) = normalized.parse::<f64>() { if let Ok(v) = normalized.parse::<f64>() {
@@ -197,9 +195,8 @@ pub fn atmosphere_has_nan(path: &Path) -> bool {
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut total_lines = 0; let mut total_lines = 0;
let mut bad_lines = 0; let mut bad_lines = 0;
let nan_re = NAN_RE.get_or_init(|| { let nan_re =
Regex::new(r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})").unwrap() NAN_RE.get_or_init(|| Regex::new(r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})").unwrap());
});
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
total_lines += 1; total_lines += 1;
@@ -274,7 +271,9 @@ mod tests {
// Rust 的 f64::from_str 接受 "NaN"/"inf",返回 NaN/Inf(非 None)。 // Rust 的 f64::from_str 接受 "NaN"/"inf",返回 NaN/Inf(非 None)。
// 这些在 check_fort9 中会被 is_finite() 判为无效 → converged=false,行为正确。 // 这些在 check_fort9 中会被 is_finite() 判为无效 → converged=false,行为正确。
assert!(parse_fortran_float("NaN").map(|v| v.is_nan()).unwrap_or(false)); assert!(parse_fortran_float("NaN")
.map(|v| v.is_nan())
.unwrap_or(false));
assert_eq!(parse_fortran_float("inf"), Some(f64::INFINITY)); assert_eq!(parse_fortran_float("inf"), Some(f64::INFINITY));
// 无法解析的垃圾 → None(调用方 continue 跳过) // 无法解析的垃圾 → None(调用方 continue 跳过)
+7 -2
View File
@@ -173,8 +173,13 @@ fn write_if_changed(target_path: &Path, content: &[u8], executable: bool) -> Res
fs::set_permissions(&tmp_path, perms)?; fs::set_permissions(&tmp_path, perms)?;
} }
fs::rename(&tmp_path, target_path) fs::rename(&tmp_path, target_path).with_context(|| {
.with_context(|| format!("原子重命名 {} -> {} 失败", tmp_path.display(), target_path.display()))?; format!(
"原子重命名 {} -> {} 失败",
tmp_path.display(),
target_path.display()
)
})?;
} }
Ok(()) Ok(())
+1 -2
View File
@@ -332,8 +332,7 @@ mod tests {
.filter(|l| { .filter(|l| {
// ions 数据行:含引号且首 token 是整数 // ions 数据行:含引号且首 token 是整数
l.contains('\'') l.contains('\'')
&& l && l.split_whitespace()
.split_whitespace()
.next() .next()
.map(|t| t.parse::<i32>().is_ok()) .map(|t| t.parse::<i32>().is_ok())
.unwrap_or(false) .unwrap_or(false)
+304
View File
@@ -280,12 +280,121 @@ impl From<&str> for GridPointStatus {
} }
} }
/// 执行策略(决定如何处理历史记录)。
///
/// 见 docs/task_engine_decoupling_design.md §2.1:阶段独立配置三维之一。
///
/// **语义(2026-08-04 修正)**:策略只决定**启动工作流时**对历史终态点(converged/failed
/// 的处理;失败后的策略链回退**只由启动时的策略链(回退优先级排序)驱动**,不受策略门控。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum StagePolicy {
/// 跳过已收敛、重试已失败:启动时把已失败点打回 pending 重试,收敛点保留(增量+重试失败)。
/// 默认值。
#[default]
SkipConverged,
/// 强制重算(无视历史状态与产物):启动时收敛 + 失败全部打回 pending。
ForceRecompute,
/// 跳过收敛及失败:启动时收敛和失败点都保留,只算从未计算过的点(最保守增量)。
SkipFailed,
}
impl StagePolicy {
/// 序列化为 DB 文本列存储用的 snake_case 字符串。
pub fn as_str(&self) -> &'static str {
match self {
StagePolicy::SkipConverged => "skip_converged",
StagePolicy::ForceRecompute => "force_recompute",
StagePolicy::SkipFailed => "skip_failed",
}
}
/// 从 DB 文本列回读;非法值兜底为默认 SkipConverged(防注入与脏数据)。
pub fn from_str_lossy(s: &str) -> Self {
match s {
"force_recompute" => StagePolicy::ForceRecompute,
"skip_failed" => StagePolicy::SkipFailed,
_ => StagePolicy::SkipConverged,
}
}
}
/// 独立阶段配置(TLUSTY / SYNSPEC 各一份)。
///
/// 见 docs/task_engine_decoupling_design.md §3:嵌套式单阶段配置模型,
/// 包含三个正交维度:enabled / policy / strategies。
///
/// 为避免与 `common::config::StageConfig`(迭代步进参数)同名冲突,命名为
/// `EngineStageConfig`。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EngineStageConfig {
/// 是否在当前计算流中启用该阶段。
#[serde(default = "default_engine_stage_enabled")]
pub enabled: bool,
/// 决定如何处理历史记录。
#[serde(default)]
pub policy: StagePolicy,
/// 策略链队列(按回退优先级排序),如 `["cold_run", "seed_step"]`。
/// 节点总是执行 `strategies[0]`;失败后由服务端弹出首项,下一顺位顶上。
#[serde(default)]
pub strategies: Vec<String>,
}
fn default_engine_stage_enabled() -> bool {
true
}
impl EngineStageConfig {
/// TLUSTY 阶段默认配置:启用、增量、策略链 `[cold_run, seed_step]`。
pub fn default_tlusty() -> Self {
Self {
enabled: true,
policy: StagePolicy::SkipConverged,
strategies: vec!["cold_run".to_string(), "seed_step".to_string()],
}
}
/// SYNSPEC 阶段默认配置:启用、增量、策略链 `[standard]`。
pub fn default_synspec() -> Self {
Self {
enabled: true,
policy: StagePolicy::SkipConverged,
strategies: vec!["standard".to_string()],
}
}
/// 当前应执行的策略(队列首项)。空链兜底为传入的 fallback。
pub fn current_strategy<'a>(&'a self, fallback: &'a str) -> &'a str {
self.strategies
.first()
.map(|s| s.as_str())
.unwrap_or(fallback)
}
/// 是否还含指定策略(任意位置)。用于回退去重等场景。
///
/// 注(审查 #6):生产回退路径现走 DB 侧策略链弹栈(`pop_stage_strategy_for_fallback`),
/// 本方法当前主要用于测试断言与诊断(判断某策略是否仍在链中),保留为公共工具。
pub fn has_strategy(&self, name: &str) -> bool {
self.strategies.iter().any(|s| s == name)
}
}
/// Task execution specification sent to Node /// Task execution specification sent to Node
///
/// 注:`EngineStageConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY
/// `[cold_run, seed_step]` vs SYNSPEC `[standard]`),无中立的默认语义。构造某阶段的配置请用
/// `..EngineStageConfig::default_tlusty()` / `..EngineStageConfig::default_synspec()`
/// 避免把 TLUSTY 默认链误用到 synspec。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSpec { pub struct TaskSpec {
pub task_id: Uuid, pub task_id: Uuid,
pub point_name: String, pub point_name: String,
pub params: GridPointParams, pub params: GridPointParams,
/// **已废弃**:保留以兼容历史 MQ 在途消息与旧节点。新代码应读
/// `tlusty_config.strategies[0]` 判定当前 TLUSTY 策略。
/// 该字段仍是必填(serde 反序列化要求),调度器在派发时会据
/// `tlusty_config.strategies[0]` 同步设置它,保证旧节点能正常工作。
pub task_type: TaskType, pub task_type: TaskType,
pub seed_point_name: Option<String>, pub seed_point_name: Option<String>,
pub timeout_sec: u64, pub timeout_sec: u64,
@@ -297,6 +406,72 @@ pub struct TaskSpec {
/// 旧 payload 反序列化时缺省为 0。 /// 旧 payload 反序列化时缺省为 0。
#[serde(default)] #[serde(default)]
pub wave: i32, pub wave: i32,
/// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。
/// 旧 payload 反序列化时缺省为 `default_tlusty()`。
#[serde(default = "EngineStageConfig::default_tlusty")]
pub tlusty_config: EngineStageConfig,
/// SYNSPEC 阶段独立配置。旧 payload 反序列化时缺省为 `default_synspec()`。
#[serde(default = "EngineStageConfig::default_synspec")]
pub synspec_config: EngineStageConfig,
/// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecConfig`)。
/// 以 `serde_json::Value` 携带避免 models ↔ config 循环依赖;executor 侧
/// 反序列化为 `SynspecConfig` 后透传给 runner。None → runner 用硬编码默认。
/// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。
#[serde(default)]
pub synspec_params: Option<serde_json::Value>,
/// 仅 SYNSPEC-only 场景(TLUSTY 关闭)拉取大气用:显式关联大气网格点名。
#[serde(default)]
pub atmosphere_ref: Option<String>,
}
impl TaskSpec {
/// 旧版兼容归一化:据废弃的 `task_type` 回填 `tlusty_config.strategies` 首项。
///
/// 修复(审查 #8):serde default 已把 strategies 填为完整默认链 `[cold_run, seed_step]`
/// 故仅判 `is_empty` 无法覆盖「旧 seed_step 消息被误判为 cold_run」的场景。
/// 现据 task_type 把首项校正为对应的单策略链(旧消息的 task_type 是权威来源):
/// - task_type=SeedStep → `[seed_step]`(旧热启动消息不应被当冷启动重跑);
/// - task_type=ColdRun → 保持默认链(cold_run 本就是默认首项)。
pub fn normalize_compat(&mut self) {
let legacy_first = match self.task_type {
TaskType::ColdRun => "cold_run",
TaskType::SeedStep => "seed_step",
};
// 仅当当前 strategies 首项与 task_type 不一致时校正(避免覆盖显式配置)。
let needs_fix =
self.tlusty_config.strategies.first().map(|s| s.as_str()) != Some(legacy_first);
if needs_fix {
self.tlusty_config.strategies = vec![legacy_first.to_string()];
}
}
}
/// 仅供测试夹具构造便利:`TaskSpec::default()` 给出合法占位(task_id 零值、
/// 空点/参数、ColdRun、默认阶段配置)。生产代码应显式构造所有字段,避免依赖占位。
impl Default for TaskSpec {
fn default() -> Self {
TaskSpec {
task_id: Uuid::nil(),
point_name: String::new(),
params: GridPointParams {
teff: 0.0.into(),
logg: 0.0.into(),
loghe: 0.0.into(),
logc: 0.0.into(),
logn: 0.0.into(),
logo: 0.0.into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 3600,
workflow_name: None,
wave: 0,
tlusty_config: EngineStageConfig::default_tlusty(),
synspec_config: EngineStageConfig::default_synspec(),
synspec_params: None,
atmosphere_ref: None,
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -331,6 +506,10 @@ pub struct TaskReport {
pub elapsed_sec: f64, pub elapsed_sec: f64,
pub error_message: Option<String>, pub error_message: Option<String>,
pub summary_json: String, pub summary_json: String,
/// 失败阶段归因(见 docs/task_engine_decoupling_design.md §4.2):`"tlusty"` / `"synspec"`。
/// 节点据 ModelSummary 推断;旧节点不携带该字段 → 服务端兜底按 TLUSTY 链回退(兼容)。
#[serde(default)]
pub failed_stage: Option<String>,
} }
/// Node registration request /// Node registration request
@@ -349,6 +528,22 @@ pub struct NodeHeartbeatRequest {
pub memory_usage: f32, pub memory_usage: f32,
} }
/// Node heartbeat response.
///
/// 设计依据见 docs/dynamic_cpu_slots_design.md:服务端在心跳响应里透传管理员设置的
/// `admin_max_slots`(并发槽位配额上限),Worker 据此动态调整本地领用并发数,
/// 避免 Pull 模式下被服务端强行拒绝 claim 而陷入空轮询。
///
/// - `status`:固定 "ok"401/403 由 HTTP 状态码承载,不会进入反序列化路径)。
/// - `admin_max_slots`:管理员强制配额上限(`null` 表示无限制,恢复节点物理槽位上限)。
/// `#[serde(default)]` 保证旧服务端(响应体不含此字段)反序列化兜底为 None。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeHeartbeatResponse {
pub status: String,
#[serde(default)]
pub admin_max_slots: Option<i32>,
}
/// Node state in database /// Node state in database
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeInfo { pub struct NodeInfo {
@@ -359,6 +554,10 @@ pub struct NodeInfo {
pub cpu_usage: f32, pub cpu_usage: f32,
pub memory_usage: f32, pub memory_usage: f32,
pub last_heartbeat: DateTime<Utc>, pub last_heartbeat: DateTime<Utc>,
/// 管理员强制并发槽位上限(动态调整 CPU 核数)。None 表示无限制,使用 max_slots。
/// 透传给前端供 Dashboard 渲染配额状态,并在心跳响应里下发给 Worker。
#[serde(default)]
pub admin_max_slots: Option<i32>,
} }
/// Single iteration convergence result parsed from fort.9 /// Single iteration convergence result parsed from fort.9
@@ -665,4 +864,109 @@ mod tests {
assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed); assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed);
assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending); assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending);
} }
/// `StagePolicy` 的 snake_case serde 往返 + DB 文本兜底。
#[test]
fn test_stage_policy_serde_roundtrip() {
for p in [
StagePolicy::SkipConverged,
StagePolicy::ForceRecompute,
StagePolicy::SkipFailed,
] {
let s = serde_json::to_string(&p).unwrap();
let back: StagePolicy = serde_json::from_str(&s).unwrap();
assert_eq!(p, back);
}
// snake_case 形态锁定(前端 payload 与 DB 列口径)
assert_eq!(
serde_json::to_string(&StagePolicy::SkipConverged).unwrap(),
"\"skip_converged\""
);
assert_eq!(
serde_json::to_string(&StagePolicy::ForceRecompute).unwrap(),
"\"force_recompute\""
);
assert_eq!(
serde_json::to_string(&StagePolicy::SkipFailed).unwrap(),
"\"skip_failed\""
);
// as_str/from_str_lossy 互逆(非法值兜底 SkipConverged
assert_eq!(
StagePolicy::from_str_lossy("skip_converged"),
StagePolicy::SkipConverged
);
assert_eq!(
StagePolicy::from_str_lossy("garbage"),
StagePolicy::SkipConverged
);
assert_eq!(
StagePolicy::from_str_lossy(StagePolicy::ForceRecompute.as_str()),
StagePolicy::ForceRecompute
);
}
/// `EngineStageConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。
#[test]
fn test_engine_stage_config_serde_and_defaults() {
let cfg = EngineStageConfig {
enabled: false,
policy: StagePolicy::ForceRecompute,
strategies: vec!["cold_run".to_string(), "seed_step".to_string()],
};
let json = serde_json::to_string(&cfg).unwrap();
let back: EngineStageConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, back);
// 空 payload 应产出默认值(enabled=true, policy=skip_converged, strategies=[]
let empty: EngineStageConfig = serde_json::from_str("{}").unwrap();
assert!(empty.enabled);
assert_eq!(empty.policy, StagePolicy::SkipConverged);
assert!(empty.strategies.is_empty());
// current_strategy 空链兜底
assert_eq!(empty.current_strategy("cold_run"), "cold_run");
assert_eq!(cfg.current_strategy("x"), "cold_run");
assert!(cfg.has_strategy("seed_step"));
assert!(!cfg.has_strategy("standard"));
}
/// 旧版 MQ 在途消息(仅含 task_type,无 tlusty_config)经 `#[serde(default)]`
/// 反序列化后,`normalize_compat()` 应据 task_type 回填 strategies。
#[test]
fn test_task_spec_normalize_compat_from_legacy_task_type() {
let legacy_json = r#"{
"task_id": "00000000-0000-0000-0000-000000000001",
"point_name": "t20000_g5.0_he-2_c-4_n-4_o-4",
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"task_type": "seed_step",
"seed_point_name": "neighbor",
"timeout_sec": 7200,
"workflow_name": "wf_a",
"wave": 0
}"#;
let mut spec: TaskSpec = serde_json::from_str(legacy_json).unwrap();
// 修复后 normalize_compat 据 task_type 校正首项:旧 seed_step 消息的 strategies
// 首项应被校正为 seed_step(而非保留默认链的 cold_run 首项,否则会被误当冷启动)。
spec.normalize_compat();
assert_eq!(
spec.tlusty_config.strategies,
vec!["seed_step".to_string()],
"旧 seed_step 消息应校正为 [seed_step] 单策略链"
);
assert_eq!(spec.tlusty_config.current_strategy("cold_run"), "seed_step");
// 对照:旧 cold_run 消息 → 首项已是 cold_run(默认链首项),无需校正。
let mut cold_spec = TaskSpec::default();
cold_spec.task_type = TaskType::ColdRun;
cold_spec.normalize_compat();
assert_eq!(
cold_spec
.tlusty_config
.strategies
.first()
.map(|s| s.as_str()),
Some("cold_run"),
"旧 cold_run 消息保持默认链"
);
}
} }
+14 -3
View File
@@ -13,7 +13,8 @@
//! //!
//! 1. **裸名保留**(有独立语义,不以 model_name 为前缀): //! 1. **裸名保留**(有独立语义,不以 model_name 为前缀):
//! `conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡) //! `conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡)
//! 2. **科学核心**`<name>.7`、`<name>.spec`、`<name>.cont`、`<name>.iden`、`<name>.log` //! 2. **科学核心**`<name>.7`、`<name>.spec`、`<name>.cont`、`<name>.iden`、`<name>.log`
//! `<name>.bfac`TLUSTY 最终 b 因子/非 LTE 偏离因子)、`<name>.emflux`TLUSTY 最终出射谱 λ–Fλ)
//! 3. **阶段快照**`<name>.<label>.5/.6/.err/.nst/.7`label ∈ lte/nc/nl/seed_nc //! 3. **阶段快照**`<name>.<label>.5/.6/.err/.nst/.7`label ∈ lte/nc/nl/seed_nc
//! 4. **收敛诊断**`<name>.<label>_chmax*.9`**唯一保留的 .9**;裸 `<name>.<label>.9` //! 4. **收敛诊断**`<name>.<label>_chmax*.9`**唯一保留的 .9**;裸 `<name>.<label>.9`
//! 已在 runner 源头停止写出,因其与 `_chmax*.9` 内容完全重复) //! 已在 runner 源头停止写出,因其与 `_chmax*.9` 内容完全重复)
@@ -21,7 +22,10 @@
//! 符号链接、子目录、`.tmp`、`fort.84` 及所有 Tlusty 中间单元均不在白名单内,自然被跳过。 //! 符号链接、子目录、`.tmp`、`fort.84` 及所有 Tlusty 中间单元均不在白名单内,自然被跳过。
/// 科学核心产物的文件名后缀(挂在 `<name>.` 之后,无阶段标签)。 /// 科学核心产物的文件名后缀(挂在 `<name>.` 之后,无阶段标签)。
const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log"]; /// 除 SYNSPEC 产物(spec/cont/iden/log)外,含 runner 快照的 TLUSTY 最终产物:
/// `bfac`b 因子/非 LTE 偏离因子,源 fort.12)与 `emflux`(出射谱 λ–Fλ,源 fort.14),
/// 二者若不快照会被 SYNSPEC 覆盖丢失(见 `runner::snapshot_tlusty_outputs`)。
const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log", "bfac", "emflux"];
/// 阶段快照的文件名后缀(挂在 `<name>.<label>.` 之后)。 /// 阶段快照的文件名后缀(挂在 `<name>.<label>.` 之后)。
const STAGE_SNAPSHOT_SUFFIXES: &[&str] = &["5", "6", "err", "nst", "7"]; const STAGE_SNAPSHOT_SUFFIXES: &[&str] = &["5", "6", "err", "nst", "7"];
@@ -120,12 +124,19 @@ mod tests {
#[test] #[test]
fn test_science_core() { fn test_science_core() {
for s in ["7", "spec", "cont", "iden", "log"] { for s in ["7", "spec", "cont", "iden", "log", "bfac", "emflux"] {
let f = format!("{}.{}", NAME, s); let f = format!("{}.{}", NAME, s);
assert!(is_result_worthy(&f, NAME), "{} 应归档", f); assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
} }
} }
/// TLUSTY 快照产物(b 因子 / 出射谱)应进入白名单,缺失时不误伤其他后缀
#[test]
fn test_tlusty_snapshots_kept() {
assert!(is_result_worthy(&format!("{}.bfac", NAME), NAME));
assert!(is_result_worthy(&format!("{}.emflux", NAME), NAME));
}
#[test] #[test]
fn test_stage_snapshots() { fn test_stage_snapshots() {
// 各阶段标签 × 快照后缀 都应保留 // 各阶段标签 × 快照后缀 都应保留
+170 -25
View File
@@ -113,13 +113,12 @@ async fn run_child_async_with_timeout(
timeout_sec: u64, timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<std::process::ExitStatus> { ) -> Result<std::process::ExitStatus> {
let timeout_fut = tokio::time::timeout( let timeout_fut =
tokio::time::Duration::from_secs(timeout_sec), tokio::time::timeout(tokio::time::Duration::from_secs(timeout_sec), child.wait());
child.wait(),
);
// 若提供了 shutdown 标志,则与超时/正常结束三路 select;否则只等超时/正常结束。 // 若提供了 shutdown 标志,则与超时/正常结束三路 select;否则只等超时/正常结束。
let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown { let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown
{
let shutdown_watcher = async move { let shutdown_watcher = async move {
// 轮询 shutdown 标志(10ms 粒度足够灵敏,开销可忽略)。 // 轮询 shutdown 标志(10ms 粒度足够灵敏,开销可忽略)。
loop { loop {
@@ -148,20 +147,12 @@ async fn run_child_async_with_timeout(
Ok(status) => Ok(status), Ok(status) => Ok(status),
Err(ShutdownOrTimeout::Shutdown) => { Err(ShutdownOrTimeout::Shutdown) => {
let _ = child.start_kill(); let _ = child.start_kill();
let _ = tokio::time::timeout( let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
std::time::Duration::from_secs(30),
child.wait(),
)
.await;
anyhow::bail!("节点收到退出信号,子进程已被终止"); anyhow::bail!("节点收到退出信号,子进程已被终止");
} }
Err(ShutdownOrTimeout::Timeout) => { Err(ShutdownOrTimeout::Timeout) => {
let _ = child.start_kill(); let _ = child.start_kill();
let _ = tokio::time::timeout( let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
std::time::Duration::from_secs(30),
child.wait(),
)
.await;
anyhow::bail!("进程计算超时 (上限: {} 秒)", timeout_sec); anyhow::bail!("进程计算超时 (上限: {} 秒)", timeout_sec);
} }
} }
@@ -173,6 +164,31 @@ enum ShutdownOrTimeout {
Timeout, Timeout,
} }
/// 快照 TLUSTY 最终模型的 b 因子与出射谱,防止被 SYNSPEC 覆盖丢失。
///
/// TLUSTY 在最终迭代(`LFIN=.TRUE.`)经 `OUTPRI` 写出(见 tlusty208.f):
/// - `fort.12`b 因子 / 非 LTE 偏离因子表(头 2I5 + 每深度 TEMP/ELEC/DENS/BFAC,格式 701/702/703)。
/// 随后 SYNSPEC 会复用 unit 12 写谱线证认表并覆盖它(runner 再将其存为 `<name>.iden`),
/// 故 TLUSTY 的 b 因子若不在此快照即静默丢失。
/// - `fort.14`:出射谱(波长 Å + Fλ,格式 614),同样会被 SYNSPEC 的谱线数据覆盖。
///
/// 在收敛链循环结束(链上最后一次 TLUSTY 运行即最终模型)、SYNSPEC 启动前调用,
/// 快照为 `<name>.bfac` / `<name>.emflux`,与科学核心产物一并进入归档白名单
/// (见 `result_filter::is_result_worthy` 的 `bfac`/`emflux` 后缀)。
/// 文件不存在时静默跳过(TLUSTY 未运行/未写出);IO 错误降级为 warn,不阻断主流程。
async fn snapshot_tlusty_outputs(model_dir: &Path, name: &str) {
for (src, suffix) in [("fort.12", "bfac"), ("fort.14", "emflux")] {
let src_path = model_dir.join(src);
if !src_path.is_file() {
continue;
}
let dst = model_dir.join(format!("{}.{}", name, suffix));
if let Err(e) = tokio::fs::copy(&src_path, &dst).await {
warn!("快照 TLUSTY {} 到 {} 失败: {}", src, dst.display(), e);
}
}
}
pub struct ExecutionRunner<'a> { pub struct ExecutionRunner<'a> {
pub runtime: &'a RuntimePaths, pub runtime: &'a RuntimePaths,
pub work_dir: PathBuf, pub work_dir: PathBuf,
@@ -199,12 +215,19 @@ impl<'a> ExecutionRunner<'a> {
custom_chain, custom_chain,
seed_atmos, seed_atmos,
synspec_cfg, synspec_cfg,
true,
true,
7200, 7200,
None, None,
) )
.await .await
} }
/// 阶段独立配置执行入口(见 docs/task_engine_decoupling_design.md §5)。
///
/// `tlusty_enabled` / `synspec_enabled` 控制各阶段是否运行:
/// - TLUSTY 关闭:跳过 chain 循环,直接以 seed_atmos(或单独拉取的大气)作 final_7;
/// - SYNSPEC 关闭:跳过光谱合成块(即便 final_7 存在)。
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn run_model_with_timeout( pub async fn run_model_with_timeout(
&self, &self,
@@ -214,6 +237,8 @@ impl<'a> ExecutionRunner<'a> {
custom_chain: Option<Vec<StageConfig>>, custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>, seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>, synspec_cfg: Option<&SynspecConfig>,
tlusty_enabled: bool,
synspec_enabled: bool,
timeout_sec: u64, timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<ModelSummary> { ) -> Result<ModelSummary> {
@@ -282,7 +307,20 @@ impl<'a> ExecutionRunner<'a> {
let mut final_chmax: Option<f64> = None; let mut final_chmax: Option<f64> = None;
let mut final_max_relc: Option<f64> = None; let mut final_max_relc: Option<f64> = None;
// 阶段独立配置(见 docs/task_engine_decoupling_design.md §5):
// TLUSTY 关闭时跳过整个 chain 循环——current_seed 直接作为 final_7 来源,
// 适配「仅 SYNSPEC」场景(用既有大气合成光谱,不重算大气结构)。
if tlusty_enabled {
info!("TLUSTY 阶段启用:执行 {} 步收敛链", chain.len());
} else {
info!("TLUSTY 阶段关闭:跳过大气结构计算,直接进入 SYNSPEC 阶段");
}
let tlusty_skipped = !tlusty_enabled;
for stage_def in &chain { for stage_def in &chain {
if tlusty_skipped {
break;
}
let stage_t0 = Instant::now(); let stage_t0 = Instant::now();
let metals = stage_def.metals.as_deref().unwrap_or("cno"); let metals = stage_def.metals.as_deref().unwrap_or("cno");
let input5_text = make_input5( let input5_text = make_input5(
@@ -335,7 +373,8 @@ impl<'a> ExecutionRunner<'a> {
.kill_on_drop(true) .kill_on_drop(true)
.spawn()?; .spawn()?;
let status_res = run_child_async_with_timeout(child, timeout_sec, shutdown.clone()).await; let status_res =
run_child_async_with_timeout(child, timeout_sec, shutdown.clone()).await;
let rc = match status_res { let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1), Ok(st) => st.code().unwrap_or(-1),
Err(e) => { Err(e) => {
@@ -440,17 +479,27 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::copy(model_dir.join("fort.7"), &final_7).await; let _ = tokio::fs::copy(model_dir.join("fort.7"), &final_7).await;
} }
// TLUSTY 最终 b 因子 + 出射谱快照。必须在此处(SYNSPEC 覆盖 fort.12/fort.14 之前)
// 完成:synspec 会复用 unit 12/14 写谱线数据,覆盖 TLUSTY 的最终产物(见上方
// snapshot_tlusty_outputs 的注释)。仅 SYNSPEC 场景(tlusty_enabled=false)下
// fort.12/14 不存在,函数内按文件是否存在静默跳过。
snapshot_tlusty_outputs(&model_dir, name).await;
let atmo_has_nan = atmosphere_has_nan(&final_7); let atmo_has_nan = atmosphere_has_nan(&final_7);
if atmo_has_nan { if atmo_has_nan {
final_converged = false; final_converged = false;
} }
// Run synspec if final .7 atmosphere exists // Run synspec if enabled and final .7 atmosphere exists
let mut synspec_rc = None; let mut synspec_rc = None;
let mut synspec_err = None; let mut synspec_err = None;
let mut synspec_sec = None; let mut synspec_sec = None;
if final_7.is_file() { if !synspec_enabled {
// SYNSPEC 关闭是合法配置(TLUSTY-only 大气计算),不写入 synspec_error
// 以免污染 conv.json 的错误归因——下游把非空 synspec_error 当「光谱有缺陷」。
info!("SYNSPEC 阶段关闭:跳过光谱合成(TLUSTY-only 模式)");
} else if final_7.is_file() {
let syn_t0 = Instant::now(); let syn_t0 = Instant::now();
// H10synspec 输入文件(fort.8 大气 / fort.55 控制卡)写入失败不可静默吞掉。 // H10synspec 输入文件(fort.8 大气 / fort.55 控制卡)写入失败不可静默吞掉。
// 历史上用 `let _ =` 忽略错误,磁盘满/inode 耗尽时 synspec 会读到旧/缺失的 // 历史上用 `let _ =` 忽略错误,磁盘满/inode 耗尽时 synspec 会读到旧/缺失的
@@ -480,7 +529,10 @@ impl<'a> ExecutionRunner<'a> {
}; };
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg)); let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
if let Err(e) = tokio::fs::write(&fort55_path, &fort55_text).await { if let Err(e) = tokio::fs::write(&fort55_path, &fort55_text).await {
warn!("synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}", e); warn!(
"synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}",
e
);
synspec_err = Some(format!("fort.55 write failed: {}", e)); synspec_err = Some(format!("fort.55 write failed: {}", e));
} else { } else {
#[cfg(unix)] #[cfg(unix)]
@@ -511,7 +563,8 @@ impl<'a> ExecutionRunner<'a> {
let synspec_timeout_sec = 600_u64.min(timeout_sec); let synspec_timeout_sec = 600_u64.min(timeout_sec);
let status_res = let status_res =
run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone()).await; run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone())
.await;
let rc = match status_res { let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1), Ok(st) => st.code().unwrap_or(-1),
Err(e) => { Err(e) => {
@@ -549,6 +602,19 @@ impl<'a> ExecutionRunner<'a> {
synspec_err = Some("No atmosphere .7 produced".to_string()); synspec_err = Some("No atmosphere .7 produced".to_string());
} }
// 收敛判定(见 docs/task_engine_decoupling_design.md §5):
// - TLUSTY 启用:final_converged 由 chain 循环内各阶段收敛状态累积得出(既有逻辑)。
// - TLUSTY 关闭(仅 SYNSPEC 场景):final_converged 不能恒为 false——否则成功的
// 光谱合成任务被误判失败并触发策略回退。此时收敛 = 大气加载干净(无 NaN)且
// SYNSPEC 成功(rc=0)或 SYNSPEC 也关闭(TLUSTY-only 等价的纯校验场景,虽罕见)。
// 仅 SYNSPEC 场景下大气来自既有产物(非本任务重算),NaN 检查仍必要(产物可能损坏)。
if !tlusty_enabled && !atmo_has_nan {
final_converged = match synspec_rc {
Some(rc) => rc == 0,
None => !synspec_enabled, // SYNSPEC 也关闭 → 仅校验大气,干净即收敛
};
}
// 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖, // 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖,
// 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其 // 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其
// .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。 // .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。
@@ -569,6 +635,29 @@ impl<'a> ExecutionRunner<'a> {
} }
let elapsed_sec = t0.elapsed().as_secs_f64(); let elapsed_sec = t0.elapsed().as_secs_f64();
// 汇总 note(修复审查 #2 后续):半失败点(大气已收敛 + 光谱失败)须让
// synspec 的错误可见——此前 synspec rc≠0 时 note 恒为 None,上报的
// error_message 为空,attempts 表与详情面板无从排查失败原因。
let note = {
let mut notes: Vec<String> = Vec::new();
if atmo_has_nan {
notes.push("Invalidated: atmosphere contains >10% NaN lines".to_string());
}
if let Some(ref err) = synspec_err {
notes.push(format!("synspec error: {}", err));
} else if let Some(rc) = synspec_rc {
if rc != 0 {
notes.push(format!("synspec rc={}", rc));
}
}
if notes.is_empty() {
None
} else {
Some(notes.join("; "))
}
};
let summary = ModelSummary { let summary = ModelSummary {
name: name.to_string(), name: name.to_string(),
params: params.clone(), params: params.clone(),
@@ -582,11 +671,7 @@ impl<'a> ExecutionRunner<'a> {
synspec_error: synspec_err, synspec_error: synspec_err,
synspec_sec, synspec_sec,
elapsed_sec, elapsed_sec,
note: if atmo_has_nan { note,
Some("Invalidated: atmosphere contains >10% NaN lines".to_string())
} else {
None
},
}; };
// Write conv.json // Write conv.json
@@ -599,6 +684,7 @@ impl<'a> ExecutionRunner<'a> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::snapshot_tlusty_outputs;
use crate::models::{GridAxisValue, GridPointParams}; use crate::models::{GridAxisValue, GridPointParams};
#[test] #[test]
@@ -647,4 +733,63 @@ mod tests {
let authoritative_name = point_name; // 即 executor 传入的 task.point_name let authoritative_name = point_name; // 即 executor 传入的 task.point_name
assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4"); assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4");
} }
/// 快照 TLUSTY b 因子与出射谱:fort.12→`<name>.bfac`、fort.14→`<name>.emflux`
/// 缺失的源文件静默跳过,未列入快照的 fort.13 不受影响。
#[tokio::test]
async fn test_snapshot_tlusty_outputs() {
let dir = tempfile::tempdir().unwrap();
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let model_dir = dir.path().join(name);
tokio::fs::create_dir_all(&model_dir).await.unwrap();
// TLUSTY 最终迭代产物:b 因子(fort.12)与出射谱(fort.14
tokio::fs::write(model_dir.join("fort.12"), "bfac payload")
.await
.unwrap();
tokio::fs::write(model_dir.join("fort.14"), "emflux payload")
.await
.unwrap();
// 不参与快照的文件(出射辐射场 fort.13、大气 fort.7
tokio::fs::write(model_dir.join("fort.13"), "emrad payload")
.await
.unwrap();
tokio::fs::write(model_dir.join("fort.7"), "atmo payload")
.await
.unwrap();
snapshot_tlusty_outputs(&model_dir, name).await;
assert_eq!(
tokio::fs::read_to_string(model_dir.join(format!("{}.bfac", name)))
.await
.unwrap(),
"bfac payload"
);
assert_eq!(
tokio::fs::read_to_string(model_dir.join(format!("{}.emflux", name)))
.await
.unwrap(),
"emflux payload"
);
// fort.13 未列入快照,不应生成 <name>.emrad
assert!(!model_dir.join(format!("{}.emrad", name)).exists());
// 原 fort.12/fort.14 保留(后续 synspec 覆盖前仍作为单元文件存在)
assert!(model_dir.join("fort.12").is_file());
assert!(model_dir.join("fort.14").is_file());
}
/// 缺失源文件(仅 SYNSPEC 场景,tlusty 未运行)时快照应是无害 no-op
#[tokio::test]
async fn test_snapshot_tlusty_outputs_noop_when_missing() {
let dir = tempfile::tempdir().unwrap();
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let model_dir = dir.path().join(name);
tokio::fs::create_dir_all(&model_dir).await.unwrap();
snapshot_tlusty_outputs(&model_dir, name).await;
assert!(!model_dir.join(format!("{}.bfac", name)).exists());
assert!(!model_dir.join(format!("{}.emflux", name)).exists());
}
} }
+15 -2
View File
@@ -27,7 +27,13 @@ fn directed_cno_distance(cand: &GridPointParams, target: &GridPointParams) -> f6
const RICH_PENALTY: f64 = 4.0; // 目标比种子富 → 该方向微扰不稳定,重罚 const RICH_PENALTY: f64 = 4.0; // 目标比种子富 → 该方向微扰不稳定,重罚
const POOR_PENALTY: f64 = 1.0; // 目标比种子贫 → 该方向微扰稳定,轻罚 const POOR_PENALTY: f64 = 1.0; // 目标比种子贫 → 该方向微扰稳定,轻罚
// delta = target cand:正 = 目标更富(坏方向),负 = 目标更贫(好方向) // delta = target cand:正 = 目标更富(坏方向),负 = 目标更贫(好方向)
let penalize = |delta: f64| if delta > 0.0 { delta * RICH_PENALTY } else { -delta * POOR_PENALTY }; let penalize = |delta: f64| {
if delta > 0.0 {
delta * RICH_PENALTY
} else {
-delta * POOR_PENALTY
}
};
penalize(target.logc.value() - cand.logc.value()) penalize(target.logc.value() - cand.logc.value())
+ penalize(target.logn.value() - cand.logn.value()) + penalize(target.logn.value() - cand.logn.value())
+ penalize(target.logo.value() - cand.logo.value()) + penalize(target.logo.value() - cand.logo.value())
@@ -64,7 +70,14 @@ mod tests {
use super::*; use super::*;
use crate::models::GridAxisValue; use crate::models::GridAxisValue;
fn params(teff: f64, logg: f64, loghe: f64, logc: f64, logn: f64, logo: f64) -> GridPointParams { fn params(
teff: f64,
logg: f64,
loghe: f64,
logc: f64,
logn: f64,
logo: f64,
) -> GridPointParams {
GridPointParams { GridPointParams {
teff: GridAxisValue::from_value(teff), teff: GridAxisValue::from_value(teff),
logg: GridAxisValue::from_value(logg), logg: GridAxisValue::from_value(logg),
+335 -14
View File
@@ -99,6 +99,14 @@ impl SqliteTaskQueue {
[], [],
)?; )?;
} }
// H1 修复:历史遗留行(旧版在途任务)的 workflow_name 为 NULL。回填为
// '__legacy__'(与主库 grid_points 迁移口径一致),否则新节点领用/上报时
// claim/report 无法定向更新 '__legacy__' 网格点,旧任务结算后点永久卡死。
// 幂等:新 push 的行恒带 workflow_name,NULL 行只会出现在迁移遗留,重复执行无害。
conn.execute(
"UPDATE task_queue SET workflow_name = '__legacy__' WHERE workflow_name IS NULL",
[],
)?;
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_queue_status_created ON task_queue(status, created_at)", "CREATE INDEX IF NOT EXISTS idx_task_queue_status_created ON task_queue(status, created_at)",
[], [],
@@ -204,6 +212,15 @@ impl SqliteTaskQueue {
} }
}; };
// 旧版兼容归一化(见 models.rs::normalize_compat 文档):
// 旧 MQ 在途消息可能只含 task_type、无 tlusty_config 字段。serde default
// 会把 tlusty_config.strategies 填为完整默认链 [cold_run, seed_step]
// 导致旧 SeedStep 热启动消息首项被误判为 cold_run。normalize_compat
// 据 task_type 校正首项策略。在 pop 出队后立即调用,保证节点拿到的是
// 语义正确的策略链。对新版消息(已显式设置 tlusty_config)无副作用。
let mut task = task;
task.normalize_compat();
// 记录任务归属:claim 时写入领用方 node_id,供 report 阶段校验, // 记录任务归属:claim 时写入领用方 node_id,供 report 阶段校验,
// 杜绝「节点 A 领用、节点 B 上报」的跨节点伪造结果投毒。 // 杜绝「节点 A 领用、节点 B 上报」的跨节点伪造结果投毒。
tx.execute( tx.execute(
@@ -270,10 +287,15 @@ impl SqliteTaskQueue {
let conn = pool let conn = pool
.get() .get()
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?; .map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
// claimed 态(尚未被 report 清理)且归属匹配才算有效领用 // claimed 或 pending 态 + 归属匹配才算有效领用。status 含 'pending' 的缘由:
// `requeue_stale_tasks` 会把超时 claimed 行打回 pending(保留 claimed_by_node_id
// 仅清 claimed_at)。若任务实际仍在运行、只是耗时接近 timeout,原节点(仍持
// 归属)迟到的上报若被拒绝 → 403 → 昂贵计算结果被静默丢弃、重投重算(代码注释
// 记录的竞态,旧实现靠 stale_sec ≥ 3×timeout 缓冲而非根治)。放行 pending 态后
// 该竞态消除;重新领用会覆盖 claimed_by_node_id,故跨节点伪造上报仍被拦截。
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT payload FROM task_queue "SELECT payload FROM task_queue
WHERE task_id = ?1 AND claimed_by_node_id = ?2 AND status = 'claimed' LIMIT 1", WHERE task_id = ?1 AND claimed_by_node_id = ?2 AND status IN ('claimed', 'pending') LIMIT 1",
)?; )?;
let row = stmt.query_row(params![task_id, claimant], |r| r.get::<_, String>(0)); let row = stmt.query_row(params![task_id, claimant], |r| r.get::<_, String>(0));
match row { match row {
@@ -345,7 +367,7 @@ impl SqliteTaskQueue {
Ok(()) Ok(())
} }
/// 仅清理指定工作流的**未被领取**的排队任务。 /// 仅清理指定工作流的**未被领取**的排队任务,并返回被删行的 task_id 列表
/// ///
/// 用于 stop_workflow / initialize_grid 按工作流隔离清理,避免在多工作流场景下 /// 用于 stop_workflow / initialize_grid 按工作流隔离清理,避免在多工作流场景下
/// 误清其他工作流的任务。 /// 误清其他工作流的任务。
@@ -354,21 +376,55 @@ impl SqliteTaskQueue {
/// 必须保留领用凭证,否则节点上报时 verify_task_claim 找不到记录 → 403 → /// 必须保留领用凭证,否则节点上报时 verify_task_claim 找不到记录 → 403 →
/// 计算结果丢失、网格点永久卡在 running(#6 修复)。 /// 计算结果丢失、网格点永久卡在 running(#6 修复)。
/// `claimed` 行会在上报成功后由 remove_task 自然清理。 /// `claimed` 行会在上报成功后由 remove_task 自然清理。
pub async fn clear_queue_by_workflow(&self, workflow_name: &str) -> Result<()> { ///
/// 返回被删 task_id 供调用方同步清理主库 `tasks` 审计表对应行(2026-08-02 僵尸
/// 任务事故修复):此前只删队列行而遗留 tasks 表 pending 行,成为孤儿回收器误判
/// 与重复派发涡旋的燃料。调用方拿到 ids 后应调 Database::delete_tasks_by_ids。
pub async fn clear_queue_by_workflow(&self, workflow_name: &str) -> Result<Vec<String>> {
let pool = self.pool.clone(); let pool = self.pool.clone();
let wf_owned = workflow_name.to_string(); let wf_owned = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> { let ids = tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let conn = pool let conn = pool
.get() .get()
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?; .map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
conn.execute( // DELETE...RETURNING 须 prepare + query_mapexecute 不返回结果集)。
"DELETE FROM task_queue WHERE workflow_name = ?1 AND status = 'pending'", let mut stmt = conn.prepare(
params![wf_owned], "DELETE FROM task_queue WHERE workflow_name = ?1 AND status = 'pending' RETURNING task_id",
)?; )?;
Ok(()) let rows = stmt.query_map(params![wf_owned], |row| row.get::<_, String>(0))?;
let mut ids = Vec::new();
for r in rows {
if let Ok(id) = r {
ids.push(id);
}
}
Ok(ids)
}) })
.await??; .await??;
Ok(()) Ok(ids)
}
/// 判断指定 task_id 是否仍有**活**队列行(pending 或 claimed)。
///
/// 供派发去重与孤儿回收做跨库活性交叉校验:主库 tasks 表的 pending 行可能是
/// 从未入队/已被清理的僵尸记录,唯有 task_queue 中存在 pending/claimed 行才证明
/// 该任务真在途(排队中或已被节点领用)。
pub async fn task_row_exists(&self, task_id: &str) -> Result<bool> {
let pool = self.pool.clone();
let id_owned = task_id.to_string();
let exists = tokio::task::spawn_blocking(move || -> Result<bool> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM task_queue WHERE task_id = ?1 AND status IN ('pending', 'claimed')",
params![id_owned],
|r| r.get(0),
)?;
Ok(count > 0)
})
.await??;
Ok(exists)
} }
} }
@@ -378,6 +434,53 @@ mod tests {
use common::models::{GridPointParams, TaskType}; use common::models::{GridPointParams, TaskType};
use uuid::Uuid; use uuid::Uuid;
/// H1 修复:队列库迁移必须把历史遗留的 NULL workflow_name 行回填为 '__legacy__'
/// (与主库 grid_points 迁移口径一致)。否则新节点领用/上报时 claim/report 无法
/// 定向更新 '__legacy__' 网格点,旧任务结算后网格点永久卡死。
#[tokio::test]
async fn test_queue_migration_backfills_null_workflow_name() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("legacy_mig.db");
// 模拟旧版队列库:建表 + 插入一条 workflow_name 为 NULL 的遗留 pending 行。
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE task_queue (
task_id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
status TEXT NOT NULL,
created_at DATETIME NOT NULL,
claimed_at DATETIME,
workflow_name TEXT,
claimed_by_node_id TEXT,
wave INTEGER NOT NULL DEFAULT 0
);",
)
.unwrap();
conn.execute(
"INSERT INTO task_queue (task_id, payload, status, created_at, wave)
VALUES ('legacy-1', '{}', 'pending', datetime('now'), 0)",
[],
)
.unwrap();
}
// 打开队列库(触发迁移:补列检查 + NULL workflow_name 回填)。
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy()).await.unwrap();
let wf: String = {
let conn = queue.pool.get().unwrap();
conn.query_row(
"SELECT workflow_name FROM task_queue WHERE task_id = 'legacy-1'",
[],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(wf, "__legacy__", "NULL workflow_name 应回填为 __legacy__");
}
#[tokio::test] #[tokio::test]
async fn test_sqlite_task_queue_operations() { async fn test_sqlite_task_queue_operations() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
@@ -405,6 +508,7 @@ mod tests {
timeout_sec: 3600, timeout_sec: 3600,
workflow_name: Some("test_wf".to_string()), workflow_name: Some("test_wf".to_string()),
wave: 0, wave: 0,
..Default::default()
}; };
queue.push_task(&task).await.unwrap(); queue.push_task(&task).await.unwrap();
@@ -453,6 +557,7 @@ mod tests {
timeout_sec: 60, timeout_sec: 60,
workflow_name: None, workflow_name: None,
wave: 0, wave: 0,
..Default::default()
}; };
queue.push_task(&task).await.unwrap(); queue.push_task(&task).await.unwrap();
@@ -486,6 +591,92 @@ mod tests {
assert!(claim_after.is_none()); assert!(claim_after.is_none());
} }
/// requeue 竞态回归(修复):任务被 `requeue_stale_tasks` 打回 pending 后(claimed_at
/// 清零但 claimed_by_node_id 保留),原节点(仍持归属)迟到的上报必须被放行——否则
/// 耗时接近 timeout 的任务会因 403 被静默丢弃计算结果、重投重算(代码注释记录的竞态,
/// 旧实现只靠 stale_sec ≥ 3×timeout 缓冲)。重新领用后归属被覆盖,原节点校验失败。
#[tokio::test]
async fn test_verify_task_claim_accepts_requeued_original_claimant() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("claim_requeue.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
let task_id = Uuid::new_v4();
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let task = TaskSpec {
task_id,
point_name: params.model_name(),
params: params.clone(),
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 7200,
workflow_name: Some("wf_rq".to_string()),
wave: 0,
..Default::default()
};
queue.push_task(&task).await.unwrap();
// node-A 领用 → claimed。
assert!(queue.pop_task("node-A").await.unwrap().is_some());
// 模拟 requeue_stale_tasks(stale=0):超时 claimed 行打回 pendingclaimed_by_node_id 保留)。
{
let pool = queue.pool.clone();
let tid = task_id.to_string();
tokio::task::spawn_blocking(move || {
let conn = pool.get().unwrap();
conn.execute(
"UPDATE task_queue SET status = 'pending', claimed_at = NULL WHERE task_id = ?1",
rusqlite::params![tid],
)
.unwrap();
})
.await
.unwrap();
}
// 原节点 node-A 迟到的上报:pending + 归属匹配 → 放行(修复后不再 403)。
let claim_a = queue
.verify_task_claim(&task_id.to_string(), "node-A")
.await
.unwrap();
assert_eq!(
claim_a.map(|(p, w)| (p, w)),
Some((params.model_name(), Some("wf_rq".to_string()))),
"requeue 后原节点仍持归属,应放行"
);
// 其它节点仍被拒(归属不变)。
let claim_b = queue
.verify_task_claim(&task_id.to_string(), "node-B")
.await
.unwrap();
assert!(claim_b.is_none(), "非归属节点仍应被拒");
// 重新领用后归属覆盖:node-B claim 后,node-A 校验失败、node-B 成功。
let popped_b = queue.pop_task("node-B").await.unwrap();
assert!(popped_b.is_some(), "pending 任务可被 node-B 重新领用");
let claim_a2 = queue
.verify_task_claim(&task_id.to_string(), "node-A")
.await
.unwrap();
assert!(claim_a2.is_none(), "归属已移交 node-Bnode-A 迟报应被拒");
let claim_b2 = queue
.verify_task_claim(&task_id.to_string(), "node-B")
.await
.unwrap();
assert!(claim_b2.is_some(), "node-B 现持归属,应放行");
}
/// 辅助:构造一个最小 TaskSpec,方便下面两个排序测试。 /// 辅助:构造一个最小 TaskSpec,方便下面两个排序测试。
fn mk_task(wf: &str, wave: i32, point_name: &str) -> TaskSpec { fn mk_task(wf: &str, wave: i32, point_name: &str) -> TaskSpec {
TaskSpec { TaskSpec {
@@ -504,6 +695,7 @@ mod tests {
timeout_sec: 60, timeout_sec: 60,
workflow_name: Some(wf.to_string()), workflow_name: Some(wf.to_string()),
wave, wave,
..Default::default()
} }
} }
@@ -585,10 +777,7 @@ mod tests {
// 第一次 pop:应跳过 poison(标记为 dead_letter)并返回合法任务 // 第一次 pop:应跳过 poison(标记为 dead_letter)并返回合法任务
let popped = queue.pop_task("n1").await.unwrap(); let popped = queue.pop_task("n1").await.unwrap();
assert!( assert!(popped.is_some(), "毒消息不应阻塞合法任务出队");
popped.is_some(),
"毒消息不应阻塞合法任务出队"
);
let task = popped.unwrap(); let task = popped.unwrap();
assert_eq!(task.point_name, "ok_point"); assert_eq!(task.point_name, "ok_point");
@@ -614,6 +803,64 @@ mod tests {
assert!(queue.pop_task("n1").await.unwrap().is_none()); assert!(queue.pop_task("n1").await.unwrap().is_none());
} }
/// 旧版 MQ 在途消息兼容(二次审查 critical 修复验证):
/// 旧消息只含 task_type=seed_step、无 tlusty_config 字段。serde default 会把
/// strategies 填为 [cold_run, seed_step],首项 cold_run 与 task_type 不符。
/// pop_task 出队时须调用 normalize_compat() 把首项校正为 seed_step
/// 否则旧热启动消息会被节点误当冷启动执行。
#[tokio::test]
async fn test_pop_normalizes_legacy_seed_step_message() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("legacy.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
// 直接写一行旧版 payload(仅 task_type,无 tlusty_config/synspec_config 字段)。
let legacy_task_id = uuid::Uuid::new_v4();
let legacy_payload = serde_json::json!({
"task_id": legacy_task_id.to_string(),
"point_name": "t35000_g5.5_he-1_c-2_n-2_o-2",
"params": {"teff": 35000.0, "logg": 5.5, "loghe": -1.0, "logc": -2.0, "logn": -2.0, "logo": -2.0},
"task_type": "seed_step",
"seed_point_name": "neighbor_seed",
"timeout_sec": 7200,
"workflow_name": "wf_legacy",
"wave": 0
}).to_string();
{
let pool = queue.pool.clone();
let payload_clone = legacy_payload.clone();
let tid = legacy_task_id.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().unwrap();
conn.execute(
"INSERT INTO task_queue (task_id, payload, status, created_at, wave)
VALUES (?1, ?2, 'pending', datetime('now'), 0)",
rusqlite::params![tid, payload_clone],
)?;
Ok(())
})
.await
.unwrap()
.unwrap();
}
let popped = queue.pop_task("n1").await.unwrap();
let task = popped.expect("旧版消息应能正常出队");
// normalize_compat 应把 strategies 校正为 [seed_step](与 task_type 一致)。
assert_eq!(
task.tlusty_config.strategies,
vec!["seed_step".to_string()],
"旧 seed_step 消息经 normalize_compat 后首项应为 seed_step"
);
assert_eq!(
task.tlusty_config.current_strategy("cold_run"),
"seed_step",
"current_strategy 应为 seed_step(而非默认链的 cold_run"
);
}
#[tokio::test] #[tokio::test]
async fn test_pop_wave_priority_within_workflow() { async fn test_pop_wave_priority_within_workflow() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
@@ -632,4 +879,78 @@ mod tests {
let p2 = queue.pop_task("n2").await.unwrap().unwrap(); let p2 = queue.pop_task("n2").await.unwrap().unwrap();
assert_eq!(p2.point_name, "hard"); assert_eq!(p2.point_name, "hard");
} }
/// 回归测试(2026-08-02 僵尸任务事故):clear_queue_by_workflow 必须返回被删
/// pending 行的 task_id(供调用方同步清理主库 tasks 审计行),且保留 claimed 行、
/// 不波及他工作流。
#[tokio::test]
async fn test_clear_queue_by_workflow_returns_deleted_ids() {
let temp_dir = tempfile::tempdir().unwrap();
let queue = SqliteTaskQueue::new(&temp_dir.path().join("clear_ret.db").to_string_lossy())
.await
.unwrap();
// wf_a 两个任务:一个被领用(claimed),一个排队(pending
queue
.push_task(&mk_task("wf_a", 0, "a_claimed"))
.await
.unwrap();
queue
.push_task(&mk_task("wf_a", 0, "a_pending"))
.await
.unwrap();
// wf_b 一个排队任务(不应被 wf_a 的清理波及)
queue
.push_task(&mk_task("wf_b", 0, "b_pending"))
.await
.unwrap();
let claimed = queue.pop_task("node-a").await.unwrap().unwrap();
assert_eq!(claimed.point_name, "a_claimed");
let deleted = queue.clear_queue_by_workflow("wf_a").await.unwrap();
assert_eq!(deleted.len(), 1, "仅 a_pending 一行被删");
// 被删行对应的点名为 a_pending:用剩余可 pop 任务反证(wf_a 已无 pending 行)
let next = queue.pop_task("node-b").await.unwrap().unwrap();
assert_eq!(
next.workflow_name,
Some("wf_b".to_string()),
"wf_b 行应完好"
);
// claimed 行保留:领用凭证仍在,原节点归属校验通过(#6 设计不动)
let verify = queue
.verify_task_claim(&claimed.task_id.to_string(), "node-a")
.await
.unwrap();
assert!(verify.is_some(), "claimed 行必须保留");
// 被删的 id 不再有任何活行
for id in &deleted {
assert!(!queue.task_row_exists(id).await.unwrap());
}
}
/// task_row_exists 三态:pending/claimed 视为活,remove 后为死。
#[tokio::test]
async fn test_task_row_exists_liveness() {
let temp_dir = tempfile::tempdir().unwrap();
let queue = SqliteTaskQueue::new(&temp_dir.path().join("exists.db").to_string_lossy())
.await
.unwrap();
let task = mk_task("wf_e", 0, "point_e");
let id = task.task_id.to_string();
assert!(!queue.task_row_exists(&id).await.unwrap(), "未入队为死");
queue.push_task(&task).await.unwrap();
assert!(queue.task_row_exists(&id).await.unwrap(), "pending 为活");
queue.pop_task("node-e").await.unwrap().unwrap();
assert!(queue.task_row_exists(&id).await.unwrap(), "claimed 仍为活");
queue.remove_task(&id).await.unwrap();
assert!(!queue.task_row_exists(&id).await.unwrap(), "删除后为死");
}
} }
+86 -155
View File
@@ -1,7 +1,8 @@
use anyhow::Result; use anyhow::Result;
use common::result_filter::is_result_worthy; use common::config::SynspecConfig;
use common::embedded::{ensure_specific_data_files, RuntimePaths}; use common::embedded::{ensure_specific_data_files, RuntimePaths};
use common::models::{ModelSummary, TaskSpec, TaskType}; use common::models::{ModelSummary, TaskSpec};
use common::result_filter::is_result_worthy;
use common::runner::ExecutionRunner; use common::runner::ExecutionRunner;
use reqwest::Client; use reqwest::Client;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -12,6 +13,7 @@ pub async fn execute_task(
server_url: &str, server_url: &str,
runtime: &RuntimePaths, runtime: &RuntimePaths,
work_dir: &Path, work_dir: &Path,
result_dir: &Path,
task: &TaskSpec, task: &TaskSpec,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<(ModelSummary, Option<Vec<u8>>)> { ) -> Result<(ModelSummary, Option<Vec<u8>>)> {
@@ -54,26 +56,64 @@ pub async fn execute_task(
let slot_work_dir = work_dir.join(format!("task_{}", task.task_id)); let slot_work_dir = work_dir.join(format!("task_{}", task.task_id));
tokio::fs::create_dir_all(&slot_work_dir).await?; tokio::fs::create_dir_all(&slot_work_dir).await?;
// 2. If seed_step, download seed .7 file from server using atomic file rename. // 2. 种子大气获取(见 docs/task_engine_decoupling_design.md §5):
// 调度入口已改为「冷启动优先」,SeedStep 仅作为冷启动失败后的救援任务出现,服务端 // - TLUSTY 启用 + 策略为 seed_step:下载近邻种子 .7 作热启动种子(既有逻辑)。
// 派发前已经 find_best_seed_from_db 确认种子存在于 DB。因此下载失败(缺种子名/HTTP // - TLUSTY 关闭(仅 SYNSPEC 场景):需拉取目标点 .7 大气作光谱合成输入。
// 错误/网络异常/响应体读取失败)按硬错误处理,直接失败该任务(fail-fast),不再无种 // 顺序:本地 result 归档 → server 拉取。
// 子继续运行:default_seed_chain 首阶段 seed_nc 的 ltgray="F" 依赖 fort.8,无种子时 // 注:不查沙盒本地——TLUSTY 与 SYNSPEC 在同一任务内串行,种子获取先于 runner,
// Tlusty 在无初始大气下运行必然崩溃。任务失败后由服务端走既有上报路径, // 全新 slot 内不可能已有目标大气;重试任务的 slot 亦全新(task_id 唯一)。
// has_seed_step_attempt 阻止重复回退,网格点保持 failed 终态。 let tlusty_enabled = task.tlusty_config.enabled;
if task.task_type == TaskType::SeedStep { let current_strategy = task.tlusty_config.current_strategy("cold_run");
let seed_name = task.seed_point_name.as_deref().ok_or_else(|| { let needs_seed_download = (tlusty_enabled && current_strategy == "seed_step")
|| (!tlusty_enabled && task.synspec_config.enabled);
if needs_seed_download {
let seed_name = if !tlusty_enabled {
// 仅 SYNSPEC 场景:大气来自目标点本身(atmosphere_ref 或 point_name)。
task.atmosphere_ref
.clone()
.unwrap_or_else(|| task.point_name.clone())
} else {
// SeedStep 热启动:大气来自近邻种子点。
task.seed_point_name.clone().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"SeedStep 任务 {} 缺少 seed_point_name,无法热启动", "SeedStep 任务 {} 缺少 seed_point_name,无法热启动",
task.point_name task.point_name
) )
})?; })?
};
// 仅 SYNSPEC 场景先查本地 result 归档目录(节点此前算过同点大气,避免 server 拉取)。
if !tlusty_enabled {
let archived = result_dir
.join(&task.point_name)
.join(format!("{}.7", task.point_name));
if archived.is_file() {
info!(
"SYNSPEC-only:在本地 result 归档找到大气 {},复用避免 server 拉取",
archived.display()
);
seed_atmos_path = Some(archived);
}
}
// 归档无 → 向 server 拉取。
if seed_atmos_path.is_none() {
let seed_url = format!("{}/api/seed/{}", server_url, seed_name); let seed_url = format!("{}/api/seed/{}", server_url, seed_name);
info!("正在从服务端下载种子大气文件: {}", seed_url); info!(
"正在从服务端下载大气文件 ({}, 用途: {}): {}",
seed_name,
if tlusty_enabled {
"TLUSTY 热启动种子"
} else {
"SYNSPEC 输入大气"
},
seed_url
);
let resp = client.get(&seed_url).send().await.map_err(|e| { let resp = client.get(&seed_url).send().await.map_err(|e| {
anyhow::anyhow!( anyhow::anyhow!(
"SeedStep 任务 {} 下载种子文件 {} 失败: {}", "任务 {} 下载大气文件 {} 失败: {}",
task.point_name, task.point_name,
seed_url, seed_url,
e e
@@ -81,7 +121,7 @@ pub async fn execute_task(
})?; })?;
if !resp.status().is_success() { if !resp.status().is_success() {
anyhow::bail!( anyhow::bail!(
"SeedStep 任务 {} 下载种子文件 {} 失败: HTTP {}", "任务 {} 下载大气文件 {} 失败: HTTP {}",
task.point_name, task.point_name,
seed_url, seed_url,
resp.status() resp.status()
@@ -89,7 +129,7 @@ pub async fn execute_task(
} }
let bytes = resp.bytes().await.map_err(|e| { let bytes = resp.bytes().await.map_err(|e| {
anyhow::anyhow!( anyhow::anyhow!(
"SeedStep 任务 {} 读取种子文件 {} 响应体失败: {}", "任务 {} 读取大气文件 {} 响应体失败: {}",
task.point_name, task.point_name,
seed_url, seed_url,
e e
@@ -98,9 +138,6 @@ pub async fn execute_task(
let temp_seed_dir = work_dir.join(".seed_cache"); let temp_seed_dir = work_dir.join(".seed_cache");
tokio::fs::create_dir_all(&temp_seed_dir).await?; tokio::fs::create_dir_all(&temp_seed_dir).await?;
// LRU 上限清理:下载新种子前,删除最旧的超出 MAX_SEED_CACHE_FILES 的
// .seed.7 文件,防止长期运行后不同种子点累积到 GB 级。同名种子会被
// 覆盖写,真正累积的维度是「不同 seed_name」的数量。
cleanup_seed_cache(&temp_seed_dir).await; cleanup_seed_cache(&temp_seed_dir).await;
let tmp_path = temp_seed_dir.join(format!( let tmp_path = temp_seed_dir.join(format!(
"{}.{}.tmp", "{}.{}.tmp",
@@ -111,18 +148,20 @@ pub async fn execute_task(
tokio::fs::write(&tmp_path, bytes).await?; tokio::fs::write(&tmp_path, bytes).await?;
tokio::fs::rename(&tmp_path, &final_seed_path).await?; tokio::fs::rename(&tmp_path, &final_seed_path).await?;
// 关键:复制一份种子到本任务沙盒私有副本,让 seed_atmos_path
// 指向私有副本而非共享缓存。此后 runner 的 current_seed 全程
// 只引用沙盒内文件,与 .seed_cache 完全解耦——这样 LRU 清理
// (含并发竞争)即便删掉该缓存文件,也不会破坏正在使用该种子
// 的 in-flight 任务。.seed_cache 退化为纯粹的下载去重缓存。
let private_seed = slot_work_dir.join("seed_atmos.seed.7"); let private_seed = slot_work_dir.join("seed_atmos.seed.7");
tokio::fs::copy(&final_seed_path, &private_seed).await?; tokio::fs::copy(&final_seed_path, &private_seed).await?;
seed_atmos_path = Some(private_seed); seed_atmos_path = Some(private_seed);
} }
}
// 3. slot_work_dir 已在种子下载前提前创建,种子私有副本亦已落盘于沙盒内。) // 3. slot_work_dir 已在种子下载前提前创建,种子私有副本亦已落盘于沙盒内。)
// 反序列化工作流携带的 SYNSPEC 数值参数(波长范围等)。None → runner 用硬编码默认。
let synspec_cfg: Option<SynspecConfig> = task
.synspec_params
.as_ref()
.and_then(|v| serde_json::from_value::<SynspecConfig>(v.clone()).ok());
let runner = ExecutionRunner::new(runtime, slot_work_dir.clone()); let runner = ExecutionRunner::new(runtime, slot_work_dir.clone());
let summary = runner let summary = runner
.run_model_with_timeout( .run_model_with_timeout(
@@ -135,7 +174,10 @@ pub async fn execute_task(
// SeedStep→default_seed_chain),节点端不再做「缺种子回退冷启动链」的降级。 // SeedStep→default_seed_chain),节点端不再做「缺种子回退冷启动链」的降级。
None, None,
seed_atmos_path.as_deref(), seed_atmos_path.as_deref(),
None, synspec_cfg.as_ref(),
// 阶段独立配置开关(见 docs/task_engine_decoupling_design.md §5)。
task.tlusty_config.enabled,
task.synspec_config.enabled,
task.timeout_sec, task.timeout_sec,
shutdown, shutdown,
) )
@@ -199,7 +241,8 @@ pub async fn cleanup_slot_work_dir(slot_work_dir: &Path) -> Result<()> {
/// ///
/// 保留内容(详见 [`is_result_worthy`]): /// 保留内容(详见 [`is_result_worthy`]):
/// - 裸名:`conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡) /// - 裸名:`conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡)
/// - 科学核心:`<name>.7/.spec/.cont/.iden/.log` /// - 科学核心:`<name>.7/.spec/.cont/.iden/.log`,以及 runner 在 SYNSPEC 覆盖前快照的
/// TLUSTY 最终产物:`<name>.bfac`b 因子/非 LTE 偏离因子)、`<name>.emflux`(出射谱 λ–Fλ)
/// - 阶段快照:`<name>.<label>.5/.6/.err/.nst/.7` /// - 阶段快照:`<name>.<label>.5/.6/.err/.nst/.7`
/// - 收敛诊断:`<name>.<label>_chmax*.9`**唯一保留的 .9** /// - 收敛诊断:`<name>.<label>_chmax*.9`**唯一保留的 .9**
/// ///
@@ -211,11 +254,7 @@ pub async fn cleanup_slot_work_dir(slot_work_dir: &Path) -> Result<()> {
/// `name` 为网格点权威名:取自 summary.namerunner 现用 task.point_name 作权威名), /// `name` 为网格点权威名:取自 summary.namerunner 现用 task.point_name 作权威名),
/// 严重失败(runner 抛 Err、无 summary)时回退到 task.point_name,确保失败任务的 /// 严重失败(runner 抛 Err、无 summary)时回退到 task.point_name,确保失败任务的
/// 排错日志也能落盘。 /// 排错日志也能落盘。
pub async fn save_result_artifacts( pub async fn save_result_artifacts(result_dir: &Path, slot_work_dir: &Path, name: &str) {
result_dir: &Path,
slot_work_dir: &Path,
name: &str,
) {
let src_dir = slot_work_dir.join(name); let src_dir = slot_work_dir.join(name);
if !src_dir.is_dir() { if !src_dir.is_dir() {
// 模型子目录不存在(极早期失败),无可归档内容 // 模型子目录不存在(极早期失败),无可归档内容
@@ -276,7 +315,11 @@ pub async fn save_result_artifacts(
let dest_path = dest_dir.join(&file_name); let dest_path = dest_dir.join(&file_name);
// 原子写入:先拷到 .result.tmp.<uuid> 再 rename,防止中途崩溃产生半截文件 // 原子写入:先拷到 .result.tmp.<uuid> 再 rename,防止中途崩溃产生半截文件
let tmp_path = dest_dir.join(format!("{}.result.tmp.{}", file_name, uuid::Uuid::new_v4().simple())); let tmp_path = dest_dir.join(format!(
"{}.result.tmp.{}",
file_name,
uuid::Uuid::new_v4().simple()
));
match tokio::fs::copy(&path, &tmp_path).await { match tokio::fs::copy(&path, &tmp_path).await {
Ok(_) => { Ok(_) => {
if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await { if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await {
@@ -284,9 +327,7 @@ pub async fn save_result_artifacts(
let _ = tokio::fs::remove_file(&tmp_path).await; let _ = tokio::fs::remove_file(&tmp_path).await;
warn!( warn!(
"归档网格点 {} 的文件 {} rename 失败: {}", "归档网格点 {} 的文件 {} rename 失败: {}",
name, name, file_name, e
file_name,
e
); );
continue; continue;
} }
@@ -294,12 +335,7 @@ pub async fn save_result_artifacts(
} }
Err(e) => { Err(e) => {
let _ = tokio::fs::remove_file(&tmp_path).await; let _ = tokio::fs::remove_file(&tmp_path).await;
warn!( warn!("归档网格点 {} 的文件 {} 拷贝失败: {}", name, file_name, e);
"归档网格点 {} 的文件 {} 拷贝失败: {}",
name,
file_name,
e
);
} }
} }
} }
@@ -314,52 +350,9 @@ pub async fn save_result_artifacts(
); );
} }
/// 归档目录保留的网格点(子目录)数量上限。超过则按 mtime 删除最旧的。 /// 归档目录不做数量上限治理:所有已算网格点的完整产物(.spec/.cont/.iden/各阶段
/// 200 足以覆盖中等规模网格的完整归档;更大网格可经环境变量或常量调整。 /// 快照/日志/种子二进制等)一律永久保留,避免 LRU 淘汰导致科学产物丢失
const MAX_RESULT_MODELS: usize = 200; /// 2026-08-02 修正:撤销 1bfa240 引入的 MAX_RESULT_MODELS=200 LRU 上限)。
/// LRU 治理归档目录:当网格点子目录数超过 `MAX_RESULT_MODELS` 时,
/// 按 mtime 升序删除最旧的若干个子目录,直到不超过上限。
/// 仅统计子目录(每个对应一个网格点),忽略散落文件。错误降级为 warn,不阻断主流程。
pub async fn cleanup_result_dir(result_dir: &Path) {
let mut entries: Vec<(std::time::SystemTime, PathBuf)> =
match tokio::fs::read_dir(result_dir).await {
Ok(mut rd) => {
let mut v = Vec::new();
while let Ok(Some(entry)) = rd.next_entry().await {
let path = entry.path();
// 仅纳入子目录(网格点归档目录),跳过散落文件
if !path.is_dir() {
continue;
}
let meta = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
v.push((mtime, path));
}
v
}
// 归档目录不存在或不可读:无操作(首次归档尚未创建)
Err(_) => return,
};
if entries.len() <= MAX_RESULT_MODELS {
return;
}
// 按 mtime 升序(最旧在前),删除超出上限的最旧子目录
entries.sort_by_key(|(mtime, _)| *mtime);
let to_remove = entries.len().saturating_sub(MAX_RESULT_MODELS);
for (_, path) in entries.into_iter().take(to_remove) {
if let Err(e) = tokio::fs::remove_dir_all(&path).await {
warn!("LRU 清理归档目录 {} 失败: {}", path.display(), e);
} else {
info!("LRU 清理归档目录: {}", path.display());
}
}
}
/// `.seed_cache/` 内保留的 `.seed.7` 文件上限。超过则按 mtime 删除最旧的。 /// `.seed_cache/` 内保留的 `.seed.7` 文件上限。超过则按 mtime 删除最旧的。
/// 典型网格内活跃种子点数量有限,8 足以覆盖常用邻域且把磁盘占用控制在 ~8 个种子文件。 /// 典型网格内活跃种子点数量有限,8 足以覆盖常用邻域且把磁盘占用控制在 ~8 个种子文件。
@@ -480,6 +473,8 @@ mod tests {
format!("{}.cont", summary.name), // 连续谱 format!("{}.cont", summary.name), // 连续谱
format!("{}.iden", summary.name), // 谱线证认 format!("{}.iden", summary.name), // 谱线证认
format!("{}.log", summary.name), // synspec 日志 format!("{}.log", summary.name), // synspec 日志
format!("{}.bfac", summary.name), // TLUSTY 最终 b 因子(快照 fort.12
format!("{}.emflux", summary.name), // TLUSTY 最终出射谱(快照 fort.14
"conv.json".to_string(), // 摘要 "conv.json".to_string(), // 摘要
"fort.8".to_string(), // synspec 输入大气(裸名保留) "fort.8".to_string(), // synspec 输入大气(裸名保留)
"fort.55".to_string(), // synspec 控制卡(裸名保留) "fort.55".to_string(), // synspec 控制卡(裸名保留)
@@ -496,7 +491,7 @@ mod tests {
// 无 _chmax 的重复 .9 快照、未知后缀 // 无 _chmax 的重复 .9 快照、未知后缀
let skipped_files: [String; 14] = [ let skipped_files: [String; 14] = [
"fort.1".to_string(), // 空单元 "fort.1".to_string(), // 空单元
"fort.13".to_string(), // Tlusty NLTE 跃迁频率网格 "fort.13".to_string(), // Tlusty 出射辐射场 (FREQ/FLUX/FH),未快照,丢弃
"fort.18".to_string(), // Tlusty 大气结构内部表 "fort.18".to_string(), // Tlusty 大气结构内部表
"fort.22".to_string(), // Tlusty 中间大气副本 "fort.22".to_string(), // Tlusty 中间大气副本
"fort.82".to_string(), // Tlusty 运行时诊断表 "fort.82".to_string(), // Tlusty 运行时诊断表
@@ -535,19 +530,11 @@ mod tests {
assert!(dest_dir.is_dir(), "归档目标目录应被创建"); assert!(dest_dir.is_dir(), "归档目标目录应被创建");
// 验证白名单产物都被拷贝 // 验证白名单产物都被拷贝
for f in &kept_files { for f in &kept_files {
assert!( assert!(dest_dir.join(f).is_file(), "白名单产物 {} 应被归档", f);
dest_dir.join(f).is_file(),
"白名单产物 {} 应被归档",
f
);
} }
// 验证非白名单文件未进归档 // 验证非白名单文件未进归档
for f in &skipped_files { for f in &skipped_files {
assert!( assert!(!dest_dir.join(f).exists(), "非白名单文件 {} 应被跳过", f);
!dest_dir.join(f).exists(),
"非白名单文件 {} 应被跳过",
f
);
} }
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -561,65 +548,9 @@ mod tests {
let mut rd = tokio::fs::read_dir(&dest_dir).await.unwrap(); let mut rd = tokio::fs::read_dir(&dest_dir).await.unwrap();
while let Ok(Some(e)) = rd.next_entry().await { while let Ok(Some(e)) = rd.next_entry().await {
let name = e.file_name().to_string_lossy().to_string(); let name = e.file_name().to_string_lossy().to_string();
assert!( assert!(!name.contains(".result.tmp"), "不应残留 tmp 文件: {}", name);
!name.contains(".result.tmp"),
"不应残留 tmp 文件: {}",
name
);
} }
let _ = tokio::fs::remove_dir_all(&root).await; let _ = tokio::fs::remove_dir_all(&root).await;
} }
/// 验证 LRU 治理:超过上限时按 mtime 删最旧的子目录
#[tokio::test]
async fn test_cleanup_result_dir() {
let result_dir =
std::env::temp_dir().join(format!("test_result_lru_{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&result_dir).await.unwrap();
// 创建 MAX+10 个子目录,按创建顺序递增 mtime(每个 sleep 制造可测的时间差)。
// model_0000 最早创建(最旧),model_0209 最新创建。
let total = MAX_RESULT_MODELS + 10;
for i in 0..total {
let dir = result_dir.join(format!("model_{:04}", i));
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("marker"), format!("{}", i))
.await
.unwrap();
// 10ms 间隔足以让多数文件系统的 mtime 分辨出先后顺序
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
cleanup_result_dir(&result_dir).await;
let mut remaining: Vec<String> = Vec::new();
let mut rd = tokio::fs::read_dir(&result_dir).await.unwrap();
while let Ok(Some(e)) = rd.next_entry().await {
if e.path().is_dir() {
remaining.push(e.file_name().to_string_lossy().to_string());
}
}
// 清理后剩余数量应恰为上限
assert_eq!(
remaining.len(),
MAX_RESULT_MODELS,
"清理后应剩余 {} 个,实际 {} 个",
MAX_RESULT_MODELS,
remaining.len()
);
// 最旧的那批(model_0000~model_0009)应被删除,最新的 MAX 个应保留
remaining.sort();
assert!(
!remaining.contains(&"model_0000".to_string()),
"最旧的 model_0000 应被 LRU 删除"
);
assert!(
remaining.contains(&format!("model_{:04}", total - 1)),
"最新的 model_{:04} 应被保留",
total - 1
);
let _ = tokio::fs::remove_dir_all(&result_dir).await;
}
} }
+191 -7
View File
@@ -4,6 +4,46 @@ use reqwest::multipart::{Form, Part};
use reqwest::Client; use reqwest::Client;
use tracing::{info, warn}; use tracing::{info, warn};
/// 失败阶段归因(见 docs/task_engine_decoupling_design.md §4.2):从 summary + 任务阶段开关
/// 推断本次失败发生在 TLUSTY 还是 SYNSPEC,供服务端按对应策略链弹栈回退。
///
/// 判定顺序(顺序敏感):
/// - `converged=false` 时:大气未收敛。若 TLUSTY 启用 → 归因 TLUSTY(含"TLUSTY 产出大气
/// 失败导致 synspec 无输入"的级联场景);TLUSTY 关闭 → 归因 SYNSPECsynspec-only 任务
/// 的 converged 由 synspec_rc 决定)。
/// - `converged=true` 但 synspec 有错误/非零 rc:归因 SYNSPEC(大气已收敛、光谱失败)。
fn infer_failed_stage(task: &TaskSpec, summary: &ModelSummary) -> Option<String> {
if !summary.converged {
return if task.tlusty_config.enabled {
Some("tlusty".to_string())
} else {
Some("synspec".to_string())
};
}
if summary.synspec_error.is_some() || matches!(summary.synspec_rc, Some(rc) if rc != 0) {
return Some("synspec".to_string());
}
None
}
/// 任务整体成败判定(修复审查 #2 后续)。
///
/// 旧实现 `if s.converged { Completed } else { Failed }` 只看大气收敛:当 TLUSTY 收敛但
/// SYNSPEC 失败(rc≠0 / synspec_error)时,`converged` 仍为 true(它是大气收敛标志),
/// 任务被误报为 Completed → 服务端 `record_task_report` 按 converged 吸收为终态:
/// 半失败点(大气好、光谱坏)既不回退重试,错误也无从查证。
///
/// 新判定:**半失败 = 失败**。`converged` 仍保留 true 随上报(大气产物有效,服务端仍会
/// 保存 .7 作种子),但 status=Failed 让服务端弹 synspec 链重试,且错误对运维可见。
fn derive_report_status(s: &ModelSummary) -> TaskStatus {
let synspec_failed = s.synspec_error.is_some() || matches!(s.synspec_rc, Some(rc) if rc != 0);
if s.converged && !synspec_failed {
TaskStatus::Completed
} else {
TaskStatus::Failed
}
}
pub async fn report_result( pub async fn report_result(
client: &Client, client: &Client,
server_url: &str, server_url: &str,
@@ -13,14 +53,19 @@ pub async fn report_result(
) -> Result<()> { ) -> Result<()> {
let report_url = format!("{}/api/task/report", server_url); let report_url = format!("{}/api/task/report", server_url);
let (status, converged, max_relc, atmo_has_nan, elapsed_sec, err_msg, summary_json, seed_bytes) = let (
match exec_res { status,
converged,
max_relc,
atmo_has_nan,
elapsed_sec,
err_msg,
summary_json,
seed_bytes,
failed_stage,
) = match exec_res {
Ok((s, s_bytes)) => ( Ok((s, s_bytes)) => (
if s.converged { derive_report_status(&s),
TaskStatus::Completed
} else {
TaskStatus::Failed
},
s.converged, s.converged,
s.final_max_relc, s.final_max_relc,
s.atmosphere_has_nan, s.atmosphere_has_nan,
@@ -28,6 +73,7 @@ pub async fn report_result(
s.note.clone(), s.note.clone(),
serde_json::to_string(&s).unwrap_or_default(), serde_json::to_string(&s).unwrap_or_default(),
s_bytes, s_bytes,
infer_failed_stage(task, &s),
), ),
Err(e) => ( Err(e) => (
TaskStatus::Failed, TaskStatus::Failed,
@@ -38,6 +84,7 @@ pub async fn report_result(
Some(e.clone()), Some(e.clone()),
serde_json::json!({"error": e}).to_string(), serde_json::json!({"error": e}).to_string(),
None, None,
None,
), ),
}; };
@@ -53,6 +100,7 @@ pub async fn report_result(
elapsed_sec, elapsed_sec,
error_message: err_msg, error_message: err_msg,
summary_json, summary_json,
failed_stage,
}; };
let report_bytes = serde_json::to_vec(&report)?; let report_bytes = serde_json::to_vec(&report)?;
@@ -93,6 +141,17 @@ pub async fn report_result(
); );
anyhow::bail!("node token 失效 (HTTP {}),结果未上报", status); anyhow::bail!("node token 失效 (HTTP {}),结果未上报", status);
} }
// M3 修复:409 = 任务已被其他节点重新领用(超时重投后重领)或已由他节点结算。
// 这是迟到的冗余结果,服务端据此弃掉本次上报——**不是凭据问题**,切勿误报
// "token 失效"误导运维换 token;也无需重试(重试必再 409)。产物仍会归档,
// 返回 Ok 让 worker 正常收尾(归档 + 清理沙盒 + 归还 slot)。
if status.as_u16() == 409 {
info!(
"任务 {} 上报被服务端拒绝 (HTTP 409):任务已被其他节点重新领用或已结算,本次结果丢弃(不重试,产物仍归档)",
task.task_id
);
return Ok(());
}
warn!( warn!(
"向服务端上报任务 {} 结果失败 (尝试 {}/{}): HTTP {}", "向服务端上报任务 {} 结果失败 (尝试 {}/{}): HTTP {}",
task.task_id, attempt, max_attempts, status task.task_id, attempt, max_attempts, status
@@ -119,3 +178,128 @@ pub async fn report_result(
task.task_id task.task_id
) )
} }
#[cfg(test)]
mod tests {
use super::*;
use common::models::GridPointParams;
fn mk_task(tlusty_enabled: bool) -> TaskSpec {
TaskSpec {
task_id: uuid::Uuid::new_v4(),
point_name: "t20000_g5.0_he-2_c-4_n-4_o-4".to_string(),
params: GridPointParams {
teff: 20000.0.into(),
logg: 5.0.into(),
loghe: (-2.0).into(),
logc: (-4.0).into(),
logn: (-4.0).into(),
logo: (-4.0).into(),
},
tlusty_config: common::models::EngineStageConfig {
enabled: tlusty_enabled,
..common::models::EngineStageConfig::default_tlusty()
},
..TaskSpec::default()
}
}
fn mk_summary(
converged: bool,
synspec_rc: Option<i32>,
synspec_error: Option<&str>,
) -> ModelSummary {
ModelSummary {
name: "t20000_g5.0_he-2_c-4_n-4_o-4".to_string(),
params: GridPointParams {
teff: 20000.0.into(),
logg: 5.0.into(),
loghe: (-2.0).into(),
logc: (-4.0).into(),
logn: (-4.0).into(),
logo: (-4.0).into(),
},
stages: vec![],
converged,
final_max_relc: None,
final_chmax: None,
seed: None,
atmosphere_has_nan: false,
synspec_rc,
synspec_error: synspec_error.map(|s| s.to_string()),
synspec_sec: None,
elapsed_sec: 10.0,
note: None,
}
}
/// 失败阶段归因(docs/task_engine_decoupling_design.md §4.2 语义):
/// 大气未收敛 → TLUSTYtlusty 启用时);tlusty 关闭 → SYNSPEC
/// 大气已收敛但 synspec 失败 → SYNSPEC;全成功 → None。
#[test]
fn test_infer_failed_stage() {
// tlusty 启用 + 大气未收敛(synspec 无输入级联)→ tlusty
assert_eq!(
infer_failed_stage(
&mk_task(true),
&mk_summary(false, None, Some("No atmosphere .7 produced"))
),
Some("tlusty".to_string())
);
// tlusty 关闭 + synspec 失败(converged=false)→ synspec
assert_eq!(
infer_failed_stage(&mk_task(false), &mk_summary(false, Some(1), None)),
Some("synspec".to_string())
);
// tlusty 启用 + 大气收敛但 synspec rc≠0 → synspec
assert_eq!(
infer_failed_stage(&mk_task(true), &mk_summary(true, Some(2), None)),
Some("synspec".to_string())
);
// tlusty 启用 + 大气收敛 + synspec_error(如 fort.8 写入失败)→ synspec
assert_eq!(
infer_failed_stage(
&mk_task(true),
&mk_summary(true, None, Some("fort.8 copy failed"))
),
Some("synspec".to_string())
);
// 全成功 → None
assert_eq!(
infer_failed_stage(&mk_task(true), &mk_summary(true, Some(0), None)),
None
);
}
/// 半失败判定(修复审查 #2 后续):
/// 大气收敛 + 光谱失败 → 任务整体 Failed(否则服务端吸收为 converged 终态,无重试);
/// 大气收敛 + 光谱成功 → Completed;大气未收敛 → Failed。
#[test]
fn test_derive_report_status_semi_failed() {
// 大气收敛 + synspec rc≠0 → Failed(半失败点不再被误报成功)
assert_eq!(
derive_report_status(&mk_summary(true, Some(2), None)),
TaskStatus::Failed
);
// 大气收敛 + synspec_error(如 fort.8 写入失败)→ Failed
assert_eq!(
derive_report_status(&mk_summary(true, None, Some("fort.8 copy failed"))),
TaskStatus::Failed
);
// 大气收敛 + synspec 成功(rc=0)→ Completed
assert_eq!(
derive_report_status(&mk_summary(true, Some(0), None)),
TaskStatus::Completed
);
// TLUSTY-only 模式(synspec 关闭,无 rc 无 error+ 大气收敛 → Completed
assert_eq!(
derive_report_status(&mk_summary(true, None, None)),
TaskStatus::Completed
);
// 大气未收敛(无论光谱状态)→ Failed
assert_eq!(
derive_report_status(&mk_summary(false, Some(0), None)),
TaskStatus::Failed
);
}
}
+60 -11
View File
@@ -3,11 +3,11 @@ use crate::reporter::report_result;
use anyhow::Result; use anyhow::Result;
use common::config::NodeConfig; use common::config::NodeConfig;
use common::embedded::RuntimePaths; use common::embedded::RuntimePaths;
use common::models::{NodeHeartbeatRequest, NodeRegisterRequest, TaskSpec}; use common::models::{NodeHeartbeatRequest, NodeHeartbeatResponse, NodeRegisterRequest, TaskSpec};
use reqwest::Client; use reqwest::Client;
use serde_json::Value; use serde_json::Value;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
use tracing::{info, warn}; use tracing::{info, warn};
@@ -56,15 +56,23 @@ pub struct NodeWorker {
client: Client, client: Client,
runtime: RuntimePaths, runtime: RuntimePaths,
active_slots: Arc<AtomicI32>, active_slots: Arc<AtomicI32>,
/// 生效并发槽位上限:心跳线程据此写入,领用主循环据此读取。
///
/// 取自 `min(admin_max_slots, physical_max_slots)`;管理员未设配额时等于物理上限
/// (见 docs/dynamic_cpu_slots_design.md §3.2)。用 `Arc<AtomicUsize>` 在心跳线程与
/// 领用线程间共享,无需 Mutex:单写者(心跳线程)单读者(领用线程),原子读写即可。
effective_max_slots: Arc<AtomicUsize>,
} }
impl NodeWorker { impl NodeWorker {
pub fn new(config: NodeConfig, runtime: RuntimePaths, client: Client) -> Self { pub fn new(config: NodeConfig, runtime: RuntimePaths, client: Client) -> Self {
let physical = config.max_slots;
Self { Self {
config, config,
client, client,
runtime, runtime,
active_slots: Arc::new(AtomicI32::new(0)), active_slots: Arc::new(AtomicI32::new(0)),
effective_max_slots: Arc::new(AtomicUsize::new(physical)),
} }
} }
@@ -206,6 +214,8 @@ impl NodeWorker {
let hb_url = format!("{}/api/node/heartbeat", self.config.server_url); let hb_url = format!("{}/api/node/heartbeat", self.config.server_url);
let hb_node_id = self.config.node_id.clone(); let hb_node_id = self.config.node_id.clone();
let hb_slots = self.active_slots.clone(); let hb_slots = self.active_slots.clone();
let hb_effective = self.effective_max_slots.clone();
let hb_physical = self.config.max_slots;
let hb_interval = self.config.heartbeat_sec; let hb_interval = self.config.heartbeat_sec;
let hb_runtime_dir = self.config.runtime_dir.clone(); let hb_runtime_dir = self.config.runtime_dir.clone();
@@ -280,6 +290,34 @@ impl NodeWorker {
); );
std::process::exit(1); std::process::exit(1);
} }
// 解析心跳响应体,提取管理员配额(见 docs/dynamic_cpu_slots_design.md
// §3.2):计算生效配额并更新 effective_max_slots。反序列化失败不致命
// ——旧服务端响应体可能不含 admin_max_slots 字段,保持现有配额不变即可;
// 但真正解析失败(服务端改了响应形状/网关篡改 body)须留日志,
// 否则运维无法定位「配额为何没生效」(审查修复 N1)。
if status.is_success() {
match resp.json::<NodeHeartbeatResponse>().await {
Ok(hb_resp) => {
let target = match hb_resp.admin_max_slots {
Some(admin_limit) => {
std::cmp::min(admin_limit.max(0) as usize, hb_physical)
}
None => hb_physical,
};
let prev = hb_effective.swap(target, Ordering::AcqRel);
if prev != target {
info!(
"节点 {} 并发槽位配额已调整: {} → {}admin_max_slots={:?}, physical={}",
hb_node_id, prev, target, hb_resp.admin_max_slots, hb_physical
);
}
}
Err(e) => warn!(
"节点 {} 心跳响应解析失败 (HTTP {}),保持当前配额: {}",
hb_node_id, status, e
),
}
}
} }
Err(e) => warn!("节点 {} 心跳上报失败: {}", hb_node_id, e), Err(e) => warn!("节点 {} 心跳上报失败: {}", hb_node_id, e),
} }
@@ -375,7 +413,7 @@ impl NodeWorker {
} }
let active = self.active_slots.load(Ordering::Acquire); let active = self.active_slots.load(Ordering::Acquire);
if (active as usize) < self.config.max_slots { if (active as usize) < self.effective_max_slots.load(Ordering::Acquire) {
match self.claim_task().await { match self.claim_task().await {
Ok(ClaimOutcome::Task(task)) => { Ok(ClaimOutcome::Task(task)) => {
if was_disconnected { if was_disconnected {
@@ -397,14 +435,23 @@ impl NodeWorker {
slots_counter.fetch_add(1, Ordering::AcqRel); slots_counter.fetch_add(1, Ordering::AcqRel);
// RAII 守卫仅负责 drop 时 -1(含 panic 展栈),杜绝 spawned future // RAII 守卫仅负责 drop 时 -1(含 panic 展栈),杜绝 spawned future
// panic 导致的活动 slot 永久泄漏。 // panic 导致的活动 slot 永久泄漏。
let slot_guard = SlotGuard { counter: slots_counter.clone() }; let slot_guard = SlotGuard {
counter: slots_counter.clone(),
};
tokio::spawn(async move { tokio::spawn(async move {
// 把守卫移入 future,确保任务结束(含 panic)时归还 slot。 // 把守卫移入 future,确保任务结束(含 panic)时归还 slot。
let _slot_guard = slot_guard; let _slot_guard = slot_guard;
let slot_work_dir = work_dir.join(format!("task_{}", task.task_id)); let slot_work_dir = work_dir.join(format!("task_{}", task.task_id));
let res = let res = execute_task(
execute_task(&client, &server_url, &runtime, &work_dir, &task, Some(shutdown.clone())) &client,
&server_url,
&runtime,
&work_dir,
&result_dir,
&task,
Some(shutdown.clone()),
)
.await .await
.map_err(|e| e.to_string()); .map_err(|e| e.to_string());
@@ -413,23 +460,25 @@ impl NodeWorker {
// 严重失败(runner 抛 Err)时 summary_opt=None,此时回退到 task.point_name // 严重失败(runner 抛 Err)时 summary_opt=None,此时回退到 task.point_name
// (二者均源自 params.model_name())作为归档目录名,确保失败任务的 // (二者均源自 params.model_name())作为归档目录名,确保失败任务的
// 排错日志也能落盘而非随沙盒删除丢失。 // 排错日志也能落盘而非随沙盒删除丢失。
let summary_opt = let summary_opt = res.as_ref().ok().map(|(s, _)| s.clone());
res.as_ref().ok().map(|(s, _)| s.clone()); let result_name = summary_opt
let result_name = .as_ref()
summary_opt.as_ref().map(|s| s.name.clone()).unwrap_or_else(|| task.point_name.clone()); .map(|s| s.name.clone())
.unwrap_or_else(|| task.point_name.clone());
let report_res = let report_res =
report_result(&client, &server_url, &node_id, &task, res).await; report_result(&client, &server_url, &node_id, &task, res).await;
if report_res.is_ok() { if report_res.is_ok() {
// 上报成功后、清理沙盒前,先把完整产物归档到持久目录, // 上报成功后、清理沙盒前,先把完整产物归档到持久目录,
// 避免随沙盒删除丢失(.spec/.cont/.iden/各阶段快照/日志等)。 // 避免随沙盒删除丢失(.spec/.cont/.iden/各阶段快照/日志等)。
// 归档不做 LRU 淘汰:所有已算网格点产物一律永久保留
// 2026-08-02 修正:撤销 1bfa240 引入的 200 目录上限)。
crate::executor::save_result_artifacts( crate::executor::save_result_artifacts(
&result_dir, &result_dir,
&slot_work_dir, &slot_work_dir,
&result_name, &result_name,
) )
.await; .await;
crate::executor::cleanup_result_dir(&result_dir).await;
if let Err(e) = if let Err(e) =
crate::executor::cleanup_slot_work_dir(&slot_work_dir).await crate::executor::cleanup_slot_work_dir(&slot_work_dir).await
{ {
+100
View File
@@ -191,3 +191,103 @@ pub async fn enable_node(
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
/// POST /api/admin/nodes/:node_id/quota — 设置节点并发槽位配额(动态调整 CPU 核数)。
///
/// 见 `docs/dynamic_cpu_slots_design.md`:管理员通过本接口下发配额上限,服务端写入
/// `nodes.admin_max_slots`Worker 在下一次心跳响应里取回并据此调整本地领用并发数。
/// 不中断正在运行的任务,实现平滑降级。
///
/// 请求体:`{"admin_max_slots": 4}``null` 表示解除限制,恢复物理 `max_slots``0` 表示
/// 暂停接新任务)。配额合法后立即落库,Worker 下次心跳(`heartbeat_sec` 秒内)即生效。
#[derive(serde::Deserialize)]
pub struct SetNodeQuotaRequest {
/// 双层 Option 区分「字段缺失」与「显式 null」:
/// - 外层 `None`JSON 字段缺失)→ 400(防止畸形输入被静默当作清除配额)
/// - 外层 `Some(None)`JSON `null`)→ 清除限制
/// - 外层 `Some(Some(n))`JSON 数字)→ 设为 nn >= 0
///
/// 注:serde 对嵌套 `Option<Option<T>>` 默认把 `null` 也映射为外层 `None`
/// (与缺失字段不可区分),必须用 `deserialize_with` 显式区分(审查修复 M1)。
#[serde(default, deserialize_with = "deserialize_quota")]
pub admin_max_slots: Option<Option<i32>>,
}
/// 自定义反序列化:字段**存在**时把 `null` 映射为 `Some(None)`(清除),数字映射为
/// `Some(Some(n))`(设值)。字段**缺失**时 serde 走 `#[serde(default)]`(外层 None),
/// 与显式 null 语义区分开。
fn deserialize_quota<'de, D>(deserializer: D) -> Result<Option<Option<i32>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let inner: Option<i32> = serde::Deserialize::deserialize(deserializer)?;
Ok(Some(inner))
}
pub async fn set_node_quota(
State(state): State<AppState>,
AxumPath(node_id): AxumPath<String>,
Json(req): Json<SetNodeQuotaRequest>,
) -> Result<impl IntoResponse, crate::api::AppError> {
if !is_valid_node_id(&node_id) {
return Err(crate::api::AppError::BadRequest(
"非法的节点 ID 参数".to_string(),
));
}
// 字段缺失(`{}`、字段名拼错等)是畸形输入而非「清除」:显式拒绝,避免破坏性默认值。
// 审查修复(M1):旧实现 `Option<i32>` 在缺字段时 serde 默认 None,会把任何畸形请求
// 静默当成「清除配额」执行,客户端只见 200「已清除限制」。
let admin_max_slots = match req.admin_max_slots {
None => {
return Err(crate::api::AppError::BadRequest(
"请求体缺少 admin_max_slots 字段(传 null 清除限制,传非负整数设置配额)"
.to_string(),
));
}
Some(v) => v,
};
// 配额非负校验:0 合法(暂停接新任务),仅拒绝负数。
if let Some(n) = admin_max_slots {
if n < 0 {
return Err(crate::api::AppError::BadRequest(
"admin_max_slots 不能为负数(0 = 暂停接新任务,null = 清除限制)".to_string(),
));
}
}
// 节点须存在(防幽灵 node_id 写入空更新)
match state.db.get_node_exists(&node_id).await {
Ok(false) => {
return Err(crate::api::AppError::NotFound(format!(
"节点 '{}' 不存在",
node_id
)));
}
Ok(true) => {}
Err(e) => return Err(e.into()),
}
match state
.db
.set_node_admin_max_slots(&node_id, admin_max_slots)
.await
{
Ok(true) => {
let desc = match admin_max_slots {
Some(n) => format!("配额上限设为 {}(下一次心跳后生效)", n),
None => "已清除配额限制(恢复物理 max_slots,下一次心跳后生效)".to_string(),
};
info!("管理员已调整节点 {} 的并发槽位配额:{}", node_id, desc);
Ok((
StatusCode::OK,
Json(json!({
"success": true,
"message": format!("节点 '{}' {}", node_id, desc),
})),
))
}
Ok(false) => Err(crate::api::AppError::NotFound(format!(
"节点 '{}' 不存在",
node_id
))),
Err(e) => Err(e.into()),
}
}
+1 -4
View File
@@ -127,10 +127,7 @@ pub async fn check_auth() -> impl IntoResponse {
/// 一致的 `extract_token_from_headers`,同时支持 Authorization: Bearer 与 X-API-Key、 /// 一致的 `extract_token_from_headers`,同时支持 Authorization: Bearer 与 X-API-Key、
/// 拒绝空值)并从 `admin_sessions` 中移除,使该 token 在服务端立即失效(而非等 24h 过期)。 /// 拒绝空值)并从 `admin_sessions` 中移除,使该 token 在服务端立即失效(而非等 24h 过期)。
/// 这样即便 token 已被窃取,登出操作也能立即阻断重放。 /// 这样即便 token 已被窃取,登出操作也能立即阻断重放。
pub async fn logout( pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
// 与 auth_middleware 口径一致地提取 token(支持 X-API-Key、拒绝空值)。 // 与 auth_middleware 口径一致地提取 token(支持 X-API-Key、拒绝空值)。
let token = crate::api::extract_token_from_headers(&headers); let token = crate::api::extract_token_from_headers(&headers);
+4 -1
View File
@@ -181,7 +181,10 @@ pub async fn heartbeat_node(
)); ));
} }
match state.db.heartbeat_node(&req).await { match state.db.heartbeat_node(&req).await {
Ok(_) => Ok(Json(json!({"status": "ok"}))), Ok(admin_max_slots) => Ok(Json(json!({
"status": "ok",
"admin_max_slots": admin_max_slots,
}))),
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
+92 -15
View File
@@ -40,10 +40,24 @@ pub async fn claim_task(
Ok(Some(task)) => { Ok(Some(task)) => {
// 多工作流分区:mark_grid_point_running 须带 workflow_name,避免按 name 全局更新 // 多工作流分区:mark_grid_point_running 须带 workflow_name,避免按 name 全局更新
// 误改其他工作流的同名点。TaskSpec.workflow_name 在调度时已绑定。 // 误改其他工作流的同名点。TaskSpec.workflow_name 在调度时已绑定。
let wf = task.workflow_name.as_deref().unwrap_or(""); // 终态守卫(2026-08-02 涡旋事故修复):仅 pending/queued → running;返回
if let Err(e) = state.db.mark_grid_point_running(&task.point_name, wf).await { // false 表示点已在 running 或终态(迟到/重复领用),记录后照常下发任务
// (队列凭证有效,计算结果仍会被 record_task_report 的终态守卫正确吸收)。
// 旧版在途任务 payload 无 workflow_nameNone)→ 归一到主库迁移回填的
// '__legacy__' 标记,使 mark_grid_point_running 能命中 legacy 网格点(H1 修复)。
let wf = crate::db::normalize_workflow_name(task.workflow_name.as_deref());
match state.db.mark_grid_point_running(&task.point_name, &wf).await {
Ok(false) => {
info!(
"领用任务 {}(网格点 {})时点已非 pending/queued 态,跳过 running 标记(迟到/重复领用)",
task.task_id, task.point_name
);
}
Err(e) => {
warn!("领用任务 {} 后同步变更为 running 状态遇到异常: {}. 后置 stale 定时自取检索引索将介入修复维护", task.task_id, e); warn!("领用任务 {} 后同步变更为 running 状态遇到异常: {}. 后置 stale 定时自取检索引索将介入修复维护", task.task_id, e);
} }
_ => {}
}
Ok((StatusCode::OK, Json(json!({"status": "ok", "task": task})))) Ok((StatusCode::OK, Json(json!({"status": "ok", "task": task}))))
} }
Ok(None) => Ok(( Ok(None) => Ok((
@@ -127,6 +141,11 @@ pub async fn report_task(
// 2. 上报的 point_name 必须与该 task 绑定的 point_name 一致(防跨点上报)。 // 2. 上报的 point_name 必须与该 task 绑定的 point_name 一致(防跨点上报)。
// 3. 忽略 body 里声称的 node_id,统一以鉴权 node_id 写库(修复审计归因断裂)。 // 3. 忽略 body 里声称的 node_id,统一以鉴权 node_id 写库(修复审计归因断裂)。
// 4. 取 task 绑定的 workflow_name,用于定向更新该工作流的 grid_points(多工作流分区)。 // 4. 取 task 绑定的 workflow_name,用于定向更新该工作流的 grid_points(多工作流分区)。
// 幂等吸收:verify_task_claim 落空有两种可能——(a) 首轮上报已完成且 MQ 领用行已被
// remove_task 清除,但响应在链路上丢失,节点重试;(b) 真·伪造。凭 tasks 表已结算记录
// 区分:若该任务确已由本节点结算(find_settled_task_claim),按幂等重放处理(补写种子、
// 返回 200),避免节点误判「token 失效」而中止并导致种子/结算丢失。
let mut idempotent = false;
let (claimed_point, claimed_workflow) = match state let (claimed_point, claimed_workflow) = match state
.queue .queue
.verify_task_claim(&report.task_id.to_string(), &auth_node.node_id) .verify_task_claim(&report.task_id.to_string(), &auth_node.node_id)
@@ -134,14 +153,38 @@ pub async fn report_task(
{ {
Ok(Some((p, w))) => (p, w), Ok(Some((p, w))) => (p, w),
Ok(None) => { Ok(None) => {
match state
.db
.find_settled_task_claim(&report.task_id.to_string(), &auth_node.node_id)
.await
{
Ok(Some((p, w))) => {
info!(
"任务 {} 已由本节点结算(幂等重放上报),吸收处理并补写种子",
report.task_id
);
idempotent = true;
(p, w)
}
Ok(None) => {
// M3 修复:归属校验落空 ≠ token 失效。verify 落空且非本节点已结算,
// 通常是因为任务被 requeue_stale_tasks 重投后被**其他节点**重新领用
// (或已由他节点结算)——迟到冗余结果应被识别为"结果被弃",而非引导
// 运维去换 token。返回 409 Conflict 与 401/403(真·鉴权失败)区分开。
warn!( warn!(
"任务归属校验失败:node={} 上报 task_id={} 未领用或已被清理", "任务归属校验失败:node={} 上报 task_id={} 未由本节点领用/结算(可能已被其他节点重新领用或已结算)",
auth_node.node_id, report.task_id auth_node.node_id, report.task_id
); );
return Err(crate::api::AppError::Forbidden( return Err(crate::api::AppError::Conflict(
"任务未由本节点领用或已上报过".to_string(), "任务未由本节点领用:可能已被其他节点重新领用或已结算".to_string(),
)); ));
} }
Err(e) => {
tracing::error!("校验任务已结算状态数据库异常: {}", e);
return Err(crate::api::AppError::Internal(e));
}
}
}
Err(e) => { Err(e) => {
tracing::error!("校验任务归属数据库异常: {}", e); tracing::error!("校验任务归属数据库异常: {}", e);
return Err(crate::api::AppError::Internal(e)); return Err(crate::api::AppError::Internal(e));
@@ -159,7 +202,9 @@ pub async fn report_task(
// 统一以鉴权 node_id 覆盖 body 里的 node_id,保证归因可信 // 统一以鉴权 node_id 覆盖 body 里的 node_id,保证归因可信
report.node_id = auth_node.node_id.clone(); report.node_id = auth_node.node_id.clone();
// workflow_name 以领用记录为准(claim 时从 TaskSpec 落库),body 无权声称。 // workflow_name 以领用记录为准(claim 时从 TaskSpec 落库),body 无权声称。
let workflow_name = claimed_workflow.unwrap_or_default(); // 旧版任务(payload 无 workflow_name)归一到 '__legacy__',定向更新主库 legacy 网格点
// H1 修复:否则 record_task_report 按 workflow_name='' 更新 0 行,点永久卡死)。
let workflow_name = crate::db::normalize_workflow_name(claimed_workflow.as_deref());
let name = report.point_name.clone(); let name = report.point_name.clone();
@@ -187,13 +232,24 @@ pub async fn report_task(
}; };
// Record in DB(带 workflow_name 定向更新该工作流的 grid_points // Record in DB(带 workflow_name 定向更新该工作流的 grid_points
if let Err(e) = state.db.record_task_report(&report, &workflow_name).await { // state_changed:网格点状态是否实际迁移。被终态守卫吸收的重复报告返回 false,
// 下方种子回退据此跳过,杜绝重复失败报告触发多余 seed_step2026-08-02 事故修复)。
// 幂等重放(idempotent=true):首轮已结算,跳过结算与队列清理(终态守卫已吸收)。
let state_changed = if idempotent {
false
} else {
match state.db.record_task_report(&report, &workflow_name).await {
Ok(changed) => changed,
Err(e) => {
// DB 错误细节进日志,对客户端只返回通用消息(避免泄露表结构/内部错误给未授权方) // DB 错误细节进日志,对客户端只返回通用消息(避免泄露表结构/内部错误给未授权方)
tracing::error!("记录网格点 {} 任务结果到数据库失败: {}", name, e); tracing::error!("记录网格点 {} 任务结果到数据库失败: {}", name, e);
return Err(crate::api::AppError::Internal(e)); return Err(crate::api::AppError::Internal(e));
} }
}
};
// Clean up task from task_queue table to prevent queue DB bloat // Clean up task from task_queue table to prevent queue DB bloat
if !idempotent {
if let Err(e) = state.queue.remove_task(&report.task_id.to_string()).await { if let Err(e) = state.queue.remove_task(&report.task_id.to_string()).await {
tracing::warn!( tracing::warn!(
"从任务队列中清理已上报任务记录 {} 失败: {}", "从任务队列中清理已上报任务记录 {} 失败: {}",
@@ -201,6 +257,7 @@ pub async fn report_task(
e e
); );
} }
}
// 采用原子写入模式保持 conv.json 与核心二进制数据完整落地后才揭晓真实文件名 // 采用原子写入模式保持 conv.json 与核心二进制数据完整落地后才揭晓真实文件名
let model_dir = Path::new(&state.seeds_dir).join(&name); let model_dir = Path::new(&state.seeds_dir).join(&name);
@@ -240,19 +297,29 @@ pub async fn report_task(
} }
} }
if !report.converged if state_changed
&& (!report.converged
|| report.atmosphere_has_nan || report.atmosphere_has_nan
|| report.status == TaskStatus::Failed || report.status == TaskStatus::Failed
|| report.status == TaskStatus::Timeout || report.status == TaskStatus::Timeout)
{ {
// Task did not succeed -> check if seed_step fallback should be triggered // Task did not succeed -> check if strategy chain fallback should be triggered.
info!("网格点 {} 计算未成功完成,检查种子回退机制...", name); // 仅在网格点状态实际迁移时才检查回退:被终态守卫吸收的迟到/重复失败报告
// state_changed=false)不再触发,避免重复派发(2026-08-02 涡旋事故修复)。
// 失败阶段归因(docs/task_engine_decoupling_design.md §4.2):节点据 summary 推断
// 失败发生在 TLUSTY 还是 SYNSPEC,服务端弹对应策略链;旧节点不携带该字段 →
// 兜底 "tlusty"(行为与旧版一致)。
let failed_stage = report.failed_stage.as_deref().unwrap_or("tlusty");
info!(
"网格点 {} 计算未成功完成(失败阶段: {}),检查策略链回退机制...",
name, failed_stage
);
if let Err(e) = state if let Err(e) = state
.scheduler .scheduler
.trigger_seed_step_fallback(&params, &name, &workflow_name) .trigger_strategy_fallback(&params, &name, &workflow_name, failed_stage)
.await .await
{ {
warn!("网格点 {} 触发种子回退机制失败: {}", name, e); warn!("网格点 {} 触发策略链回退机制失败: {}", name, e);
} }
} }
@@ -402,9 +469,17 @@ pub async fn import_seed(
// 1. 幂等写入 grid_pointsON CONFLICT DO NOTHING):无需事先 start 工作流。 // 1. 幂等写入 grid_pointsON CONFLICT DO NOTHING):无需事先 start 工作流。
// 用权威 name(旧 conv.json 的源精度真名),而非从 params 重推——导入路径的 // 用权威 name(旧 conv.json 的源精度真名),而非从 params 重推——导入路径的
// params 来自旧 JSON(无源文本,model_name() 会失真)。 // params 来自旧 JSON(无源文本,model_name() 会失真)。
// wave 修复(2026-08-04):此前硬编码 wave=0,导入点全部被错误归入第一波,
// 前端难度波次推进显示错误。现按 initialize_grid 同口径计算波次(该工作流内
// cno_sum 严格小于本点的去重值个数),新导入点落库即归入正确波次。
let wave = state
.db
.compute_wave_for_cno_sum(&workflow_name, params.cno_sum())
.await
.unwrap_or(0);
if let Err(e) = state if let Err(e) = state
.db .db
.upsert_grid_point_named(&name, &params, 0, &workflow_name) .upsert_grid_point_named(&name, &params, wave, &workflow_name)
.await .await
{ {
tracing::error!("历史种子导入:upsert grid_points {} 失败: {}", name, e); tracing::error!("历史种子导入:upsert grid_points {} 失败: {}", name, e);
@@ -469,7 +544,9 @@ pub async fn import_seed(
info!( info!(
"历史种子导入完成:网格点 {} (workflow={}, converged={}, success_method={}, max_relc={:?})", "历史种子导入完成:网格点 {} (workflow={}, converged={}, success_method={}, max_relc={:?})",
name, workflow_name, converged, name,
workflow_name,
converged,
success_method.as_deref().unwrap_or("(default cold_run)"), success_method.as_deref().unwrap_or("(default cold_run)"),
max_relc max_relc
); );
+117 -9
View File
@@ -96,12 +96,37 @@ pub async fn save_workflow(
} }
// Validate YAML config string(用源精度解析,校验 + 保留 grid 轴书写小数位) // Validate YAML config string(用源精度解析,校验 + 保留 grid 轴书写小数位)
if let Err(e) = GridConfig::from_yaml_str(&req.config_yaml) { let cfg = match GridConfig::from_yaml_str(&req.config_yaml) {
Ok(cfg) => cfg,
Err(e) => {
return Err(crate::api::AppError::BadRequest(format!( return Err(crate::api::AppError::BadRequest(format!(
"无效的 YAML 配置: {}", "无效的 YAML 配置: {}",
e e
))); )));
} }
};
// 阶段配置合法性校验(见 docs/task_engine_decoupling_design.md §3,修复审查 #4/#5):
// - 启用的阶段必须配置 ≥1 个策略(空链 → 调度无顺位可派,任务必失败);
// - 至少一个阶段启用(双关 → 无任何计算可执行,任务必失败)。
// 校验基于 resolve_* 的最终生效配置(含旧字段推断),口径与调度器一致。
let tlusty_cfg = cfg.resolve_tlusty_config();
let synspec_cfg = cfg.resolve_synspec_config();
if !tlusty_cfg.enabled && !synspec_cfg.enabled {
return Err(crate::api::AppError::BadRequest(
"TLUSTY 与 SYNSPEC 阶段均被禁用:至少应启用一个计算阶段".to_string(),
));
}
if tlusty_cfg.enabled && tlusty_cfg.strategies.is_empty() {
return Err(crate::api::AppError::BadRequest(
"TLUSTY 阶段已启用但策略链为空:至少需要 1 个策略(如 cold_run)".to_string(),
));
}
if synspec_cfg.enabled && synspec_cfg.strategies.is_empty() {
return Err(crate::api::AppError::BadRequest(
"SYNSPEC 阶段已启用但策略链为空:至少需要 1 个策略(如 standard)".to_string(),
));
}
// 检查被编辑的工作流是否正处于激活运行中 // 检查被编辑的工作流是否正处于激活运行中
if let Ok(Some(existing)) = state.db.get_workflow(&req.name).await { if let Ok(Some(existing)) = state.db.get_workflow(&req.name).await {
@@ -304,7 +329,13 @@ pub struct ProgressQuery {
/// ///
/// - `series`:窗口内的计数快照(超 300 点自动降采样,首末点保留); /// - `series`:窗口内的计数快照(超 300 点自动降采样,首末点保留);
/// - `now`:服务端当前 UTC 时刻(同 ts 格式),供前端把曲线右缘锚定为“现在”、横轴按真实时间铺开; /// - `now`:服务端当前 UTC 时刻(同 ts 格式),供前端把曲线右缘锚定为“现在”、横轴按真实时间铺开;
/// - `rate_per_hour`窗口首末 converged 增量 ÷ 时长(快照 <2 条或时长 ≤0 为 null); /// - `rate_per_hour`**最近 2 小时**的平均收敛速率(点/小时)。取末快照往前 2h 子窗口的
/// 首末 converged 增量 ÷ 实际跨度;子窗口不足 2 个快照时回退整窗。只看近 2h 是因为速率
/// 首要用于 ETA,应由当前吞吐驱动,而非被整窗(最长 24h)的早期快慢/停滞抹平;
/// - `done_rate_per_hour`:终态完成(converged+failed)的同口径近 2h 平均速率,即队列实际
/// 清空速率,供前端 ETA 使用(剩余点数已同时扣除 converged 与 failed,口径须一致);
/// - `rate_span_hours`:速率统计的实际时间跨度(近 2h 子窗口首末间隔,≤2h;回退整窗时为
/// 整窗跨度)。前端须按实际跨度展示,避免把短数据跨度误读为固定 2h 或整窗平均;
/// - `stalled_minutes`:终态数(converged+failed)最后一次增长到窗口末端的分钟数 /// - `stalled_minutes`:终态数(converged+failed)最后一次增长到窗口末端的分钟数
/// (用于"进度停滞"预警;快照 <2 条为 null)。 /// (用于"进度停滞"预警;快照 <2 条为 null)。
pub async fn get_workflow_progress( pub async fn get_workflow_progress(
@@ -346,21 +377,72 @@ pub async fn get_workflow_progress(
// SQLite datetime('now') 为 UTC 'YYYY-MM-DD HH:MM:SS' // SQLite datetime('now') 为 UTC 'YYYY-MM-DD HH:MM:SS'
let parse_ts = |ts: &str| chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S").ok(); let parse_ts = |ts: &str| chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S").ok();
let rate_per_hour: Option<f64> = match (series.first(), series.last()) { // 经验速率 = 最近 2 小时的平均速率(点/小时)。
(Some(first), Some(last)) if series.len() >= 2 => { // 为什么只看近 2h 而非整窗:速率的首要用途是 ETA(“剩余点还要多久”),应由**当前
match (parse_ts(&first.ts), parse_ts(&last.ts)) { // 吞吐**驱动。整窗(最长 24h)平均会把早期的快/慢、中途停滞一并抹平,对“接下来”的
(Some(t0), Some(t1)) => { // 预测失真;近 2h 平均更贴近当下的真实处理速度。
const RATE_WINDOW_SEC: i64 = 2 * 3600;
// 近 2h 子窗口起点下标:升序序列中首个「距末快照 ≤2h」的快照。用末快照(而非 now)
// 锚定,使停滞期不被算进窗口尾部。子窗口不足 2 个快照(极慢/刚启动/长期停滞)时
// 回退整窗,尽量仍给出数值。
let recent_start: usize = if series.len() >= 2 {
let s = match parse_ts(&series[series.len() - 1].ts) {
Some(t_last) => series
.iter()
.position(|p| {
parse_ts(&p.ts)
.map(|t| (t_last - t).num_seconds() <= RATE_WINDOW_SEC)
.unwrap_or(false)
})
.unwrap_or(0),
None => 0,
};
if series.len() - s < 2 {
0
} else {
s
}
} else {
0
};
// 子窗口首末增量 ÷ 实际跨度 = 时段平均速率(弦斜率)。跨度即 rate_span_hours(≤2h
// 回退整窗时为整窗跨度),前端据此展示“近 X 小时平均”,不暗示固定 2h 或整窗。
let avg_per_hour = |count: fn(&crate::db::ProgressPoint) -> i64| -> Option<f64> {
let sub = &series[recent_start..];
if sub.len() < 2 {
return None;
}
let first = sub.first()?;
let last = sub.last()?;
let t0 = parse_ts(&first.ts)?;
let t1 = parse_ts(&last.ts)?;
let dh = (t1 - t0).num_seconds() as f64 / 3600.0; let dh = (t1 - t0).num_seconds() as f64 / 3600.0;
if dh > 0.0 { if dh > 0.0 {
Some((last.converged - first.converged) as f64 / dh) Some((count(last) - count(first)) as f64 / dh)
} else { } else {
None None
} }
};
let rate_per_hour = avg_per_hour(|p| p.converged);
let done_rate_per_hour = avg_per_hour(|p| p.converged + p.failed);
// 速率统计的实际时间跨度(近 2h 子窗口首末间隔,≤2h;回退整窗时为整窗跨度)。
let rate_span_hours: Option<f64> = {
let sub = &series[recent_start..];
match (sub.first(), sub.last()) {
(Some(first), Some(last)) if sub.len() >= 2 => {
match (parse_ts(&first.ts), parse_ts(&last.ts)) {
(Some(t0), Some(t1)) => {
Some(((t1 - t0).num_seconds() as f64 / 3600.0).max(0.0))
} }
_ => None, _ => None,
} }
} }
_ => None, _ => None,
}
}; };
let stalled_minutes: Option<f64> = if series.len() >= 2 { let stalled_minutes: Option<f64> = if series.len() >= 2 {
@@ -399,6 +481,8 @@ pub async fn get_workflow_progress(
"now": now, "now": now,
"series": series, "series": series,
"rate_per_hour": rate_per_hour, "rate_per_hour": rate_per_hour,
"done_rate_per_hour": done_rate_per_hour,
"rate_span_hours": rate_span_hours,
"stalled_minutes": stalled_minutes, "stalled_minutes": stalled_minutes,
})), })),
}), }),
@@ -481,6 +565,13 @@ pub async fn get_workflow_points(
"teff" => format!("gp.teff {dir}, gp.wave ASC, gp.cno_sum ASC"), "teff" => format!("gp.teff {dir}, gp.wave ASC, gp.cno_sum ASC"),
"max_relc" => format!("t.max_relc IS NULL ASC, t.max_relc {dir}, gp.wave ASC"), "max_relc" => format!("t.max_relc IS NULL ASC, t.max_relc {dir}, gp.wave ASC"),
"attempts" => format!("gp.attempt_count {dir}, gp.wave ASC, gp.cno_sum ASC"), "attempts" => format!("gp.attempt_count {dir}, gp.wave ASC, gp.cno_sum ASC"),
// 耗时取最近一次尝试的真实墙钟(与列表展示同口径 COALESCE),NULL(从未派发)靠后。
"elapsed" => {
format!(
"COALESCE(t.elapsed_sec, gp.last_elapsed_sec) IS NULL ASC, \
COALESCE(t.elapsed_sec, gp.last_elapsed_sec) {dir}, gp.wave ASC"
)
}
"last_completed_at" => { "last_completed_at" => {
format!("t.completed_at IS NULL ASC, t.completed_at {dir}, gp.wave ASC") format!("t.completed_at IS NULL ASC, t.completed_at {dir}, gp.wave ASC")
} }
@@ -594,9 +685,26 @@ pub async fn stop_workflow(
match state.db.update_workflow_status(&name, "paused").await { match state.db.update_workflow_status(&name, "paused").await {
Ok(_) => { Ok(_) => {
// 多工作流分区:清理与重置都限定在本工作流内,避免误伤其他并发运行的工作流。 // 多工作流分区:清理与重置都限定在本工作流内,避免误伤其他并发运行的工作流。
// - clear_queue_by_workflow:只删本工作流的排队任务。 // - clear_queue_by_workflow:只删本工作流的排队任务,返回被删 task_id
// - delete_tasks_by_ids:同步清理主库 tasks 表对应 pending 行(2026-08-02
// 涡旋事故修复:此前只删队列行遗留僵尸 pending 行,成为重复派发燃料)。
// - reset_queued_grid_points_to_pending(&name):只把本工作流的 queued 点打回 pending。 // - reset_queued_grid_points_to_pending(&name):只把本工作流的 queued 点打回 pending。
let _ = state.queue.clear_queue_by_workflow(&name).await; match state.queue.clear_queue_by_workflow(&name).await {
Ok(cleared_ids) if !cleared_ids.is_empty() => {
if let Err(e) = state.db.delete_tasks_by_ids(&cleared_ids).await {
tracing::warn!(
"暂停工作流 {} 时同步清理 {} 条被删队列任务的 tasks 历史行失败: {}",
name,
cleared_ids.len(),
e
);
}
}
Err(e) => {
tracing::warn!("暂停工作流 {} 时清理排队任务失败: {}", name, e);
}
_ => {}
}
let _ = state.db.reset_queued_grid_points_to_pending(&name).await; let _ = state.db.reset_queued_grid_points_to_pending(&name).await;
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
+1767 -77
View File
File diff suppressed because it is too large Load Diff
+27 -15
View File
@@ -106,11 +106,13 @@ async fn main() -> Result<()> {
); );
for (wf_name, wf_yaml) in &stuck { for (wf_name, wf_yaml) in &stuck {
match GridConfig::from_yaml_str(wf_yaml) { match GridConfig::from_yaml_str(wf_yaml) {
Ok(cfg) => { Ok(cfg) => match scheduler.initialize_grid(&cfg, wf_name).await {
match scheduler.initialize_grid(&cfg, wf_name).await {
Ok(_) => { Ok(_) => {
let _ = db.update_workflow_status(wf_name, "running").await; let _ = db.update_workflow_status(wf_name, "running").await;
info!("启动恢复:工作流 {} 已完成重新初始化并切回 running", wf_name); info!(
"启动恢复:工作流 {} 已完成重新初始化并切回 running",
wf_name
);
} }
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
@@ -120,8 +122,7 @@ async fn main() -> Result<()> {
); );
let _ = db.update_workflow_status(wf_name, "idle").await; let _ = db.update_workflow_status(wf_name, "idle").await;
} }
} },
}
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
"启动恢复:工作流 {} 的 YAML 配置解析失败,回退为 idle: {}", "启动恢复:工作流 {} 的 YAML 配置解析失败,回退为 idle: {}",
@@ -188,8 +189,11 @@ async fn main() -> Result<()> {
let mut by_wf: std::collections::HashMap<String, Vec<String>> = let mut by_wf: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new(); std::collections::HashMap::new();
for (point, wf) in &requeued { for (point, wf) in &requeued {
// 旧版任务(payload 无 workflow_name)归一到 '__legacy__'
// 使 reset_specific_grid_points_to_pending 命中 legacy 网格点
// H1 修复:否则按 '' 更新 0 行,重投后的 legacy 点永远重置不回 pending)。
by_wf by_wf
.entry(wf.clone().unwrap_or_default()) .entry(server::db::normalize_workflow_name(wf.as_deref()))
.or_default() .or_default()
.push(point.clone()); .push(point.clone());
} }
@@ -218,17 +222,23 @@ async fn main() -> Result<()> {
} }
} }
// 孤儿 running 点回收(#6 修复兜底):queue 已消失(误删/崩溃丢队列) // 孤儿点回收(#6 修复兜底2026-08-02 涡旋事故重构):queue 凭证已消失
// 但 grid_points 仍卡在 running 的点,requeue_stale_tasks 找不到它们, // (误删/崩溃丢队列/insert 后 push 前崩溃)但 grid_points 仍卡在
// 在此按 tasks 表的 stale pending 记录兜底重置为 pending,让调度器重新派发。 // running/queued 的点,requeue_stale_tasks 找不到它们。经 MQ 活性交叉
match bg_db_clone.reset_orphaned_running_points(stale_sec).await { // 校验确认真孤儿后重置为 pending 让调度器重新派发,并清除作为判据的
// 僵尸 tasks 行(旧实现仅凭 tasks 表 stale pending 行判定,僵尸行使
// 判据恒真 → 重复派发涡旋,已废弃)。
match bg_scheduler_clone.reclaim_orphaned_points(stale_sec).await {
Ok(reset) => { Ok(reset) => {
if reset > 0 { if reset > 0 {
info!("已回收 {} 个孤儿 running 网格点(领用凭证丢失,重置为 pending)", reset); info!(
"已回收 {} 个孤儿网格点(领用凭证丢失,重置为 pending)",
reset
);
} }
} }
Err(e) => { Err(e) => {
tracing::warn!("回收孤儿 running 网格点失败: {}", e); tracing::warn!("回收孤儿网格点失败: {}", e);
has_error = true; has_error = true;
} }
} }
@@ -402,6 +412,10 @@ async fn main() -> Result<()> {
"/admin/nodes/:node_id/enable", "/admin/nodes/:node_id/enable",
post(api::admin::enable_node), post(api::admin::enable_node),
) )
.route(
"/admin/nodes/:node_id/quota",
post(api::admin::set_node_quota),
)
.layer(DefaultBodyLimit::max(DEFAULT_BODY_LIMIT)); .layer(DefaultBodyLimit::max(DEFAULT_BODY_LIMIT));
// 合并两个子 router:各自携带自己的 body limit,互不覆盖。 // 合并两个子 router:各自携带自己的 body limit,互不覆盖。
@@ -431,9 +445,7 @@ async fn main() -> Result<()> {
let auth_layer = axum::middleware::from_fn_with_state(state.clone(), api::auth_middleware); let auth_layer = axum::middleware::from_fn_with_state(state.clone(), api::auth_middleware);
api_router.layer(auth_layer).layer(rate_limit_layer) api_router.layer(auth_layer).layer(rate_limit_layer)
} else { } else {
info!( info!("DCTS_AUTH_DISABLE=1 已生效:服务端运行在无鉴权模式(仅限本地调试,切勿用于生产)。");
"DCTS_AUTH_DISABLE=1 已生效:服务端运行在无鉴权模式(仅限本地调试,切勿用于生产)。"
);
api_router api_router
}; };
File diff suppressed because it is too large Load Diff
+575 -3
View File
@@ -1466,7 +1466,8 @@ async fn test_import_seed_admin_endpoint() {
// 4. 未收敛点 → 200,但不写 .7、grid_points 维持 pending(未建 converged)。 // 4. 未收敛点 → 200,但不写 .7、grid_points 维持 pending(未建 converged)。
let conv_fail = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4_fail", false); let conv_fail = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4_fail", false);
let body_bytes = make_import_multipart("boundary4", &conv_fail, b"WONT_BE_USED", "x.7", "cold_run"); let body_bytes =
make_import_multipart("boundary4", &conv_fail, b"WONT_BE_USED", "x.7", "cold_run");
let res = app let res = app
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -1538,8 +1539,13 @@ async fn test_import_seed_python_legacy_conv_json() {
let name = "t20000_g5.0_he-2_c-4_n-4_o-4"; let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let conv = make_python_legacy_conv_json(name); let conv = make_python_legacy_conv_json(name);
let body_bytes = let body_bytes = make_import_multipart(
make_import_multipart("boundaryL", &conv, b"FAKE_ATMOS_7", &format!("{name}.7"), "cold_run"); "boundaryL",
&conv,
b"FAKE_ATMOS_7",
&format!("{name}.7"),
"cold_run",
);
let res = app let res = app
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -1667,6 +1673,7 @@ async fn test_node_disable_enable_flow() {
timeout_sec: 60, timeout_sec: 60,
workflow_name: Some("wf_test".to_string()), workflow_name: Some("wf_test".to_string()),
wave: 0, wave: 0,
..Default::default()
}; };
queue.push_task(&task).await.unwrap(); queue.push_task(&task).await.unwrap();
@@ -1883,6 +1890,232 @@ async fn test_node_disable_enable_flow() {
); );
} }
#[tokio::test]
async fn test_admin_set_node_quota_and_heartbeat_sync() {
// 验证动态 CPU 槽位配额下发链路(见 docs/dynamic_cpu_slots_design.md):
// admin POST /quota 设 admin_max_slots → 落库 nodes.admin_max_slots →
// list_nodes_with_credentials 返回配额 → 心跳响应体透传 admin_max_slots。
// 覆盖:设值 / 清除(null) / 负数 400 / 不存在节点 404。
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("quota_db.db");
let queue_db_path = temp_dir.path().join("quota_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
// 注册并审批 node-quota-test,颁发 token
let reg = common::models::NodeRegisterRequest {
node_id: "node-quota-test".to_string(),
max_slots: 4,
};
db.register_node(&reg).await.unwrap();
let token = db.approve_node("node-quota-test").await.unwrap();
let state = AppState {
db: db.clone(),
queue: queue.clone(),
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
5,
std::time::Duration::from_secs(300),
),
admin_token: Some("admin-secret".to_string()),
auth_disabled: false,
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(),
)),
};
let api_router = axum::Router::new()
.route(
"/node/heartbeat",
axum::routing::post(server::api::node::heartbeat_node),
)
.route(
"/admin/nodes/:node_id/quota",
axum::routing::post(server::api::admin::set_node_quota),
);
let auth_layer =
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
let app = axum::Router::new()
.nest("/api", api_router.layer(auth_layer))
.with_state(state);
// 1. 基线心跳:admin_max_slots 应为 null(无配额限制)
let hb = serde_json::json!({"node_id":"node-quota-test","active_slots":0,"cpu_usage":10.0,"memory_usage":20.0});
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/node/heartbeat")
.header("authorization", format!("Bearer {}", token))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(body["status"], "ok");
assert!(
body["admin_max_slots"].is_null(),
"未设配额时心跳响应 admin_max_slots 应为 null"
);
// 2. admin 设配额 = 2 → 200
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/node-quota-test/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"admin_max_slots": 2})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// 2a. 配额落库(list_nodes_with_credentials
let nodes = db.list_nodes_with_credentials().await.unwrap();
let me = nodes
.iter()
.find(|n| n.node_id == "node-quota-test")
.unwrap();
assert_eq!(me.admin_max_slots, Some(2), "配额应已落库为 Some(2)");
// 3. 心跳响应透传 admin_max_slots = 2
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/node/heartbeat")
.header("authorization", format!("Bearer {}", token))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(body["admin_max_slots"], 2, "心跳响应应透传配额 2");
// 3a. 请求体缺 admin_max_slots 字段(畸形输入)→ 400,绝不能静默当作「清除配额」
// (审查修复 M1Option<i32> 缺字段默认 None 会把畸形请求误判为清除限制)。
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/node-quota-test/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"缺字段应 400 而非静默清除配额"
);
// 畸形请求无副作用:既有配额仍为 Some(2),未被静默清除
let nodes = db.list_nodes_with_credentials().await.unwrap();
let me = nodes
.iter()
.find(|n| n.node_id == "node-quota-test")
.unwrap();
assert_eq!(me.admin_max_slots, Some(2), "畸形请求不应改动既有配额");
// 4. 设 null 清除配额 → 心跳响应 admin_max_slots = null
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/node-quota-test/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"admin_max_slots": null})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let nodes = db.list_nodes_with_credentials().await.unwrap();
let me = nodes
.iter()
.find(|n| n.node_id == "node-quota-test")
.unwrap();
assert_eq!(me.admin_max_slots, None, "清除后配额应为 None");
// 5. 负数 → 400
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/node-quota-test/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"admin_max_slots": -1})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "负数配额应 400");
// 6. 不存在节点 → 404
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/ghost-node/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"admin_max_slots": 1})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND, "不存在节点应 404");
}
// ===== 工作流执行观测端点测试(stats / points / point detail ===== // ===== 工作流执行观测端点测试(stats / points / point detail =====
/// 测试专用:走真实写路径派发并回报一个网格点任务。 /// 测试专用:走真实写路径派发并回报一个网格点任务。
@@ -1907,6 +2140,7 @@ async fn dispatch_and_report(
timeout_sec: 7200, timeout_sec: 7200,
workflow_name: Some(wf.to_string()), workflow_name: Some(wf.to_string()),
wave: 0, wave: 0,
..Default::default()
}; };
db.insert_task(&spec).await.unwrap(); db.insert_task(&spec).await.unwrap();
let report = common::models::TaskReport { let report = common::models::TaskReport {
@@ -1929,6 +2163,7 @@ async fn dispatch_and_report(
Some("nl stage diverged".to_string()) Some("nl stage diverged".to_string())
}, },
summary_json: "{}".to_string(), summary_json: "{}".to_string(),
failed_stage: None,
}; };
db.record_task_report(&report, wf).await.unwrap(); db.record_task_report(&report, wf).await.unwrap();
} }
@@ -2844,3 +3079,340 @@ async fn test_wf_progress_endpoint() {
.unwrap(); .unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND); assert_eq!(res.status(), StatusCode::NOT_FOUND);
} }
/// 构造仅含 report 字段的 multipart body(模拟节点上报,不带 seed_file)。
fn make_report_only_multipart(boundary: &str, report_json: &str) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"report\"\r\n");
body.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
body.extend_from_slice(report_json.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
body
}
/// 端到端回归(2026-08-02 涡旋事故):已收敛点收到迟到的重复失败报告时,
/// 走完整 HTTP 链路(claim → report)后状态保持 converged,且不触发种子回退
/// state_changed=false 跳过 fallback;纵有可用种子也不产生 seed_step 任务)。
#[tokio::test]
async fn test_duplicate_failure_report_cannot_flip_converged() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("flip_db.db");
let queue_db_path = temp_dir.path().join("flip_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
// 预置节点与 token。
let reg = common::models::NodeRegisterRequest {
node_id: "node-flip".to_string(),
max_slots: 2,
};
db.register_node(&reg).await.unwrap();
let token = db.issue_node_token("node-flip").await.unwrap();
let state = AppState {
db: db.clone(),
queue: queue.clone(),
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
100,
std::time::Duration::from_secs(300),
),
admin_token: None,
auth_disabled: false,
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let api_router = axum::Router::new()
.route(
"/task/claim",
axum::routing::post(server::api::task::claim_task),
)
.route(
"/task/report",
axum::routing::post(server::api::task::report_task),
);
let auth_layer =
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
let app = axum::Router::new()
.nest("/api", api_router.layer(auth_layer))
.with_state(state);
// 工作流 running + 网格点 + 可用种子(若回退被错误触发,必然能匹配到种子并派发任务,
// 使"无 seed_step 任务"断言成为强证据)。
db.upsert_workflow("wf_flip", None, "", "running")
.await
.unwrap();
let params = common::models::GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, "wf_flip").await.unwrap();
db.insert_seed_named(&name, &params, "/tmp/flip_seed.7")
.await
.unwrap();
// ---- 任务 A:正常收敛 ----
let task_a = common::models::TaskSpec {
task_id: uuid::Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
task_type: common::models::TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 7200,
workflow_name: Some("wf_flip".to_string()),
wave: 0,
..Default::default()
};
db.insert_task(&task_a).await.unwrap();
queue.push_task(&task_a).await.unwrap();
let claim_res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/task/claim")
.header("authorization", format!("Bearer {}", token))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(claim_res.status(), StatusCode::OK);
let report_a = common::models::TaskReport {
task_id: task_a.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "node-flip".to_string(),
status: common::models::TaskStatus::Completed,
converged: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 120.0,
error_message: None,
summary_json: "{}".to_string(),
failed_stage: None,
};
let body_a =
make_report_only_multipart("flipbound1", &serde_json::to_string(&report_a).unwrap());
let report_res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/task/report")
.header("authorization", format!("Bearer {}", token))
.header("content-type", "multipart/form-data; boundary=flipbound1")
.body(Body::from(body_a))
.unwrap(),
)
.await
.unwrap();
assert_eq!(report_res.status(), StatusCode::OK);
assert_eq!(
db.get_grid_point_status(&name, "wf_flip")
.await
.unwrap()
.unwrap()
.0,
"converged"
);
// ---- 任务 B:迟到的重复失败报告(涡旋残留任务的典型行为)----
let task_b = common::models::TaskSpec {
task_id: uuid::Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
task_type: common::models::TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 7200,
workflow_name: Some("wf_flip".to_string()),
wave: 0,
..Default::default()
};
db.insert_task(&task_b).await.unwrap();
queue.push_task(&task_b).await.unwrap();
let claim_b = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/task/claim")
.header("authorization", format!("Bearer {}", token))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(claim_b.status(), StatusCode::OK);
let report_b = common::models::TaskReport {
task_id: task_b.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "node-flip".to_string(),
status: common::models::TaskStatus::Failed,
converged: false,
max_relc: Some(9.5e5),
atmosphere_has_nan: false,
elapsed_sec: 130.0,
error_message: Some("nl stage diverged".to_string()),
summary_json: "{}".to_string(),
failed_stage: None,
};
let body_b =
make_report_only_multipart("flipbound2", &serde_json::to_string(&report_b).unwrap());
let report_b_res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/task/report")
.header("authorization", format!("Bearer {}", token))
.header("content-type", "multipart/form-data; boundary=flipbound2")
.body(Body::from(body_b))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
report_b_res.status(),
StatusCode::OK,
"上报本身应成功(吸收)"
);
// 核心断言:converged 不被翻黑,且未触发任何种子回退任务。
assert_eq!(
db.get_grid_point_status(&name, "wf_flip")
.await
.unwrap()
.unwrap()
.0,
"converged",
"迟到失败报告不得翻黑 converged 点"
);
assert!(
db.has_pending_tasks_for_point(&name, "wf_flip", Some("seed_step"))
.await
.unwrap()
.is_empty(),
"不得因迟到失败报告派发 seed_step"
);
assert!(
!db.has_seed_step_attempt(&name, "wf_flip").await.unwrap(),
"全程不应产生任何 seed_step 行"
);
}
/// save_workflow 阶段配置合法性校验(修复审查 #4/#5):
/// - 双阶段全关 → 400(无任何计算可执行);
/// - 启用阶段空策略链 → 400(调度无顺位可派,任务必失败);
/// - 合法配置(synspec-only 场景 B / 默认双开)→ 200。
#[tokio::test]
async fn test_save_workflow_validates_stage_configs() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("wfval_db.db");
let queue_db_path = temp_dir.path().join("wfval_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
let state = AppState {
db: db.clone(),
queue,
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
5,
std::time::Duration::from_secs(300),
),
admin_token: Some("admin-secret".to_string()),
auth_disabled: false,
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let app = axum::Router::new()
.route(
"/api/workflows",
axum::routing::post(server::api::workflow::save_workflow),
)
.with_state(state);
let base_yaml = "grid:\n teff: [35000]\n logg: [5.5]\n loghe: [-1]\n logc: [-2]\n logn: [-2]\n logo: [-2]";
let post_save = |name: &str, yaml: &str| {
let app = app.clone();
let body = serde_json::json!({
"name": name,
"description": null,
"config_yaml": yaml,
});
async move {
app.oneshot(
Request::builder()
.method("POST")
.uri("/api/workflows")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap()
}
};
// 1. 双阶段全关 → 400
let both_off = format!(
"{}\ntlusty:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
synspec_stage:\n enabled: false\n policy: skip_converged\n strategies: [standard]\n",
base_yaml
);
let res = post_save("wf_val_a", &both_off).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "双阶段全关应 400");
// 2. 启用阶段空策略链 → 400
let empty_chain = format!(
"{}\ntlusty:\n enabled: true\n policy: skip_converged\n strategies: []\n",
base_yaml
);
let res = post_save("wf_val_b", &empty_chain).await;
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"启用阶段空策略链应 400"
);
// 3. 合法:TLUSTY 关 + SYNSPEC 启(设计 §2.2 场景 B:仅更新光谱)→ 200
let syn_only = format!(
"{}\ntlusty:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
synspec_stage:\n enabled: true\n policy: force_recompute\n strategies: [standard]\n",
base_yaml
);
let res = post_save("wf_val_c", &syn_only).await;
assert_eq!(res.status(), StatusCode::OK, "synspec-only 合法配置应 200");
// 4. 合法:无阶段块(默认双开)→ 200
let res = post_save("wf_val_d", base_yaml).await;
assert_eq!(res.status(), StatusCode::OK, "默认配置应 200");
}
+99 -136
View File
@@ -6,7 +6,9 @@
<title>DCTS — 分布式恒星大气网格计算控制台</title> <title>DCTS — 分布式恒星大气网格计算控制台</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet"> <!-- 字重精简:Inter 去掉未使用的 300,Space Grotesk 去掉未使用的 500(仅 600/700 用于标题),
减少 2 个字体文件下载。display=swap 避免 FOIT。 -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
<script> <script>
/* 首帧前落主题,避免 prefers-color-scheme 先绘制导致深/浅闪变 (FOUC) */ /* 首帧前落主题,避免 prefers-color-scheme 先绘制导致深/浅闪变 (FOUC) */
(function () { (function () {
@@ -20,18 +22,25 @@
<link rel="stylesheet" href="/src/style.css" /> <link rel="stylesheet" href="/src/style.css" />
</head> </head>
<body> <body>
<a href="#view-root" class="skip-link">跳到主内容</a>
<div id="app"> <div id="app">
<!-- ===== 登录遮罩(鉴权) ===== --> <!-- ===== 登录遮罩(鉴权) ===== -->
<div id="login-overlay" class="login-overlay"> <div id="login-overlay" class="login-overlay" role="dialog" aria-modal="true" aria-labelledby="login-title" aria-describedby="login-subtitle">
<div class="login-card card"> <div class="login-card card">
<div class="login-header"> <div class="login-header">
<h2>DCTS 控制台登录</h2> <h2 id="login-title">DCTS 控制台登录</h2>
<p>请输入管理员密码以访问管理控制台</p> <p id="login-subtitle">请输入管理员密码以访问管理控制台</p>
</div> </div>
<form id="form-login" class="stack-col stack-col-md"> <form id="form-login" class="stack-col stack-col-md">
<div class="form-group"> <div class="form-group">
<label for="input-admin-token">管理员密码</label> <label for="input-admin-token">管理员密码</label>
<div class="input-with-toggle">
<input type="password" id="input-admin-token" class="form-input" placeholder="输入管理员密码..." required autocomplete="current-password" /> <input type="password" id="input-admin-token" class="form-input" placeholder="输入管理员密码..." required autocomplete="current-password" />
<button type="button" class="btn-toggle-password" aria-label="显示密码" title="显示/隐藏密码">
<svg class="icon-eye-open" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="icon-eye-closed hidden" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
</div> </div>
<div id="login-error-msg" class="login-error-msg alert-error hidden"></div> <div id="login-error-msg" class="login-error-msg alert-error hidden"></div>
<button type="submit" id="btn-submit-login" class="btn btn-primary">登录</button> <button type="submit" id="btn-submit-login" class="btn btn-primary">登录</button>
@@ -111,133 +120,6 @@
<div id="view-root"></div> <div id="view-root"></div>
</main> </main>
<!-- 首页视图模板:router 挂载时克隆(见 src/views/home.js),ID 为渲染锚点,保持原样 -->
<template id="view-home-template">
<!-- 核心指标卡片 -->
<section class="metrics-grid">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">在线计算节点</span>
<div class="metric-icon nodes-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-active-nodes">0</span>
<span class="metric-sub" id="val-total-slots">0 / 0 CPU 槽位占用</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill" id="slots-progress-fill"></div>
</div>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">待计算网格点</span>
<div class="metric-icon pending-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-pending-tasks">0</span>
<span class="metric-sub" id="val-running-tasks">0 个任务计算中</span>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">已计算网格模型</span>
<div class="metric-icon success-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-completed-tasks">0</span>
<span class="metric-sub" id="val-completion-rate">网格总数: 0</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" id="converged-progress-fill"></div>
</div>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">计算工作流</span>
<div class="metric-icon workflow-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-total-workflows">0</span>
<span class="metric-sub" id="val-active-wf">系统已就绪</span>
</div>
</div>
</section>
<!-- 主要内容双栏 -->
<div class="content-grid">
<!-- 左侧:计算节点集群与凭据管理 (统一全功能控制大表) -->
<div class="panel card">
<div class="panel-header">
<h2>计算节点集群与凭据管理</h2>
<div class="row-wrap" style="align-items: center; gap: 0.5rem;">
<span class="badge" id="node-count-badge">0 个节点</span>
<button id="btn-refresh-nodes" class="btn btn-secondary btn-sm">刷新</button>
</div>
</div>
<div class="panel-body table-responsive">
<table class="data-table">
<thead>
<tr>
<th class="col-node-id">节点 ID</th>
<th class="col-slots">CPU 槽位</th>
<th class="col-usage">CPU/内存</th>
<th class="col-status">节点状态</th>
<th class="col-token">Token 凭据</th>
<th class="col-heartbeat">心跳时间</th>
<th class="col-actions">管理操作</th>
</tr>
</thead>
<tbody id="nodes-table-body">
<tr>
<td colspan="7" class="empty-cell">正在连接服务端获取计算节点...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 右侧:工作流管理 -->
<div class="stack-col stack-col-lg">
<!-- 工作流面板 -->
<div class="panel card">
<div class="panel-header">
<h2>恒星大气网格工作流</h2>
</div>
<div class="panel-body">
<div id="workflows-list" class="workflows-container">
<div class="empty-cell">正在获取工作流配置...</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 创建 / 编辑工作流 模态弹窗 --> <!-- 创建 / 编辑工作流 模态弹窗 -->
<div id="modal-create-wf" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title"> <div id="modal-create-wf" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
@@ -250,8 +132,12 @@
</div> </div>
<form id="form-create-wf" class="stack-col stack-col-md"> <form id="form-create-wf" class="stack-col stack-col-md">
<div class="form-group"> <div class="form-group">
<label for="input-wf-name">工作流名称 (Identifier)</label> <label for="input-wf-name">工作流名称 (Identifier) <span class="req-mark" aria-hidden="true">*</span></label>
<input type="text" id="input-wf-name" class="form-input" placeholder="例如: sdB_cno" required /> <input type="text" id="input-wf-name" class="form-input" placeholder="例如: sdB_cno" required
pattern="[A-Za-z0-9._\-]+" maxlength="64" autocomplete="off"
aria-describedby="wf-name-hint" />
<small id="wf-name-hint" class="field-hint">仅支持字母、数字、点、下划线与连字符,最长 64 字符</small>
<small id="wf-name-error" class="field-error hidden" role="alert"></small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="input-wf-desc">描述信息</label> <label for="input-wf-desc">描述信息</label>
@@ -259,10 +145,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="row-wrap" style="justify-content: space-between; align-items: center;"> <div class="row-wrap" style="justify-content: space-between; align-items: center;">
<label for="input-wf-yaml">YAML 配置内容 (GridConfig)</label> <label for="input-wf-yaml">YAML 配置内容 (GridConfig) <span class="req-mark" aria-hidden="true">*</span></label>
<button type="button" id="btn-load-preset-yaml" class="btn btn-secondary btn-sm">填入默认示例</button> <button type="button" id="btn-load-preset-yaml" class="btn btn-secondary btn-sm">填入默认示例</button>
</div> </div>
<textarea id="input-wf-yaml" class="form-textarea" required placeholder="请粘贴工作流 YAML 参数网格定义..."></textarea> <textarea id="input-wf-yaml" class="form-textarea" required placeholder="请粘贴工作流 YAML 参数网格定义..." aria-describedby="wf-yaml-hint"></textarea>
<small id="wf-yaml-hint" class="field-hint">定义 grid 参数网格与 chain 计算链,保存前服务端会校验 YAML 结构</small>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" id="cancel-create-wf" class="btn btn-secondary">取消</button> <button type="button" id="cancel-create-wf" class="btn btn-secondary">取消</button>
@@ -304,6 +191,10 @@
<div class="modal-footer yaml-editor-footer"> <div class="modal-footer yaml-editor-footer">
<span id="yaml-editor-meta" class="yaml-editor-meta tabular-num"></span> <span id="yaml-editor-meta" class="yaml-editor-meta tabular-num"></span>
<span id="yaml-editor-lock" class="yaml-editor-lock hidden"></span> <span id="yaml-editor-lock" class="yaml-editor-lock hidden"></span>
<button type="button" id="btn-export-yaml" class="btn btn-secondary btn-sm" title="下载导出当前 YAML 配置文件">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
导出配置
</button>
<button type="button" id="btn-edit-yaml" class="btn btn-primary btn-sm"> <button type="button" id="btn-edit-yaml" class="btn btn-primary btn-sm">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
编辑 编辑
@@ -340,13 +231,85 @@
</div> </div>
</div> </div>
<!-- 节点详情与管理模态弹窗(三点菜单入口:配额调整 / 启停 / 重发 Token) -->
<div id="modal-node-manage" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="node-manage-title">
<div class="modal-card">
<div class="modal-header">
<h3 id="node-manage-title">节点详情与管理</h3>
<button id="node-manage-close-btn" class="modal-close" aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<!-- 基础信息 + 槽位展示 -->
<div id="node-manage-overview" class="node-manage-section">
<div class="form-group">
<label>节点 ID</label>
<div id="node-manage-id" class="node-manage-value mono"></div>
</div>
<div class="node-manage-grid">
<div class="form-group">
<label>节点状态</label>
<div id="node-manage-status"></div>
</div>
<div class="form-group">
<label>CPU 槽位 (占用/配额/最大)</label>
<div id="node-manage-slots" class="node-manage-value tabular-num"></div>
</div>
<div class="form-group">
<label>CPU / 内存</label>
<div id="node-manage-usage" class="node-manage-value tabular-num"></div>
</div>
<div class="form-group">
<label>心跳时间</label>
<div id="node-manage-heartbeat" class="node-manage-value"></div>
</div>
</div>
</div>
<!-- 修改配额 (CPU 核数) -->
<div class="node-manage-section">
<h4 class="node-manage-section-title">并发槽位配额</h4>
<p class="text-hint-mb">限制该节点最大并发领用的任务数(CPU 核数)。正在运行的任务不会被中断,等其结束后自然回落到新配额以下;设为 0 可暂停接收新任务,留空清除限制恢复物理上限。</p>
<div class="row-wrap" style="gap: 0.5rem; align-items: flex-end;">
<div class="form-group" style="flex: 1; min-width: 120px;">
<label for="node-manage-quota-input">配额上限</label>
<input type="number" id="node-manage-quota-input" class="form-input" min="0" step="1" placeholder="留空 = 无限制" />
</div>
<button type="button" id="btn-node-save-quota" class="btn btn-primary btn-sm">保存配额</button>
<button type="button" id="btn-node-clear-quota" class="btn btn-secondary btn-sm">清除配额</button>
</div>
</div>
<!-- 节点调度控制 -->
<div class="node-manage-section">
<h4 class="node-manage-section-title">调度控制</h4>
<div class="row-wrap" style="gap: 0.5rem;">
<button type="button" id="btn-node-disable" class="btn btn-secondary btn-sm">停用节点(暂停分发)</button>
<button type="button" id="btn-node-enable" class="btn btn-primary btn-sm">启用节点(恢复分发)</button>
</div>
</div>
<!-- 安全管理 -->
<div class="node-manage-section">
<h4 class="node-manage-section-title">安全管理</h4>
<p class="text-hint-mb">重发后旧 Token 立即失效,该节点下次心跳/领用将 401 退出。请随后把新 Token 同步到节点。</p>
<button type="button" id="btn-node-reissue" class="btn btn-secondary btn-sm">重新颁发专属 Token</button>
</div>
</div>
<div class="modal-footer">
<button type="button" id="btn-close-node-manage" class="btn btn-secondary">关闭</button>
</div>
</div>
</div>
<!-- 页脚 --> <!-- 页脚 -->
<footer class="footer"> <footer class="footer">
<p>DCTS 分布式恒星大气计算系统 &copy; 2026 TLUSTY/SYNSPEC Project</p> <p>DCTS 分布式恒星大气计算系统 &copy; 2026 TLUSTY/SYNSPEC Project</p>
</footer> </footer>
<!-- ===== 全局 Toast 提示容器 ===== --> <!-- ===== 全局 Toast 提示容器 ===== -->
<div id="toast-container" class="toast-container"></div> <div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="true"></div>
<!-- ===== 通用自定义 Confirm 模态弹窗 ===== --> <!-- ===== 通用自定义 Confirm 模态弹窗 ===== -->
<div id="modal-confirm" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" aria-describedby="confirm-modal-msg"> <div id="modal-confirm" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" aria-describedby="confirm-modal-msg">
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "node --test \"test/*.test.js\""
}, },
"devDependencies": { "devDependencies": {
"vite": "^5.4.0" "vite": "^5.4.0"
+100 -16
View File
@@ -1,13 +1,91 @@
/* DCTS Dashboard API & Authentication Module */ /* DCTS Dashboard API & Authentication Module
*
* 集中所有后端调用与鉴权逻辑类型契约以 JSDoc 标注, IDE 提示与重构参考
* (项目保持原生 ESM,不引入 TypeScript)
*
* 安全token 存储在 sessionStorage 而非 localStorage
* sessionStorage 生命周期仅限当前标签页会话关闭即失效缩小了 XSS 窃取后的利用窗口
* localStorage 会跨标签页/重启长期持久化一旦泄露配合 CSP 'unsafe-inline'即等于
* 长期账号接管服务端 session 仍有 24h 过期 + 容量淘汰前端不再持有永久凭据
*/
import { showToast } from './components/toast.js'; import { showToast } from './components/toast.js';
// 安全:token 存储在 sessionStorage 而非 localStorage。 // escapeHtml 的权威定义已迁至 utils/format.js;此处 re-export 保持向后兼容
// sessionStorage 生命周期仅限当前标签页会话(关闭即失效),缩小了 XSS 窃取后的利用窗口; // (nodesTable/workflows 等历史 import 自 api.js,改动成本高于收益)。
// localStorage 会跨标签页/重启长期持久化,一旦泄露(配合 CSP 的 'unsafe-inline')即等于 export { escapeHtml } from './utils/format.js';
// 长期账号接管。服务端 session 仍有 24h 过期 + 容量淘汰,前端不再持有永久凭据。
const TOKEN_KEY = 'dcts_admin_token'; const TOKEN_KEY = 'dcts_admin_token';
/**
* @typedef {Object} ApiResponse 通用响应包装
* @property {boolean} success
* @property {string} [message] 失败时的错误描述
* @property {*} [data] 业务数据
*/
/**
* @typedef {Object} ClusterStatus GET /api/status 返回的集群总体状态
* @property {number} [nodes_online]
* @property {number} [active_nodes_count]
* @property {number} [total_active_slots]
* @property {number} [occupied_slots]
* @property {number} [total_max_slots]
* @property {Array} [nodes]
* @property {object} [grid_stats] - { pending, queued, running, converged, failed }
* @property {number} [pending_points]
* @property {number} [running_points]
* @property {number} [converged_points]
* @property {number} [failed_points]
*/
/**
* @typedef {Object} NodeInfo GET /api/admin/nodes 列表项
* @property {string} node_id
* @property {string} [id] - node_id 的别名(后端历史字段)
* @property {string} status - online/active/disabled/pending_approval/offline
* @property {string} token_status - active/inactive
* @property {number} active_slots
* @property {number} max_slots
* @property {number} cpu_usage
* @property {number} memory_usage
* @property {string} last_heartbeat
* @property {number|null} admin_max_slots - 管理员强制并发槽位上限(null=无限制)
*/
/**
* @typedef {Object} Workflow GET /api/workflows 列表项
* @property {string} name
* @property {string} [description]
* @property {string} status - idle/initializing/running/paused/completed
* @property {string} [config_yaml]
* @property {WorkflowStats} [stats] 列表接口内联的统计概要
*/
/**
* @typedef {Object} WorkflowStats GET /api/workflows/:name/stats
* @property {string} status
* @property {number} total
* @property {number} pending
* @property {number} queued
* @property {number} running
* @property {number} converged
* @property {number} failed
* @property {number} [eta_sec]
* @property {Array<{label:string,count:number}>} [waves]
*/
/**
* @typedef {Object} WorkflowPoint GET /api/workflows/:name/points 列表项
* @property {string} name
* @property {string} status
* @property {string} [method]
* @property {number} [relc]
* @property {number} [runtime_sec]
* @property {number} [attempts]
* @property {string} [last_completed_at]
*/
export function getAdminToken() { export function getAdminToken() {
try { try {
return sessionStorage.getItem(TOKEN_KEY); return sessionStorage.getItem(TOKEN_KEY);
@@ -32,17 +110,6 @@ export function clearAdminToken() {
} }
} }
// 安全 HTML 转义,防止存储型 XSS
export function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 401 处理去重:并发请求(如详情页 Promise.allSettled 三路拉取)可能各自命中 401 并 // 401 处理去重:并发请求(如详情页 Promise.allSettled 三路拉取)可能各自命中 401 并
// 各触发一次 location.reload,竞态下不规范。用一个模块级 guard 保证只处理一次。 // 各触发一次 location.reload,竞态下不规范。用一个模块级 guard 保证只处理一次。
let handling401 = false; let handling401 = false;
@@ -132,24 +199,28 @@ export async function logoutAdmin() {
clearAdminToken(); clearAdminToken();
} }
/** 获取集群总体状态(在线节点、槽位占用、网格点统计)。 */
export async function fetchClusterStatus() { export async function fetchClusterStatus() {
const res = await apiFetch('/api/status'); const res = await apiFetch('/api/status');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`); if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json(); return res.json();
} }
/** 获取工作流列表(含内联 stats 概要)。 */
export async function fetchWorkflowsList() { export async function fetchWorkflowsList() {
const res = await apiFetch('/api/workflows'); const res = await apiFetch('/api/workflows');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`); if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json(); return res.json();
} }
/** 获取计算节点列表(管理员视角,含 token_status/cpu_usage 等)。 */
export async function fetchNodesList() { export async function fetchNodesList() {
const res = await apiFetch('/api/admin/nodes'); const res = await apiFetch('/api/admin/nodes');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`); if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json(); return res.json();
} }
/** 创建工作流。 */
export async function createWorkflow(workflowData) { export async function createWorkflow(workflowData) {
return apiFetch('/api/workflows', { return apiFetch('/api/workflows', {
method: 'POST', method: 'POST',
@@ -235,3 +306,16 @@ export async function disableNodeApi(nodeId) {
export async function enableNodeApi(nodeId) { export async function enableNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/enable`, { method: 'POST' }); return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/enable`, { method: 'POST' });
} }
/**
* 设置节点并发槽位配额动态调整 CPU 核数
* @param {string} nodeId
* @param {number|null} adminMaxSlots - null=清除限制(恢复物理 max_slots)number=配额上限(0=暂停接新任务)
*/
export async function setNodeQuotaApi(nodeId, adminMaxSlots) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/quota`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ admin_max_slots: adminMaxSlots }),
});
}
+36
View File
@@ -22,6 +22,10 @@ export function setupFocusTrap(modalEl) {
if (closeBtn && typeof closeBtn.click === 'function') { if (closeBtn && typeof closeBtn.click === 'function') {
e.preventDefault(); e.preventDefault();
closeBtn.click(); closeBtn.click();
// 阻止 Escape 冒泡到 document 级全局关闭处理器(审查修复 M2):
// 否则 confirm 堆叠在 node-manage 之上时,一次 Escape 会「取消确认」+
// 「顺带关闭下层管理弹窗」。本 trap 已处理该 Modal 的 Escape,全局兜底无需再触发。
e.stopPropagation();
return; return;
} }
} }
@@ -66,6 +70,38 @@ export function releaseFocusTrap(modalEl) {
} }
} }
/**
* 带退出动画的模态关闭state-transition / exit-faster-than-enter
* 先加 .modal-closing 触发 CSS 退出动画遮罩淡出 + 卡片下滑150ms
* 动画结束后animationend 或兜底超时才真正隐藏并释放焦点陷阱
* prefers-reduced-motion 下跳过动画直接隐藏重复调用安全hidden 守卫
*/
export function hideModal(modalEl) {
if (!modalEl || modalEl.classList.contains('hidden')) return;
const finish = () => {
if (modalEl.classList.contains('hidden')) return; // 已关闭(防 animationend 与超时双触发)
modalEl.classList.remove('modal-closing');
modalEl.classList.add('hidden');
releaseFocusTrap(modalEl);
};
const reduce =
window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
finish();
return;
}
modalEl.classList.add('modal-closing');
const card = modalEl.querySelector('.modal-card, .login-card');
if (card) {
card.addEventListener('animationend', finish, { once: true });
}
// 兜底:animationend 未触发(如卡片不存在或动画被覆盖)时仍能关闭。
setTimeout(finish, 220);
}
export function setupModalDismiss(modalEl, onClose) { export function setupModalDismiss(modalEl, onClose) {
if (!modalEl) return; if (!modalEl) return;
+19 -33
View File
@@ -1,6 +1,7 @@
/* DCTS Dashboard Nodes Table Component (增量 DOM 更新与 Skeleton) */ /* DCTS Dashboard Nodes Table Component (增量 DOM 更新与 Skeleton) */
import { escapeHtml } from '../api.js'; import { escapeHtml, fmtClock } from '../utils/format.js';
import { ICONS } from '../utils/icons.js';
export function renderNodesTableSkeleton() { export function renderNodesTableSkeleton() {
const tbody = document.getElementById('nodes-table-body'); const tbody = document.getElementById('nodes-table-body');
@@ -21,16 +22,10 @@ export function renderNodesTableSkeleton() {
tbody.innerHTML = skeletonRows; tbody.innerHTML = skeletonRows;
} }
// 心跳时间容错:字段缺失或非法时间戳一律显示占位符,避免渲染出 "Invalid Date" // 行重建签名:状态/凭据决定徽章与操作按钮。槽位占用、负载、心跳是秒级高频字段,
function formatHeartbeat(ts) {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('zh-CN');
}
// 行重建签名:仅状态/凭据决定徽章与操作按钮。槽位、负载、心跳是秒级高频字段,
// 走 textContent 原位刷新——整行 innerHTML 重建会销毁用户正悬停/聚焦的按钮。 // 走 textContent 原位刷新——整行 innerHTML 重建会销毁用户正悬停/聚焦的按钮。
// 注:表格槽位列按设计文档 §3.3.1 保持「占用 / 最大」两段式(不展示配额);
// 配额的三段式展示(占用/配额/最大)只在节点详情管理 Modal 内呈现(§3.3.3)。
function rowSignature(node) { function rowSignature(node) {
return `${node.status || ''}|${node.token_status || ''}`; return `${node.status || ''}|${node.token_status || ''}`;
} }
@@ -39,7 +34,7 @@ function volatileValues(node) {
return { return {
slots: `${Number(node.active_slots || 0)} / ${Number(node.max_slots || 4)}`, slots: `${Number(node.active_slots || 0)} / ${Number(node.max_slots || 4)}`,
usage: `${Number(node.cpu_usage || 0).toFixed(1)}% / ${Number(node.memory_usage || 0).toFixed(1)}%`, usage: `${Number(node.cpu_usage || 0).toFixed(1)}% / ${Number(node.memory_usage || 0).toFixed(1)}%`,
heartbeat: formatHeartbeat(node.last_heartbeat), heartbeat: fmtClock(node.last_heartbeat),
}; };
} }
@@ -60,7 +55,7 @@ function buildRowHtml(node, nodeId) {
let statusBadge; let statusBadge;
if (isPending) { if (isPending) {
statusBadge = '<span class="status-badge warning"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="badge-icon-space"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>待审批</span>'; statusBadge = `<span class="status-badge warning">${ICONS.clock({ size: 11, cls: 'badge-icon-space' })}待审批</span>`;
} else if (isDisabled) { } else if (isDisabled) {
statusBadge = '<span class="status-badge secondary">已停用</span>'; statusBadge = '<span class="status-badge secondary">已停用</span>';
} else if (isOnline) { } else if (isOnline) {
@@ -83,29 +78,20 @@ function buildRowHtml(node, nodeId) {
actionBtns = ` actionBtns = `
<div class="action-cell-wrap"> <div class="action-cell-wrap">
<button class="btn-action-icon btn-action-approve" data-node-action="approve" data-node-id="${nodeId}" title="同意节点接入申请" aria-label="同意节点接入申请"> <button class="btn-action-icon btn-action-approve" data-node-action="approve" data-node-id="${nodeId}" title="同意节点接入申请" aria-label="同意节点接入申请">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> ${ICONS.check({ size: 14 })}
</button> </button>
<button class="btn-action-icon btn-action-danger" data-node-action="reject" data-node-id="${nodeId}" title="拒绝节点接入申请" aria-label="拒绝节点接入申请"> <button class="btn-action-icon btn-action-danger" data-node-action="reject" data-node-id="${nodeId}" title="拒绝节点接入申请" aria-label="拒绝节点接入申请">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> ${ICONS.x({ size: 14, sw: 2.5 })}
</button> </button>
</div> </div>
`; `;
} else { } else {
// 启停切换:disabled 时显示「启用」(绿 ▶),否则显示「停用」(琥珀 ⏸)。 // 已审批节点:收敛至统一「三点菜单」入口,点击弹出节点详情管理 Modal
// 停用用琥珀+暂停符号,表达"可恢复的软暂停"——节点保持存活待命,与「重发 Token」 // (配额调整 / 启用·停用 / 重发 Token)。设计依据见 docs/dynamic_cpu_slots_design.md §3.3。
// (蓝 🔄,轮换凭据、旧 token 立即失效)是两类不同运维操作,颜色与图标刻意区分。
const toggleBtn = isDisabled
? `<button class="btn-action-icon btn-action-approve" data-node-action="enable" data-node-id="${nodeId}" title="重新启用节点(恢复分发任务)" aria-label="重新启用节点">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
</button>`
: `<button class="btn-action-icon btn-action-warn" data-node-action="disable" data-node-id="${nodeId}" title="停用节点(不再分发任务,保持空闲待命)" aria-label="停用节点">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
</button>`;
actionBtns = ` actionBtns = `
<div class="action-cell-wrap"> <div class="action-cell-wrap">
${toggleBtn} <button class="btn-action-icon btn-action-manage" data-node-action="manage" data-node-id="${nodeId}" title="节点详情与管理(配额 / 启停 / 重发 Token" aria-label="节点详情与管理">
<button class="btn-action-icon btn-action-reissue" data-node-action="reissue" data-node-id="${nodeId}" title="重新颁发专属 Token(旧 Token 失效)" aria-label="重新颁发专属 Token"> ${ICONS.moreVertical({ size: 18, fill: true })}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
</button> </button>
</div> </div>
`; `;
@@ -115,12 +101,12 @@ function buildRowHtml(node, nodeId) {
return ` return `
<td class="col-node-id node-id" title="${nodeId}">${nodeId}</td> <td class="col-node-id node-id" title="${nodeId}">${nodeId}</td>
<td class="col-slots tabular-num">${v.slots}</td> <td class="col-slots tabular-num" data-label="CPU 槽位">${v.slots}</td>
<td class="col-usage tabular-num">${v.usage}</td> <td class="col-usage tabular-num" data-label="CPU/内存">${v.usage}</td>
<td class="col-status">${statusBadge}</td> <td class="col-status" data-label="节点状态">${statusBadge}</td>
<td class="col-token">${tokenBadge}</td> <td class="col-token" data-label="Token 凭据">${tokenBadge}</td>
<td class="col-heartbeat tabular-num">${v.heartbeat}</td> <td class="col-heartbeat tabular-num" data-label="心跳时间">${v.heartbeat}</td>
<td class="col-actions">${actionBtns}</td> <td class="col-actions" data-label="管理操作">${actionBtns}</td>
`; `;
} }
+21 -3
View File
@@ -1,9 +1,11 @@
/* DCTS Dashboard Toast Component */ /* DCTS Dashboard Toast Component */
import { ICONS } from '../utils/icons.js';
const TOAST_ICONS = { const TOAST_ICONS = {
success: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`, success: ICONS.checkCircle({ cls: 'toast-icon', sw: 2.5 }),
error: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`, error: ICONS.alert({ cls: 'toast-icon', sw: 2.5 }),
info: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>` info: ICONS.info({ cls: 'toast-icon', sw: 2.5 }),
}; };
export function showToast(message, type = 'info') { export function showToast(message, type = 'info') {
@@ -29,11 +31,27 @@ export function showToast(message, type = 'info') {
textEl.textContent = message; textEl.textContent = message;
toast.appendChild(textEl); toast.appendChild(textEl);
// 关闭按钮:键盘可达(Tab 聚焦 + Enter/Space 触发),aria-label 供读屏播报。
// 此前仅支持点击关闭,键盘/读屏用户无法主动消除(尤其 role=alert 的错误提示)。
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'toast-close';
closeBtn.setAttribute('aria-label', '关闭提示');
closeBtn.innerHTML = ICONS.x({ size: 14, sw: 2.2 });
toast.appendChild(closeBtn);
let dismissed = false;
const dismiss = () => { const dismiss = () => {
if (dismissed) return;
dismissed = true;
toast.classList.add('toast-fadeOut'); toast.classList.add('toast-fadeOut');
setTimeout(() => toast.remove(), 250); setTimeout(() => toast.remove(), 250);
}; };
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
dismiss();
});
toast.addEventListener('click', dismiss); toast.addEventListener('click', dismiss);
container.appendChild(toast); container.appendChild(toast);
-78
View File
@@ -1,78 +0,0 @@
/* DCTS
*
* 统一模式showConfirm 二次确认 调用 API toast 反馈 fetchAllData 立即刷新
* 详情页调用方可传回调afterRefresh启动/暂停后刷新本页统计
* afterDelete删除成功后返回上一视图
*/
import {
startWorkflowApi,
stopWorkflowApi,
deleteWorkflowApi
} from '../api.js';
import { showToast } from './toast.js';
import { showConfirm } from './modal.js';
import { fetchAllData } from '../state.js';
import { openYamlEditor } from './yamlEditor.js';
export async function handleStartWorkflow(name, afterRefresh = null) {
const ok = await showConfirm('启动计算网格', `确定要启动工作流 '${name}' 展开网格计算任务吗?`);
if (!ok) return;
try {
const res = await startWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已成功启动', 'success');
fetchAllData();
if (afterRefresh) afterRefresh();
} else {
showToast(json.message || '启动工作流失败', 'error');
}
} catch (err) {
showToast(`启动失败: ${err.message}`, 'error');
}
}
export async function handleStopWorkflow(name, afterRefresh = null) {
const ok = await showConfirm('暂停工作流', `确定要暂停工作流 '${name}' 的后续任务派发吗?`);
if (!ok) return;
try {
const res = await stopWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已暂停', 'info');
fetchAllData();
if (afterRefresh) afterRefresh();
} else {
showToast(json.message || '暂停失败', 'error');
}
} catch (err) {
showToast(`暂停失败: ${err.message}`, 'error');
}
}
export async function handleDeleteWorkflow(name, afterDelete = null) {
const ok = await showConfirm('删除工作流', `确定要彻底删除工作流 '${name}' 及其关联的网格点数据吗?此操作无法撤销。`);
if (!ok) return;
try {
const res = await deleteWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流删除成功', 'success');
fetchAllData();
if (afterDelete) afterDelete();
} else {
showToast(json.message || '删除失败', 'error');
}
} catch (err) {
showToast(`删除失败: ${err.message}`, 'error');
}
}
/** 查看/编辑工作流 YAML:统一入口,双模式编辑器见 yamlEditor.js。 */
export async function handleViewWorkflow(name) {
await openYamlEditor(name);
}
+585
View File
@@ -0,0 +1,585 @@
/* DCTS docs/task_engine_decoupling_design.md §6.2
*
* 替代旧版卡片下方YAML配置/启动/暂停/删除按钮组与 YAML 编辑模态的工作流操作入口
* 内嵌在工作流详情页顶部直接展示 TLUSTY / SYNSPEC 双阶段的正交三维配置
* - enabled启用开关
* - policy执行策略skip_converged / force_recompute / skip_failed
* - strategies策略链队列可增删上下排序
*
* 数据流
* GET /api/workflows/:name config_yaml正则提取 tlusty/synspec_stage
* 把编辑后的 tlusty/synspec_stage 块写回 config_yaml PUT /api/workflows/:name
* 操作启动/停止/删除复用 api.js 既有端点
*
* 安全所有用户输入与来自服务端的 YAML 片段一律经 escapeHtml 注入策略名走白名单
* 校验后才能进入配置杜绝任意字符串拼入 YAML
*/
import {
getWorkflowDetailApi,
updateWorkflowApi,
startWorkflowApi,
stopWorkflowApi,
deleteWorkflowApi,
} from '../api.js';
import { showToast } from './toast.js';
import { showConfirm } from './modal.js';
// 完整 YAML 查看/编辑弹窗(yamlEditor.js,独立于本面板的结构化阶段编辑)。
import { openYamlEditor } from './yamlEditor.js';
import { escapeHtml } from '../utils/format.js';
import { friendlyError } from '../utils/errors.js';
import { ICONS } from '../utils/icons.js';
import { fetchAllData } from '../state.js';
// YAML 阶段块解析/序列化纯函数(无浏览器依赖,可单测覆盖)。
import {
defaultStage,
extractTopBlock,
parseStageFromYaml,
resolveTlustyFromYaml,
serializeStageBlock,
applyStageBlocks,
validateStageConfigs,
} from '../utils/yamlStage.js';
// 策略白名单:仅这些值可进入策略链(防注入 / 防拼写错误)。
const TLUSTY_STRATEGIES = [
{ value: 'cold_run', label: '冷启动 (Cold Run)' },
{ value: 'seed_step', label: '种子步进 (Seed Step)' },
];
const SYNSPEC_STRATEGIES = [
{ value: 'standard', label: '标准 (Standard)' },
];
const POLICIES = [
{ value: 'skip_converged', label: '增量(跳过收敛·重试失败)' },
{ value: 'force_recompute', label: '强制重算(全量重置)' },
{ value: 'skip_failed', label: '跳过收敛及失败' },
];
// 面板状态(每次挂载新建,卸载即丢弃)。
const panelState = {
name: '',
status: 'idle',
description: null, // 工作流描述(回显保存,避免 null 覆盖既有描述)
configYaml: '', // 原始 YAML(未经编辑)
tlusty: defaultStage('tlusty'),
synspec: defaultStage('synspec'),
wired: false,
dirty: false,
collapsed: true, // 默认折叠成一行(标题+操作按钮),点「配置 ▾」展开阶段卡
};
// ===== YAML 阶段块的解析/序列化见 utils/yamlStage.js(纯函数,可单测覆盖) =====
// 此处仅保留渲染层逻辑。
// ===== 渲染 =====
function policyOptions(selected) {
return POLICIES.map(p =>
`<option value="${p.value}"${p.value === selected ? ' selected' : ''}>${escapeHtml(p.label)}</option>`
).join('');
}
function strategyOptions(kind, selected) {
const opts = kind === 'tlusty' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
return opts.map(o =>
`<option value="${o.value}"${o.value === selected ? ' selected' : ''}>${escapeHtml(o.label)}</option>`
).join('');
}
/** 单个阶段卡片(TLUSTY 或 SYNSPEC)。 */
function stageCardHtml(kind, label, stage) {
const idPrefix = kind === 'tlusty' ? 'tlusty' : 'synspec';
const stratRows = stage.strategies.map((s, i) => {
const opts = strategyOptions(kind, s);
return `
<li class="strat-row" data-strat-idx="${i}" data-strat-kind="${kind}">
<span class="strat-idx">${i + 1}.</span>
<select class="strat-select" data-engine-field="strat" data-strat-kind="${kind}" data-strat-idx="${i}">
${opts}
</select>
<button type="button" class="btn-icon strat-up" data-engine-field="strat-up" data-strat-kind="${kind}" data-strat-idx="${i}" title="上移" ${i === 0 ? 'disabled' : ''}></button>
<button type="button" class="btn-icon strat-down" data-engine-field="strat-down" data-strat-kind="${kind}" data-strat-idx="${i}" title="下移" ${i === stage.strategies.length - 1 ? 'disabled' : ''}></button>
<button type="button" class="btn-icon btn-icon-danger strat-remove" data-engine-field="strat-remove" data-strat-kind="${kind}" data-strat-idx="${i}" title="移除"></button>
</li>`;
}).join('');
return `
<div class="engine-stage-card${stage.enabled ? '' : ' stage-disabled'}" data-stage-kind="${kind}">
<label class="stage-enable-toggle">
<input type="checkbox" data-engine-field="enabled" data-strat-kind="${idPrefix}" ${stage.enabled ? 'checked' : ''}>
<span class="stage-title">${label}</span>
</label>
<div class="stage-body${stage.enabled ? '' : ' hidden'}">
<div class="stage-row">
<label class="stage-field-label">执行策略</label>
<select class="stage-policy-select" data-engine-field="policy" data-strat-kind="${idPrefix}">
${policyOptions(stage.policy)}
</select>
</div>
<div class="stage-row">
<label class="stage-field-label">策略链按回退优先级排序</label>
<ul class="strategy-chain" data-strat-list="${idPrefix}">
${stratRows || '<li class="strat-empty">策略链为空(将无法执行该阶段)</li>'}
</ul>
<button type="button" class="btn btn-secondary btn-xs" data-engine-field="strat-add" data-strat-kind="${idPrefix}">
${ICONS.plus({ size: 11 })} 添加回退策略
</button>
</div>
${kind === 'synspec'
? '<p class="text-hint engine-stage-note">光谱数值参数(波长范围、展宽等)在 YAML 中配置,请用「导出 YAML」编辑后重新导入。</p>'
: ''}
</div>
</div>
`;
}
/** 折叠切换按钮文案:折叠+空闲 →「启动配置 ▾」(展开核对后启动);折叠+运行中 →「配置 ▾」;展开 →「配置 ▴」。 */
function toggleBtnLabel() {
if (!panelState.collapsed) return '配置 ▴';
const isRunning = panelState.status === 'running' || panelState.status === 'initializing';
return isRunning ? '配置 ▾' : '启动配置 ▾';
}
/** 折叠切换按钮悬浮提示。 */
function toggleBtnTitle() {
if (!panelState.collapsed) return '收起阶段配置';
const isRunning = panelState.status === 'running' || panelState.status === 'initializing';
return isRunning ? '展开阶段配置(运行中不可启动)' : '展开阶段配置,核对 TLUSTY/SYNSPEC 设置后启动工作流';
}
/** 整个引擎面板骨架(含操作按钮)。 */
function panelHtml() {
const isRunning = panelState.status === 'running' || panelState.status === 'initializing';
const startDisabled = isRunning;
const stopDisabled = !isRunning;
return `
<section class="wf-engine-panel card">
<div class="wf-engine-panel-header">
<h2 class="wf-engine-title">执行引擎配置 · ${escapeHtml(panelState.name)}</h2>
<span class="wf-engine-dirty hidden" id="wf-engine-dirty">未保存</span>
</div>
<div class="wf-engine-stages${panelState.collapsed ? ' hidden' : ''}">
${stageCardHtml('tlusty', 'TLUSTY 大气结构计算', panelState.tlusty)}
${stageCardHtml('synspec', 'SYNSPEC 光谱合成', panelState.synspec)}
</div>
<div class="wf-engine-actions">
<button type="button" class="btn btn-secondary btn-sm" data-engine-action="yaml" title="查看 / 编辑完整 YAML 配置">
YAML 配置
</button>
<button type="button" class="btn btn-secondary btn-sm" data-engine-action="export" title="导出完整 YAML">
导出 YAML
</button>
<span class="wf-engine-actions-spacer"></span>
<button type="button" class="btn btn-primary btn-sm${panelState.collapsed ? ' hidden' : ''}" data-engine-action="start" ${startDisabled ? 'disabled' : ''}>
${ICONS.start({ size: 12 })} 启动
</button>
<button type="button" class="btn btn-secondary btn-sm${panelState.collapsed && !isRunning ? ' hidden' : ''}" data-engine-action="stop" ${stopDisabled ? 'disabled' : ''}>
${ICONS.stop({ size: 12 })} 暂停
</button>
<button type="button" class="btn btn-danger btn-sm" data-engine-action="delete">
${ICONS.trash({ size: 12 })} 删除
</button>
<button type="button" class="btn btn-primary btn-sm${panelState.collapsed && !panelState.dirty ? ' hidden' : ''}" data-engine-action="save">
保存配置
</button>
<button type="button" class="btn btn-secondary btn-sm" data-engine-action="toggle-config"
title="${toggleBtnTitle()}">
${toggleBtnLabel()}
</button>
</div>
<div class="wf-engine-error hidden" id="wf-engine-error" role="alert"></div>
</section>
`;
}
// ===== 状态读写 =====
function setDirty(dirty) {
panelState.dirty = dirty;
document.getElementById('wf-engine-dirty')?.classList.toggle('hidden', !dirty);
// 折叠态下保存按钮可见性随 dirty 联动(有未保存修改时折叠也能直接保存)。
syncActionVisibility(document.querySelector('.wf-engine-panel'));
}
function showError(msg) {
const el = document.getElementById('wf-engine-error');
if (!el) return;
el.textContent = msg;
el.classList.toggle('hidden', !msg);
}
/** 重新渲染某阶段的策略链列表(增删/排序后调用)。 */
function rerenderStrategyList(kind) {
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const idPrefix = kind === 'tlusty' ? 'tlusty' : 'synspec';
const ul = document.querySelector(`[data-strat-list="${idPrefix}"]`);
if (!ul) return;
ul.innerHTML = stage.strategies.map((s, i) => {
const opts = strategyOptions(kind, s);
return `
<li class="strat-row" data-strat-idx="${i}" data-strat-kind="${kind}">
<span class="strat-idx">${i + 1}.</span>
<select class="strat-select" data-engine-field="strat" data-strat-kind="${kind}" data-strat-idx="${i}">
${opts}
</select>
<button type="button" class="btn-icon strat-up" data-engine-field="strat-up" data-strat-kind="${kind}" data-strat-idx="${i}" title="上移" ${i === 0 ? 'disabled' : ''}></button>
<button type="button" class="btn-icon strat-down" data-engine-field="strat-down" data-strat-kind="${kind}" data-strat-idx="${i}" title="下移" ${i === stage.strategies.length - 1 ? 'disabled' : ''}></button>
<button type="button" class="btn-icon btn-icon-danger strat-remove" data-engine-field="strat-remove" data-strat-kind="${kind}" data-strat-idx="${i}" title="移除"></button>
</li>`;
}).join('') || '<li class="strat-empty">策略链为空(将无法执行该阶段)</li>';
setDirty(true);
}
// ===== 事件处理 =====
function handleFieldChange(e) {
const t = e.target;
const field = t.getAttribute('data-engine-field');
const kindRaw = t.getAttribute('data-strat-kind');
// strat-kind 在 enabled/policy 用 tlusty/synspec;在 strat* 用 tlusty/synspec。
const kind = kindRaw === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
if (field === 'enabled') {
stage.enabled = t.checked;
// 切换 stage-body 显隐 + 卡片 disabled 样式。
const card = t.closest('.engine-stage-card');
card?.classList.toggle('stage-disabled', !stage.enabled);
card?.querySelector('.stage-body')?.classList.toggle('hidden', !stage.enabled);
setDirty(true);
} else if (field === 'policy') {
stage.policy = t.value;
setDirty(true);
} else if (field === 'strat') {
const idx = parseInt(t.getAttribute('data-strat-idx'), 10);
stage.strategies[idx] = t.value;
setDirty(true);
}
}
function handleClick(e) {
const btn = e.target.closest('[data-engine-field], [data-engine-action]');
if (!btn) return;
const field = btn.getAttribute('data-engine-field');
const action = btn.getAttribute('data-engine-action');
if (field === 'strat-add') {
const kind = btn.getAttribute('data-strat-kind') === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const opts = kind === 'tlusty' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
// 默认添加白名单首项(若已含则添加下一项)。
const next = opts.find(o => !stage.strategies.includes(o.value)) || opts[0];
stage.strategies.push(next.value);
rerenderStrategyList(kind);
return;
}
if (field && field.startsWith('strat-')) {
const kind = btn.getAttribute('data-strat-kind') === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const idx = parseInt(btn.getAttribute('data-strat-idx'), 10);
if (field === 'strat-remove') {
stage.strategies.splice(idx, 1);
rerenderStrategyList(kind);
} else if (field === 'strat-up' && idx > 0) {
[stage.strategies[idx - 1], stage.strategies[idx]] = [stage.strategies[idx], stage.strategies[idx - 1]];
rerenderStrategyList(kind);
} else if (field === 'strat-down' && idx < stage.strategies.length - 1) {
[stage.strategies[idx + 1], stage.strategies[idx]] = [stage.strategies[idx], stage.strategies[idx + 1]];
rerenderStrategyList(kind);
}
return;
}
if (action === 'save') {
saveConfig();
} else if (action === 'start') {
doStart();
} else if (action === 'stop') {
doStop();
} else if (action === 'delete') {
doDelete();
} else if (action === 'toggle-config') {
// 折叠/展开:仅切 DOM 显隐,不重渲染(保留编辑中状态)。
panelState.collapsed = !panelState.collapsed;
const panelEl = btn.closest('.wf-engine-panel');
panelEl?.querySelector('.wf-engine-stages')?.classList.toggle('hidden', panelState.collapsed);
syncActionVisibility(panelEl);
btn.textContent = toggleBtnLabel();
btn.title = toggleBtnTitle();
return;
} else if (action === 'yaml') {
// 完整 YAML 查看/编辑弹窗(独立于结构化阶段编辑,二者保存互不感知,
// 弹窗保存后由详情页轮询刷新本面板的 configYaml)。
// 传入 onSaved 回调:YAML 编辑器保存成功后立即重拉并重渲染面板(2026-08-04 修复,
// 否则面板停留在挂载时的旧配置)。
openYamlEditor(panelState.name, refreshEnginePanel);
} else if (action === 'export') {
exportYaml();
}
}
// ===== 操作动作 =====
async function saveConfig() {
if (!panelState.dirty) {
showToast('配置未变更', 'info');
return;
}
// 阶段配置合法性校验(与服务端 save_workflow 同口径,修复审查 #4/#5):
// 双阶段全关、或启用阶段空策略链 → 拦截保存,避免保存出必然失败的任务配置。
const validationErr = validateStageConfigs(panelState.tlusty, panelState.synspec);
if (validationErr) {
showError(validationErr);
return;
}
const newYaml = applyStageBlocks(panelState.configYaml, panelState.tlusty, panelState.synspec);
showError('');
try {
// 回显既有描述(修复审查 #6:旧实现硬编码 description:null 会清空工作流描述)。
const res = await updateWorkflowApi(panelState.name, {
name: panelState.name,
description: panelState.description,
config_yaml: newYaml,
});
const json = await res.json().catch(() => ({}));
if (res.ok && json.success) {
panelState.configYaml = newYaml;
setDirty(false);
showToast(`工作流 '${panelState.name}' 阶段配置已保存`, 'success');
} else {
showError(json.message || `保存失败(HTTP ${res.status}`);
}
} catch (err) {
showError(`请求异常:${friendlyError(err, '保存失败')}`);
}
}
async function doStart() {
// 启动前若有未保存改动,提示先保存。
if (panelState.dirty) {
const ok = await showConfirm('启动前保存', '当前阶段配置有未保存的修改,启动将使用上一次保存的配置。是否先保存?');
if (ok) {
await saveConfig();
if (panelState.dirty) return; // 保存失败则中止
}
}
const ok = await showConfirm('启动计算网格', `确定要启动工作流 '${panelState.name}' 展开网格计算任务吗?`);
if (!ok) return;
try {
const res = await startWorkflowApi(panelState.name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已成功启动', 'success');
refreshEnginePanel();
} else {
showError(json.message || '启动失败:请确认有在线计算节点且配置有效');
}
} catch (err) {
showError(`启动失败:${friendlyError(err, '启动失败')}`);
}
}
async function doStop() {
const ok = await showConfirm('暂停工作流', `确定要暂停工作流 '${panelState.name}' 的后续任务派发吗?`);
if (!ok) return;
try {
const res = await stopWorkflowApi(panelState.name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已暂停', 'info');
refreshEnginePanel();
} else {
showError(json.message || '暂停失败:请稍后重试');
}
} catch (err) {
showError(`暂停失败:${friendlyError(err, '暂停失败')}`);
}
}
async function doDelete() {
const ok = await showConfirm('删除工作流', `确定要彻底删除工作流 '${panelState.name}' 及其关联的网格点数据吗?此操作无法撤销。`);
if (!ok) return;
try {
const res = await deleteWorkflowApi(panelState.name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流删除成功', 'success');
location.hash = '#/';
} else {
showError(json.message || '删除失败:请确认工作流未在运行后重试');
}
} catch (err) {
showError(`删除失败:${friendlyError(err, '删除失败')}`);
}
}
function exportYaml() {
const yaml = applyStageBlocks(panelState.configYaml, panelState.tlusty, panelState.synspec);
if (!yaml.trim()) {
showToast('配置内容为空,无法导出', 'warning');
return;
}
const filename = `${panelState.name || 'workflow'}.yaml`;
try {
const blob = new Blob([yaml], { type: 'text/yaml;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`已导出 ${filename}`, 'success');
} catch (err) {
showToast(`导出失败:${friendlyError(err, '文件生成异常')}`, 'error');
}
}
// ===== 刷新与挂载 =====
/** panelStatefetch + parse
* mount poll 共用此逻辑mount 调用后强制重渲染poll 仅在状态变化时更新按钮 */
async function fetchAndApplyDetail({ forceConfig }) {
const res = await getWorkflowDetailApi(panelState.name);
const json = await res.json();
if (!json.success || !json.data) return false;
const prevStatus = panelState.status;
const prevConfigYaml = panelState.configYaml;
panelState.status = json.data.status || 'idle';
// 始终回填描述(保存时回显,避免 null 覆盖既有描述)。
panelState.description = json.data.description ?? null;
// mountforceConfig=true)或非编辑中(dirty=false)时同步服务端配置到本地。
if (forceConfig || !panelState.dirty) {
panelState.configYaml = json.data.config_yaml || '';
// TLUSTY 生效配置:无 `tlusty:` 块时按旧 `seed_step_fallback` 推断(与服务端
// resolve_tlusty_config 同口径),避免保存时把 `seed_step_fallback: false` 静默改写。
panelState.tlusty = resolveTlustyFromYaml(panelState.configYaml);
const s = parseStageFromYaml(panelState.configYaml, 'synspec_stage');
// 无 synspec_stage 块 → 兜底默认(与服务端 resolve_synspec_config 同口径),
// 避免面板停留在上一次的旧值。
panelState.synspec = s || defaultStage('synspec');
}
if (prevStatus !== panelState.status) {
updateActionButtons();
}
// 2026-08-04 修复:此前 poll 只把服务端新配置同步进 panelState、**不重渲染面板 DOM**
// 用户在 YAML 编辑器保存配置后,面板仍显示挂载时的旧值(必须刷新页面/离开再进才更新)。
// 现检测到服务端配置在非编辑中发生变化 → 用新 panelState 重渲染面板(保持折叠状态)。
if (!forceConfig && !panelState.dirty && prevConfigYaml !== panelState.configYaml) {
rerenderPanel();
}
return true;
}
/**
* 供详情页轮询调用仅在非 dirty 时同步配置状态变化时更新按钮可用性 */
export async function refreshEnginePanel() {
try {
await fetchAndApplyDetail({ forceConfig: false });
} catch (err) {
// 静默:详情页主轮询会处理 404。
}
}
/** panelState DOM/
* 事件委托一次性绑定在挂载容器上wireOnce替换 innerHTML 不丢监听
* 折叠状态/编辑内容由 panelState 承载重渲染天然保持2026-08-04 修复 */
function rerenderPanel() {
const mount = document.getElementById('wf-engine-panel-mount');
if (!mount) return;
mount.innerHTML = panelHtml();
// wireOnce 已 wired,此调用为 no-op(委托监听在容器上,innerHTML 替换后仍有效)。
wireOnce(mount);
}
/** collapsed, isRunning /
* 启动 仅展开时可见暂停 仅运行中或展开时可见折叠空闲态不出现冗余的禁用按钮 */
function syncActionVisibility(panelEl) {
const isRunning = panelState.status === 'running' || panelState.status === 'initializing';
const startBtn = panelEl?.querySelector('[data-engine-action="start"]');
const stopBtn = panelEl?.querySelector('[data-engine-action="stop"]');
const saveBtn = panelEl?.querySelector('[data-engine-action="save"]');
if (startBtn) {
startBtn.classList.toggle('hidden', panelState.collapsed);
startBtn.disabled = isRunning;
}
if (stopBtn) {
stopBtn.classList.toggle('hidden', panelState.collapsed && !isRunning);
stopBtn.disabled = !isRunning;
}
// 保存:折叠态仅在有未保存修改时显示(折叠后无法编辑,无修改时保存无意义);
// 展开态始终显示。保存成功后 dirty=false 时若仍折叠则自动隐藏。
if (saveBtn) saveBtn.classList.toggle('hidden', panelState.collapsed && !panelState.dirty);
}
function updateActionButtons() {
syncActionVisibility(document.querySelector('.wf-engine-panel'));
// 折叠态下切换按钮文案随运行状态联动(空闲「启动配置 ▾」/ 运行中「配置 ▾」)。
const toggleBtn = document.querySelector('[data-engine-action="toggle-config"]');
if (toggleBtn) {
toggleBtn.textContent = toggleBtnLabel();
toggleBtn.title = toggleBtnTitle();
}
}
/**
*
* 修复二次审查 critical旧版的 mounting 守卫会阻塞 mount 自身调用的
* refreshEnginePanelguard 早于 fetch 置位 fetch 被跳过 panelState 永远是默认值
* 面板显示默认配置且 poll refreshEnginePanel 不会重渲染输入控件 用户保存会覆写
* 服务端真实配置 mount 直接调用 fetchAndApplyDetailforceConfig=true
* 不经 refreshEnginePanel故无需 mounting 守卫fetch 成功后单次渲染服务端真实配置 */
export async function mountEnginePanel(name, container) {
panelState.name = name;
panelState.status = 'idle';
panelState.description = null;
panelState.configYaml = '';
panelState.tlusty = defaultStage('tlusty');
panelState.synspec = defaultStage('synspec');
panelState.dirty = false;
panelState.wired = false;
panelState.collapsed = true;
// 先渲染骨架(默认配置),让用户立即看到面板;事件委托绑定一次。
container.innerHTML = panelHtml();
wireOnce(container);
// 直接拉取并填充 panelStateforceConfig=true 覆盖默认值),不依赖 refreshEnginePanel。
try {
await fetchAndApplyDetail({ forceConfig: true });
} catch (err) {
// 拉取失败:保留默认配置骨架,不阻断详情页其余部分;poll 会持续重试。
}
// fetch 完成后单次重建面板内容(reflect 服务端真实配置 + 描述)。
container.innerHTML = panelHtml();
wireOnce(container);
setDirty(false);
}
/** 绑定事件(仅一次)。 */
function wireOnce(container) {
if (panelState.wired) return;
panelState.wired = true;
container.addEventListener('change', handleFieldChange);
container.addEventListener('click', handleClick);
}
/** 卸载:清理引用(容器由详情页 unmount 整体清空,这里只重置状态)。 */
export function unmountEnginePanel() {
panelState.wired = false;
panelState.dirty = false;
}
// 导出纯函数供单元测试(YAML 解析/序列化的注释处理与边界场景回归覆盖)。
// 实际定义在 utils/yamlStage.js;此处仅 re-export 便于按需 import。
export {
defaultStage,
extractTopBlock,
parseStageFromYaml,
serializeStageBlock,
applyStageBlocks,
validateStageConfigs,
} from '../utils/yamlStage.js';
+67 -54
View File
@@ -1,6 +1,60 @@
/* DCTS Dashboard Workflows Component */ /* DCTS Dashboard Workflows Component */
import { escapeHtml } from '../api.js'; import { escapeHtml } from '../utils/format.js';
import { ICONS } from '../utils/icons.js';
/** ///
*
* 卡片下方的YAML配置/启动/暂停/删除按钮已移除 docs/task_engine_decoupling_design.md
* §6.1所有操作启动/停止/删除/保存配置收敛至工作流详情页顶部的内嵌阶段配置面板
* wfEnginePanel.js首页卡片仅作状态概览与入口 */
function buildCardSkeleton({ name, href, desc, statusCn, statusClass, hasProgress, pct, countsText }) {
return `
<a class="wf-card-link" href="${href}" title="进入工作流详情">
<div class="workflow-header">
<div>
<h3 class="workflow-name">${name}</h3>
<p class="workflow-desc">${desc}</p>
</div>
<span class="status-badge ${statusClass}" data-wf-badge>${statusCn}</span>
</div>
<div class="wf-card-progress${hasProgress ? '' : ' hidden'}" data-wf-progress>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" data-wf-fill style="width: ${pct}%"></div>
</div>
<span class="wf-card-counts tabular-num" data-wf-counts>${countsText}</span>
</div>
</a>
`;
}
/** 原位更新卡片易变部分:状态徽章 / 进度条宽度 / 计数文本。 */
function updateCardInPlace(card, { statusCn, statusClass, hasProgress, pct, countsText }) {
// 状态徽章
const badge = card.querySelector('[data-wf-badge]');
if (badge) {
if (badge.textContent !== statusCn) badge.textContent = statusCn;
if (!badge.classList.contains(statusClass)) {
badge.className = `status-badge ${statusClass}`;
badge.setAttribute('data-wf-badge', '');
}
}
// 进度条
const progWrap = card.querySelector('[data-wf-progress]');
if (progWrap) {
const shouldShow = hasProgress;
const isHidden = progWrap.classList.contains('hidden');
if (shouldShow && isHidden) progWrap.classList.remove('hidden');
else if (!shouldShow && !isHidden) progWrap.classList.add('hidden');
if (shouldShow) {
const fill = progWrap.querySelector('[data-wf-fill]');
const wStr = `${pct}%`;
if (fill && fill.style.width !== wStr) fill.style.width = wStr;
const counts = progWrap.querySelector('[data-wf-counts]');
if (counts && counts.textContent !== countsText) counts.textContent = countsText;
}
}
}
export function renderWorkflowsSkeleton() { export function renderWorkflowsSkeleton() {
const container = document.getElementById('workflows-list'); const container = document.getElementById('workflows-list');
@@ -15,9 +69,8 @@ export function renderWorkflowsSkeleton() {
</div> </div>
<div class="skeleton-shimmer skeleton-badge"></div> <div class="skeleton-shimmer skeleton-badge"></div>
</div> </div>
<div class="workflow-actions wf-actions-mt"> <div class="wf-card-progress">
<div class="skeleton-shimmer skeleton-btn"></div> <div class="skeleton-shimmer skeleton-w-full skeleton-h-8"></div>
<div class="skeleton-shimmer skeleton-btn"></div>
</div> </div>
</div> </div>
`).join(''); `).join('');
@@ -35,14 +88,10 @@ export function renderWorkflows(workflows) {
if (wfSub) wfSub.textContent = '暂无已注册工作流'; if (wfSub) wfSub.textContent = '暂无已注册工作流';
const emptyHtml = ` const emptyHtml = `
<div class="empty-state-box"> <div class="empty-state-box">
<svg class="empty-state-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> ${ICONS.layers({ size: 36, cls: 'empty-state-icon' })}
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
<p>暂无恒星大气网格工作流配置</p> <p>暂无恒星大气网格工作流配置</p>
<button type="button" class="btn btn-primary btn-sm" data-wf-action="create-first"> <button type="button" class="btn btn-primary btn-sm" data-wf-action="create-first">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> ${ICONS.plus({ size: 14 })}
注册第一个工作流 注册第一个工作流
</button> </button>
</div> </div>
@@ -91,61 +140,25 @@ export function renderWorkflows(workflows) {
const name = escapeHtml(wf.name); const name = escapeHtml(wf.name);
const href = `#/workflows/${encodeURIComponent(wf.name)}`; const href = `#/workflows/${encodeURIComponent(wf.name)}`;
const desc = escapeHtml(wf.description || '无描述'); const desc = escapeHtml(wf.description || '无描述');
const isRunning = wf.status === 'running';
// 内联进度(来自列表接口内联 stats;未启动的工作流 stats=null → 不渲染) // 内联进度(来自列表接口内联 stats;未启动的工作流 stats=null → 不渲染)
const s = wf.stats; const s = wf.stats;
let progressHtml = ''; const hasProgress = s && s.total > 0;
if (s && s.total > 0) { const pct = hasProgress ? Math.min(100, Math.max(0, Math.round((s.converged / s.total) * 100))) : 0;
const pct = Math.min(100, Math.max(0, Math.round((s.converged / s.total) * 100))); const countsText = hasProgress
progressHtml = ` ? `${s.converged}/${s.total} 收敛 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行`
<div class="wf-card-progress"> : '';
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" style="width: ${pct}%"></div>
</div>
<span class="wf-card-counts tabular-num">${s.converged}/${s.total} 收敛 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行</span>
</div>`;
}
const cardInnerHtml = `
<a class="wf-card-link" href="${href}" title="进入工作流详情">
<div class="workflow-header">
<div>
<h3 class="workflow-name">${name}</h3>
<p class="workflow-desc">${desc}</p>
</div>
<span class="status-badge ${statusClass}">${statusCn}</span>
</div>
${progressHtml}
</a>
<div class="workflow-actions">
<button class="btn btn-secondary btn-sm" data-wf-action="view" data-wf-name="${name}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m16 13-2 2 2 2"/><path d="m8 13 2 2-2 2"/></svg>
YAML 配置
</button>
${
isRunning
? `<button class="btn btn-secondary btn-sm" data-wf-action="stop" data-wf-name="${name}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> 暂停</button>`
: `<button class="btn btn-primary btn-sm" data-wf-action="start" data-wf-name="${name}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> 启动</button>`
}
<button class="btn btn-danger btn-sm" data-wf-action="delete" data-wf-name="${name}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
</div>
`;
let card = existingCards.get(name); let card = existingCards.get(name);
if (card) { if (card) {
if (card.innerHTML.trim() !== cardInnerHtml.trim()) { // 原位更新:状态徽章 / 进度条 / 计数文本——各自 diff,避免整卡 innerHTML 重建
card.innerHTML = cardInnerHtml; updateCardInPlace(card, { statusCn, statusClass, hasProgress, pct, countsText });
}
existingCards.delete(name); existingCards.delete(name);
} else { } else {
card = document.createElement('div'); card = document.createElement('div');
card.className = 'workflow-card'; card.className = 'workflow-card';
card.setAttribute('data-wf-card-name', name); card.setAttribute('data-wf-card-name', name);
card.innerHTML = cardInnerHtml; card.innerHTML = buildCardSkeleton({ name, href, desc, statusCn, statusClass, hasProgress, pct, countsText });
} }
updatedCards.push(card); updatedCards.push(card);
}); });
+43 -23
View File
@@ -13,20 +13,14 @@
import { getWorkflowDetailApi, updateWorkflowApi } from '../api.js'; import { getWorkflowDetailApi, updateWorkflowApi } from '../api.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './modal.js'; import { showConfirm, setupFocusTrap, hideModal } from './modal.js';
import { fetchAllData } from '../state.js'; import { fetchAllData, isAppPolling } from '../state.js';
import { statusBadge } from '../utils/format.js';
import { friendlyError } from '../utils/errors.js';
const LOCKED_STATUSES = ['running', 'initializing']; const LOCKED_STATUSES = ['running', 'initializing'];
const RESET_WARN_STATUSES = ['paused', 'completed']; const RESET_WARN_STATUSES = ['paused', 'completed'];
const STATUS_META = {
running: ['运行中', 'online'],
completed: ['已完成', 'completed'],
initializing: ['初始化中', 'warning'],
paused: ['已暂停', 'secondary'],
idle: ['闲置', 'secondary'],
};
const editorState = { const editorState = {
name: '', name: '',
description: '', description: '',
@@ -35,6 +29,8 @@ const editorState = {
dirty: false, dirty: false,
mode: 'view', mode: 'view',
wired: false, wired: false,
// 保存成功后的回调(由调用方注入,如引擎面板传 refreshEnginePanel 以立即同步配置)。
onSaved: null,
}; };
// ===== 语法高亮(轻量 YAML 分词:键/数字/布尔/字符串/注释/列表符) ===== // ===== 语法高亮(轻量 YAML 分词:键/数字/布尔/字符串/注释/列表符) =====
@@ -136,11 +132,6 @@ function renderYamlView(container, text) {
// ===== 面板填充与模式切换 ===== // ===== 面板填充与模式切换 =====
function statusBadge(status) {
const [cn, cls] = STATUS_META[status] || ['闲置', 'secondary'];
return `<span class="status-badge ${cls}">${cn}</span>`;
}
function setDirty(dirty) { function setDirty(dirty) {
editorState.dirty = dirty; editorState.dirty = dirty;
document.getElementById('yaml-editor-dirty')?.classList.toggle('hidden', !dirty); document.getElementById('yaml-editor-dirty')?.classList.toggle('hidden', !dirty);
@@ -223,10 +214,8 @@ function hideEditError() {
} }
function closeModal() { function closeModal() {
const modal = document.getElementById('modal-view-wf'); // 走统一的带动画关闭(modal.js hideModal)。
if (!modal) return; hideModal(document.getElementById('modal-view-wf'));
modal.classList.add('hidden');
releaseFocusTrap(modal);
} }
/** 带脏状态守卫的关闭:编辑中且有未保存修改时先确认放弃。 */ /** 带脏状态守卫的关闭:编辑中且有未保存修改时先确认放弃。 */
@@ -238,6 +227,32 @@ async function requestClose() {
closeModal(); closeModal();
} }
// ===== 导出配置文件 =====
function exportConfig() {
const area = document.getElementById('yaml-edit-area');
const content = editorState.mode === 'edit' && area ? area.value : editorState.original;
if (!content || !content.trim()) {
showToast('配置内容为空,无法导出', 'warning');
return;
}
const filename = `${editorState.name || 'workflow'}.yaml`;
try {
const blob = new Blob([content], { type: 'text/yaml;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`成功导出工作流配置文件 ${filename}`, 'success');
} catch (err) {
showToast(`导出文件失败:${friendlyError(err, '文件生成异常')}`, 'error');
}
}
// ===== 保存 ===== // ===== 保存 =====
async function saveConfig() { async function saveConfig() {
@@ -267,12 +282,15 @@ async function saveConfig() {
switchToView(); switchToView();
const statusEl = document.getElementById('yaml-editor-status'); const statusEl = document.getElementById('yaml-editor-status');
if (statusEl) statusEl.innerHTML = statusBadge(editorState.status); if (statusEl) statusEl.innerHTML = statusBadge(editorState.status);
fetchAllData(); if (isAppPolling()) fetchAllData();
// 2026-08-04 修复:通知调用方(引擎面板)立即拉取并重渲染新配置,
// 否则面板显示挂载时的旧配置、需手动刷新页面才更新。
editorState.onSaved?.();
} else { } else {
showEditError(json.message || `保存失败(HTTP ${res.status}`); showEditError(json.message || `保存失败(HTTP ${res.status}`);
} }
} catch (err) { } catch (err) {
showEditError(`请求异常:${err.message}`); showEditError(`请求异常:${friendlyError(err, '保存请求失败')}`);
} finally { } finally {
if (btnSave) btnSave.disabled = false; if (btnSave) btnSave.disabled = false;
} }
@@ -286,6 +304,7 @@ function wireOnce() {
const modal = document.getElementById('modal-view-wf'); const modal = document.getElementById('modal-view-wf');
if (!modal) return; if (!modal) return;
document.getElementById('btn-export-yaml')?.addEventListener('click', exportConfig);
document.getElementById('btn-edit-yaml')?.addEventListener('click', switchToEdit); document.getElementById('btn-edit-yaml')?.addEventListener('click', switchToEdit);
document.getElementById('btn-close-view-wf')?.addEventListener('click', requestClose); document.getElementById('btn-close-view-wf')?.addEventListener('click', requestClose);
document.getElementById('close-view-wf')?.addEventListener('click', requestClose); document.getElementById('close-view-wf')?.addEventListener('click', requestClose);
@@ -339,7 +358,7 @@ function wireOnce() {
// ===== 入口 ===== // ===== 入口 =====
export async function openYamlEditor(name) { export async function openYamlEditor(name, onSaved) {
try { try {
const res = await getWorkflowDetailApi(name); const res = await getWorkflowDetailApi(name);
const json = await res.json(); const json = await res.json();
@@ -352,6 +371,7 @@ export async function openYamlEditor(name) {
editorState.description = data.description || ''; editorState.description = data.description || '';
editorState.status = data.status; editorState.status = data.status;
editorState.original = data.config_yaml || ''; editorState.original = data.config_yaml || '';
editorState.onSaved = onSaved || null;
wireOnce(); wireOnce();
@@ -365,6 +385,6 @@ export async function openYamlEditor(name) {
modal?.classList.remove('hidden'); modal?.classList.remove('hidden');
setupFocusTrap(modal); setupFocusTrap(modal);
} catch (err) { } catch (err) {
showToast(`请求异常: ${err.message}`, 'error'); showToast(`请求异常: ${friendlyError(err, '获取配置失败')}`, 'error');
} }
} }
+83 -12
View File
@@ -16,9 +16,10 @@ import {
} from './api.js'; } from './api.js';
import { showToast } from './components/toast.js'; import { showToast } from './components/toast.js';
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './components/modal.js'; import { showConfirm, setupFocusTrap, hideModal } from './components/modal.js';
import { fetchAllData } from './state.js'; import { fetchAllData } from './state.js';
import { triggerRefreshAnimation } from './utils.js'; import { triggerRefreshAnimation } from './utils.js';
import { friendlyError } from './utils/errors.js';
import { startRouter } from './router.js'; import { startRouter } from './router.js';
const defaultYamlConfig = `# DCTS 恒星大气网格配置 (GridConfig) const defaultYamlConfig = `# DCTS 恒星大气网格配置 (GridConfig)
@@ -62,6 +63,43 @@ function showLoginError(msg) {
} }
} }
// 工作流名称规则与服务端 is_valid_workflow_name 对齐:字母数字与 . _ -,长度 ≤64。
const WF_NAME_RE = /^[A-Za-z0-9._-]+$/;
/** inline-validationblur
* 返回 true 表示通过showError=false 时仅静默返回结果用于提交前判断 */
function validateWfName(showError = true) {
const input = document.getElementById('input-wf-name');
const errEl = document.getElementById('wf-name-error');
if (!input) return true;
const v = input.value.trim();
let msg = '';
if (!v) msg = '工作流名称不能为空';
else if (v.length > 64) msg = '名称最长 64 字符';
else if (!WF_NAME_RE.test(v)) msg = '仅支持字母、数字、点、下划线与连字符';
if (showError) {
input.classList.toggle('input-invalid', !!msg);
if (errEl) {
errEl.textContent = msg;
errEl.classList.toggle('hidden', !msg);
}
}
return !msg;
}
/** 清空创建工作流表单的校验态(打开弹窗时调用)。 */
function resetCreateWfValidation() {
const input = document.getElementById('input-wf-name');
const errEl = document.getElementById('wf-name-error');
const yamlInput = document.getElementById('input-wf-yaml');
input?.classList.remove('input-invalid');
yamlInput?.classList.remove('input-invalid');
if (errEl) {
errEl.textContent = '';
errEl.classList.add('hidden');
}
}
async function handleLoginSubmit(password) { async function handleLoginSubmit(password) {
const errorEl = document.getElementById('login-error-msg'); const errorEl = document.getElementById('login-error-msg');
const btnSubmit = document.getElementById('btn-submit-login'); const btnSubmit = document.getElementById('btn-submit-login');
@@ -91,7 +129,7 @@ async function handleLoginSubmit(password) {
showLoginError(errMsg); showLoginError(errMsg);
} }
} catch (err) { } catch (err) {
showLoginError(`登录请求异常:${err.message}`); showLoginError(`登录请求异常:${friendlyError(err, '登录请求异常')}`);
} finally { } finally {
if (btnSubmit) btnSubmit.disabled = false; if (btnSubmit) btnSubmit.disabled = false;
} }
@@ -104,15 +142,15 @@ document.addEventListener('DOMContentLoaded', async () => {
const modalReissueToken = document.getElementById('modal-reissue-token'); const modalReissueToken = document.getElementById('modal-reissue-token');
function closeModal(modal) { function closeModal(modal) {
if (!modal) return; // 走带动画的统一关闭(modal.js hideModal):先播退出动画再隐藏并释放焦点陷阱。
modal.classList.add('hidden'); hideModal(modal);
releaseFocusTrap(modal);
} }
btnCreateWf?.addEventListener('click', () => { btnCreateWf?.addEventListener('click', () => {
document.getElementById('input-wf-name').value = ''; document.getElementById('input-wf-name').value = '';
document.getElementById('input-wf-desc').value = ''; document.getElementById('input-wf-desc').value = '';
document.getElementById('input-wf-yaml').value = defaultYamlConfig; document.getElementById('input-wf-yaml').value = defaultYamlConfig;
resetCreateWfValidation();
modalCreateWf?.classList.remove('hidden'); modalCreateWf?.classList.remove('hidden');
setupFocusTrap(modalCreateWf); setupFocusTrap(modalCreateWf);
}); });
@@ -143,6 +181,11 @@ document.addEventListener('DOMContentLoaded', async () => {
closeModal(modalReissueToken); closeModal(modalReissueToken);
}); });
// 节点详情管理 Modal 关闭(背景点击 / Escape 由全局监听统一处理,此处只接关闭按钮)
const modalNodeManage = document.getElementById('modal-node-manage');
document.getElementById('node-manage-close-btn')?.addEventListener('click', () => closeModal(modalNodeManage));
document.getElementById('btn-close-node-manage')?.addEventListener('click', () => closeModal(modalNodeManage));
// 点击 Backdrop 背景关闭弹窗(YAML 编辑器自带脏守卫关闭,除外) // 点击 Backdrop 背景关闭弹窗(YAML 编辑器自带脏守卫关闭,除外)
document.querySelectorAll('.login-overlay, .modal-backdrop').forEach(overlay => { document.querySelectorAll('.login-overlay, .modal-backdrop').forEach(overlay => {
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
@@ -167,17 +210,34 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
}); });
// 创建工作流:名称 blur 内联校验 + 输入时实时复检(仅已处于错误态时)
const wfNameInput = document.getElementById('input-wf-name');
wfNameInput?.addEventListener('blur', () => validateWfName(true));
wfNameInput?.addEventListener('input', () => {
if (wfNameInput.classList.contains('input-invalid')) validateWfName(true);
});
// 创建工作流提交处理 // 创建工作流提交处理
document.getElementById('form-create-wf')?.addEventListener('submit', async (e) => { document.getElementById('form-create-wf')?.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const name = document.getElementById('input-wf-name').value.trim(); const nameInput = document.getElementById('input-wf-name');
const yamlInput = document.getElementById('input-wf-yaml');
const name = nameInput.value.trim();
const description = document.getElementById('input-wf-desc').value.trim(); const description = document.getElementById('input-wf-desc').value.trim();
const config_yaml = document.getElementById('input-wf-yaml').value; const config_yaml = yamlInput.value;
if (!name || !config_yaml) { // 逐字段校验并聚焦第一个无效字段(focus-management
showToast('请填写工作流名称及 YAML 配置内容!', 'error'); if (!validateWfName(true)) {
nameInput?.focus();
return; return;
} }
if (!config_yaml.trim()) {
yamlInput?.classList.add('input-invalid');
showToast('请填写 YAML 配置内容,或点「填入默认示例」', 'error');
yamlInput?.focus();
return;
}
yamlInput?.classList.remove('input-invalid');
try { try {
const res = await createWorkflow({ name, description, config_yaml }); const res = await createWorkflow({ name, description, config_yaml });
@@ -190,7 +250,7 @@ document.addEventListener('DOMContentLoaded', async () => {
showToast(`保存失败: ${json.message}`, 'error'); showToast(`保存失败: ${json.message}`, 'error');
} }
} catch (err) { } catch (err) {
showToast(`请求异常: ${err.message}`, 'error'); showToast(`请求异常: ${friendlyError(err, '保存请求失败')}`, 'error');
} }
}); });
@@ -208,9 +268,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// header 全局刷新 & 登出 // header 全局刷新 & 登出
document.getElementById('refresh-btn')?.addEventListener('click', (e) => { document.getElementById('refresh-btn')?.addEventListener('click', (e) => {
triggerRefreshAnimation(e.currentTarget);
showToast('正在刷新集群全量数据...', 'info'); showToast('正在刷新集群全量数据...', 'info');
fetchAllData(); triggerRefreshAnimation(e.currentTarget, () => fetchAllData());
}); });
document.getElementById('btn-logout')?.addEventListener('click', async () => { document.getElementById('btn-logout')?.addEventListener('click', async () => {
@@ -259,6 +318,18 @@ document.addEventListener('DOMContentLoaded', async () => {
handleLoginSubmit(pass); handleLoginSubmit(pass);
}); });
// 密码显示/隐藏切换
document.querySelector('.btn-toggle-password')?.addEventListener('click', () => {
const input = document.getElementById('input-admin-token');
const btn = document.querySelector('.btn-toggle-password');
if (!input || !btn) return;
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
btn.setAttribute('aria-label', show ? '隐藏密码' : '显示密码');
btn.querySelector('.icon-eye-open')?.classList.toggle('hidden', show);
btn.querySelector('.icon-eye-closed')?.classList.toggle('hidden', !show);
});
// 初始化鉴权判断:校验 sessionStorage 里的 token 是否仍被服务端认可。 // 初始化鉴权判断:校验 sessionStorage 里的 token 是否仍被服务端认可。
// 旧实现仅凭 token 存在就直接进入,过期/被淘汰的 token 会"假登录"一瞬后被轮询 401 闪回。 // 旧实现仅凭 token 存在就直接进入,过期/被淘汰的 token 会"假登录"一瞬后被轮询 401 闪回。
// 现改为先调 /api/auth/check 验证,失败则清 token 显示登录遮罩,成功才 startRouter。 // 现改为先调 /api/auth/check 验证,失败则清 token 显示登录遮罩,成功才 startRouter。
+37 -7
View File
@@ -4,13 +4,21 @@
* 视图挂载/卸载成对调用各视图在 mount 里启动自己的轮询 unmount 里彻底清理 * 视图挂载/卸载成对调用各视图在 mount 里启动自己的轮询 unmount 里彻底清理
* 保证切换视图不留定时器/监听器泄漏登录门禁 token 时不渲染任何视图 * 保证切换视图不留定时器/监听器泄漏登录门禁 token 时不渲染任何视图
* 登录遮罩由 main.js 管理登录成功后调用 startRouter() * 登录遮罩由 main.js 管理登录成功后调用 startRouter()
*
* 路由级代码分割bundle-splitting首页与工作流详情页 Parallel Sets 收敛
* 分析体积最大改为动态 import 按需加载首次进入首页不再下载/解析详情页代码
* 模块引用在首次加载后缓存后续切换零开销
*/ */
import { getAdminToken } from './api.js'; import { getAdminToken } from './api.js';
import { mountHome, unmountHome } from './views/home.js';
import { mountWorkflowDetail, unmountWorkflowDetail } from './views/workflowDetail.js';
let current = null; // { view: 'home' | 'workflow', name?: string } let current = null; // { view: 'home' | 'workflow', name?: string }
let savedScrollY = 0; // 视图切换时保留滚动位置
let renderToken = 0; // 渲染竞态守卫:快速连续 hashchange 时只有最后一次生效
// 动态加载的视图模块缓存(首次 import 后复用)。
let homeMod = null;
let detailMod = null;
export function parseHash() { export function parseHash() {
const h = location.hash.replace(/^#/, ''); const h = location.hash.replace(/^#/, '');
@@ -30,18 +38,40 @@ export function parseHash() {
return { view: 'home' }; return { view: 'home' };
} }
function render() { async function render() {
const token = ++renderToken;
if (!getAdminToken()) return; if (!getAdminToken()) return;
const next = parseHash(); const next = parseHash();
if (current && current.view === next.view && current.name === next.name) return; if (current && current.view === next.view && current.name === next.name) return;
// 离开首页时保存滚动位置,返回时恢复
if (current?.view === 'home') savedScrollY = window.scrollY;
if (current) { if (current) {
if (current.view === 'home') unmountHome(); if (current.view === 'home') homeMod?.unmountHome();
else unmountWorkflowDetail(); else detailMod?.unmountWorkflowDetail();
} }
if (next.view === 'workflow') mountWorkflowDetail(next.name);
else mountHome(); if (next.view === 'workflow') {
if (!detailMod) detailMod = await import('./views/workflowDetail.js');
if (token !== renderToken) return; // 加载期间又发生导航,放弃本次渲染
// 先把 current 标记为 nextmount 是 async(含网络 fetch),期间若发生二次导航,
// 其 render 会据此 unmount 正在挂载的视图(unmountWorkflowDetail 幂等),
// 避免 ctx/abort 句柄泄漏与双轮询(二次审查 major 修复)。
current = next; current = next;
await detailMod.mountWorkflowDetail(next.name);
if (token !== renderToken) return; // mount 期间又发生导航,放弃后续(滚动恢复等)
} else {
if (!homeMod) homeMod = await import('./views/home.js');
if (token !== renderToken) return;
current = next;
homeMod.mountHome();
}
// 回到首页时恢复滚动位置
if (next.view === 'home' && savedScrollY > 0) {
requestAnimationFrame(() => window.scrollTo(0, savedScrollY));
}
} }
/** 注册 hashchange 并按当前 hash 首次渲染(登录成功后调用)。 */ /** 注册 hashchange 并按当前 hash 首次渲染(登录成功后调用)。 */
+19 -48
View File
@@ -1,65 +1,36 @@
/* DCTS Dashboard Application State & Polling Scheduler */ /* DCTS Dashboard Application State & Polling Scheduler
*
* 首页全局轮询5s 基准指数退避上限 60sTab 切入后台自动暂停
* 均由 utils/polling.js createPoller 统一承担与详情页作用域轮询共用一套实现
*/
import { fetchClusterStatus, fetchWorkflowsList, fetchNodesList } from './api.js'; import { fetchClusterStatus, fetchWorkflowsList, fetchNodesList } from './api.js';
import { renderNodesTable } from './components/nodesTable.js'; import { renderNodesTable } from './components/nodesTable.js';
import { renderWorkflows } from './components/workflows.js'; import { renderWorkflows } from './components/workflows.js';
import { createPoller } from './utils/polling.js';
import { logError } from './utils/errors.js';
let isPolling = false;
let pollTimer = null;
// 连续失败退避计数:服务端持续不可达时拉长轮询间隔(指数退避,上限 60s),
// 避免空轮询刷请求;恢复正常即重置为基准 5s。
let pollFailCount = 0;
// 最近一次 cluster 状态拉取是否成功(由 updateServerStatus 维护,供 fetchAllData 判定退避)。
let serverOnline = false; let serverOnline = false;
// 监听浏览器 Tab 标签页切换,切入后台时暂停轮询,切回前台时恢复并立即刷新 // 全局轮询:fetchAllData 返回布尔(以 cluster 状态能否获取为整体健康判定)。
document.addEventListener('visibilitychange', () => { const poller = createPoller(fetchAllData, { baseMs: 5000, maxMs: 60000 });
if (document.hidden) {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
} else if (isPolling) {
scheduleFetchData();
}
});
export function isAppPolling() { export function isAppPolling() {
return isPolling; return poller.isRunning();
} }
export function startPolling() { export function startPolling() {
if (isPolling) return; poller.start();
isPolling = true;
scheduleFetchData();
} }
export function stopPolling() { export function stopPolling() {
isPolling = false; poller.stop();
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
} }
export async function scheduleFetchData() { /** 手动立即触发一次全量拉取(兼容旧调用方:header 刷新 / 建工作流后 / wfActions 已改由
if (pollTimer) { * isAppPolling 守卫本函数保留给需要无条件刷新的调用点 */
clearTimeout(pollTimer); export function scheduleFetchData() {
pollTimer = null; poller.triggerNow();
}
if (document.hidden) return;
const ok = await fetchAllData();
if (ok) {
pollFailCount = 0;
} else {
pollFailCount = Math.min(pollFailCount + 1, 4);
}
// 指数退避:5s * 2^failCountclamp 到 [5s, 60s]
const delay = Math.min(5000 * Math.pow(2, pollFailCount), 60000);
if (isPolling && !document.hidden) {
pollTimer = setTimeout(scheduleFetchData, delay);
}
} }
// 返回主状态拉取是否成功(用于轮询退避计数)。三项并行拉取, // 返回主状态拉取是否成功(用于轮询退避计数)。三项并行拉取,
@@ -80,7 +51,7 @@ export async function fetchStatus() {
updateUI(data); updateUI(data);
updateServerStatus(true); updateServerStatus(true);
} catch (err) { } catch (err) {
console.warn('获取 DCTS 状态失败:', err); logError(err, '获取 DCTS 状态失败');
updateServerStatus(false); updateServerStatus(false);
} }
} }
@@ -92,7 +63,7 @@ export async function fetchWorkflows() {
renderWorkflows(json.data); renderWorkflows(json.data);
} }
} catch (err) { } catch (err) {
console.warn('获取工作流列表失败:', err); logError(err, '获取工作流列表失败');
} }
} }
@@ -103,7 +74,7 @@ export async function fetchNodes() {
renderNodesTable(json.data); renderNodesTable(json.data);
} }
} catch (err) { } catch (err) {
console.warn('获取计算节点列表失败:', err); logError(err, '获取计算节点列表失败');
} }
} }
+642 -110
View File
File diff suppressed because it is too large Load Diff
+21 -6
View File
@@ -1,16 +1,31 @@
/* DCTS 前端小工具 */ /* DCTS 前端小工具 */
/** /**
* 刷新按钮旋转动画svg .spin 800ms 后恢复并解禁按钮 * 刷新按钮旋转动画svg .spin 并禁用按钮**等待传入的异步任务真正完成**
* header 全局刷新与首页节点面板刷新共用 * 或失败后才停转解禁旧版固定 800ms 后解禁与网络实际耗时脱钩慢网时
* 按钮已可再点但数据未到快网时数据已到按钮仍空转现以 asyncFn 的生命周期为准
*
* @param {HTMLElement} btn 触发按钮内含待旋转的 svg
* @param {() => Promise<unknown>} [asyncFn] 可选的异步任务缺省时回退到最短 600ms 的视觉反馈
*/ */
export function triggerRefreshAnimation(btn) { export async function triggerRefreshAnimation(btn, asyncFn) {
if (!btn) return; if (!btn) {
if (asyncFn) await asyncFn();
return;
}
const svg = btn.querySelector('svg'); const svg = btn.querySelector('svg');
if (svg) svg.classList.add('spin'); if (svg) svg.classList.add('spin');
btn.disabled = true; btn.disabled = true;
setTimeout(() => { // 最短旋转时长:避免极快请求下动画一闪而过(也避免 0ms 状态突变)。
const minSpin = new Promise((r) => setTimeout(r, 600));
try {
if (asyncFn) {
await Promise.all([Promise.resolve(asyncFn()), minSpin]);
} else {
await minSpin;
}
} finally {
if (svg) svg.classList.remove('spin'); if (svg) svg.classList.remove('spin');
btn.disabled = false; btn.disabled = false;
}, 800); }
} }
+125
View File
@@ -0,0 +1,125 @@
/* DCTS
*
* 历史上各 catch 块都 toast(`失败:${err.message}`),不区分网络/业务/服务端,
* 用户体验差(网络挂起时提示undefined 失败)本模块按错误来源分类,
* 给出对应的中文提示与重试建议
*
* 错误分类:
* - network: fetch 抛错(AbortError/TypeError),通常是断网/超时/DNS
* - auth: HTTP 401(已在 apiFetch 顶层处理,这里仅归类)
* - business: HTTP 4xx( 401),服务端返回 json.message
* - server: HTTP 5xx,服务端异常
* - unknown: 其他
*/
/**
* @param {Error} [err] catch 块的 error 对象
* @param {Response} [res] fetch Response(可能为 null,如网络层抛错)
* @param {object} [json] 解析后的响应体(可能为 null)
* @returns {{kind: 'network'|'auth'|'business'|'server'|'unknown', message: string, retryable: boolean, httpStatus: number|null}}
*/
export function classifyError(err, res, json) {
const httpStatus = res ? res.status : null;
// 网络层错误:fetch 直接抛错(AbortError/超时/TypeError 断网)
if (!res && err) {
const isAbort = err.name === 'AbortError';
return {
kind: 'network',
message: isAbort ? '请求已取消' : (err.message || '网络请求失败'),
retryable: !isAbort,
httpStatus: null,
};
}
if (httpStatus === 401) {
return { kind: 'auth', message: '会话已过期,请重新登录', retryable: false, httpStatus };
}
if (httpStatus >= 500 && httpStatus < 600) {
return {
kind: 'server',
message: json?.message || `服务端异常 (HTTP ${httpStatus})`,
retryable: true,
httpStatus,
};
}
if (httpStatus >= 400 && httpStatus < 500) {
return {
kind: 'business',
message: json?.message || `请求失败 (HTTP ${httpStatus})`,
retryable: false,
httpStatus,
};
}
// res.ok 但走到了 catch,或 res 为 undefined 且无 err:未知
return {
kind: 'unknown',
message: err?.message || json?.message || '未知错误',
retryable: false,
httpStatus,
};
}
/** 是否为「主动取消」类错误(AbortError),这类错误应静默,不提示用户。 */
export function isAbortError(err) {
return err && err.name === 'AbortError';
}
/**
* 面向用户的轻量错误文案(catch 块只有 err res/json 时用)
* 网络层 fetch TypeError('Failed to fetch'),直接展示原文对用户无意义,
* 映射为可读提示;其余保留 err.message,缺省回退 fallback
* @param {Error} [err]
* @param {string} [fallback='请求失败']
* @returns {string}
*/
export function friendlyError(err, fallback = '请求失败') {
if (!err) return fallback;
if (err instanceof TypeError) {
// fetch 网络失败在 Chromium/Firefox 的标准文案是 "Failed to fetch"(无有用信息),
// 映射为可读提示;其余 TypeError 多为代码缺陷(如对已解析对象再调 .json()),
// 保留真实 message,避免用「网络连接失败」掩盖代码 bug(2026-08 节点管理误报排查)。
if (!err.message || err.message === 'Failed to fetch') {
return '网络连接失败,请检查服务端是否在线后重试';
}
return err.message || fallback;
}
return err.message || fallback;
}
/**
* 统一 console 出口生产构建可在 vite 配置中 drop console,这里集中便于 strip
* AbortError 静默不打扰(多为视图卸载主动 abort)
* @param {Error} [err]
* @param {string} [context] 上下文描述,获取工作流统计
*/
export function logError(err, context = '') {
if (isAbortError(err)) return;
const prefix = context ? `[${context}]` : '[DCTS]';
// eslint-disable-next-line no-console
console.warn(prefix, err);
}
/**
* 生成面向用户的错误提示文案(配合 toast 使用)
* @param {{kind: string, message: string, retryable: boolean}} info classifyError 的返回值
* @param {string} [action] 操作描述,启动工作流保存配置
* @returns {string}
*/
export function userMessage(info, action = '操作') {
switch (info.kind) {
case 'network':
return `${action}失败:网络异常,请检查连接后重试`;
case 'auth':
return info.message; // 401 已由 apiFetch 顶层处理,这里仅兜底
case 'server':
return `${action}失败:服务端异常,请稍后重试`;
case 'business':
return info.message; // 后端业务错误,直接显示 message
default:
return `${action}失败:${info.message}`;
}
}
+157
View File
@@ -0,0 +1,157 @@
/* DCTS
*
* 集中所有纯函数HTML 转义时间/时长格式化状态徽章
* 历史上 escapeHtml 定义在 api.js fmt* 散落在 workflowDetail.js改一处要全局搜
* 现统一到本模块api.js 通过 re-export 保持向后兼容
*
* SQLite 约定datetime('now') 形如 'YYYY-MM-DD HH:MM:SS'UTC无时区标记
* 解析前需规范化为 ISO所有时间解析函数共用 parseIso/parseTsMs
*
* 语义与后端契约对齐crates/server/src/db.rs
* - 网格点状态pending/queued/running/converged/failed/canceled
* - success_method收敛途径cold_run / seed_step
*/
/**
* 安全 HTML 转义防止存储型 XSS
* 顺序敏感& 必须先转义否则后续转义产生的 &amp; 会被二次转义
* @param {unknown} s
* @returns {string}
*/
export function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 规范化为 Date(解析失败返回 null)。SQLite 'YYYY-MM-DD HH:MM:SS' 视为 UTC。 */
export function parseIso(iso) {
if (!iso) return null;
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
const d = new Date(normalized);
return Number.isNaN(d.getTime()) ? null : d;
}
/** 同 parseIso 但返回 epoch 毫秒(解析失败为 NaN)。供进度曲线按真实时间铺横轴。 */
export function parseTsMs(iso) {
if (!iso) return NaN;
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
return new Date(normalized).getTime();
}
/** SQLite datetime('now') 为 'YYYY-MM-DD HH:MM:SS'UTC 无时区标记),规范化后解析。 */
export function fmtTime(iso) {
if (!iso) return '—';
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) return escapeHtml(iso);
return d.toLocaleTimeString('zh-CN');
}
/** MM-DD HH:MM
* 隐去年份与秒活跃网格数据不跨年秒级精度放 title定宽 11 字符列内对齐 */
export function fmtDateTime(iso) {
const d = parseIso(iso);
if (!d) return iso ? escapeHtml(iso) : '—';
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 完整时间戳「YYYY-MM-DD HH:MM:SS」,供单元格 title 悬停查看精确时刻。 */
export function fmtFullTs(iso) {
const d = parseIso(iso);
if (!d) return iso || '';
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/** 工作流级时长格式化:「N 分钟」或「N 小时 M 分」(控制条 ETA 用)。null/非有限值/≤0 返回占位符。 */
export function fmtDuration(sec) {
// ≤0 视为无数据(与 fmtDur 同口径):失败任务上报的 last_elapsed_sec 可能是 0.0
// runner 无 summary 时),显示"0 分钟"会误导,应展示占位符。
if (sec == null || !Number.isFinite(sec) || Number(sec) <= 0) return '—';
const m = Math.round(sec / 60);
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
return `${h} 小时 ${m % 60}`;
}
/** 网格点级紧凑时长(表格单元格用):42s / 12m 30s / 1h 20m
* 0 视为无数据tlusty 单点不可能 0 秒完成 */
export function fmtDur(sec) {
if (sec == null || sec === '') return '—';
const n = Number(sec);
if (!Number.isFinite(n) || n <= 0) return '—';
const s = Math.round(n);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${s % 60 ? ` ${s % 60}s` : ''}`;
const h = Math.floor(m / 60);
return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`;
}
/** 相对收敛度指数记数(点表/尝试表 max_relc 单元格)。空值/非法值返回占位符。 */
export function fmtRelc(v) {
if (v == null || v === '') return '—';
const n = Number(v);
return Number.isFinite(n) ? n.toExponential(2) : '—';
}
/** CSV 字段转义:含逗号/引号/换行时用双引号包裹并翻倍内部引号。 */
export function csvEscape(v) {
const s = String(v ?? '');
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
/** 心跳时间容错:字段缺失或非法时间戳一律显示占位符,避免渲染出 "Invalid Date"。 */
export function fmtClock(ts) {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('zh-CN');
}
// ===== 状态徽章(返回 HTML 字符串,供 innerHTML 注入) =====
/** 工作流状态映射:[中文标签, badge class]。后端可发 idle/initializing/running/paused/completed。 */
export const STATUS_MAP = {
running: ['运行中', 'online'],
completed: ['已完成', 'completed'],
initializing: ['初始化中', 'warning'],
paused: ['已暂停', 'secondary'],
idle: ['闲置', 'secondary'],
};
/** 工作流状态徽章 HTML。 */
export function statusBadge(status) {
const [cn, cls] = STATUS_MAP[status] || ['闲置', 'secondary'];
return `<span class="status-badge ${cls}">${cn}</span>`;
}
/** 网格点状态映射:[中文标签, badge class]。与后端 grid_points.status 对齐。 */
export const POINT_STATUS_MAP = {
converged: ['已收敛', 'online'],
failed: ['失败', 'danger'],
running: ['运行中', 'warning'],
queued: ['排队中', 'info'],
pending: ['未开始', 'secondary'],
};
/** 网格点状态徽章 HTML。 */
export function pointStatusBadge(status) {
const [cn, cls] = POINT_STATUS_MAP[status] || ['未知', 'secondary'];
return `<span class="status-badge ${cls}">${cn}</span>`;
}
/** 网格点收敛途径徽章 HTMLsuccess_methodcold_run / seed_step / 策略名)。 */
export function pointMethodBadge(method) {
if (method === 'cold_run') return '<span class="method-badge cold">冷启动</span>';
if (method === 'seed_step') return '<span class="method-badge seed">种子步进</span>';
// 其它策略名(如 synspec-only 任务归因的 "standard")原样展示,让光谱归因可见。
if (method) return `<span class="method-badge syn">${escapeHtml(method)}</span>`;
return '<span class="text-hint">—</span>';
}
+82
View File
@@ -0,0 +1,82 @@
/* DCTS
*
* 统一管理 JS 字符串模板里用到的内联 SVG,消除跨文件复制粘贴
* 设计:每个图标是一个 (opts?) => string 的函数,返回完整 <svg> 字符串
* ICONS.trash() 默认尺寸/线宽
* ICONS.trash({ size: 14 }) 自定义尺寸
* ICONS.check({ cls: 'x' }) 附加 class
*
* :index.html 中被 JS 通过 class 查询的静态 SVG(logo / eye / sun / moon /
* metric icons / modal close)不在此处管理它们是静态 HTML, JS 模板注入
*/
/**
* 包裹 SVG inner 为完整标签字符串
* @param {string} inner SVG 子元素 markup
* @param {object} [o]
* @param {number} [o.size=16] width/height
* @param {number} [o.sw=2] stroke-width
* @param {string} [o.cls] 附加 class
* @param {boolean} [o.linecap=true] 是否加 stroke-linecap/linejoin=round
* @param {boolean} [o.fill=false] 是否用 currentColor 填充(实心图标如三点菜单)
*/
function svg(inner, { size = 16, sw = 2, cls = '', linecap = true, fill = false } = {}) {
const lc = linecap ? ' stroke-linecap="round" stroke-linejoin="round"' : '';
const c = cls ? ` class="${cls}"` : '';
const f = fill ? ' fill="currentColor"' : ' fill="none"';
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" ${f} stroke="currentColor" stroke-width="${sw}"${lc}${c} aria-hidden="true">${inner}</svg>`;
}
export const ICONS = {
// ===== 通用 =====
/** 勾选(nodesTable 审批通过) */
check: (o) => svg(`<polyline points="20 6 9 17 4 12"/>`, { sw: 2.5, ...o }),
/** 关闭叉(modal/toast 关闭、节点拒绝) */
x: (o) => svg(`<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`, { sw: 2.2, ...o }),
/** 信息圆圈(toast info) */
info: (o) => svg(`<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>`, { sw: 2.5, ...o }),
/** 错误圆圈(toast error) */
alert: (o) => svg(`<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>`, { sw: 2.5, ...o }),
/** 圆中带勾(toast success、指标卡「已计算」) */
checkCircle: (o) => svg(`<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>`, { sw: 2.5, ...o }),
/** 警告三角(YAML 重置警示) */
warn: (o) => svg(`<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`, { sw: 2.5, ...o }),
/** 加号(创建工作流、空状态注册) */
plus: (o) => svg(`<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>`, { ...o }),
/** 刷新(header 刷新、节点重发 Token) */
refresh:(o) => svg(`<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>`, { ...o }),
/** 返回箭头(详情页返回控制台) */
back: (o) => svg(`<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>`, { ...o }),
/** 下载/导出(点表导出 CSV) */
download: (o) => svg(`<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>`, { ...o }),
// ===== 工作流操作 =====
/** 启动/播放 */
start: (o) => svg(`<polygon points="5 3 19 12 5 21 5 3"/>`, { ...o }),
/** 暂停(双竖条) */
stop: (o) => svg(`<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>`, { ...o }),
/** 删除/垃圾桶 */
trash: (o) => svg(`<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>`, { ...o }),
/** 文档/YAML 查看 */
view: (o) => svg(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m16 13-2 2 2 2"/><path d="m8 13 2 2-2 2"/>`, { ...o }),
/** 编辑/铅笔 */
edit: (o) => svg(`<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>`, { ...o }),
/** 保存/软盘 */
save: (o) => svg(`<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>`, { ...o }),
// ===== 节点操作 =====
/** 电源/启用 */
power: (o) => svg(`<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>`, { sw: 2.5, ...o }),
/** 停用/双竖条(圆角) */
pause: (o) => svg(`<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>`, { sw: 2.5, ...o }),
/** 时钟(节点待审批徽章、待计算指标) */
clock: (o) => svg(`<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`, { sw: 2.5, ...o }),
/** 服务器/节点(指标卡「在线计算节点」) */
server: (o) => svg(`<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>`, { ...o }),
/** 竖排三点(节点管理「更多操作」入口) */
moreVertical: (o) => svg(`<circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/>`, { sw: 0, fill: true, ...o }),
// ===== 容器/图层(空状态、指标卡) =====
/** 堆叠图层(工作流指标卡、空状态) */
layers: (o) => svg(`<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>`, { ...o }),
};
+100
View File
@@ -0,0 +1,100 @@
/* DCTS
*
* 消除 state.js(首页全局) workflowDetail.js(详情页作用域)各写一遍的
* 指数退避 + visibilitychange 暂停 + failCount逻辑
*
* 用法:
* const poller = createPoller(() => fetchAllData(), { baseMs: 5000 });
* poller.start(); // 启动(立即跑一次)
* await poller.triggerNow(); // 手动立即触发一次(用于刷新按钮)
* poller.stop(); // 停止并清 timer
*
* fn 返回值约定:falsy 视为失败(计入退避),truthy 视为成功(重置 failCount)
* 抛错同样视为失败fn 可以是 async
*
* visibilitychange:Tab 切入后台暂停轮询,切回前台立即恢复并跑一次
*/
/**
* @param {() => (boolean | Promise<boolean>)} fn 每轮调用的函数,返回 truthy=falsy 决定退避
* @param {object} [opts]
* @param {number} [opts.baseMs=5000] 基准间隔(成功时)
* @param {number} [opts.maxMs=60000] 退避上限
* @param {number} [opts.maxFails=4] failCount clamp 上限(对应 baseMs * 2^maxFails)
* @returns {{start: () => void, stop: () => void, triggerNow: () => Promise<void>, isRunning: () => boolean}}
*/
export function createPoller(fn, { baseMs = 5000, maxMs = 60000, maxFails = 4 } = {}) {
let timer = null;
let running = false;
let failCount = 0;
let onVisibility = null;
function clearTimer() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
async function schedule() {
clearTimer();
if (document.hidden) return;
let ok = true;
try {
const r = await fn();
ok = !!r;
} catch (_) {
ok = false;
}
if (ok) {
failCount = 0;
} else {
failCount = Math.min(failCount + 1, maxFails);
}
// 指数退避:baseMs * 2^failCount,clamp 到 [baseMs, maxMs]
const delay = Math.min(baseMs * Math.pow(2, failCount), maxMs);
if (running && !document.hidden) {
timer = setTimeout(schedule, delay);
}
}
function start() {
if (running) return;
running = true;
if (!onVisibility) {
onVisibility = () => {
if (document.hidden) {
clearTimer();
} else if (running) {
schedule();
}
};
document.addEventListener('visibilitychange', onVisibility);
}
schedule();
}
function stop() {
running = false;
clearTimer();
if (onVisibility) {
document.removeEventListener('visibilitychange', onVisibility);
onVisibility = null;
}
}
/** 立即触发一次(不影响 running 状态与退避计数,用于手动刷新)。 */
async function triggerNow() {
clearTimer();
await schedule();
}
return {
start,
stop,
triggerNow,
isRunning: () => running,
};
}
+160
View File
@@ -0,0 +1,160 @@
/* 工作流阶段配置(tlusty / synspec_stage的轻量 YAML 解析与序列化
*
* 无依赖纯函数模块刻意从 wfEnginePanel.js 拆出使其可在 Node 原生 test
* 运行器下被单测覆盖wfEnginePanel.js 导入了浏览器侧的 toast/modal/api无法
* Node 直接 import
*
* 不引入完整 YAML 这两个块结构固定enabled/policy/strategies 三字段
* 用正则在顶层缩进定位块的起止行解析其内联或块式表达strategies 既可能是
* flow 序列 `[cold_run, seed_step]`也可能是块式列表统一在写入时序列化为
* flow 序列与设计文档示例一致
*/
/** stage 配置默认值。 */
export function defaultStage(kind) {
if (kind === 'tlusty') {
return { enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'] };
}
return { enabled: true, policy: 'skip_converged', strategies: ['standard'] };
}
/** save_workflow docs/task_engine_decoupling_design.md §3
* - 至少一个阶段启用双关 无任何计算可执行任务必失败
* - 启用的阶段必须配置 1 个策略空链 调度无顺位可派任务必失败
* 返回错误文案校验通过返回 null */
export function validateStageConfigs(tlusty, synspec) {
if (!tlusty.enabled && !synspec.enabled) {
return 'TLUSTY 与 SYNSPEC 阶段均被禁用:至少应启用一个计算阶段';
}
if (tlusty.enabled && (!tlusty.strategies || tlusty.strategies.length === 0)) {
return 'TLUSTY 阶段已启用但策略链为空:至少需要 1 个策略(如 cold_run';
}
if (synspec.enabled && (!synspec.strategies || synspec.strategies.length === 0)) {
return 'SYNSPEC 阶段已启用但策略链为空:至少需要 1 个策略(如 standard';
}
return null;
}
/** `^key:`
*
* 顶层注释 0 `#`无缩进必须终止块它是独立的顶层结构不属于上一块
* 否则 `tlusty:` `synspec_stage:` 之间的顶层注释会被吞进 tlusty
* 随后 replaceOrAppendBlock 覆盖时丢失/破坏 synspec_stage 审查 #4
* 缩进的注释` # ...`仍属块体块内行内注释 */
export function extractTopBlock(yaml, key) {
const lines = yaml.split('\n');
const startIdx = lines.findIndex(l => new RegExp(`^${key}:\\s*(\\S.*)?$`).test(l));
if (startIdx < 0) return null;
// 块的缩进为 0(顶层键);块体为后续缩进 > 0 的连续行。
const blockLines = [lines[startIdx]];
for (let i = startIdx + 1; i < lines.length; i++) {
const l = lines[i];
// 空行属块体(块间分隔)。
if (l.trim() === '') {
blockLines.push(l);
continue;
}
// 顶层键或顶层注释(无缩进)→ 块结束。
if (!/^\s/.test(l)) break;
blockLines.push(l);
}
return { startIdx, lines: blockLines };
}
/** `#...` #
* 整行注释`#...`由调用方在拼接前跳过审查 #5 */
function stripComment(line) {
const m = line.match(/(^|\s)(#.*)$/);
if (!m) return line;
return line.slice(0, m.index + m[1].length);
}
/** YAML stage enabled/policy/strategies
* 先剥离每行注释再拼接避免注释里的 enabled:/policy:/strategies: */
export function parseStageFromYaml(yaml, key) {
const block = extractTopBlock(yaml, key);
if (!block) return null;
const text = block.lines
.map(l => (l.trim().startsWith('#') ? '' : stripComment(l)))
.join('\n');
const enabled = /enabled:\s*(true|false)/i.test(text)
? /enabled:\s*(true|false)/i.exec(text)[1].toLowerCase() === 'true'
: true;
const policyMatch = /policy:\s*([a-z_]+)/i.exec(text);
const policy = policyMatch ? policyMatch[1] : 'skip_converged';
// strategiesflow 序列 `[a, b]` 或块式 `- a\n - b`。
let strategies = [];
const flowMatch = /strategies:\s*\[([^\]]*)\]/i.exec(text);
if (flowMatch) {
strategies = flowMatch[1].split(',').map(s => s.trim()).filter(Boolean);
} else {
const blockMatch = /strategies:\s*\n((?:\s*-\s*.+\n?)+)/i.exec(text);
if (blockMatch) {
strategies = blockMatch[1].split('\n')
.map(l => l.replace(/^\s*-\s*/, '').trim())
.filter(Boolean);
}
}
return { enabled, policy, strategies };
}
/** TLUSTY **** `tlusty:`
* `seed_step_fallback` 推断与服务端 `GridConfig::resolve_tlusty_config` 同口径
* - `seed_step_fallback: false` `[cold_run]`不回退种子步进
* - `true` / 缺省 默认链 `[cold_run, seed_step]`
*
* 用途审查 #2 修复面板编辑保存会物化 `tlusty:` 若无此推断而直接回退到
* `defaultStage` `[cold_run, seed_step]`会把旧 `seed_step_fallback: false`
* 的工作流静默改写为启用种子回退 */
export function resolveTlustyFromYaml(yaml) {
const block = parseStageFromYaml(yaml, 'tlusty');
if (block) return block;
// 行级查找顶层键(`^\s*seed_step_fallback` 天然排除 `#` 注释行)。
const hit = (yaml || '').split('\n')
.find(l => /^\s*seed_step_fallback\s*:\s*(true|false)\b/i.test(l));
if (hit && /false\b/i.test(hit)) {
return { enabled: true, policy: 'skip_converged', strategies: ['cold_run'] };
}
return defaultStage('tlusty');
}
/** 把 stage 配置序列化为 YAML 块文本(flow 序列风格)。 */
export function serializeStageBlock(key, stage) {
const strat = stage.strategies.length > 0
? `[${stage.strategies.join(', ')}]`
: '[]';
return `${key}:\n enabled: ${stage.enabled}\n policy: ${stage.policy}\n strategies: ${strat}`;
}
/** tlusty/synspec_stage config_yaml
* 已存在该块 替换不存在 追加到末尾 */
export function applyStageBlocks(yaml, tlusty, synspec) {
let out = yaml;
out = replaceOrAppendBlock(out, 'tlusty', serializeStageBlock('tlusty', tlusty));
out = replaceOrAppendBlock(out, 'synspec_stage', serializeStageBlock('synspec_stage', synspec));
return out;
}
/**
*
* 保留块间空行分隔extractTopBlock 会把块体后的空行吸收入 block.lines
* 替换时若直接用 block.lines.length endIdx 会把这些空行一并删除导致相邻块
* 在多次保存后逐渐粘连改为在替换后的块末尾补一个空行分隔除非已是文件末尾
* 或后续已是空行保证块间视觉分隔稳定二次审查 minor 修复 */
function replaceOrAppendBlock(yaml, key, blockText) {
const block = extractTopBlock(yaml, key);
if (!block) {
const sep = yaml.endsWith('\n') ? '' : '\n';
return yaml + sep + '\n' + blockText + '\n';
}
const lines = yaml.split('\n');
const startIdx = block.startIdx;
const endIdx = startIdx + block.lines.length; // 不含(含被吸收的尾部空行)
const before = lines.slice(0, startIdx);
let after = lines.slice(endIdx);
// 若后续非空且不以空行开头,补一个空行作块间分隔(恢复被 extractTopBlock 吞掉的空行)。
const needSep = after.length > 0 && after[0].trim() !== '';
const blockLines = blockText.split('\n');
const joined = needSep ? [...blockLines, '', ...after] : [...blockLines, ...after];
return [...before, ...joined].join('\n');
}
+54
View File
@@ -0,0 +1,54 @@
/* detail ctx
*
* workflowDetail.js 拆分后 detail 子模块需要访问当前工作流名AbortController
* 最新统计进度曲线点表过滤Parallel Sets 状态等共享可变状态这些原本是
* workflowDetail.js 顶部的 15+ 个模块级 let跨工作流切换须手动逐字段 reset
* 是历史遗留的泄漏/残留 bug 源头现收敛到本模块
*
* 生命周期mount createDetailCtx(name) 新建 子模块经参数读写 ctx
* unmount 后整体丢弃 abort 在途请求
*
* 子模块以 ctx 为第一参数接收直接读写字段vanilla JS 下的务实选择
* 避免 getter/setter 样板这些字段本就属于同一挂载作用域
*/
/**
* 创建详情页上下文
* @param {string} name 工作流名
*/
export function createDetailCtx(name) {
return {
name,
// 本轮作用域轮询的 AbortControllerunmount / 换工作流时 abort 在途请求)
abortCtl: new AbortController(),
// ===== Tab1 执行概览 =====
activeTab: 'overview',
latestStats: null,
overviewBuilt: false, // 概览骨架仅首次构建,其后轮询原位 diff
lastWavesSig: null,
lastActivitySig: null,
knownActivityKeys: new Set(), // 已在 feed 中的条目指纹(点名|完成时间)
// 进度曲线(30s 节流拉取,DOM 跨轮询保留)
lastProgressAt: 0,
lastProgress: null,
sparkBuilt: false,
lastSparkSig: null,
sparkHoverCtx: null, // 悬停上下文:mousemove 读取、数据刷新时写入
sparkKbIdx: -1, // 键盘十字线索引(-1=无)
// ===== Tab2 网格点明细 =====
pointsBuilt: false,
pointsFilter: { status: '', method: '', wave: '', q: '', sort: 'wave', order: 'asc', limit: 100, offset: 0 },
// ===== Tab3 收敛性分析(Parallel Sets 平行集合图) =====
psBuilt: false,
psState: { points: [], hover: null, layout: null, dimX: null, segY: null, ts: null }, // ts: 数据快照时间(epoch ms)
// ===== 浮层单例(unmount 时清理) =====
psTooltipEl: null,
psPointListEl: null,
pointPanelEl: null,
};
}
+453
View File
@@ -0,0 +1,453 @@
/* · Tab1 + + 线 +
*
* 性能守则历史回归总结
* - 概览骨架仅首次构建ctx.overviewBuilt其后轮询一律原位 diff 更新
* - 进度曲线 SVG 骨架只建一次事件监听只绑一次数据刷新仅原位改 points 属性
* - 最近动态 stale-while-revalidate请求期间/失败时保留旧内容签名 diff 才碰 DOM
* 整块 innerHTML tick 重建是页面抖动feed 高度脉动与悬停状态丢失的根源彻底避免
*/
import {
escapeHtml, parseTsMs, fmtTime, fmtDuration, statusBadge, pointMethodBadge,
} from '../../utils/format.js';
import { fetchWorkflowPointsApi, fetchWorkflowProgressApi } from '../../api.js';
import { isAbortError, logError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
/** 分段进度条档位:顺序与计数行一致。五段常驻(空档宽度 0),原位改 width 复用 CSS 过渡。 */
const SEG_DEFS = [
['converged', 'seg-converged', '收敛'],
['failed', 'seg-failed', '失败'],
['running', 'seg-running', '运行'],
['queued', 'seg-queued', '排队'],
['pending', 'seg-pending', '待定'],
];
/** 任务控制条:状态徽章 / 分段进度条 / 计数与 ETA
*
* 操作按钮启动/暂停/删除/查看已移除全部收敛至内嵌引擎面板
* wfEnginePanel.js docs/task_engine_decoupling_design.md §6
* 剩余元素签名 diff 后原位更新轮询不重建 DOM */
export function renderStrip(s) {
const statusEl = document.getElementById('wf-detail-status');
if (statusEl && statusEl.getAttribute('data-status') !== s.status) {
statusEl.setAttribute('data-status', s.status);
statusEl.innerHTML = statusBadge(s.status);
}
const segEl = document.getElementById('wf-seg-progress');
if (segEl) {
if (!segEl.firstElementChild) {
segEl.innerHTML = SEG_DEFS.map(([key, cls, label]) =>
`<div class="seg ${cls}" data-seg="${key}" style="width:0%" title="${label} 0"></div>`).join('');
}
const total = s.total || 0;
SEG_DEFS.forEach(([key, , label]) => {
const el = segEl.querySelector(`[data-seg="${key}"]`);
if (!el) return;
const n = s[key] || 0;
// total=0 时 pending 占满整条,沿用旧版「暂无网格点」语义
const w = total > 0 ? (n / total) * 100 : (key === 'pending' ? 100 : 0);
const wStr = `${w}%`;
if (el.style.width !== wStr) el.style.width = wStr;
const title = total === 0 && key === 'pending' ? '暂无网格点' : `${label} ${n}`;
if (el.title !== title) el.title = title;
});
}
const countsEl = document.getElementById('wf-strip-counts');
if (countsEl) {
const pct = s.total ? Math.round((s.converged / s.total) * 100) : 0;
const parts = [
`${s.total} 网格点`,
`${s.converged} 收敛${s.total ? ` (${pct}%)` : ''}`,
`${s.running} 运行`,
`${s.queued} 排队`,
`${s.pending} 待定`,
`${s.failed} 失败`,
];
if (s.eta_sec != null) parts.push(`ETA ≈ ${fmtDuration(s.eta_sec)}`);
const text = parts.join(' · ');
if (countsEl.textContent !== text) countsEl.textContent = text;
}
}
/** Tab1 入口:轮询每 tick 调用。骨架仅首次构建,其后 updateOverview 原位 diff。 */
export function renderOverview(ctx, s) {
const panel = document.getElementById('wf-tab-overview');
if (!panel) return;
if (!ctx.overviewBuilt) {
ctx.overviewBuilt = true;
panel.innerHTML = overviewSkeleton();
}
updateOverview(ctx, s);
refreshActivityFeed(ctx);
}
/** 概览骨架:动态部分全部挂 ID,供 updateOverview 原位更新。 */
function overviewSkeleton() {
return `
<section class="metrics-grid">
<div class="metric-card card">
<span class="metric-title">网格总数</span>
<span class="metric-value tabular-num" id="ov-m-total"></span>
<span class="metric-sub">6 维参数笛卡尔积展开</span>
</div>
<div class="metric-card card">
<span class="metric-title">已收敛</span>
<span class="metric-value tabular-num" id="ov-m-converged"></span>
<span class="metric-sub" id="ov-m-converged-sub"></span>
</div>
<div class="metric-card card">
<span class="metric-title">失败</span>
<span class="metric-value tabular-num" id="ov-m-failed"></span>
<span class="metric-sub">冷启动发散且无种子可救</span>
</div>
<div class="metric-card card">
<span class="metric-title">运行 + 排队</span>
<span class="metric-value tabular-num" id="ov-m-runqueue"></span>
<span class="metric-sub" id="ov-m-runqueue-sub"></span>
</div>
</section>
<section class="panel card">
<div class="panel-header"><h2>进度曲线最近 24 小时</h2></div>
<div class="panel-body">
<div id="wf-sparkline" class="sparkline-wrap"></div>
<p id="wf-rate-line" class="rate-line text-hint"></p>
<div id="wf-stall-banner" class="stall-banner hidden" role="alert">
${ICONS.warn({ size: 14 })}
<span class="stall-banner-text"></span>
</div>
</div>
</section>
<section class="wf-overview-grid">
<div class="panel card">
<div class="panel-header"><h2>难度波次推进</h2></div>
<div class="panel-body" id="ov-waves"></div>
</div>
<div class="panel card">
<div class="panel-header"><h2>最近动态</h2></div>
<div class="panel-body">
<div id="wf-activity-feed" class="activity-feed"><p class="text-hint">加载中</p></div>
</div>
</div>
</section>
`;
}
/** 原位文本更新:内容相同则跳过,避免无谓 DOM 写。 */
function setOvText(id, v) {
const el = document.getElementById(id);
const str = String(v);
if (el && el.textContent !== str) el.textContent = str;
}
/** 轮询原位刷新概览数值(指标卡 + 归因徽章 + 波次)。 */
function updateOverview(ctx, s) {
const pct = s.total ? Math.round((s.converged / s.total) * 100) : 0;
setOvText('ov-m-total', s.total);
setOvText('ov-m-converged', s.converged);
setOvText('ov-m-converged-sub', `完成率 ${pct}%`);
setOvText('ov-m-failed', s.failed);
setOvText('ov-m-runqueue', s.running + s.queued);
setOvText('ov-m-runqueue-sub', `${s.running} 运行 · ${s.queued} 排队`);
// 收敛手段归因面板已移除(见 docs/task_engine_decoupling_design.md §6.1),
// cold/seed 计数仍在点表明细与 parSets 中可用,概览不再单独展示。
updateWaves(ctx, s);
}
/** 波次行:签名 diff,数据不变不重建。行高只随波次数量增长,不随进度值脉动。 */
function updateWaves(ctx, s) {
const el = document.getElementById('ov-waves');
if (!el) return;
const waves = s.waves || [];
const sig = waves.map(w => `${w.wave}:${w.converged}/${w.total}/${w.failed || 0}`).join('|');
if (sig === ctx.lastWavesSig) return;
ctx.lastWavesSig = sig;
el.innerHTML = waves.length > 0
? waves.map(w => {
const wp = w.total ? Math.round((w.converged / w.total) * 100) : 0;
return `
<div class="wave-row">
<span class="wave-label tabular-num">wave ${w.wave}</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" style="width:${wp}%"></div>
</div>
<span class="wave-count tabular-num">${w.converged}/${w.total}${w.failed ? ` · ${w.failed} 失败` : ''}</span>
</div>`;
}).join('')
: '<p class="text-hint">尚无波次数据(启动工作流后按难度分批生成)</p>';
}
/** stale-while-revalidate/
* 签名 diff数据不变则完全不碰 DOM保留滚动位置不重放动画
* 仅新出现的条目带 activity-new 入场动画 */
async function refreshActivityFeed(ctx) {
const feed = document.getElementById('wf-activity-feed');
if (!feed || !ctx.name) return;
try {
const res = await fetchWorkflowPointsApi(
ctx.name,
{ sort: 'last_completed_at', order: 'desc', limit: 12 },
{ signal: ctx.abortCtl?.signal }
);
if (!res.ok) return;
const json = await res.json();
const pts = (json.data?.points || []).filter(p => p.last_completed_at);
const keyOf = p => `${p.name}|${p.last_completed_at}`;
const sig = pts.map(keyOf).join(';');
if (sig === ctx.lastActivitySig) return;
const prevKeys = ctx.knownActivityKeys;
ctx.lastActivitySig = sig;
ctx.knownActivityKeys = new Set(pts.map(keyOf));
if (pts.length === 0) {
feed.innerHTML = '<p class="text-hint">暂无已完成的计算记录</p>';
return;
}
feed.innerHTML = pts.map(p => {
const isNew = !prevKeys.has(keyOf(p));
const ok = p.status === 'converged';
// 归因徽章用 success_method(收敛点权威归因,能正确显示 synspec-only 的 "standard"),
// 不用 last_task_type——后者是 task_type 兼容兜底字段,synspec-only 任务被调度器固定
// 填 cold_run,会把光谱重算误标成「冷启动」。失败点 success_method 为空 → 显示 —。
const method = pointMethodBadge(p.success_method);
return `
<div class="activity-item${isNew ? ' activity-new' : ''}">
<span class="activity-point" title="${escapeHtml(p.name)}">${escapeHtml(p.name)}</span>
${method}
<span class="activity-result ${ok ? 'ok' : 'fail'}">${ok ? '收敛' : '失败'}</span>
<span class="activity-elapsed tabular-num" title="该次计算墙钟耗时">${fmtDuration(p.last_elapsed_sec)}</span>
<span class="activity-time tabular-num">${fmtTime(p.last_completed_at)}</span>
</div>`;
}).join('');
} catch (err) {
if (isAbortError(err)) return;
logError(err, '获取最近动态失败');
}
}
// ===== 进度曲线(概览内嵌,30s 节流拉取快照序列) =====
/** 节流:快照 ~30s 一条,前端 30s 拉一次即可。 */
export function maybeRefreshProgress(ctx) {
const now = Date.now();
if (now - ctx.lastProgressAt < 30000) return;
ctx.lastProgressAt = now;
refreshProgress(ctx);
}
export async function refreshProgress(ctx) {
if (!ctx.name || !document.getElementById('wf-sparkline')) return;
try {
const res = await fetchWorkflowProgressApi(ctx.name, { hours: 24 }, { signal: ctx.abortCtl?.signal });
if (!res.ok) return;
const json = await res.json();
if (json.success && json.data) {
ctx.lastProgress = json.data;
renderSparkline(ctx, json.data);
}
} catch (err) {
if (isAbortError(err)) return;
logError(err, '获取进度曲线失败');
}
}
/** 线SVG ctx.sparkBuilt
* 数据刷新仅原位更新 polyline/polygon points 属性 + 悬停上下文
* 杜绝每 30s innerHTML 重建导致的悬停状态丢失与事件监听器泄漏
* 键盘交互SVG 可聚焦ArrowLeft/Right 移动十字线Escape 清除 */
export function renderSparkline(ctx, prog) {
const wrap = document.getElementById('wf-sparkline');
if (!wrap) return;
const series = prog.series || [];
renderRateLine(ctx, prog);
if (series.length < 2) {
if (!wrap.querySelector('.text-hint')) {
wrap.innerHTML = '<p class="text-hint">暂无足够快照(后台循环在进度变化时写入,约每 30 秒检测一次)</p>';
}
ctx.sparkBuilt = false;
ctx.lastSparkSig = null;
ctx.sparkHoverCtx = null;
ctx.sparkKbIdx = -1;
return;
}
const W = 100;
const H = 32;
const PAD = 2;
const n = series.length;
const pctOf = (p, key) => (p.total > 0 ? (p[key] / p.total) * 100 : 0);
const tsMs = series.map((p) => parseTsMs(p.ts));
const tNow = parseTsMs(prog.now);
const tEnd = Number.isFinite(tNow) ? tNow : tsMs[n - 1];
const tStart = tEnd - (prog.hours || 24) * 3600 * 1000;
const span = Math.max(1, tEnd - tStart);
const x = (i) => ((tsMs[i] - tStart) / span) * W;
const y = (pct) => H - PAD - (Math.min(100, Math.max(0, pct)) / 100) * (H - PAD * 2);
const line = (key) => series.map((p, i) => `${x(i).toFixed(2)},${y(pctOf(p, key)).toFixed(2)}`).join(' ');
const areaPts = `${x(0).toFixed(2)},${H} ${line('converged')} ${x(n - 1).toFixed(2)},${H}`;
// 时间轴标签:窗口起点 / 中点 / 终点
const fmtAxisTime = (ms) => {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const axisLabels = [fmtAxisTime(tStart), fmtAxisTime(tStart + span / 2), fmtAxisTime(tEnd)];
// 签名 diff
const sig = `${n}|${tsMs[0]}|${tsMs[n - 1]}|${series[n - 1].converged}|${series[n - 1].failed}`;
if (sig === ctx.lastSparkSig && ctx.sparkBuilt) {
ctx.sparkHoverCtx = { tsMs, tStart, span, series, n, pctOf, x };
return;
}
ctx.lastSparkSig = sig;
ctx.sparkHoverCtx = { tsMs, tStart, span, series, n, pctOf, x };
if (!ctx.sparkBuilt) {
ctx.sparkBuilt = true;
wrap.innerHTML = `
<svg class="sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img"
tabindex="0" aria-label="最近 ${prog.hours} 小时收敛率与失败率曲线,可用方向键浏览数据点">
<polygon class="spark-area" />
<polyline class="spark-failed" />
<polyline class="spark-converged" />
<line class="spark-crosshair" x1="0" y1="0" x2="0" y2="${H}" visibility="hidden" />
</svg>
<div class="spark-time-axis" aria-hidden="true">
<span class="spark-axis-label" data-ax="0"></span>
<span class="spark-axis-label" data-ax="1"></span>
<span class="spark-axis-label" data-ax="2"></span>
</div>
<div class="spark-legend">
<span class="spark-legend-item"><svg width="18" height="8" aria-hidden="true"><line x1="0" y1="4" x2="18" y2="4" class="spark-converged"/></svg></span>
<span class="spark-legend-item"><svg width="18" height="8" aria-hidden="true"><line x1="0" y1="4" x2="18" y2="4" class="spark-failed"/></svg></span>
</div>
<p id="spark-readout" class="spark-readout tabular-num" aria-live="polite"></p>
`;
const svg = wrap.querySelector('svg.sparkline');
const crosshair = wrap.querySelector('.spark-crosshair');
const readout = wrap.querySelector('#spark-readout');
// 共享:按索引显示十字线 + 读数
const showIdx = (idx) => {
const c = ctx.sparkHoverCtx;
if (!c || idx < 0 || idx >= c.n) return;
const p = c.series[idx];
crosshair.setAttribute('x1', c.x(idx));
crosshair.setAttribute('x2', c.x(idx));
crosshair.setAttribute('visibility', 'visible');
readout.textContent = `${p.ts} · 收敛 ${p.converged}/${p.total} (${c.pctOf(p, 'converged').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
};
const hideCrosshair = () => {
crosshair.setAttribute('visibility', 'hidden');
readout.textContent = '';
ctx.sparkKbIdx = -1;
};
svg.addEventListener('mousemove', (e) => {
const c = ctx.sparkHoverCtx;
if (!c) return;
const rect = svg.getBoundingClientRect();
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const t = c.tStart + rel * c.span;
let lo = 0, hi = c.n - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (c.tsMs[mid] < t) lo = mid + 1;
else hi = mid;
}
if (lo > 0 && Math.abs(c.tsMs[lo - 1] - t) <= Math.abs(c.tsMs[lo] - t)) lo -= 1;
ctx.sparkKbIdx = lo;
showIdx(lo);
});
svg.addEventListener('mouseleave', () => {
if (ctx.sparkKbIdx < 0) hideCrosshair();
});
svg.addEventListener('blur', hideCrosshair);
// 键盘交互:ArrowLeft/Right 逐步移动,Home/End 跳首尾,Escape 清除
svg.addEventListener('keydown', (e) => {
const c = ctx.sparkHoverCtx;
if (!c) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
ctx.sparkKbIdx = Math.min(c.n - 1, (ctx.sparkKbIdx < 0 ? 0 : ctx.sparkKbIdx + 1));
showIdx(ctx.sparkKbIdx);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
ctx.sparkKbIdx = Math.max(0, (ctx.sparkKbIdx < 0 ? c.n - 1 : ctx.sparkKbIdx - 1));
showIdx(ctx.sparkKbIdx);
} else if (e.key === 'Home') {
e.preventDefault();
ctx.sparkKbIdx = 0;
showIdx(0);
} else if (e.key === 'End') {
e.preventDefault();
ctx.sparkKbIdx = c.n - 1;
showIdx(c.n - 1);
} else if (e.key === 'Escape') {
hideCrosshair();
}
});
}
// 原位更新 points + 时间轴标签
const area = wrap.querySelector('.spark-area');
const lineFailed = wrap.querySelector('.spark-failed');
const lineConverged = wrap.querySelector('.spark-converged');
if (area) area.setAttribute('points', areaPts);
if (lineFailed) lineFailed.setAttribute('points', line('failed'));
if (lineConverged) lineConverged.setAttribute('points', line('converged'));
wrap.querySelectorAll('.spark-axis-label').forEach((el, i) => {
if (el.textContent !== axisLabels[i]) el.textContent = axisLabels[i];
});
}
/** 速率行与停滞横幅(读 ctx.latestStats 计算 ETA/剩余量)。 */
export function renderRateLine(ctx, prog) {
const el = document.getElementById('wf-rate-line');
if (el) {
const parts = [];
const rate = prog.rate_per_hour;
const span = prog.rate_span_hours;
if (rate != null && Number.isFinite(rate)) {
// 速率是「最近 2 小时」子窗口上的平均(后端 rate_per_hour),rate_span_hours 为其
// 实际数据跨度(≤2h,数据不足时回退整窗)。展示真实跨度,避免误读为固定 2h 或整窗。
let spanTxt = '';
if (span != null && Number.isFinite(span) && span > 0) {
spanTxt = span >= 1
? `(近 ${span.toFixed(1)} 小时平均)`
: `(近 ${Math.max(1, Math.round(span * 60))} 分钟平均)`;
}
parts.push(`经验速率 ≈ +${Number(rate).toFixed(1)} 点/小时${spanTxt}`);
// ETA 用终态处理速率(converged+failed 的近 2h 平均):与「剩余 = total converged
// − failed」同口径,即队列实际清空速率。仅用收敛速率会在失败较多时高估剩余时间。
const doneRate = prog.done_rate_per_hour;
const etaRate = (doneRate != null && Number.isFinite(doneRate) && doneRate > 0)
? doneRate
: rate;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.converged - ctx.latestStats.failed : 0;
if (etaRate > 0 && remaining > 0) {
parts.push(`按当前处理速率剩余 ${remaining} 点约需 ${fmtDuration((remaining / etaRate) * 3600)}`);
}
} else {
parts.push('快照积累中,稍后给出经验速率');
}
el.textContent = parts.join(' · ');
}
const banner = document.getElementById('wf-stall-banner');
if (!banner) return;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.converged - ctx.latestStats.failed : 0;
const stalled = prog.stalled_minutes;
if (stalled != null && stalled > 10 && remaining > 0 && ctx.latestStats?.status === 'running') {
banner.querySelector('.stall-banner-text').textContent =
`进度已停滞约 ${Math.round(stalled)} 分钟(仍有 ${remaining} 点未完成)——请检查计算节点在线状态与任务超时设置`;
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
}
+530
View File
@@ -0,0 +1,530 @@
/* · Tab3 Parallel Sets 6 + =
*
* 为什么用 Parallel Sets 而非 PCP网格是离散采样点每维有限取值+ 分类状态
* PCP 用单条折线表示个体离散维度上大量线重叠无法区分Parallel Sets 用色带宽度
* 表示聚合数量专为此场景设计一眼看出某维度某值的状态构成跨维度流向
* 手写 SVG零依赖不碰 CSP几何 = 按状态分段的堆叠条 + 条间贝塞尔色带
*
* 全部可变状态在 ctx.psStatepoints/hover/layout/dimX/segY浮层单例
* ctx.psTooltipEl / ctx.psPointListEl unmount 由控制器清理
*/
import { fetchWorkflowPointsApi } from '../../api.js';
import { escapeHtml, fmtClock } from '../../utils/format.js';
import { isAbortError, logError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
import { openPointPanel } from './pointPanel.js';
const PS_DIMS = ['teff', 'logg', 'loghe', 'logc', 'logn', 'logo'];
// ===== 平行集合图数据缓存(sessionStorage 持久化) =====
//
// 网格点全量数据量大(可达上千条),平行集合图布局/统计是 CPU 密集运算,默认策略是
// 「首次拉取后缓存、切 Tab / 轮询不重拉」。为让浏览器刷新(F5)后不白白重拉全量,
// 把拉到的 points 序列化到 sessionStorage——同标签页刷新保留、关标签页即清空,
// 生命周期与内存缓存同样安全。
//
// 缓存按工作流名隔离(key 含工作流名),**多个工作流的缓存并存、切换不删除彼此**;
// sessionStorage 容量上限(~5MB)由 QuotaExceededError 兜底:写不进去的工作流
// 静默降级为刷新后重新拉取,不影响其它工作流的缓存。
// 恢复的缓存带快照时间戳(图上标注「快照 HH:MM:SS」便于识别数据新旧);
// 「刷新数据」按钮仍可强制重拉。
const PS_CACHE_KEY = 'dcts_ps_points';
/** 写缓存:按工作流隔离,多缓存并存互不删除。容量超限时静默降级(见文件头注释)。 */
export function savePsCache(ctx) {
try {
sessionStorage.setItem(
PS_CACHE_KEY + '_' + ctx.name,
JSON.stringify({ ts: Date.now(), points: ctx.psState.points })
);
} catch (_) {
/* 持久化失败不影响内存缓存,静默降级为刷新后重拉 */
}
}
/** 读缓存:成功填充 ctx.psState.points 并返回 true;无缓存/内容损坏返回 false。 */
export function restorePsCache(ctx) {
try {
const raw = sessionStorage.getItem(PS_CACHE_KEY + '_' + ctx.name);
if (!raw) return false;
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.points)) return false;
ctx.psState.points = parsed.points;
ctx.psState.ts = parsed.ts || Date.now();
return true;
} catch (_) {
return false;
}
}
/** / vs
* 顺序即图例/轴布局/每值内分段/色带绘制层叠的唯一来源语义约定
* 已收敛最受关注置顶 进行中按任务进展方向runningqueuedpending 失败沉底
* 色带绘制按此顺序收敛色带先画底层失败最后画最上层数量少且颜色醒目 */
const PS_STATUS = ['cold', 'seed', 'running', 'queued', 'pending', 'failed'];
const PS_STATUS_LABEL = {
cold: '冷启动收敛', seed: '种子步进', failed: '失败',
running: '运行', queued: '排队', pending: '未开始',
};
/** 点 → 状态档位 key。 */
function psSlot(p) {
if (p.status === 'converged') return p.success_method === 'seed_step' ? 'seed' : 'cold';
if (p.status === 'failed') return 'failed';
if (p.status === 'running') return 'running';
if (p.status === 'queued') return 'queued';
return 'pending';
}
/** 维度取值的显示格式。 */
function psFmtDim(dim, v) {
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
if (dim === 'teff') return `${Math.round(n)}K`;
if (dim === 'logg') return n.toFixed(1);
return n.toFixed(0); // loghe/logc/logn/logo 都是整数采样
}
export function renderAnalysisTab(ctx) {
const el = document.getElementById('wf-tab-analysis');
if (!el) return;
if (!ctx.psBuilt) {
ctx.psBuilt = true;
el.innerHTML = `
<div class="panel card">
<div class="panel-header"><h2>收敛性联合分析平行集合图</h2></div>
<div class="panel-body">
<div class="ps-controls">
<button type="button" class="btn btn-secondary btn-sm" data-ps-refresh title="重新拉取全量网格点(数据快照缓存于本标签页,刷新页面后自动恢复,无需重拉)">刷新数据</button>
<span id="ps-summary" class="ps-summary tabular-num"></span>
</div>
<p class="text-hint ps-hint">每条色带宽度 = 网格点数量按收敛状态着色轴上每个取值的色块高度反映该值下各状态的点数占比悬停高亮某一状态的全链路流向点击色块查看该组网格点</p>
<div id="ps-container" class="ps-container"><p class="text-hint">加载中</p></div>
<p id="ps-conclusion" class="ps-conclusion"></p>
<p id="ps-sr-summary" class="sr-only" aria-live="polite"></p>
</div>
</div>
`;
}
// 缓存策略:内存缓存 + sessionStorage 持久化——切 Tab / 轮询不自动拉;
// 本地无数据时先尝试恢复持久化缓存(刷新页面后免重拉),否则拉取全量。
// 唯一强制重拉入口是「刷新数据」按钮。
if (ctx.psState.points.length === 0) {
if (restorePsCache(ctx)) renderParSets(ctx);
else refreshPsData(ctx);
} else {
renderParSets(ctx);
}
}
/** 拉全量网格点(后端 limit 缺省即全量)。force=true 用于刷新按钮。 */
export async function refreshPsData(ctx, force = false) {
if (!ctx.name) return;
if (!force && ctx.psState.points.length > 0) return;
const container = document.getElementById('ps-container');
const btn = document.querySelector('[data-ps-refresh]');
if (container) container.innerHTML = '<p class="text-hint">加载全量网格点…</p>';
if (btn) { btn.disabled = true; btn.textContent = '加载中…'; }
try {
const res = await fetchWorkflowPointsApi(ctx.name, {}, { signal: ctx.abortCtl?.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
ctx.psState.points = json.data?.points || [];
ctx.psState.ts = Date.now();
savePsCache(ctx); // 持久化:浏览器刷新后可直接恢复,免重拉全量
renderParSets(ctx);
} catch (err) {
if (isAbortError(err)) return;
logError(err, '获取收敛性分析数据失败');
if (container) container.innerHTML = `<p class="text-hint">加载失败:${escapeHtml(err.message)}(点「刷新数据」重试)</p>`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = '刷新数据'; }
}
}
/** 维度的有序离散取值列表。 */
function psDimValues(points, dim) {
const set = new Set(points.map(p => Number(p[dim])));
return [...set].filter(Number.isFinite).sort((a, b) => a - b);
}
/**
* 核心渲染Parallel Sets
* 布局7 条垂直类别轴6 物理 + 状态等间距分布每轴内按取值纵向堆叠
* 每个取值的色块再按状态分段高度=点数占比相邻轴同状态间用贝塞尔色带连接
* 关键数据结构flows[i] = { from:{dim,val,seg}, to:{dim,val,seg}, count, slot }
* seg是该取值×状态在轴上的堆叠段色带从 from.seg 的状态段连到 to.seg 的同状态段
*/
function renderParSets(ctx) {
const container = document.getElementById('ps-container');
if (!container) return;
const points = ctx.psState.points;
if (points.length === 0) {
container.innerHTML = '<p class="text-hint">暂无网格点数据(启动工作流后生成)</p>';
return;
}
const dims = [...PS_DIMS, 'status'];
// 每维取值列表(status 用 PS_STATUS 顺序)
const dimValues = {};
PS_DIMS.forEach(d => { dimValues[d] = psDimValues(points, d); });
dimValues.status = PS_STATUS;
// 预计算每点的状态档位与各维数值(一次解析、多处复用)。
// 旧版在「统计」与「6 对相邻轴色带计数」里反复调用 psSlot()(多次字符串比较)
// 与 Number()(字符串解析),网格点上千时是主要 CPU 开销;现一次性物化到数组。
const nPts = points.length;
const ptSlotArr = new Array(nPts);
const ptNum = {};
PS_DIMS.forEach(d => { ptNum[d] = new Float64Array(nPts); });
for (let i = 0; i < nPts; i++) {
const p = points[i];
ptSlotArr[i] = psSlot(p);
for (const d of PS_DIMS) ptNum[d][i] = Number(p[d]);
}
// 统计:每维每值的总数 + 每维每值每状态计数
const dimValueTotal = {}; // dim -> val -> count
const dimValSlotCount = {}; // dim -> val -> slot -> count
dims.forEach(d => { dimValueTotal[d] = {}; dimValSlotCount[d] = {}; });
for (let i = 0; i < nPts; i++) {
const slot = ptSlotArr[i];
for (const d of PS_DIMS) {
const v = ptNum[d][i];
if (!Number.isFinite(v)) continue;
dimValueTotal[d][v] = (dimValueTotal[d][v] || 0) + 1;
if (!dimValSlotCount[d][v]) dimValSlotCount[d][v] = {};
dimValSlotCount[d][v][slot] = (dimValSlotCount[d][v][slot] || 0) + 1;
}
// status 轴:取值即档位本身
dimValueTotal.status[slot] = (dimValueTotal.status[slot] || 0) + 1;
if (!dimValSlotCount.status[slot]) dimValSlotCount.status[slot] = {};
dimValSlotCount.status[slot][slot] = (dimValSlotCount.status[slot][slot] || 0) + 1;
}
// 布局尺寸(像素 viewBox,与容器等比避免拉伸)
const cw = Math.max(360, container.clientWidth || 700);
const W = cw;
const H = Math.round(W * 9 / 16);
const PAD_T = 16, PAD_B = 36;
const top = PAD_T, bot = H - PAD_B;
const nDims = dims.length;
const colW = W / nDims;
const axisW = Math.min(28, colW * 0.16); // 轴色块宽度
const gap = 3; // 色块间距
ctx.psState.layout = { W, H, top, bot, dims, dimValues, dimValueTotal, dimValSlotCount, colW, axisW, gap };
ctx.psState.dimX = (i) => i * colW + colW / 2; // 轴中心 x
// 计算每个轴上每个取值的 y 区间(按总值占比纵向铺满 [top,bot])
// segY: dim -> val -> slot -> {y0, y1}(该状态段在轴上的像素区间)
// 两个关键约束(历史回归):
// 1) 取值间的 gap 会随取值数累积,必须从高度预算中扣除(availH),否则底部
// 色块下探超出 bot,与 H-16 处的轴标签(参数名)重叠——teff 取值多、状态轴
// 6 个固定档位最明显。
// 2) 状态轴是固定档位数组,可能出现数量为 0 的状态(如 pending=0):cnt=0 的
// 取值不占位、不建 segY——否则渲染端 segList 为空会把标签回退到 top(图右上角)。
const segY = {};
dims.forEach((d, di) => {
segY[d] = {};
const vals = dimValues[d].filter(v => (dimValueTotal[d][v] || 0) > 0);
const totalAll = vals.reduce((s, v) => s + dimValueTotal[d][v], 0) || 1;
const availH = Math.max(1, (bot - top) - (vals.length - 1) * gap);
let yCursor = top;
vals.forEach(v => {
const cnt = dimValueTotal[d][v] || 0;
const h = (cnt / totalAll) * availH;
segY[d][v] = {};
let sy = yCursor;
// 按固定状态顺序在该值内分段
PS_STATUS.forEach(sl => {
const sc = (dimValSlotCount[d][v] && dimValSlotCount[d][v][sl]) || 0;
if (sc === 0) return;
const sh = (sc / cnt) * h;
segY[d][v][sl] = { y0: sy, y1: sy + sh };
sy += sh;
});
yCursor += h + gap;
});
});
ctx.psState.segY = segY;
// 渲染轴色块(每值一个 g,内含按状态分段的 rect)+ 取值标签
const axisHtml = dims.map((d, di) => {
const x = ctx.psState.dimX(di);
const rx = x - axisW / 2;
const vals = dimValues[d].filter(v => segY[d][v]); // 与布局过滤一致(cnt>0 才有 segY
const blocks = vals.map(v => {
const vSegs = segY[d][v];
const segs = PS_STATUS.map(sl => {
const seg = vSegs[sl];
if (!seg) return '';
const h = Math.max(0.5, seg.y1 - seg.y0);
const dimmed = ctx.psState.hover && ctx.psState.hover !== sl ? ' ps-dim' : '';
const hi = ctx.psState.hover === sl ? ' ps-hit' : '';
return `<rect class="ps-seg ps-${sl}${dimmed}${hi}" data-dim="${d}" data-val="${v}" data-slot="${sl}" x="${rx.toFixed(1)}" y="${seg.y0.toFixed(1)}" width="${axisW.toFixed(1)}" height="${h.toFixed(1)}" tabindex="0" role="button" aria-label="${d}=${psFmtDim(d, v)} ${PS_STATUS_LABEL[sl]}"></rect>`;
}).join('');
// 取值标签(标在色块右侧,垂直居中于该值整块)。状态轴用中文+对应状态色,
// 状态轴本身即图例(刻度=状态名、颜色=该状态色),不再单列底部图例。
const segList = PS_STATUS.map(s => vSegs[s]).filter(Boolean);
const yMid = (segList[0].y0 + segList[segList.length - 1].y1) / 2;
const isStatusAxis = d === 'status';
const labelTxt = isStatusAxis ? PS_STATUS_LABEL[v] : psFmtDim(d, v);
const colorCls = isStatusAxis ? ` ps-vlabel-${v}` : '';
return `${segs}<text class="ps-vlabel tabular-num${colorCls}" x="${(rx + axisW + 4).toFixed(1)}" y="${yMid.toFixed(1)}" dominant-baseline="central">${escapeHtml(labelTxt)}</text>`;
}).join('');
const labelTxt = d === 'status' ? '状态' : d;
return `<g class="ps-axis" data-dim="${d}">${blocks}<text class="ps-axis-label" x="${x.toFixed(1)}" y="${(H - 16).toFixed(1)}" text-anchor="middle">${labelTxt}</text></g>`;
}).join('');
// 渲染色带:相邻轴同状态之间连贝塞尔。flow[slot] 累加该 slot 在相邻段间的路径。
// 关键:色带从 (fromDim,fromVal,slot) 的段 → (toDim,toVal,slot) 的段,
// 宽度 = 同时满足两维取值的点数中该 slot 的数量。
const ribbonHtml = [];
for (let di = 0; di < dims.length - 1; di++) {
const fromD = dims[di], toD = dims[di + 1];
const x0 = ctx.psState.dimX(di) + axisW / 2;
const x1 = ctx.psState.dimX(di + 1) - axisW / 2;
const xc1 = x0 + (x1 - x0) * 0.5;
// 统计 fromVal×toVal×slot 计数(复用预计算的 ptSlotArr/ptNum,避免逐点重解析)
const triple = new Map(); // key "fv|tv|slot" -> count
const fromIsStatus = fromD === 'status';
const toIsStatus = toD === 'status';
for (let i = 0; i < nPts; i++) {
const slot = ptSlotArr[i];
const fv = fromIsStatus ? slot : ptNum[fromD][i];
const tv = toIsStatus ? slot : ptNum[toD][i];
if (!fromIsStatus && !Number.isFinite(fv)) continue;
if (!toIsStatus && !Number.isFinite(tv)) continue;
const k = `${fv}|${tv}|${slot}`;
triple.set(k, (triple.get(k) || 0) + 1);
}
// 为每个 (fromVal,toVal,slot) 画色带。为避免色带互相覆盖顺序混乱,
// 按 fromVal 的轴顺序遍历,同 fromVal 内按 slot 顺序。
dimValues[fromD].forEach(fv => {
PS_STATUS.forEach(slot => {
// cnt=0 的取值不建 segY(见布局段),此处需防御性访问
const fromSeg = segY[fromD]?.[fv]?.[slot];
if (!fromSeg) return;
dimValues[toD].forEach(tv => {
const toSeg = segY[toD]?.[tv]?.[slot];
if (!toSeg) return;
const cnt = triple.get(`${fv}|${tv}|${slot}`) || 0;
if (cnt === 0) return;
// 色带两端高度按各自轴上该 slot 段的像素高 × 此流量占比,形成渐变带(流量大则宽)。
// fromCount/toCount 是该 slot 在 fromVal/toVal 的总点数;cnt 是两者交集。
const fromCount = dimValSlotCount[fromD][fv][slot] || cnt;
const toCount = dimValSlotCount[toD][tv][slot] || cnt;
const h0 = Math.max(0.6, (fromSeg.y1 - fromSeg.y0) * (cnt / fromCount));
const h1 = Math.max(0.6, (toSeg.y1 - toSeg.y0) * (cnt / toCount));
const fy = (fromSeg.y0 + fromSeg.y1) / 2;
const ty = (toSeg.y0 + toSeg.y1) / 2;
const dimmed = ctx.psState.hover && ctx.psState.hover !== slot ? ' ps-dim' : '';
const hi = ctx.psState.hover === slot ? ' ps-hit' : '';
// 梯形色带:上沿从 (x0,fy-h0/2) 经贝塞尔到 (x1,ty-h1/2),下沿对称返回
const y0t = (fy - h0 / 2), y0b = (fy + h0 / 2);
const y1t = (ty - h1 / 2), y1b = (ty + h1 / 2);
const dpath = `M${x0.toFixed(1)},${y0t.toFixed(1)} C${xc1.toFixed(1)},${y0t.toFixed(1)} ${xc1.toFixed(1)},${y1t.toFixed(1)} ${x1.toFixed(1)},${y1t.toFixed(1)} L${x1.toFixed(1)},${y1b.toFixed(1)} C${xc1.toFixed(1)},${y1b.toFixed(1)} ${xc1.toFixed(1)},${y0b.toFixed(1)} ${x0.toFixed(1)},${y0b.toFixed(1)} Z`;
ribbonHtml.push(`<path class="ps-ribbon ps-${slot}${dimmed}${hi}" data-dim="${fromD}" data-from="${fv}" data-to-dim="${toD}" data-to="${tv}" data-slot="${slot}" data-cnt="${cnt}" d="${dpath}"></path>`);
});
});
});
}
container.innerHTML = `
<svg class="ps" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img"
aria-label="6 维参数与收敛状态的平行集合分布图">
<g class="ps-ribbons">${ribbonHtml.join('')}</g>
${axisHtml}
</svg>
`;
bindPsHover(ctx);
renderPsConclusion(ctx, points);
const sum = document.getElementById('ps-summary');
if (sum) {
// 快照时间:数据来自缓存(刷新后恢复)时提示新旧,便于判断是否需要手动刷新
sum.textContent = `${points.length}${ctx.psState.ts ? ` · 快照 ${fmtClock(ctx.psState.ts)}` : ''}`;
}
}
/** 窗口缩放后重算布局(控制器 resize 防抖后调用)。 */
export function reflowParSets(ctx) {
const c = document.getElementById('ps-container');
const prev = ctx.psState.layout?.W || 0;
if (c && Math.abs((c.clientWidth || 0) - prev) > 20) renderParSets(ctx);
}
/** 自动洞察:扫各维度取值,挑失败率显著高于均值的位置。 */
function renderPsConclusion(ctx, pts) {
const el = document.getElementById('ps-conclusion');
if (!el) return;
const total = pts.length;
if (total === 0) { el.textContent = ''; return; }
const cold = pts.filter(p => p.status === 'converged' && p.success_method === 'cold_run').length;
const seed = pts.filter(p => p.status === 'converged' && p.success_method === 'seed_step').length;
const failed = pts.filter(p => p.status === 'failed').length;
const baseFail = failed / total;
const findings = [];
PS_DIMS.forEach(d => {
const groups = new Map();
pts.forEach(p => {
const v = Number(p[d]);
if (!Number.isFinite(v)) return;
if (!groups.has(v)) groups.set(v, []);
groups.get(v).push(p);
});
[...groups.entries()].forEach(([v, g]) => {
if (g.length < 3) return;
const gf = g.filter(p => p.status === 'failed').length / g.length;
if (gf >= 0.3 && gf - baseFail >= 0.15) {
findings.push({ score: gf - baseFail, text: `${d}=${psFmtDim(d, v)}:失败率 ${Math.round(gf * 100)}%${g.length} 点,均值 ${Math.round(baseFail * 100)}%` });
}
});
});
findings.sort((a, b) => b.score - a.score);
const lines = [
`冷启动 ${cold} · 种子步进 ${seed} · 失败 ${failed} / 共 ${total}`,
...findings.slice(0, 3).map(f => f.text),
];
el.innerHTML = lines.map(l => `<span class="ps-concl-line">${escapeHtml(l)}</span>`).join('');
// 屏幕阅读器文本摘要(screen-reader-summary):平行集合图是 SVG role=img
// 读屏用户无法感知图形内容,关键洞察须以文本同步播报。
const sr = document.getElementById('ps-sr-summary');
if (sr) sr.textContent = `收敛性分析摘要:${lines.join('')}`;
}
/** 悬停交互:移到色带/色块 → 高亮该状态的全链路(色带 + 同状态色块),其余压暗。 */
function bindPsHover(ctx) {
const svg = document.querySelector('.ps');
if (!svg) return;
const apply = (slot) => {
ctx.psState.hover = slot;
svg.querySelectorAll('.ps-ribbon, .ps-seg').forEach(el => {
const s = el.getAttribute('data-slot');
el.classList.toggle('ps-dim', slot != null && s !== slot);
el.classList.toggle('ps-hit', slot != null && s === slot);
});
};
const clear = () => { ctx.psState.hover = null; apply(null); hidePsTooltip(ctx); };
svg.addEventListener('mousemove', (e) => {
const target = e.target.closest('[data-slot]');
if (!target) { if (ctx.psState.hover) clear(); return; }
const slot = target.getAttribute('data-slot');
if (ctx.psState.hover !== slot) apply(slot);
const cnt = target.getAttribute('data-cnt');
const dim = target.getAttribute('data-dim');
const isRibbon = target.classList.contains('ps-ribbon');
const label = isRibbon
? `${PS_STATUS_LABEL[slot]}${dim}=${psFmtDim(dim, target.getAttribute('data-from'))}${target.getAttribute('data-to-dim')}=${target.getAttribute('data-to')}${cnt}`
: `${dim}=${psFmtDim(dim, target.getAttribute('data-val'))}${PS_STATUS_LABEL[slot]}${(ctx.psState.dimValSlotCount[dim]?.[target.getAttribute('data-val')]?.[slot]) || 0}`;
showPsTooltip(ctx, e.clientX, e.clientY, label);
});
svg.addEventListener('mouseleave', clear);
// 键盘交互:focus 色块 → 高亮 + tooltipEnter/Space → drill-down
svg.addEventListener('focusin', (e) => {
const seg = e.target.closest('.ps-seg');
if (!seg) return;
const slot = seg.getAttribute('data-slot');
const dim = seg.getAttribute('data-dim');
const val = seg.getAttribute('data-val');
if (ctx.psState.hover !== slot) apply(slot);
const label = `${dim}=${psFmtDim(dim, val)}${PS_STATUS_LABEL[slot]}${(ctx.psState.dimValSlotCount[dim]?.[val]?.[slot]) || 0}`;
const rect = seg.getBoundingClientRect();
showPsTooltip(ctx, rect.left + rect.width / 2, rect.top, label);
});
svg.addEventListener('focusout', (e) => {
if (!svg.contains(e.relatedTarget)) clear();
});
svg.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const seg = e.target.closest('.ps-seg');
if (!seg) return;
e.preventDefault();
const dim = seg.getAttribute('data-dim');
const val = seg.getAttribute('data-val');
const slot = seg.getAttribute('data-slot');
const names = ctx.psState.points
.filter(p => psSlot(p) === slot && (dim === 'status' || Number(p[dim]) === Number(val)))
.map(p => p.name).slice(0, 200);
if (names.length === 0) return;
if (names.length === 1) { openPointPanel(ctx, names[0]); return; }
const rect = seg.getBoundingClientRect();
showPsPointList(ctx, rect.left + rect.width / 2, rect.bottom, dim, val, slot, names);
});
// 点击色块 → 弹出该维度值下的网格点名列表(点点名开详情面板)
svg.addEventListener('click', (e) => {
const seg = e.target.closest('.ps-seg');
if (!seg) return;
const dim = seg.getAttribute('data-dim');
const val = seg.getAttribute('data-val');
const slot = seg.getAttribute('data-slot');
const names = ctx.psState.points
.filter(p => psSlot(p) === slot && (dim === 'status' || Number(p[dim]) === Number(val)))
.map(p => p.name).slice(0, 200);
if (names.length === 0) return;
if (names.length === 1) { openPointPanel(ctx, names[0]); return; }
showPsPointList(ctx, e.clientX, e.clientY, dim, val, slot, names);
});
}
/** tooltip 单例(挂在 ctx 上,unmount 由控制器移除)。 */
function showPsTooltip(ctx, clientX, clientY, html) {
if (!ctx.psTooltipEl) {
ctx.psTooltipEl = document.createElement('div');
ctx.psTooltipEl.className = 'ps-tooltip';
document.body.appendChild(ctx.psTooltipEl);
}
ctx.psTooltipEl.textContent = html;
const tw = ctx.psTooltipEl.offsetWidth || 160, th = ctx.psTooltipEl.offsetHeight || 32;
let x = clientX + 14, y = clientY + 14;
if (x + tw > window.innerWidth) x = clientX - tw - 14;
if (y + th > window.innerHeight) y = clientY - th - 14;
ctx.psTooltipEl.style.left = x + 'px';
ctx.psTooltipEl.style.top = y + 'px';
ctx.psTooltipEl.style.display = 'block';
}
function hidePsTooltip(ctx) {
if (ctx.psTooltipEl) ctx.psTooltipEl.style.display = 'none';
}
/** 点击色块时,若该组有多个点,弹出点名列表浮层(点点名 → openPointPanel)。 */
function showPsPointList(ctx, clientX, clientY, dim, val, slot, names) {
closePsPointList(ctx);
ctx.psPointListEl = document.createElement('div');
ctx.psPointListEl.className = 'ps-pointlist card';
ctx.psPointListEl.innerHTML = `
<div class="ps-pointlist-head">
<span>${dim}=${escapeHtml(psFmtDim(dim, val))} · ${PS_STATUS_LABEL[slot]} · ${names.length} </span>
<button type="button" class="modal-close ps-pointlist-close" aria-label="关闭">
${ICONS.x({ size: 14 })}
</button>
</div>
<div class="ps-pointlist-body">${names.map(n => `<button type="button" class="ps-pointlink" data-name="${escapeHtml(n)}" title="查看详情">${escapeHtml(n)}</button>`).join('')}</div>
`;
document.body.appendChild(ctx.psPointListEl);
// 定位(锚定点击处,限制在视口内)
const r = ctx.psPointListEl.getBoundingClientRect();
let x = clientX, y = clientY;
if (x + r.width > window.innerWidth - 8) x = window.innerWidth - r.width - 8;
if (y + r.height > window.innerHeight - 8) y = window.innerHeight - r.height - 8;
x = Math.max(8, x); y = Math.max(8, y);
ctx.psPointListEl.style.left = x + 'px';
ctx.psPointListEl.style.top = y + 'px';
ctx.psPointListEl.addEventListener('click', (e) => {
if (e.target.closest('.ps-pointlist-close')) { closePsPointList(ctx); return; }
const link = e.target.closest('[data-name]');
if (link) { openPointPanel(ctx, link.getAttribute('data-name')); closePsPointList(ctx); }
});
}
export function closePsPointList(ctx) {
if (ctx.psPointListEl) { ctx.psPointListEl.remove(); ctx.psPointListEl = null; }
}
+194
View File
@@ -0,0 +1,194 @@
/* · +
*
* 从点表查看按钮Parallel Sets 点名列表 / 单点 drill-down 进入
* 面板 DOM 挂在 ctx.pointPanelElunmount 由控制器调用 closePointPanel 清理
*/
import { fetchPointDetailApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import { setupFocusTrap, releaseFocusTrap } from '../../components/modal.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs,
} from '../../utils/format.js';
import { isAbortError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
function buildPanelShell(ctx, name) {
const backdrop = document.createElement('div');
backdrop.className = 'point-panel-backdrop';
backdrop.innerHTML = `
<aside class="point-panel card" role="dialog" aria-modal="true" aria-label="网格点详情">
<div class="point-panel-header">
<div class="point-panel-title">
<h3 class="point-panel-name" title="${escapeHtml(name)}">${escapeHtml(name)}</h3>
<span class="point-panel-badges" id="point-panel-badges"></span>
</div>
<div class="point-panel-header-actions">
<button type="button" class="btn btn-secondary btn-sm" data-panel-copy>复制点名</button>
<button type="button" class="modal-close point-panel-close" aria-label="关闭">
${ICONS.x({ size: 16 })}
</button>
</div>
</div>
<div class="point-panel-body" id="point-panel-body">
<p class="text-hint">加载中</p>
</div>
</aside>
`;
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
closePointPanel(ctx);
return;
}
if (e.target.closest('.point-panel-close')) {
closePointPanel(ctx);
return;
}
if (e.target.closest('[data-panel-copy]')) {
navigator.clipboard?.writeText(name).then(
() => showToast('点名已复制', 'success'),
() => showToast('复制失败,请手动复制', 'info')
);
return;
}
// 种子来源点名跳查:换载面板内容到该点
const seedLink = e.target.closest('[data-panel-point]');
if (seedLink) {
e.preventDefault();
openPointPanel(ctx, seedLink.getAttribute('data-panel-point'));
}
});
document.body.appendChild(backdrop);
setupFocusTrap(backdrop.querySelector('.point-panel'));
return backdrop;
}
export function closePointPanel(ctx) {
if (!ctx.pointPanelEl) return;
const panel = ctx.pointPanelEl.querySelector('.point-panel');
if (panel) releaseFocusTrap(panel);
ctx.pointPanelEl.remove();
ctx.pointPanelEl = null;
}
export async function openPointPanel(ctx, pointName) {
if (!ctx.name) return;
closePointPanel(ctx);
ctx.pointPanelEl = buildPanelShell(ctx, pointName);
const body = ctx.pointPanelEl.querySelector('#point-panel-body');
const badges = ctx.pointPanelEl.querySelector('#point-panel-badges');
try {
const res = await fetchPointDetailApi(ctx.name, pointName, { signal: ctx.abortCtl?.signal });
if (res.status === 404) {
body.innerHTML = '<p class="text-hint">该点不存在(可能已被删除)</p>';
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!json.success || !json.data) throw new Error(json.message || '无数据');
const { point, attempts, conv } = json.data;
badges.innerHTML = `${pointStatusBadge(point.status)} ${pointMethodBadge(point.success_method)}`;
body.innerHTML = '';
body.appendChild(buildAttemptSection(attempts));
body.appendChild(buildConvSection(conv));
} catch (err) {
if (isAbortError(err)) return;
body.innerHTML = `<p class="text-hint">加载失败:${escapeHtml(err.message)}(请关闭面板后重试,或检查网络)</p>`;
}
}
function buildAttemptSection(attempts) {
const sec = document.createElement('section');
sec.className = 'point-section';
const list = attempts || [];
const rows = list.map((a, i) => {
const ok = a.status === 'completed';
const seedHtml = a.seed_point_name
? `<a href="#" data-panel-point="${escapeHtml(a.seed_point_name)}" class="seed-link" title="跳查种子来源点">${escapeHtml(a.seed_point_name)}</a>`
: '—';
const resultHtml = ok
? '<span class="activity-result ok">成功</span>'
: `<span class="activity-result fail">${escapeHtml(a.status)}</span>`;
return `
<tr>
<td class="tabular-num">${i + 1}</td>
<td>${pointMethodBadge(a.task_type)}</td>
<td>${seedHtml}</td>
<td class="tabular-num">${fmtRelc(a.max_relc)}</td>
<td>${resultHtml}</td>
<td>${escapeHtml(a.node_id || '—')}</td>
<td class="tabular-num" title="${escapeHtml(fmtFullTs(a.completed_at))}">${fmtDateTime(a.completed_at)}</td>
<td class="tabular-num">${fmtDur(a.elapsed_sec)}</td>
</tr>`;
}).join('') || '<tr><td colspan="8" class="empty-cell">尚无尝试记录(点未被派发)</td></tr>';
sec.innerHTML = `
<h3 class="point-section-title">尝试历史${list.length}</h3>
<div class="table-responsive">
<table class="data-table attempts-table">
<thead>
<tr><th scope="col">#</th><th scope="col"></th><th scope="col"></th><th scope="col">max_relc</th><th scope="col"></th><th scope="col"></th><th scope="col"></th><th scope="col"></th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
// 最近一次失败尝试的错误文本:textContent 渲染,杜绝 XSS
const lastErr = [...list].reverse().find(a => a.error_message);
if (lastErr) {
const pre = document.createElement('pre');
pre.className = 'code-pre-wrap point-error';
pre.textContent = `最近错误:${lastErr.error_message}`;
sec.appendChild(pre);
}
return sec;
}
function buildConvSection(conv) {
const sec = document.createElement('section');
sec.className = 'point-section';
if (!conv) {
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<p class="text-hint">诊断文件不可用conv.json 缺失或解析失败</p>`;
return sec;
}
const stagesHtml = (conv.stages || []).map(st => {
// 阶段状态图标用 SVG(与全局图标族一致),不用 emoji——emoji 跨平台渲染不一、
// 无法由 CSS 控色,在暗色模式下尤其违和(no-emoji-icons)。
const icon = st.converged
? ICONS.checkCircle({ size: 14, cls: 'stage-icon stage-icon-ok' })
: ICONS.warn({ size: 14, cls: 'stage-icon stage-icon-warn' });
const relc = st.best_max_relc != null ? Number(st.best_max_relc).toExponential(2) : '—';
const iter = st.last_iter != null ? ` · ${st.last_iter} iter` : '';
const depth = st.worst_depth != null ? ` · 最差深度 ${st.worst_depth}/${st.n_depths ?? '?'}` : '';
const secs = st.elapsed_sec != null ? ` · ${Math.round(st.elapsed_sec)}s` : '';
return `
<div class="stage-chip ${st.converged ? 'stage-ok' : 'stage-warn'}">
<span class="stage-label">${icon} ${escapeHtml(st.label)}</span>
<span class="stage-meta tabular-num">relc ${relc} · chmax ${st.chmax ?? '—'}${iter}${depth}${secs}</span>
</div>`;
}).join('') || '<p class="text-hint">无阶段数据</p>';
const seedTxt = conv.seed ? escapeHtml(String(conv.seed).split('/').pop()) : '无(冷启动)';
// 半失败可见性(审查 #2 后续):大气已收敛但光谱失败时,converged 仍为 true 而
// synspec_rc/synspec_error 非空——必须把这两个量展示出来,否则半失败点无从排查。
const synspecRc = conv.synspec_rc != null ? `<span class="tabular-num">${conv.synspec_rc}</span>` : '—';
const synspecErrHtml = conv.synspec_error
? `<span class="activity-result fail" title="${escapeHtml(conv.synspec_error)}">${escapeHtml(conv.synspec_error)}</span>`
: '—';
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<div class="stage-chain">${stagesHtml}</div>
<dl class="conv-meta">
<div><dt>最终 max_relc</dt><dd class="tabular-num">${conv.final_max_relc != null ? Number(conv.final_max_relc).toExponential(3) : ''}</dd></div>
<div><dt>收敛阈值 chmax</dt><dd class="tabular-num">${conv.final_chmax ?? ''}</dd></div>
<div><dt>种子</dt><dd>${seedTxt}</dd></div>
<div><dt>总耗时</dt><dd class="tabular-num">${conv.elapsed_sec != null ? `${Math.round(conv.elapsed_sec)}s` : ''}</dd></div>
<div><dt>synspec 耗时</dt><dd class="tabular-num">${conv.synspec_sec != null ? `${Math.round(conv.synspec_sec)}s` : ''}</dd></div>
<div><dt>synspec 返回码</dt><dd>${synspecRc}</dd></div>
<div><dt>synspec 错误</dt><dd>${synspecErrHtml}</dd></div>
<div><dt>大气 NaN</dt><dd>${conv.atmosphere_has_nan ? '<span class="activity-result fail"></span>' : ''}</dd></div>
</dl>
`;
return sec;
}
+246
View File
@@ -0,0 +1,246 @@
/* · Tab2
*
* 行签名 diff状态/方法/尝试数不变时只原位刷新 max_relc 等高频单元格
* 不整行重建避免悬停/聚焦丢失与表格抖动分页/过滤/排序状态在 ctx.pointsFilter
*/
import { fetchWorkflowPointsApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs, csvEscape,
} from '../../utils/format.js';
import { isAbortError, logError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
/** 首次进入 Tab2 构建表格骨架与过滤器;其后轮询只走 refreshPoints 原位更新。 */
export function renderPointsTab(ctx) {
const el = document.getElementById('wf-tab-points');
if (!el) return;
if (ctx.pointsBuilt) {
refreshPoints(ctx);
return;
}
ctx.pointsBuilt = true;
const waveOpts = (ctx.latestStats?.waves || [])
.map(w => `<option value="${escapeHtml(w.wave)}">wave ${escapeHtml(w.wave)}</option>`)
.join('');
el.innerHTML = `
<div class="panel card">
<div class="panel-header">
<h2>网格点明细</h2>
<button type="button" class="btn btn-secondary btn-sm" data-wf-points-export title="按当前过滤条件导出全部匹配点为 CSV">
${ICONS.download({ size: 12 })}
导出 CSV
</button>
</div>
<div class="panel-body">
<div class="points-filters">
<select data-wf-filter="status" aria-label="按状态过滤">
<option value="">全部状态</option>
<option value="pending">未开始</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="converged">已收敛</option>
<option value="failed">失败</option>
</select>
<select data-wf-filter="method" aria-label="按收敛手段过滤">
<option value="">全部手段</option>
<option value="cold_run">冷启动</option>
<option value="seed_step">种子步进</option>
</select>
<select data-wf-filter="wave" aria-label="按波次过滤">
<option value="">全部波次</option>${waveOpts}
</select>
<input type="search" data-wf-filter="q" placeholder="搜索点名(回车生效)" aria-label="点名搜索" />
<button type="button" class="btn btn-secondary btn-sm" data-wf-points-refresh>刷新</button>
</div>
<div class="table-responsive">
<table class="data-table points-table">
<thead>
<tr>
<th class="col-point-name" scope="col">点名</th>
<th class="pt-param sortable" scope="col" data-wf-sort="teff" aria-sort="none" title="点击切换排序">Teff</th><th class="pt-param" scope="col">logg</th><th class="pt-param" scope="col">logHe</th><th class="pt-param" scope="col">CNO</th><th class="pt-param sortable" scope="col" data-wf-sort="wave" aria-sort="ascending" title="">wave</th>
<th scope="col">状态</th><th scope="col"></th><th class="sortable" scope="col" data-wf-sort="max_relc" aria-sort="none" title="">max_relc</th><th class="sortable" scope="col" data-wf-sort="elapsed" aria-sort="none" title=""></th><th class="sortable" scope="col" data-wf-sort="last_completed_at" aria-sort="none" title=""></th><th class="sortable" scope="col" data-wf-sort="attempts" aria-sort="none" title=""></th><th scope="col"></th>
</tr>
</thead>
<tbody id="points-table-body">
<tr><td colspan="13" class="empty-cell">正在加载网格点数据</td></tr>
</tbody>
</table>
</div>
<div class="points-pagination">
<button type="button" class="btn btn-secondary btn-sm" data-wf-page="prev"> 上一页</button>
<span id="points-page-info" class="tabular-num"></span>
<button type="button" class="btn btn-secondary btn-sm" data-wf-page="next">下一页 </button>
</div>
</div>
</div>
`;
updateSortIndicators(ctx);
refreshPoints(ctx);
}
export async function refreshPoints(ctx) {
if (!ctx.name) return;
const tbody = document.getElementById('points-table-body');
if (!tbody) return;
try {
const params = {
sort: ctx.pointsFilter.sort,
order: ctx.pointsFilter.order,
limit: ctx.pointsFilter.limit,
offset: ctx.pointsFilter.offset,
};
if (ctx.pointsFilter.status) params.status = ctx.pointsFilter.status;
if (ctx.pointsFilter.method) params.method = ctx.pointsFilter.method;
if (ctx.pointsFilter.wave !== '') params.wave = ctx.pointsFilter.wave;
if (ctx.pointsFilter.q) params.q = ctx.pointsFilter.q;
const res = await fetchWorkflowPointsApi(ctx.name, params, { signal: ctx.abortCtl?.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!json.success || !json.data) return;
renderPointsRows(tbody, json.data.points);
renderPointsPagination(json.data.total, ctx);
} catch (err) {
if (isAbortError(err)) return;
logError(err, '获取网格点列表失败');
}
}
/** 关键行签名 diff:状态/方法/尝试数不变时只原位刷新 max_relc 等高频单元格。 */
function renderPointsRows(tbody, points) {
if (!points || points.length === 0) {
// 守卫:已有空行占位时不重复写 innerHTML(避免每 tick 无谓 DOM 写)
if (!tbody.querySelector('.empty-cell')) {
tbody.innerHTML = '<tr><td colspan="13" class="empty-cell">没有符合条件的网格点</td></tr>';
}
return;
}
const existing = new Map();
tbody.querySelectorAll('tr[data-point-row]').forEach(tr => {
existing.set(tr.getAttribute('data-point-row'), tr);
});
const updated = [];
points.forEach(p => {
const name = escapeHtml(p.name);
const sig = `${p.status}|${p.success_method || ''}|${p.attempt_count}`;
let tr = existing.get(p.name);
if (tr && tr.getAttribute('data-sig') === sig) {
const cell = tr.querySelector('.col-max-relc');
const relcHtml = fmtRelc(p.last_max_relc);
if (cell && cell.innerHTML !== relcHtml) cell.innerHTML = relcHtml;
} else {
const rescued = p.status === 'converged' && p.attempt_count > 1;
const html = `
<td class="col-point-name node-id" title="${name}">${name}</td>
<td class="tabular-num pt-param">${escapeHtml(p.teff)}</td>
<td class="tabular-num pt-param">${escapeHtml(p.logg)}</td>
<td class="tabular-num pt-param">${escapeHtml(p.loghe)}</td>
<td class="tabular-num pt-param">${escapeHtml(p.cno_sum)}</td>
<td class="tabular-num pt-param">${escapeHtml(p.wave)}</td>
<td>${pointStatusBadge(p.status)}</td>
<td>${pointMethodBadge(p.success_method)}</td>
<td class="col-max-relc tabular-num">${fmtRelc(p.last_max_relc)}</td>
<td class="tabular-num">${fmtDur(p.last_elapsed_sec)}</td>
<td class="tabular-num" title="${escapeHtml(fmtFullTs(p.last_completed_at))}">${fmtDateTime(p.last_completed_at)}</td>
<td class="tabular-num"${rescued ? ' title="冷启动失败后被种子步进救回"' : ''}>${escapeHtml(p.attempt_count)}${rescued ? ' ↻' : ''}</td>
<td>
<button type="button" class="btn btn-secondary btn-sm" data-point-name="${name}">查看</button>
</td>`;
if (!tr) {
tr = document.createElement('tr');
tr.setAttribute('data-point-row', p.name);
}
tr.innerHTML = html;
tr.setAttribute('data-sig', sig);
}
existing.delete(p.name);
updated.push(tr);
});
existing.forEach(tr => tr.remove());
updated.forEach((tr, i) => {
if (tbody.children[i] !== tr) tbody.insertBefore(tr, tbody.children[i] || null);
});
}
function renderPointsPagination(total, ctx) {
const info = document.getElementById('points-page-info');
if (!info) return;
const pages = Math.max(1, Math.ceil(total / ctx.pointsFilter.limit));
const cur = Math.floor(ctx.pointsFilter.offset / ctx.pointsFilter.limit) + 1;
info.textContent = total > 0 ? `${cur}/${pages} 页 · 共 ${total}` : `${total}`;
const prev = document.querySelector('[data-wf-page="prev"]');
const next = document.querySelector('[data-wf-page="next"]');
if (prev) prev.disabled = cur <= 1;
if (next) next.disabled = cur >= pages;
}
/** aria-sort sortable-table/ none
* 屏幕阅读器依赖 aria-sort 播报排序视觉上用 ::after 箭头 style.css */
export function updateSortIndicators(ctx) {
document.querySelectorAll('.points-table th[data-wf-sort]').forEach(th => {
const key = th.getAttribute('data-wf-sort');
th.setAttribute(
'aria-sort',
key === ctx.pointsFilter.sort
? (ctx.pointsFilter.order === 'desc' ? 'descending' : 'ascending')
: 'none'
);
});
}
/** CSVBOM Excel
* 科学计算场景常需把点数据带离控制台做后续分析export-option */
export async function exportPointsCsv(ctx) {
if (!ctx.name) return;
const btn = document.querySelector('[data-wf-points-export]');
if (btn) {
btn.disabled = true;
btn.textContent = '导出中…';
}
try {
// limit 缺省即全量(后端语义),此处显式不传 limit/offset 以取全部匹配点。
const params = {};
if (ctx.pointsFilter.status) params.status = ctx.pointsFilter.status;
if (ctx.pointsFilter.method) params.method = ctx.pointsFilter.method;
if (ctx.pointsFilter.wave !== '') params.wave = ctx.pointsFilter.wave;
if (ctx.pointsFilter.q) params.q = ctx.pointsFilter.q;
const res = await fetchWorkflowPointsApi(ctx.name, params, { signal: ctx.abortCtl?.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const pts = json.data?.points || [];
if (pts.length === 0) {
showToast('当前过滤条件下没有可导出的网格点', 'info');
return;
}
const header = ['name','teff','logg','loghe','logc','logn','logo','cno_sum','wave','status','success_method','attempt_count','last_max_relc','last_task_type','seed_point_name','node_id','last_completed_at','last_elapsed_sec','last_error'];
const rows = pts.map(p => [
p.name, p.teff, p.logg, p.loghe, p.logc, p.logn, p.logo, p.cno_sum, p.wave,
p.status, p.success_method ?? '', p.attempt_count ?? '',
p.last_max_relc ?? '', p.last_task_type ?? '', p.seed_point_name ?? '',
p.node_id ?? '', p.last_completed_at ?? '', p.last_elapsed_sec ?? '', p.last_error ?? '',
].map(csvEscape).join(','));
const csv = '\uFEFF' + [header.join(','), ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${ctx.name}_points.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast(`已导出 ${pts.length} 个网格点`, 'success');
} catch (err) {
if (!isAbortError(err)) {
showToast(`导出失败:${err.message}(请检查网络后重试)`, 'error');
}
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = `${ICONS.download({ size: 12 })} 导出 CSV`;
}
}
}
+302 -52
View File
@@ -1,10 +1,10 @@
/* DCTS /* DCTS
* *
* 内容核心指标卡 + 计算节点集群与凭据管理大表 + 工作流卡片列表 * 内容核心指标卡 + 计算节点集群与凭据管理大表 + 工作流卡片列表
* 标记来自 index.html #view-home-template克隆挂载ID 与原版逐字一致 * 模板原为 index.html #view-home-templatetemplate 标签克隆为统一视图渲染方式
* state.js 轮询渲染逻辑无需任何改动 * 现改为本文件的 HOME_TEMPLATE 字符串ID 与原版逐字一致state.js 轮询渲染无需改动
* *
* 生命周期mountHome 克隆模板 绑定首页作用域事件委托 骨架屏 启动全局轮询 * 生命周期mountHome 挂载模板 绑定首页作用域事件委托 骨架屏 启动全局轮询
* unmountHome 停轮询 清空 #view-root委托监听随 DOM 移除无泄漏 * unmountHome 停轮询 清空 #view-root委托监听随 DOM 移除无泄漏
*/ */
@@ -13,22 +13,134 @@ import {
rejectNodeApi, rejectNodeApi,
reissueNodeApi, reissueNodeApi,
disableNodeApi, disableNodeApi,
enableNodeApi enableNodeApi,
setNodeQuotaApi,
fetchNodesList
} from '../api.js'; } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { showConfirm, setupFocusTrap } from '../components/modal.js'; import { showConfirm, setupFocusTrap, hideModal, releaseFocusTrap } from '../components/modal.js';
import { renderNodesTableSkeleton } from '../components/nodesTable.js'; import { renderNodesTableSkeleton } from '../components/nodesTable.js';
import { renderWorkflowsSkeleton } from '../components/workflows.js'; import { renderWorkflowsSkeleton } from '../components/workflows.js';
import { startPolling, stopPolling, fetchAllData, fetchNodes } from '../state.js'; import { startPolling, stopPolling, fetchAllData, fetchNodes } from '../state.js';
import { triggerRefreshAnimation } from '../utils.js'; import { triggerRefreshAnimation } from '../utils.js';
import { import { friendlyError } from '../utils/errors.js';
handleStartWorkflow, import { ICONS } from '../utils/icons.js';
handleStopWorkflow, import { fmtFullTs } from '../utils/format.js';
handleDeleteWorkflow,
handleViewWorkflow // 注:工作流操作(启动/停止/删除/保存配置)已收敛至详情页内嵌配置面板
} from '../components/wfActions.js'; // wfEnginePanel.js),首页不再 import wfActions 处理器。
// ===== 首页 DOM 模板(原 index.html #view-home-template,统一为 JS 模板字符串) =====
const HOME_TEMPLATE = `
<section class="metrics-grid">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">在线计算节点</span>
<div class="metric-icon nodes-icon">
${ICONS.server({ size: 20 })}
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-active-nodes">0</span>
<span class="metric-sub" id="val-total-slots">0 / 0 CPU 槽位占用</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill" id="slots-progress-fill"></div>
</div>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">待计算网格点</span>
<div class="metric-icon pending-icon">
${ICONS.clock({ size: 20 })}
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-pending-tasks">0</span>
<span class="metric-sub" id="val-running-tasks">0 个任务计算中</span>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">已计算网格模型</span>
<div class="metric-icon success-icon">
${ICONS.checkCircle({ size: 20 })}
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-completed-tasks">0</span>
<span class="metric-sub" id="val-completion-rate">网格总数: 0</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" id="converged-progress-fill"></div>
</div>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">计算工作流</span>
<div class="metric-icon workflow-icon">
${ICONS.layers({ size: 20 })}
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-total-workflows">0</span>
<span class="metric-sub" id="val-active-wf">系统已就绪</span>
</div>
</div>
</section>
<div class="content-grid">
<div class="panel card">
<div class="panel-header">
<h2>计算节点集群与凭据管理</h2>
<div class="row-wrap" style="align-items: center; gap: 0.5rem;">
<span class="badge" id="node-count-badge">0 个节点</span>
<button id="btn-refresh-nodes" class="btn btn-secondary btn-sm">刷新</button>
</div>
</div>
<div class="panel-body table-responsive">
<table class="data-table nodes-table">
<thead>
<tr>
<th class="col-node-id" scope="col">节点 ID</th>
<th class="col-slots" scope="col">CPU 槽位</th>
<th class="col-usage" scope="col">CPU/内存</th>
<th class="col-status" scope="col">节点状态</th>
<th class="col-token" scope="col">Token 凭据</th>
<th class="col-heartbeat" scope="col">心跳时间</th>
<th class="col-actions" scope="col">管理操作</th>
</tr>
</thead>
<tbody id="nodes-table-body">
<tr>
<td colspan="7" class="empty-cell">正在连接服务端获取计算节点...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="stack-col stack-col-lg">
<div class="panel card">
<div class="panel-header">
<h2>恒星大气网格工作流</h2>
</div>
<div class="panel-body">
<div id="workflows-list" class="workflows-container">
<div class="empty-cell">正在获取工作流配置...</div>
</div>
</div>
</div>
</div>
</div>
`;
// ===== 节点授权管理交互处理(首页专属) ===== // ===== 节点授权管理交互处理(首页专属) =====
async function handleApprove(nodeId) { async function handleApprove(nodeId) {
const ok = await showConfirm('同意节点接入', `确定同意授权计算节点 '${nodeId}' 加入集群吗?\n系统将自动分配身份 Token 并在节点轮询时下发。`); const ok = await showConfirm('同意节点接入', `确定同意授权计算节点 '${nodeId}' 加入集群吗?\n系统将自动分配身份 Token 并在节点轮询时下发。`);
if (!ok) return; if (!ok) return;
@@ -40,10 +152,10 @@ async function handleApprove(nodeId) {
showToast(json.message || '节点接入成功已授权', 'success'); showToast(json.message || '节点接入成功已授权', 'success');
fetchAllData(); fetchAllData();
} else { } else {
showToast(json.message || '审批失败', 'error'); showToast(json.message || '审批失败:请稍后重试,或检查服务端日志', 'error');
} }
} catch (err) { } catch (err) {
showToast(`请求异常: ${err.message}`, 'error'); showToast(`审批请求失败:${friendlyError(err, '审批请求失败')}`, 'error');
} }
} }
@@ -58,90 +170,231 @@ async function handleReject(nodeId) {
showToast(json.message || '已拒绝节点接入申请', 'info'); showToast(json.message || '已拒绝节点接入申请', 'info');
fetchAllData(); fetchAllData();
} else { } else {
showToast(json.message || '操作失败', 'error'); showToast(json.message || '拒绝操作失败:请稍后重试', 'error');
} }
} catch (err) { } catch (err) {
showToast(`请求异常: ${err.message}`, 'error'); showToast(`拒绝请求失败:${friendlyError(err, '拒绝请求失败')}`, 'error');
} }
} }
async function handleDisable(nodeId) { // 启用 / 停用 / 重发 Token 的离散表格按钮已收敛至 #modal-node-manage 三点菜单
const ok = await showConfirm('停用节点', `确定停用节点 '${nodeId}' 吗?\n停用后该节点保持在线但不再被分发任务,worker 将空闲待命。可随时重新启用。`); // (见 handleManage)。以下三个 handler 不再被表格委托直接调用,但其 API 调用逻辑
if (!ok) return; // 已内联到 handleManage 的对应按钮,故此处移除独立函数避免死代码与 lint 未使用告警。
// 拉取最新节点详情并填充 #modal-node-manage 后打开。
// 内部按钮(保存配额/清除配额/启停/重发)每次打开时重新绑定:先 cloneNode 移除旧监听,
// 再 addEventListener,避免重复打开导致监听累积。
async function handleManage(nodeId) {
const modal = document.getElementById('modal-node-manage');
if (!modal) return;
// 拉最新节点数据(避免用到过期的 active_slots/状态)
let node = null;
try {
// fetchNodesList 内部已 return res.json()(解析后的 JSON),不要再对其调 .json()——
// 否则在普通对象上调 .json() 抛 TypeError,被 friendlyError 误报为「网络连接失败」。
const json = await fetchNodesList();
if (json.success && Array.isArray(json.data)) {
node = json.data.find((n) => (n.node_id || n.id) === nodeId) || null;
}
} catch (err) {
showToast(`获取节点详情失败:${friendlyError(err, '获取节点详情失败')}`, 'error');
return;
}
if (!node) {
showToast(`未找到节点 '${nodeId}',可能已被移除`, 'error');
return;
}
// 填充基础信息
const idEl = document.getElementById('node-manage-id');
if (idEl) idEl.textContent = nodeId;
const isOnline = node.status === 'online' || node.status === 'active';
const isDisabled = node.status === 'disabled';
const statusEl = document.getElementById('node-manage-status');
if (statusEl) {
let badge = '<span class="status-badge offline">离线</span>';
if (isDisabled) badge = '<span class="status-badge secondary">已停用</span>';
else if (isOnline) badge = '<span class="status-badge online">在线</span>';
statusEl.innerHTML = badge;
}
const slotsEl = document.getElementById('node-manage-slots');
if (slotsEl) {
const active = Number(node.active_slots || 0);
// 缺省 4 与表格列(nodesTable volatileValues)口径一致,避免 0/0 误导(审查修复 m9)
const max = Number(node.max_slots || 4);
slotsEl.textContent =
node.admin_max_slots != null
? `${active} / ${Number(node.admin_max_slots)} / ${max}`
: `${active} / ${max}`;
}
const usageEl = document.getElementById('node-manage-usage');
if (usageEl) {
usageEl.textContent = `${Number(node.cpu_usage || 0).toFixed(1)}% / ${Number(node.memory_usage || 0).toFixed(1)}%`;
}
const hbEl = document.getElementById('node-manage-heartbeat');
if (hbEl) hbEl.textContent = fmtFullTs(node.last_heartbeat) || '—';
// 配额输入框:当前配额回填(无配额留空)
const quotaInput = document.getElementById('node-manage-quota-input');
if (quotaInput) {
quotaInput.value = node.admin_max_slots != null ? String(node.admin_max_slots) : '';
}
// 内部按钮:cloneNode 清旧监听后重新绑定(每次打开节点不同,闭包需捕获当前 nodeId/node
const rebind = (id, handler) => {
const old = document.getElementById(id);
if (!old) return;
const fresh = old.cloneNode(true);
old.replaceWith(fresh);
fresh.addEventListener('click', handler);
};
rebind('btn-node-save-quota', async () => {
const raw = (quotaInput?.value || '').trim();
// 留空 → 与「清除配额」按钮同语义。为避免想改数没输全就点保存导致配额被静默清除,
// 留空时走确认弹窗(审查修复 m1),两条清除路径的确认机制保持一致。
if (raw === '') {
const ok = await showConfirm('清除配额限制', `输入框留空且点击了保存。确定清除节点 '${nodeId}' 的并发槽位配额吗?\n清除后将恢复该节点的物理最大槽位上限。`);
if (!ok) return;
try {
const res = await setNodeQuotaApi(nodeId, null);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '已清除配额限制', 'success');
hideModal(modal);
fetchAllData();
} else {
showToast(json.message || '清除配额失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`清除配额失败:${friendlyError(err, '清除配额失败')}`, 'error');
}
return;
}
// 解析为非负整数;上限校验防止超出后端 i32 范围(审查修复 m2)
const val = Number(raw);
if (!Number.isInteger(val) || val < 0) {
showToast('配额必须是非负整数(0=暂停接新任务,留空=无限制)', 'error');
return;
}
if (val > 1024) {
showToast('配额上限过大(最大 1024', 'error');
return;
}
try {
const res = await setNodeQuotaApi(nodeId, val);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '配额已保存', 'success');
hideModal(modal);
fetchAllData();
} else {
showToast(json.message || '保存配额失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`保存配额失败:${friendlyError(err, '保存配额失败')}`, 'error');
}
});
rebind('btn-node-clear-quota', async () => {
const ok = await showConfirm('清除配额限制', `确定清除节点 '${nodeId}' 的并发槽位配额吗?\n清除后将恢复该节点的物理最大槽位上限。`);
if (!ok) return;
try {
const res = await setNodeQuotaApi(nodeId, null);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '已清除配额限制', 'success');
hideModal(modal);
fetchAllData();
} else {
showToast(json.message || '清除配额失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`清除配额失败:${friendlyError(err, '清除配额失败')}`, 'error');
}
});
rebind('btn-node-disable', async () => {
const ok = await showConfirm('停用节点', `确定停用节点 '${nodeId}' 吗?\n停用后保持在线但不再分发任务,可随时重新启用。`);
if (!ok) return;
try { try {
const res = await disableNodeApi(nodeId); const res = await disableNodeApi(nodeId);
const json = await res.json(); const json = await res.json();
if (res.ok && json.success) { if (res.ok && json.success) {
showToast(json.message || '节点已停用', 'info'); showToast(json.message || '节点已停用', 'info');
hideModal(modal);
fetchAllData(); fetchAllData();
} else { } else {
showToast(json.message || '停用失败', 'error'); showToast(json.message || '停用失败:请稍后重试', 'error');
} }
} catch (err) { } catch (err) {
showToast(`请求异常: ${err.message}`, 'error'); showToast(`停用请求失败:${friendlyError(err, '停用请求失败')}`, 'error');
} }
} });
async function handleEnable(nodeId) { rebind('btn-node-enable', async () => {
const ok = await showConfirm('启用节点', `确定重新启用节点 '${nodeId}' 吗?\n节点将在下一次心跳后恢复分发任务。`); const ok = await showConfirm('启用节点', `确定重新启用节点 '${nodeId}' 吗?\n节点将在下一次心跳后恢复分发任务。`);
if (!ok) return; if (!ok) return;
try { try {
const res = await enableNodeApi(nodeId); const res = await enableNodeApi(nodeId);
const json = await res.json(); const json = await res.json();
if (res.ok && json.success) { if (res.ok && json.success) {
showToast(json.message || '节点已重新启用', 'success'); showToast(json.message || '节点已重新启用', 'success');
hideModal(modal);
fetchAllData(); fetchAllData();
} else { } else {
showToast(json.message || '启用失败', 'error'); showToast(json.message || '启用失败:请稍后重试', 'error');
} }
} catch (err) { } catch (err) {
showToast(`请求异常: ${err.message}`, 'error'); showToast(`启用请求失败:${friendlyError(err, '启用请求失败')}`, 'error');
} }
} });
async function handleReissue(nodeId) { rebind('btn-node-reissue', async () => {
const ok = await showConfirm('重发节点 Token', `确定为节点 '${nodeId}' 重新颁发专属 Token 吗?旧 Token 将立即失效,请随后把新 Token 同步到该节点`); const ok = await showConfirm('重发节点 Token', `确定为节点 '${nodeId}' 重新颁发专属 Token 吗?旧 Token 将立即失效。`);
if (!ok) return; if (!ok) return;
try { try {
const res = await reissueNodeApi(nodeId); const res = await reissueNodeApi(nodeId);
const json = await res.json(); const json = await res.json();
if (json.success && json.node_token) { if (res.ok && json.success && json.node_token) {
// 弹窗展示新 token 明文(仅此一次):管理员须手动复制并写入节点本地 .node_token // 先**同步**关闭管理 Modal,再弹 Token 展示窗(审查修复 M1):
// 后重启节点。服务端不持久化明文,故必须在此处交给管理员。 // hideModal 走 220ms 退出动画,期间两个 backdrop 会同时存在(双重暗化)且
const modal = document.getElementById('modal-reissue-token'); // 旧 Modal 的焦点陷阱会在动画结束时强制把焦点拽回表格按钮——导致新 Token 窗
// 的 focus trap 失效。改为跳过动画同步隐藏:释放 trap → 移除动画类 → 立即 hidden。
releaseFocusTrap(modal);
modal.classList.remove('modal-closing');
modal.classList.add('hidden');
const tokenModal = document.getElementById('modal-reissue-token');
document.getElementById('reissue-token-content').textContent = json.node_token; document.getElementById('reissue-token-content').textContent = json.node_token;
modal?.classList.remove('hidden'); tokenModal?.classList.remove('hidden');
setupFocusTrap(modal); setupFocusTrap(tokenModal);
showToast('已重新生成专属 Token,请复制并同步到节点', 'success'); showToast('已重新生成专属 Token,请复制并同步到节点', 'success');
fetchAllData(); fetchAllData();
} else { } else {
showToast(json.message || '重发失败', 'error'); showToast(json.message || '重发失败:请稍后重试', 'error');
} }
} catch (err) { } catch (err) {
showToast(`请求异常: ${err.message}`, 'error'); showToast(`重发请求失败:${friendlyError(err, '重发请求失败')}`, 'error');
} }
});
modal.classList.remove('hidden');
setupFocusTrap(modal);
} }
export function mountHome() { export function mountHome() {
const root = document.getElementById('view-root'); const root = document.getElementById('view-root');
const tpl = document.getElementById('view-home-template'); if (!root) return;
if (!root || !tpl) return; root.innerHTML = HOME_TEMPLATE;
root.replaceChildren(tpl.content.cloneNode(true));
// 工作流卡片事件委托(随 DOM 移除而销毁,无需手动解绑) // 工作流卡片事件委托:卡片操作按钮已全部移入详情页内嵌配置面板
// (见 wfEnginePanel.js),首页仅保留「注册第一个工作流」空态入口。
document.getElementById('workflows-list')?.addEventListener('click', (e) => { document.getElementById('workflows-list')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-wf-action]'); const btn = e.target.closest('[data-wf-action]');
if (!btn) return; if (!btn) return;
const action = btn.getAttribute('data-wf-action'); const action = btn.getAttribute('data-wf-action');
const name = btn.getAttribute('data-wf-name'); if (action === 'create-first') document.getElementById('btn-create-wf')?.click();
if (action === 'start') handleStartWorkflow(name);
else if (action === 'stop') handleStopWorkflow(name);
else if (action === 'delete') handleDeleteWorkflow(name);
else if (action === 'view') handleViewWorkflow(name);
else if (action === 'create-first') document.getElementById('btn-create-wf')?.click();
}); });
// 节点大表事件委托 // 节点大表事件委托
@@ -152,16 +405,13 @@ export function mountHome() {
const nodeId = btn.getAttribute('data-node-id'); const nodeId = btn.getAttribute('data-node-id');
if (action === 'approve') handleApprove(nodeId); if (action === 'approve') handleApprove(nodeId);
else if (action === 'reject') handleReject(nodeId); else if (action === 'reject') handleReject(nodeId);
else if (action === 'reissue') handleReissue(nodeId); else if (action === 'manage') handleManage(nodeId);
else if (action === 'disable') handleDisable(nodeId);
else if (action === 'enable') handleEnable(nodeId);
}); });
// 节点面板刷新按钮(header 的全局刷新按钮仍由 main.js 管理) // 节点面板刷新按钮(header 的全局刷新按钮仍由 main.js 管理)
document.getElementById('btn-refresh-nodes')?.addEventListener('click', (e) => { document.getElementById('btn-refresh-nodes')?.addEventListener('click', (e) => {
triggerRefreshAnimation(e.currentTarget);
showToast('正在刷新节点列表...', 'info'); showToast('正在刷新节点列表...', 'info');
fetchNodes(); triggerRefreshAnimation(e.currentTarget, () => fetchNodes());
}); });
renderNodesTableSkeleton(); renderNodesTableSkeleton();
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
/* DCTS node:test npm install
*
* 覆盖 utils/format.js 的格式化/转义/徽章逻辑这些是最容易出回归 bug 的纯函数
* 尤其 SQLite UTC 时间解析指数记数CSV 转义
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
escapeHtml, parseIso, parseTsMs, fmtTime, fmtDateTime, fmtFullTs,
fmtDuration, fmtDur, fmtRelc, csvEscape, fmtClock,
statusBadge, pointStatusBadge, pointMethodBadge,
} from '../src/utils/format.js';
// ===== escapeHtml =====
test('escapeHtml 转义五个特殊字符', () => {
assert.equal(escapeHtml('<script>&"\'</script>'), '&lt;script&gt;&amp;&quot;&#39;&lt;/script&gt;');
});
test('escapeHtml 顺序敏感:& 先转义,不二次转义', () => {
assert.equal(escapeHtml('a & b'), 'a &amp; b');
assert.equal(escapeHtml('&amp;'), '&amp;amp;'); // 输入本身是实体时也如实转义(不做解码)
});
test('escapeHtml 空值/null 返回空串', () => {
assert.equal(escapeHtml(null), '');
assert.equal(escapeHtml(undefined), '');
assert.equal(escapeHtml(''), '');
});
// ===== 时间解析(SQLite 'YYYY-MM-DD HH:MM:SS' 视为 UTC =====
test('parseIso 解析 SQLite UTC 无时区格式', () => {
const d = parseIso('2026-08-02 15:30:00');
assert.ok(d instanceof Date);
assert.equal(d.toISOString(), '2026-08-02T15:30:00.000Z');
});
test('parseIso 解析带 T 的 ISO 原样', () => {
const d = parseIso('2026-08-02T15:30:00Z');
assert.equal(d.toISOString(), '2026-08-02T15:30:00.000Z');
});
test('parseIso 非法输入返回 null', () => {
assert.equal(parseIso('not-a-date'), null);
assert.equal(parseIso(null), null);
assert.equal(parseIso(''), null);
});
test('parseTsMs 返回 epoch 毫秒,非法输入为 NaN', () => {
assert.equal(parseTsMs('2026-08-02 00:00:00'), Date.UTC(2026, 7, 2));
assert.ok(Number.isNaN(parseTsMs('garbage')));
assert.ok(Number.isNaN(parseTsMs(null)));
});
test('fmtDateTime 输出 MM-DD HH:MM 定宽(按浏览器本地时区)', () => {
// 服务器存 UTC、前端按本地时区显示(既有行为)。断言值用同一 Date 计算,与时区无关。
const d = parseIso('2026-08-02 09:05:07');
const p = (n) => String(n).padStart(2, '0');
assert.equal(
fmtDateTime('2026-08-02 09:05:07'),
`${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
);
assert.equal(fmtDateTime('bad'), 'bad'); // 解析失败原样返回
assert.equal(fmtDateTime(null), '—');
});
test('fmtFullTs 输出完整时间戳(按浏览器本地时区)', () => {
const d = parseIso('2026-08-02 09:05:07');
const p = (n) => String(n).padStart(2, '0');
assert.equal(
fmtFullTs('2026-08-02 09:05:07'),
`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
);
assert.equal(fmtFullTs(null), '');
});
test('fmtTime 非法时间戳回退为转义原文', () => {
assert.equal(fmtTime('garbage'), 'garbage');
assert.equal(fmtTime(null), '—');
});
// ===== 时长格式化 =====
test('fmtDuration(控制条 ETA):分钟粒度', () => {
assert.equal(fmtDuration(30), '1 分钟'); // 30s → round(0.5)=1 分钟(沿用原版算法)
assert.equal(fmtDuration(3600), '1 小时 0 分');
assert.equal(fmtDuration(5400), '1 小时 30 分');
assert.equal(fmtDuration(null), '—');
assert.equal(fmtDuration(NaN), '—');
// ≤0 视为无数据(L3 修复:失败任务 last_elapsed_sec=0.0 不应显示"0 分钟"
assert.equal(fmtDuration(0), '—');
assert.equal(fmtDuration(-5), '—');
});
test('fmtDur(表格单元格):42s / 12m 30s / 1h 20m', () => {
assert.equal(fmtDur(42), '42s');
assert.equal(fmtDur(750), '12m 30s');
assert.equal(fmtDur(4800), '1h 20m');
assert.equal(fmtDur(0), '—'); // ≤0 视为无数据
assert.equal(fmtDur(-5), '—');
assert.equal(fmtDur(''), '—');
assert.equal(fmtDur(null), '—');
});
test('fmtRelc 指数记数(max_relc 相对收敛度)', () => {
assert.equal(fmtRelc(0.00123), '1.23e-3');
assert.equal(fmtRelc(''), '—');
assert.equal(fmtRelc(null), '—');
assert.equal(fmtRelc('abc'), '—');
});
test('fmtClock 心跳时间容错', () => {
assert.match(fmtClock('2026-08-02T09:05:07Z'), /^\d{2}:\d{2}:\d{2}$/);
assert.equal(fmtClock('invalid'), '—');
assert.equal(fmtClock(null), '—');
});
// ===== CSV 转义 =====
test('csvEscape 逗号/引号/换行包裹并翻倍引号', () => {
assert.equal(csvEscape('plain'), 'plain');
assert.equal(csvEscape('a,b'), '"a,b"');
assert.equal(csvEscape('say "hi"'), '"say ""hi"""');
assert.equal(csvEscape('line1\nline2'), '"line1\nline2"');
assert.equal(csvEscape('a\rb'), '"a\rb"');
assert.equal(csvEscape(null), '');
assert.equal(csvEscape(123), '123');
});
// ===== 状态徽章 =====
test('statusBadge 覆盖全部后端工作流状态', () => {
assert.equal(statusBadge('running'), '<span class="status-badge online">运行中</span>');
assert.equal(statusBadge('completed'), '<span class="status-badge completed">已完成</span>');
assert.equal(statusBadge('initializing'), '<span class="status-badge warning">初始化中</span>');
assert.equal(statusBadge('paused'), '<span class="status-badge secondary">已暂停</span>');
assert.equal(statusBadge('idle'), '<span class="status-badge secondary">闲置</span>');
assert.equal(statusBadge('unknown-status'), '<span class="status-badge secondary">闲置</span>'); // 兜底
});
test('pointStatusBadge 覆盖网格点状态', () => {
assert.equal(pointStatusBadge('converged'), '<span class="status-badge online">已收敛</span>');
assert.equal(pointStatusBadge('failed'), '<span class="status-badge danger">失败</span>');
assert.equal(pointStatusBadge('running'), '<span class="status-badge warning">运行中</span>');
assert.equal(pointStatusBadge('queued'), '<span class="status-badge info">排队中</span>');
assert.equal(pointStatusBadge('pending'), '<span class="status-badge secondary">未开始</span>');
assert.equal(pointStatusBadge('???'), '<span class="status-badge secondary">未知</span>');
});
test('pointMethodBadge 识别收敛途径(与后端 success_method 契约对齐)', () => {
assert.equal(pointMethodBadge('cold_run'), '<span class="method-badge cold">冷启动</span>');
assert.equal(pointMethodBadge('seed_step'), '<span class="method-badge seed">种子步进</span>');
// 其它策略名(synspec-only 任务归因的 success_method,如 "standard")原样展示
assert.equal(pointMethodBadge('standard'), '<span class="method-badge syn">standard</span>');
// 含 HTML 元字符须转义(XSS 防护)
assert.equal(
pointMethodBadge('<img src=x onerror=alert(1)>'),
'<span class="method-badge syn">&lt;img src=x onerror=alert(1)&gt;</span>'
);
assert.equal(pointMethodBadge(null), '<span class="text-hint">—</span>');
assert.equal(pointMethodBadge(''), '<span class="text-hint">—</span>');
});
+135
View File
@@ -0,0 +1,135 @@
/* DCTS node:test
*
* utils/polling.js createPoller指数退避failCount 重置/增长stop 清理
* 通过 stubbing setTimeout / clearTimeout 捕获调度延迟不真等 5s+
*
* 时序说明start() 触发 schedule() fn 是同步调用的calls 立即生效
* 后续的 failCount 更新与 setTimeout 排期发生在微任务里`await p.start()`
* 之后测试续体排在其后故断言时调度已完成确定性
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createPoller } from '../src/utils/polling.js';
// 最小 document stubnode 环境无 DOM,轮询器只在 visibilitychange 处触碰 document。
function installDocStub() {
const doc = { hidden: false, addEventListener() {}, removeEventListener() {} };
const prev = globalThis.document;
globalThis.document = doc;
return { doc, restore() { globalThis.document = prev; } };
}
// 捕获 setTimeout 调用。fire(n) 手动执行第 n 次已排定的回调(await 其完整完成)。
function captureTimers() {
const realSet = globalThis.setTimeout;
const realClear = globalThis.clearTimeout;
const scheduled = [];
globalThis.setTimeout = (fn, ms) => {
const id = { fn, ms };
scheduled.push(id);
return id;
};
globalThis.clearTimeout = (id) => {
const i = scheduled.indexOf(id);
if (i >= 0) scheduled.splice(i, 1);
};
return {
delays: () => scheduled.map(t => t.ms),
fire: async (n) => { await scheduled[n].fn(); },
restore() {
globalThis.setTimeout = realSet;
globalThis.clearTimeout = realClear;
},
};
}
test('start() 立即执行一次 fn', async () => {
const stub = installDocStub();
const timers = captureTimers();
let calls = 0;
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
await p.start();
assert.equal(calls, 1); // 立即跑了一次(fn 同步调用)
assert.equal(p.isRunning(), true);
// 成功后下一轮间隔为 baseMs
assert.deepEqual(timers.delays(), [5000]);
p.stop();
timers.restore();
stub.restore();
});
test('fn 返回 false 触发指数退避,成功后重置', async () => {
const stub = installDocStub();
const timers = captureTimers();
let ok = false;
const p = createPoller(() => ok, { baseMs: 5000, maxMs: 60000 });
await p.start();
// 第一次失败 → failCount=1 → 5k*2^1
assert.deepEqual(timers.delays(), [10000]);
await timers.fire(0); // 第二轮仍失败 → failCount=2
assert.deepEqual(timers.delays().slice(-1), [20000]);
ok = true;
await timers.fire(0); // 第三轮成功 → failCount 重置
assert.deepEqual(timers.delays().slice(-1), [5000]);
p.stop();
timers.restore();
stub.restore();
});
test('fn 抛错同样计为失败', async () => {
const stub = installDocStub();
const timers = captureTimers();
const p = createPoller(() => { throw new Error('boom'); }, { baseMs: 5000 });
await p.start();
assert.deepEqual(timers.delays().slice(-1), [10000]);
p.stop();
timers.restore();
stub.restore();
});
test('stop() 清除挂起定时器且不再调度', async () => {
const stub = installDocStub();
const timers = captureTimers();
let calls = 0;
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
await p.start(); // calls=1,已排下一轮
assert.equal(timers.delays().length, 1);
p.stop();
assert.equal(timers.delays().length, 0); // 挂起定时器已被清除
assert.equal(calls, 1); // fn 未再被调用
assert.equal(p.isRunning(), false);
timers.restore();
stub.restore();
});
test('triggerNow() 立即触发一次并续排', async () => {
const stub = installDocStub();
const timers = captureTimers();
let calls = 0;
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
await p.start(); // calls=1
await p.triggerNow(); // calls=2
assert.equal(calls, 2);
assert.equal(p.isRunning(), true);
p.stop();
timers.restore();
stub.restore();
});
test('退避上限 maxMs 生效(长时间连续失败不无限增长)', async () => {
const stub = installDocStub();
const timers = captureTimers();
const p = createPoller(() => false, { baseMs: 5000, maxMs: 60000, maxFails: 4 });
await p.start();
let maxDelay = 0;
for (let i = 0; i < 10; i++) {
maxDelay = Math.max(maxDelay, timers.delays()[timers.delays().length - 1] || 0);
await timers.fire(0);
}
assert.ok(maxDelay <= 60000, `maxDelay=${maxDelay} 不应超过 60s`);
assert.equal(maxDelay, 60000); // 5k*2^4 clamp 到 60s
p.stop();
timers.restore();
stub.restore();
});
+85
View File
@@ -0,0 +1,85 @@
/* DCTS sessionStorage node:test
*
* 覆盖 parSets.js savePsCache / restorePsCache刷新页面后恢复数据单槽位清理
* 损坏数据回退node sessionStorage测试前用内存 Map stub
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
// 内存 sessionStorage stubsetItem 可注入配额错误用于 QuotaExceeded 分支)
let store = new Map();
let quotaExceeded = false;
globalThis.sessionStorage = {
get length() { return store.size; },
key: (i) => [...store.keys()][i] ?? null,
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => {
if (quotaExceeded) throw new Error('QuotaExceededError');
store.set(k, String(v));
},
removeItem: (k) => { store.delete(k); },
};
const { savePsCache, restorePsCache } = await import('../src/views/detail/parSets.js');
function ctx(name, points = [], ts = null) {
return { name, psState: { points, ts } };
}
test('savePsCache 写入 JSON 并可由 restorePsCache 恢复', () => {
store.clear();
const before = Date.now();
const c = ctx('sdB_cno', [{ name: 'p1', teff: 20000, status: 'converged' }]);
savePsCache(c);
// 持久化 key 存在
assert.ok(store.has('dcts_ps_points_sdB_cno'));
// 恢复后 points 一致;快照时间由 savePsCache 记录为保存时刻(Date.now 附近)
const c2 = ctx('sdB_cno');
assert.equal(restorePsCache(c2), true);
assert.deepEqual(c2.psState.points, c.psState.points);
assert.ok(Number.isFinite(c2.psState.ts) && c2.psState.ts >= before);
assert.ok(Math.abs(c2.psState.ts - Date.now()) < 5000);
});
test('多工作流缓存并存:切换工作流不删除彼此的缓存', () => {
store.clear();
savePsCache(ctx('wf_a', [{ name: 'a' }]));
savePsCache(ctx('wf_b', [{ name: 'b' }]));
savePsCache(ctx('wf_a', [{ name: 'a2' }])); // 再回 wf_a 更新,writes 覆盖自身 key
// wf_a 与 wf_b 的缓存都保留
assert.ok(store.has('dcts_ps_points_wf_a'));
assert.ok(store.has('dcts_ps_points_wf_b'));
// 各自恢复出各自的数据(不串)
const ca = ctx('wf_a');
const cb = ctx('wf_b');
assert.equal(restorePsCache(ca), true);
assert.equal(restorePsCache(cb), true);
assert.deepEqual(ca.psState.points, [{ name: 'a2' }]); // 更新后是最新值
assert.deepEqual(cb.psState.points, [{ name: 'b' }]);
});
test('无缓存 / 空 key 时 restorePsCache 返回 false', () => {
store.clear();
const c = ctx('no_such_wf');
assert.equal(restorePsCache(c), false);
assert.deepEqual(c.psState.points, []);
});
test('损坏的 JSON / 非数组数据返回 false(降级为重新拉取)', () => {
store.clear();
store.set('dcts_ps_points_bad_json', 'not-json{{{');
store.set('dcts_ps_points_bad_shape', JSON.stringify({ foo: 1 }));
assert.equal(restorePsCache(ctx('bad_json')), false);
assert.equal(restorePsCache(ctx('bad_shape')), false);
});
test('QuotaExceeded 时 savePsCache 静默失败,不抛错', () => {
store.clear();
quotaExceeded = true;
assert.doesNotThrow(() => savePsCache(ctx('big_wf', new Array(10000).fill({ x: 1 }))));
quotaExceeded = false;
});
+259
View File
@@ -0,0 +1,259 @@
/* DCTS wfEnginePanel YAML /node:test
*
* 覆盖 utils/yamlStage.js 的注释处理块边界往返保真这些是审查 #4/#5
* 顶层注释吞块行内注释污染解析修复的回归守护
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
defaultStage,
extractTopBlock,
parseStageFromYaml,
resolveTlustyFromYaml,
serializeStageBlock,
applyStageBlocks,
validateStageConfigs,
} from '../src/utils/yamlStage.js';
test('defaultStagetlusty 默认 [cold_run, seed_step]synspec 默认 [standard]', () => {
assert.deepEqual(defaultStage('tlusty'), {
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
});
assert.deepEqual(defaultStage('synspec'), {
enabled: true, policy: 'skip_converged', strategies: ['standard'],
});
});
test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块(审查 #4)', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: skip_converged',
'# 这是顶层注释,应终止 tlusty 块',
'synspec_stage:',
' enabled: false',
' policy: force_recompute',
].join('\n');
const t = extractTopBlock(yaml, 'tlusty');
assert.equal(t.lines.length, 3, 'tlusty 块应只含自身 3 行,不含顶层注释与后续块');
assert.ok(!t.lines.some(l => l.includes('synspec_stage')), '不吞入 synspec_stage');
const s = extractTopBlock(yaml, 'synspec_stage');
assert.ok(s, 'synspec_stage 块应能被独立定位(未被 tlusty 吞掉)');
assert.equal(s.lines.length, 3);
});
test('extractTopBlock:空行属块体,缩进注释属块体', () => {
const yaml = [
'tlusty:',
' enabled: true',
'',
' # 缩进注释,属块体',
' policy: skip_converged',
].join('\n');
const t = extractTopBlock(yaml, 'tlusty');
assert.equal(t.lines.length, 5);
});
test('parseStageFromYaml:行内注释不污染 policy 解析(审查 #5', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: skip_converged # 旧值 force_recompute 已废弃',
' strategies: [cold_run, seed_step]',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
assert.equal(t.policy, 'skip_converged', '应取真值而非注释里的 force_recompute');
assert.equal(t.enabled, true);
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
});
test('parseStageFromYaml:整行注释里的 policy 不被当真值(审查 #5', () => {
const yaml = [
'tlusty:',
' # policy: force_recompute <- 注释掉的旧值',
' policy: skip_failed',
' strategies: [cold_run]',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
assert.equal(t.policy, 'skip_failed', '应跳过整行注释,取真值 skip_failed');
});
test('parseStageFromYamlenabled 行内注释', () => {
const yaml = [
'synspec_stage:',
' enabled: false # 仅计算大气',
' policy: skip_converged',
' strategies: [standard]',
].join('\n');
const s = parseStageFromYaml(yaml, 'synspec_stage');
assert.equal(s.enabled, false, 'enabled=false 应正确解析(注释不干扰)');
});
test('parseStageFromYaml:块式 strategies 列表', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: skip_converged',
' strategies:',
' - cold_run',
' - seed_step',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
});
test('parseStageFromYaml:块不存在返回 null', () => {
const yaml = 'grid:\n teff: [20000]\n';
assert.equal(parseStageFromYaml(yaml, 'tlusty'), null);
});
test('resolveTlustyFromYaml:无 tlusty 块时按 seed_step_fallback 推断(审查 #2 修复)', () => {
// seed_step_fallback: false → [cold_run](不回退),与服务端 resolve_tlusty_config 同口径。
const yamlFalse = [
'grid:',
' teff: [20000]',
'seed_step_fallback: false',
'synspec:',
' wstart: 3000.0',
].join('\n');
assert.deepEqual(resolveTlustyFromYaml(yamlFalse), {
enabled: true, policy: 'skip_converged', strategies: ['cold_run'],
}, 'seed_step_fallback:false 应推断 [cold_run](面板保存不得静默启用回退)');
// seed_step_fallback: true → 默认链 [cold_run, seed_step]。
const yamlTrue = 'seed_step_fallback: true\n';
assert.deepEqual(resolveTlustyFromYaml(yamlTrue), defaultStage('tlusty'));
// 缺省(无该字段)→ 默认链。
const yamlAbsent = 'grid:\n teff: [20000]\n';
assert.deepEqual(resolveTlustyFromYaml(yamlAbsent), defaultStage('tlusty'));
});
test('resolveTlustyFromYaml:有 tlusty 块时优先块(不受旧字段干扰)', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: force_recompute',
' strategies: [cold_run]',
'seed_step_fallback: true',
].join('\n');
assert.deepEqual(resolveTlustyFromYaml(yaml), {
enabled: true, policy: 'force_recompute', strategies: ['cold_run'],
}, '显式 tlusty 块优先');
});
test('resolveTlustyFromYaml:注释行不误判 seed_step_fallback', () => {
const yaml = '# seed_step_fallback: false(注释,非真实配置)\ngrid:\n teff: [20000]\n';
assert.deepEqual(resolveTlustyFromYaml(yaml), defaultStage('tlusty'),
'注释行被忽略,按缺省推断默认链');
});
test('applyStageBlocks:替换已存在块,保留其他配置(往返保真)', () => {
const yaml = [
'grid:',
' teff: [20000]',
'timeout_sec: 7200',
'tlusty:',
' enabled: true',
' policy: skip_converged',
' strategies: [cold_run]',
'synspec_stage:',
' enabled: true',
' policy: skip_converged',
' strategies: [standard]',
].join('\n');
const out = applyStageBlocks(yaml,
{ enabled: false, policy: 'force_recompute', strategies: ['seed_step'] },
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
);
// grid 与 timeout_sec 必须保留。
assert.match(out, /teff: \[20000\]/);
assert.match(out, /timeout_sec: 7200/);
// tlusty 块被替换为新值。
assert.match(out, /tlusty:\n enabled: false\n policy: force_recompute\n strategies: \[seed_step\]/);
// synspec_stage 块保留/替换正确。
assert.match(out, /synspec_stage:\n enabled: true\n policy: skip_converged\n strategies: \[standard\]/);
});
test('applyStageBlocks:块不存在时追加到末尾', () => {
const yaml = 'grid:\n teff: [20000]\n';
const out = applyStageBlocks(yaml, defaultStage('tlusty'), defaultStage('synspec'));
assert.match(out, /tlusty:/);
assert.match(out, /synspec_stage:/);
assert.match(out, /teff: \[20000\]/, '原配置保留');
});
test('applyStageBlocks:两块间有顶层注释时不互相破坏(审查 #4 回归)', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: skip_converged',
' strategies: [cold_run, seed_step]',
'# 分隔注释',
'synspec_stage:',
' enabled: true',
' policy: skip_converged',
' strategies: [standard]',
].join('\n');
// 修改 tlusty 不应破坏 synspec_stage(旧实现会把注释+synspec 吞进 tlusty 块后覆盖丢失)。
const out = applyStageBlocks(yaml,
{ enabled: false, policy: 'skip_converged', strategies: ['cold_run'] },
defaultStage('synspec'),
);
assert.match(out, /synspec_stage:\n enabled: true/, 'synspec_stage 块必须存活');
assert.match(out, /tlusty:\n enabled: false/, 'tlusty 块已更新');
});
test('serializeStageBlockflow 序列输出格式', () => {
const s = serializeStageBlock('tlusty', {
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
});
assert.equal(s, 'tlusty:\n enabled: true\n policy: skip_converged\n strategies: [cold_run, seed_step]');
});
test('端到端往返:解析 → 序列化 → 再解析 保持语义', () => {
const orig = [
'tlusty:',
' enabled: true',
' policy: force_recompute',
' strategies: [cold_run, seed_step]',
].join('\n');
const parsed = parseStageFromYaml(orig, 'tlusty');
const serialized = serializeStageBlock('tlusty', parsed);
const reparsed = parseStageFromYaml(serialized, 'tlusty');
assert.deepEqual(parsed, reparsed, '往返保真');
});
test('validateStageConfigs:双阶段全关拦截(修复审查 #5)', () => {
const err = validateStageConfigs(
{ enabled: false, policy: 'skip_converged', strategies: ['cold_run'] },
{ enabled: false, policy: 'skip_converged', strategies: ['standard'] },
);
assert.ok(err && err.includes('至少应启用一个计算阶段'), `应拦截双关,实际: ${err}`);
assert.equal(validateStageConfigs(
{ enabled: true, policy: 'skip_converged', strategies: ['cold_run'] },
{ enabled: false, policy: 'skip_converged', strategies: ['standard'] },
), null, '单阶段启用合法');
});
test('validateStageConfigs:启用阶段空策略链拦截(修复审查 #4)', () => {
const tlustyErr = validateStageConfigs(
{ enabled: true, policy: 'skip_converged', strategies: [] },
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
);
assert.ok(tlustyErr && tlustyErr.includes('TLUSTY'), `应拦截 TLUSTY 空链,实际: ${tlustyErr}`);
const synspecErr = validateStageConfigs(
{ enabled: true, policy: 'skip_converged', strategies: ['cold_run'] },
{ enabled: true, policy: 'skip_converged', strategies: [] },
);
assert.ok(synspecErr && synspecErr.includes('SYNSPEC'), `应拦截 SYNSPEC 空链,实际: ${synspecErr}`);
// 禁用阶段的空链不拦截(该阶段不执行)。
assert.equal(validateStageConfigs(
{ enabled: false, policy: 'skip_converged', strategies: [] },
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
), null, '禁用阶段空链合法');
});
+11
View File
@@ -13,5 +13,16 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
// 项目零第三方依赖、无 polyfill 需求,目标锁定现代浏览器:
// es2022 允许更紧凑的语法(optional chaining 等不再转译)。
target: 'es2022',
// CSS 中使用了 light-dark()Chrome 123+/FF 120+/Safari 17.5+),
// 告知 esbuild 压缩器该语法已被目标支持,避免降级/告警。
cssTarget: 'chrome123',
// 首屏 CSS 独立产物;JS 分包由 router 的动态 import(首页/详情页)天然完成,
// 无第三方 vendor 可做 manualChunks。
cssCodeSplit: true,
// 详情页含 Parallel Sets 手写 SVG 渲染逻辑,bundle 较大;放宽告警阈值避免噪音。
chunkSizeWarningLimit: 600,
}, },
}); });
+165 -245
View File
@@ -2,56 +2,56 @@
> 本文档说明完整理论光谱网格的计算流程:每个网格点的计算阶段、每阶段的配置、 > 本文档说明完整理论光谱网格的计算流程:每个网格点的计算阶段、每阶段的配置、
> 配置原理、CPU/并行机制、以及如何统计每个阶段的信息(时间、收敛等)。 > 配置原理、CPU/并行机制、以及如何统计每个阶段的信息(时间、收敛等)。
> 当前实现为 **Rust 分布式 C/S + MQ 架构**(非早期 Python 单机脚本),调度与阶段配置
> 详见 `architecture.md` / `task_engine_decoupling_design.md`
--- ---
## 1. 总体架构 ## 1. 总体架构
``` ```
config.yaml (网格点 + 收敛链配置) workflows/<wf>.yaml (网格点 + 阶段独立配置 + 策略链)
run_grid.py ── 生成 6 维笛卡尔积参数点 server GridScheduler (schedule_pending_tasks)
断点续算(跳过已成功) / 种子复用(最近邻) / 原子选点(claim_pending_grid_points) → 生成 TaskSpec
│ 失败隔离 / 冷启动失败→种子步进回退
├── worker 1 ── run_one.py ── 点 A
├── worker 2 ── run_one.py ── 点 B 每个 worker 独立工作目录
├── ... 互不干扰,并行(默认16核)
└── worker N ── run_one.py ── 点 X
冷启动链(lte→nc→nl) + synspec MQ task_queue (SQLitepending/claimed 状态机)
│ 冷启动发散且有干净邻居种子? │ Worker 轮询 claim 抢占(pull 模型)
▼ → 移失败结果到 <model>.coldfail/
种子步进链(seed_nc→nl) + synspec 改用 LTGRAY=F 热启动重试
results/<模型名>/ node executor(每任务独立沙盒 task_<id>/
conv.json ← 阶段信息(收敛/迭代/时间/seed_step_used) │ 按 tlusty_strategies[0] 执行收敛链
*.spec/.cont ← 光谱 ├── ColdRun : lte → nc → nl → synspec
*.7 ← 各阶段大气 ├── SeedStep : seed_nc → nl → synspec(凭 seed_point_name 下载种子 .7 热启动)
结果上报(report)→ 白名单归档 result_dir/<name>/ → 清理沙盒
│ 收敛且干净 → .7 入种子库 seeds(供他点热启动)
``` ```
策略链回退:当前策略失败 → 服务端弹出链首 → 下一顺位(如 `seed_step`)注入种子重试,
详见 `design.md §2``task_engine_decoupling_design.md §4.2`
--- ---
## 2. 每个网格点的计算阶段 ## 2. 每个网格点的计算阶段
每个网格点(一组 Teff/logg/logHe/logC/logN/logO 参数)经过 **4 个阶段** 每个网格点(一组 Teff/logg/logHe/logC/logN/logO 参数)经过 **TLUSTY 大气求解 + SYNSPEC
光谱合成**。TLUSTY 阶段按执行策略选用不同收敛链(`common::runner``default_cold_chain` /
`default_seed_chain`)。
**默认走"冷启动链"**DEFAULT_CHAIN适用 20-40K 及大部分易收敛点): **冷启动链**(适用 20-40K 及大部分易收敛点):
| 阶段 | 程序 | 做什么 | NITER | 典型耗时 | | 阶段 | 程序 | 做什么 | NITER | 典型耗时 |
|------|------|--------|-------|---------| |------|------|--------|-------|---------|
| 1. LTE 灰大气 | tlusty | `T T` 模式,解析求灰色 T(τ) 结构 | 0 | 1-3 秒 | | 1. LTE 灰大气 | tlusty | `lte=T, ltgray=T` 模式,解析求灰色 T(τ) 结构 | 0 | 1-3 秒 |
| 2. ncNLTE 连续谱)| tlusty | `F F` + `ilvlin=0`,收敛电离平衡(无线跃迁)| **10** | 1-5 分钟 | | 2. ncNLTE 连续谱)| tlusty | `lte=F, ltgray=F` + `ilvlin=0`,收敛电离平衡(无线跃迁)| **10** | 1-5 分钟 |
| 3. nlNLTE 含线)| tlusty | `F F` + `ilvlin=100`,加全部谱线跃迁(要求收敛)| 100 | 5-20 分钟 | | 3. nlNLTE 含线)| tlusty | `lte=F, ltgray=F` + `ilvlin=100`,加全部谱线跃迁(要求收敛)| 100 | 5-20 分钟 |
| 4. synspec | synspec | 用 nl 大气合成可观测光谱 | — | 3-10 秒 | | 4. synspec | synspec | 用 nl 大气合成可观测光谱 | — | 3-10 秒 |
**阶段间依赖**:1→2→3→4 严格顺序。每阶段用上一阶段的 `.7` 大气作种子(fort.8)。 **阶段间依赖**:1→2→3→4 严格顺序。每阶段用上一阶段的 `.7` 大气作种子(fort.8)。
> 冷启动链在高温/He-poor/富金属区会发散,此时 run_grid 自动改走下文 §2.1 > 冷启动链在高温/He-poor/富金属区会发散,此时按策略链回退走 **种子步进链**
> 的**种子步进链**seed_nc→nl,跳过 LTE grey 直接热启动)。 > seed_nc→nl,跳过 LTE grey 直接热启动)。
### 为什么是这 4 个阶段(原理) ### 为什么是这 4 个阶段(原理)
@@ -67,33 +67,31 @@ Tlusty 的 NLTE 求解用**迭代线性化**complete linearization)。线
线扰动小,快速收敛(典型 ~15 次迭代)。 线扰动小,快速收敛(典型 ~15 次迭代)。
- **阶段4synspec**:用 nl 阶段收敛的大气模型,计算指定波长范围的合成光谱。 - **阶段4synspec**:用 nl 阶段收敛的大气模型,计算指定波长范围的合成光谱。
### 2.1 种子步进链(seed_step)—— 高温区破局NEW ### 2.1 种子步进链(seed_step)—— 高温区破局
当冷启动链发散时(典型:60000-80000K + He-poor + 富金属),run_grid 自动 当冷启动链发散时(典型:60000-80000K + He-poor + 富金属),策略链回退切换到
切换到**种子步进链**`seed_step.SEED_STEP_CHAIN`),跳过 LTE grey 冷启动, **种子步进链**`default_seed_chain`),跳过 LTE grey 冷启动,直接用邻居已收敛的
直接用邻居已收敛的 `.7` 热启动: `.7` 热启动:
| 阶段 | 做什么 | 关键标志 | NITER | | 阶段 | 做什么 | 关键标志 | NITER |
|------|--------|---------|-------| |------|--------|---------|-------|
| seed_nc | 从种子热启动 NLTE 连续谱 | `LTGRAY=F`(读 fort.8) + `ICHANG=0` | 80 | | seed_nc | 从种子热启动 NLTE 连续谱 | `ltgray=F`(读 fort.8) + `ichang=0` | 20 |
| nl | 含谱线完整 NLTE(要求收敛)| `ilvlin=100` | 100 | | nl | 含谱线完整 NLTE(要求收敛)| `ilvlin=100` | 100 |
**触发流程**`run_grid.py``_worker`): **触发流程**服务端策略链机制,非节点端自行判断):
1. 先跑冷启动链(DEFAULT_CHAIN)。 1. 初始派发链首策略(如 `cold_run`);失败上报(`failed_stage="tlusty"`)后,
2. 若 `converged=false``seed_step_fallback=true` 且找到了干净邻居种子: 服务端 `trigger_strategy_fallback` 弹出链首,重置点 pending,再派发下一顺位。
- 把失败结果整体移到 `results/<model>.coldfail/`(避免污染种子库); 2. 若下一顺位为 `seed_step`:服务端在全局种子库按**有向 CNO 距离**找最近邻收敛种子,
- 用 `SEED_STEP_CHAIN``seed=<邻居>.7`)重算; 注入 `seed_point_name` 后派发;无种子则暂存顺位待种子出现。
- conv.json 里记 `seed_step_used=true``coldfail_backup=<路径>` 3. 节点凭 `seed_point_name` 下载种子 `.7``GET /api/seed/<name>`),作 fort.8 热启动跑
`seed_nc → nl`
**原理**`tlusty208.f` 源码确认,详见 EXPERIENCE.md §5Y): **原理**`tlusty208.f` 源码确认,详见 EXPERIENCE.md):
- `LTGREY=T``CALL LTEGR` 生成灰大气(**忽略 fort.8**,冷启动); - `LTGREY=T``CALL LTEGR` 生成灰大气(**忽略 fort.8**,冷启动);
- `LTGREY=F``CALL INPMOD` 读 fort.8 作初猜(**热启动**)。 - `LTGREY=F``CALL INPMOD` 读 fort.8 作初猜(**热启动**)。
- `ICHANG=0`:种子与目标模型原子完全一致(同为 H/He/CNO 设置),只改 - `ICHANG=0`:种子与目标模型原子完全一致(同为 H/He/CNO 设置),只改
Teff/logg/丰度,不需要重映射布居数。 Teff/logg/丰度,不需要重映射布居数。
**实测突破**80K + He-poor + logCNO=-4,冷启动必败点):种子步进 126s 收敛
(冷启动 970s 发散到 NaN)。详见 EXPERIENCE.md §5Y 验证表。
> **物理极限(如实标注)**80000K + He-poor + logCNO=-1 即使种子步进也发散 > **物理极限(如实标注)**80000K + He-poor + logCNO=-1 即使种子步进也发散
> (CNO 高价离子主导不透明度,金属 ×10 跳跃线性化无法阻尼)。网格如实标 > (CNO 高价离子主导不透明度,金属 ×10 跳跃线性化无法阻尼)。网格如实标
> `converged=false`,不强制成功——这些极端参数组合观测上本就罕见。 > `converged=false`,不强制成功——这些极端参数组合观测上本就罕见。
@@ -110,7 +108,7 @@ Tlusty 的 NLTE 求解用**迭代线性化**complete linearization)。线
``` ```
第1行: TEFF GRAV (三阶段相同:目标参数) 第1行: TEFF GRAV (三阶段相同:目标参数)
第2行: LTE LTGRAY (阶段1=T T,阶段2/3=F F;种子步进链全 F) 第2行: LTE LTGRAY (阶段1=T T,阶段2/3=F F;种子步进链全 F)
第3行: nst 文件名 (固定写 'nst',内容每阶段由 write_nst 生成) 第3行: nst 文件名 (固定写 'nst',内容每阶段由 nst_writer 生成)
第4行: NFREAD =2000 → 展开约 75443 个频率点;不要用 50) 第4行: NFREAD =2000 → 展开约 75443 个频率点;不要用 50)
第5行: NATOMS =8: H,He,空×3,C,N,O 第5行: NATOMS =8: H,He,空×3,C,N,O
第6+行: atoms (mode abn modpf) C/N/O 的 mode=2 显式NLTE, abn=10^logX 第6+行: atoms (mode abn modpf) C/N/O 的 mode=2 显式NLTE, abn=10^logX
@@ -121,35 +119,32 @@ ions段: iat iz nlevs ilast ilvlin nonstd typion filei
**为什么 NATOMS/ions 三阶段必须相同**:每阶段的 `.7` 大气记录了每个能级的 **为什么 NATOMS/ions 三阶段必须相同**:每阶段的 `.7` 大气记录了每个能级的
布居数。种子与目标的能级结构必须一一对应,否则读取时索引错位 → NaN。 布居数。种子与目标的能级结构必须一一对应,否则读取时索引错位 → NaN。
### 3.2 nst 文件(非标准参数,每阶段不同) ### 3.2 nst 文件(非标准参数,每阶段统一结构,NITER 不同)
**阶段1(LTE 灰大气)—— 保持干净,不加稳定化参数** `nst_writer::generate_nst_content` 对**所有阶段(含 lte)统一生成两行**
``` ```
ND=50,VTB=2.,NITER=0 ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,[CHMAX=..][,ITEK=..],NITER=<阶段>
[ORELAX=..][,IDLTE=..][,IACC=..][,ICHANG=..],IELCOR=-1
``` ```
- `NITER=0`灰大气不迭代,只做一次形式解 - lte: `NITER=0`灰大气不迭代,只做一次形式解
- nc: `NITER=10`, nl: `NITER=100`, seed_nc: `NITER=20`
**阶段2(nc)和阶段3(nl)—— 频率细化(用户验证配方,不设 CHMAX/ITEK**
```
ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,NITER=<阶段>
IELCOR=-1
```
- nc: NITER=10, nl: NITER=100
- **不设 CHMAX**(用默认 0.001,强迫 nc 真正收敛) - **不设 CHMAX**(用默认 0.001,强迫 nc 真正收敛)
- **不设 ITEK**(用默认 4 - **不设 ITEK**(用默认 4
- 尾部统一 `IELCOR=-1`(电子密度修正关闭)
- 参数分行写(line1 ≤64c, line2 余下),避免 tlusty nst 解析器 ~72 字符行宽截断
每个参数的作用与原理: 每个参数的作用与原理:
| 参数 | nc值 | nl值 | 作用 | 为什么这样设 | | 参数 | 作用 | 为什么这样设 |
|------|------|------|------|-------------| |------|------|-------------|
| `ND` | 50 | 50 | 大气深度点数 | sdB 标准配置 | | `ND` | 大气深度点数 | 50sdB 标准配置 |
| `NLAMBD` | 3 | 3 | lambda 迭代频率点数 | 频率网格细化用户验证配方) | | `NLAMBD` | lambda 迭代频率点数 | 3频率网格细化用户验证配方) |
| `VTB` | 2. | 2. | 微湍流速度 km/s | sdB 典型值 | | `VTB` | 微湍流速度 km/s | 2.sdB 典型值 |
| `ISPODF` | 1 | 1 | 频率网格开关 | 启用细化频率网格 | | `ISPODF` | 频率网格开关 | 1启用细化频率网格 |
| `DDNU` | 50. | 50. | 频率间隔因子 | 频率网格细化参数 | | `DDNU` | 频率间隔因子 | 50.频率网格细化参数 |
| `CNU1` | 6. | 6. | 频率网格起点 | 频率网格细化参数 | | `CNU1` | 频率网格起点 | 6.频率网格细化参数 |
| `NITER` | **10** | 100 | 最大迭代数 | nc 给 10 次足够(实测最优)nl 给 100 次 | | `NITER` | 最大迭代数 | nc=10(实测最优)nl=100seed_nc=20 |
| `IELCOR` | -1 | -1 | 电子密度修正 | 关闭 | | `IELCOR` | 电子密度修正 | -1关闭 |
> **关键:不设 CHMAX(用默认 0.001)、不设 ITEK(用默认 4)、不设 IDLTE/ORELAX。** > **关键:不设 CHMAX(用默认 0.001)、不设 ITEK(用默认 4)、不设 IDLTE/ORELAX。**
> 之前版本设了 CHMAX=0.1 导致 nc 没真正收敛,是大部分失败的根本原因。 > 之前版本设了 CHMAX=0.1 导致 nc 没真正收敛,是大部分失败的根本原因。
@@ -161,13 +156,14 @@ IELCOR=-1
> - nl(含谱线)会自修正到正确解,无论 nc 给什么初值; > - nl(含谱线)会自修正到正确解,无论 nc 给什么初值;
> - NITER=10 总耗时 ~12 分钟(35000K CNO),NITER=50 浪费 2.2× 时间。 > - NITER=10 总耗时 ~12 分钟(35000K CNO),NITER=50 浪费 2.2× 时间。
### 3.3 synspec 配置(fort.55.lin + 谱线表) ### 3.3 synspec 配置(fort.55 + 谱线表)
``` ```
fort.55.lin 第6行: WLMIN WLMAX WLSTEP ... CUTOFF ... fort.55 控制卡: 波长窗/展宽/截断(由 workflow YAML 的 synspec: 块或代码默认配置)
谱线表 fort.19: data/gfVIS99.dat (含 C 1412 / N 2396 / O 1885 条线) 谱线表 fort.19: data 下 gf 谱线数据(含 C/N/O 线)
``` ```
- 当前用 3000-7000Å(光学波段,覆盖 C II 4267、C III 4647 等)。 - 代码默认波长窗为 **14001410 Å**`SynspecConfig` 默认),实际使用通常配置到
目标波段(如 3000-7000Å 光学波段,覆盖 C II 4267、C III 4647 等)。
- 大气来自 nl 阶段的 `.7`(复制为 fort.8)。 - 大气来自 nl 阶段的 `.7`(复制为 fort.8)。
--- ---
@@ -177,47 +173,38 @@ fort.55.lin 第6行: WLMIN WLMAX WLSTEP ... CUTOFF ...
### 4.1 每个网格点只用一个 CPU 核 ### 4.1 每个网格点只用一个 CPU 核
**是的。** tlusty.exe 和 synspec.exe 是 Fortran 编译的单线程程序,每个实例只用 **是的。** tlusty.exe 和 synspec.exe 是 Fortran 编译的单线程程序,每个实例只用
1 个 CPU 核。网格点的并行不是靠程序内部的多线程,而是靠**同时启动多个程序实例** 1 个 CPU 核。网格点的并行不是靠程序内部的多线程,而是靠**同时启动多个程序实例**
(分布在多台计算节点上)。
### 4.2 如何做到并行 ### 4.2 如何做到并行(分布式 Pull 模型)
`run_grid.py` 用 Python 的 `multiprocessing.Pool``run_grid.py``_worker` 并行由 **多计算节点 + 每节点多槽位** 构成
```python - **节点**:每个物理设备跑一个 `node` 进程,`DCTS_MAX_SLOTS` 控制该节点并发槽位数
with Pool(nworkers) as pool: (管理员可经心跳配额动态下调)。
for res in pool.imap_unordered(_worker, worker_args): - **队列**:服务端把 pending 点推入 MQWorker 只要 `active_slots < effective_max_slots`
... 就持续 `claim` 抢占(pull 模型,天然负载均衡)。
``` - **沙盒隔离**:每任务独立工作目录 `data/work/task_<id>/`fort.* 文件互不冲突,
这是并行安全的基础。
- 早期 Python 单机 `multiprocessing.Pool` 架构已废弃。
- `nworkers`config.yaml,当前=**16**):同时运行的 worker 进程数。 ### 4.3 吞吐量估算(参考)
- 每个 worker 是一个独立的 Python 子进程,调用 `run_one.py` 跑一个网格点
(在独立的工作目录里,互不干扰)。
- `imap_unordered`:哪个点先完成就先回收,立即分配下一个点(动态负载均衡)。
- 16 核机器跑 16 个 worker = 16 个 tlusty 实例同时跑 = 满载利用。
(按机器核数调整;每个 tlusty 运行是单线程的,调大 nworkers 即可吃更多核。)
**关键:每个 worker 用独立工作目录**(`results/<模型名>/`),避免 fort.* 文件 | 模型类型 | 单点耗时 | 参考吞吐(若干节点) | 收敛性 |
冲突。这是并行安全的基础。
### 4.3 吞吐量估算
| 模型类型 | 单点耗时 | 16核并行吞吐 | 收敛性 |
|---------|---------|-------------|--------| |---------|---------|-------------|--------|
| 20000-40000K(标准 sdB 区)| ~12-25 分钟 | ~48-80 点/小时 | 冷启动全区间可靠 | | 20000-40000K(标准 sdB 区)| ~12-25 分钟 | 随节点槽位总数线性扩展 | 冷启动全区间可靠 |
| 60000K + He-rich/低金属 | ~10-15 分钟 | ~64-96 点/小时 | 冷启动或一步种子步进 | | 60000K + He-rich/低金属 | ~10-15 分钟 | 同上 | 冷启动或一步种子步进 |
| 80000K + He-rich/低金属 | ~3-5 分钟(种子步进)| 同上 | 冷启动失败→种子步进成功 | | 80000K + He-rich/低金属 | ~3-5 分钟(种子步进)| 同上 | 冷启动失败→种子步进成功 |
| 80000K + He-poor + logCNO=-1 | — | — | **真实物理极限,发散**(如实标注) | | 80000K + He-poor + logCNO=-1 | — | — | **真实物理极限,发散**(如实标注) |
当前 config.yaml 共 432 点(4×2×2×3×3*3);中等参数区冷启动为主,
高温区走种子步进回退,整体约需数小时到一天。
--- ---
## 5. 如何统计每阶段信息 ## 5. 如何统计每阶段信息
### 5.1 当前已记录的信息(conv.json) ### 5.1 当前已记录的信息(conv.json)
每个网格点完成后,`results/<模型名>/conv.json` 记录: 每个网格点完成后,`result_dir/<模型名>/conv.json`(节点归档)与
`seeds_dir/<模型名>/conv.json`(服务端)记录:
```json ```json
{ {
@@ -229,33 +216,36 @@ with Pool(nworkers) as pool:
"synspec_rc": 0, "synspec_rc": 0,
"elapsed_sec": 715.0, ← 总耗时(所有阶段+synspec之和) "elapsed_sec": 715.0, ← 总耗时(所有阶段+synspec之和)
"seed": null, ← 冷启动为 null;走种子步进时为邻居 .7 路径 "seed": null, ← 冷启动为 null;走种子步进时为邻居 .7 路径
"seed_step_used": false, ← true=种子步进链跑成功的(含 coldfail_backup 路径)
"stages": [ "stages": [
{ {
"label": "lte", "label": "lte",
"converged": true, "converged": true,
"final": {"itek":null, "rc":0, "max_relc":0.0, "max_relc": 0.0,
"note":"NITER=0 grey start (no iterations)"} "note": "NITER=0 grey start",
"elapsed_sec": 2.1
}, },
{ {
"label": "nc", "label": "nc",
"converged": false, ← nc 不要求收敛,作种子即可(NITER=10) "converged": false, ← nc 不要求收敛,作种子即可(NITER=10)
"final": {"itek":null, "rc":0, "max_relc":0.957, "max_relc": 0.957,
"worst_depth":1, "last_iter":10, "n_depths":50} "worst_depth": 1, "last_iter": 10, "n_depths": 50,
"elapsed_sec": 62.4
}, },
{ {
"label": "nl", "label": "nl",
"converged": true, "converged": true,
"final": {"itek":null, "rc":0, "max_relc":0.0069, "max_relc": 0.0069,
"worst_depth":1, "last_iter":17, "n_depths":50} "worst_depth": 1, "last_iter": 17, "n_depths": 50,
"elapsed_sec": 7.8
} }
] ]
} }
``` ```
> **走种子步进链时**`seed` 指向邻居 `.7``seed_step_used=true` > **走种子步进链时**`seed` 指向邻居 `.7``stages` 里没有 `lte`而是
> `coldfail_backup` 指向 `<model>.coldfail/``stages` 里没有 `lte`,而是 > `seed_nc``ltgray=F` 热启动,NITER=20)→ `nl`
> `seed_nc`LTGRAY=F 热启动,NITER=80)→ `nl` > 注意:`conv.json` 不含 `seed_step_used` / `coldfail_backup` 布尔字段(旧版字段已移除);
> 收敛手段归因由服务端 `tasks` 表的 `task_type`cold_run / seed_step)聚合统计。
每阶段记录:`converged`(是否收敛)、`max_relc`(最大相对变化)、 每阶段记录:`converged`(是否收敛)、`max_relc`(最大相对变化)、
`worst_depth`(最差深度点)、`last_iter`(迭代次数)、`n_depths`(深度点数)、 `worst_depth`(最差深度点)、`last_iter`(迭代次数)、`n_depths`(深度点数)、
@@ -263,90 +253,35 @@ with Pool(nworkers) as pool:
### 5.2 每阶段时间记录(已实现) ### 5.2 每阶段时间记录(已实现)
`run_one.py` 现在在每个阶段的循环开始/结束处计时,conv.json 里每个 stage 有 runner 在每个阶段的循环开始/结束处计时,conv.json 里每个 stage 有
`elapsed_sec`synspec 也有单独的 `synspec_sec` `elapsed_sec`synspec 也有单独的 `synspec_sec`。另归档保留各阶段快照
`<name>.<label>.5/.6/.err/.nst/.7` 与收敛诊断 `<name>.<label>_chmax*.9`(见
```json `tlusty_result_artifacts.md`)。
"stages": [
{"label":"lte", "elapsed_sec": 2.1, "converged":true, ...},
{"label":"nc", "elapsed_sec": 62.4, "converged":false, ...},
{"label":"nl", "elapsed_sec": 7.8, "converged":true, ...}
],
"synspec_sec": 3.1,
"elapsed_sec": 75.4
```
统计所有模型的阶段时间分布:
```bash
python3 -c "
import json,glob
for f in sorted(glob.glob('results/*/conv.json')):
j=json.load(open(f))
times = {s['label']:s.get('elapsed_sec',0) for s in j['stages']}
print('%-30s lte=%5.0fs nc=%5.0fs nl=%5.0fs syn=%4.0fs total=%5.0fs' % (
j['name'], times.get('lte',0), times.get('nc',0), times.get('nl',0),
j.get('synspec_sec',0), j['elapsed_sec']))
"
```
### 5.3 统计整个网格的信息 ### 5.3 统计整个网格的信息
`run_grid.py` 完成后写 `results/grid_status.json` 早期 `grid_status.json` 文件已废弃;当前状态统计由服务端 API 实时查询 DB 提供
```json - `GET /api/workflows`:各工作流内联进度(total/converged/failed/running…)
{ - `GET /api/workflows/:name/stats`:单工作流统计(含 `cold_run_converged` / `seed_step_converged`
"total": 432, - `GET /api/workflows/:name/points`:逐点列表(可过滤 status/method/wave/q,分页)
"elapsed_sec": 36000, - `GET /api/workflows/:name/progress`:进度-时间序列曲线
"counts": {"converged": 400, "unfinished": 18, "error": 3, "skipped": 11}, - 种子步进命中数 = `tasks``task_type='seed_step'``status='completed'` 的聚合
"seed_step_retries": 47,
"models": [
{"name":"t20000_...", "status":"converged", "max_relc":0.0065,
"seed_step_used": false},
{"name":"t80000_...", "status":"converged", "max_relc":0.00091,
"seed_step_used": true},
...
]
}
```
汇总统计命令: 前端 Dashboard 直接消费以上端点渲染(首页卡片、详情页概览/点表/平行集合分析图)。
```bash
# 成功率 + 种子步进命中数
python3 -c "import json; j=json.load(open('results/grid_status.json')); print(j['counts'], 'seed_step_retries=', j['seed_step_retries'])"
# 所有收敛模型的 max_relc 分布(标注是否走了种子步进)
python3 -c "
import json,glob
for f in sorted(glob.glob('results/*/conv.json')):
j=json.load(open(f))
if j['converged']:
tag='SEED' if j.get('seed_step_used') else 'cold'
print(j['name'], tag, j['final_max_relc'], str(j['elapsed_sec'])+'s')
"
# 失败/未收敛的模型
python3 -c "
import json,glob
for f in sorted(glob.glob('results/*/conv.json')):
j=json.load(open(f))
if not j['converged']:
print(j['name'], 'FAILED', j.get('note',''))
"
```
### 5.4 单个网格点的详细收敛诊断 ### 5.4 单个网格点的详细收敛诊断
```bash - 从节点 `result_dir/<模型>/` 读取 `<模型>.<label>_chmax*.9`(每阶段收敛诊断,
# 看某阶段的迭代收敛趋势(fort.9) 含最大相对变化随迭代下降过程)。
python3 src/check_conv.py results/<模型>/<模型>.nl.9 --chmax 0.01 - 阶段日志 `<模型>.<label>.6/.err` 查看 tlusty 输出与报错。
- 光谱 `*.spec` / 连续谱 `*.cont` / b 因子 `*.bfac` / 出射谱 `*.emflux` 供物理分析。
# 画光谱(标出 CNO 诊断线位置)
python3 src/plot_spec.py results/<模型>
```
--- ---
## 6. 完整操作步骤 ## 6. 完整操作步骤
### 第1步:配置网格密度(config.yaml 的 grid 段) ### 第1步:配置工作流(workflows/<wf>.yaml 的 grid 段)
```yaml ```yaml
grid: grid:
teff: [20000, 30000, 40000, 60000] # 各维采样点列表 teff: [20000, 30000, 40000, 60000] # 各维采样点列表
@@ -356,100 +291,85 @@ grid:
logn: [-4, -2, -1] logn: [-4, -2, -1]
logo: [-4, -2, -1] logo: [-4, -2, -1]
# 共 4*2*2*3*3*3 = 432 个点 # 共 4*2*2*3*3*3 = 432 个点
tlusty:
enabled: true
policy: skip_converged
strategies: ["cold_run", "seed_step"] # 策略链:冷启动失败回退种子步进
synspec:
enabled: true
wstart: 3000
wend: 7000
``` ```
### 第2步:设置环境变量 ### 第2步:创建并启动工作流(HTTP API / Dashboard
```bash ```bash
export TLUSTY=/home/dckj/program/tlusty/tl208-s54 # 创建/保存工作流(config_yaml 上传)
``` curl -X POST localhost:8090/api/workflows -H "Authorization: Bearer $ADMIN_TOKEN" \
-F name=sdB_cno -F config_yaml=@workflows/sdB_cno.yaml
### 第3步:预览(dry-run # 启动(进入 initializing → running,后台异步建网格并派发
curl -X POST localhost:8090/api/workflows/sdB_cno/start \
-H "Authorization: Bearer $ADMIN_TOKEN"
```
也可直接在 Dashboard「工作流 → 启动配置 → 启动」操作。启动后调度器按 wave(难度)
顺序把点推入 MQ,各节点 pull 抢占计算。
### 第3步:监控
- Dashboard:工作流卡片进度条、详情页概览(统计/进度曲线/ETA)、逐点表、平行集合分析图。
- API`GET /api/workflows/:name/stats` / `:name/points` / `:name/progress`
- 服务端日志:`RUST_LOG=info ./server`
### 第4步:断点续算 / 增量
- 工作流默认 `policy: skip_converged`:启动时跳过已收敛点、重试已失败点。
- 加密网格:往 `grid` 各维列表加更多点后保存并重启工作流(自动跳过已完成,只算新点)。
- 高温区加密建议先冷启动算 He-rich/低金属的"桥头堡"模型,建立种子库再扩散到
难收敛点(见 §7.1)。
### 单点调试
```bash ```bash
cd $TLUSTY/cno_grid # 把单个点置回 pending 重跑(或在 Dashboard 详情页查看该点尝试历史/阶段诊断)
python3 src/run_grid.py config.yaml --dry-run curl -X POST localhost:8090/api/workflows/sdB_cno/points \
# 输出:grid: 432 points total, N already done, M to compute -H "Authorization: Bearer $ADMIN_TOKEN" -F point_name=t40000_...
``` ```
> 早期 Python 的 `run_grid.py` / `run_one.py` / `seed_step.py` 脚本与 `src/` 目录已废弃。
### 第4步:启动批量计算(后台并行)
```bash
nohup python3 src/run_grid.py config.yaml > results/grid_run.log 2>&1 &
# 冷启动失败的点会自动尝试种子步进回退(seed_step_fallback: true)。
# 想关闭回退:在 config.yaml 设 seed_step_fallback: false。
```
### 第5步:监控
```bash
tail -f results/grid_run.log # 实时进度
grep seed_step results/grid_run.log # 看哪些点走了种子步进
cat results/grid_status.json # 汇总(完成后才有)
```
### 第6步:断点续算(中断后恢复,自动跳过已成功的)
```bash
python3 src/run_grid.py config.yaml # 重跑同一命令即可
```
### 第7步:检查结果 + 画图
```bash
# 成功率 + 种子步进命中数
python3 -c "import json;j=json.load(open('results/grid_status.json'));print(j['counts'],'seed_step=',j['seed_step_retries'])"
# 画某个模型光谱
python3 src/plot_spec.py results/<模型名>
```
### 单点调试(不走批量)
```bash
# 冷启动单点(适用 20-40K 大部分点)
python3 src/run_one.py --teff 40000 --logg 6.0 --loghe 0 --logc -1 --logn -1 --logo -1
# 种子步进单点(高温 He-poor 等冷启动失败点)
python3 src/seed_step.py --teff 80000 --logg 6.5 --loghe -4 \
--logc -4 --logn -4 --logo -4 \
--seed results/<seed-model>/<seed-model>.7
```
### 第8步:加密网格(Phase 2
在 config.yaml 的各维列表里加更多点,重跑 `run_grid.py`(自动跳过已完成的,
只算新点)。高温区加密时建议先冷启动算 He-rich/低金属的"桥头堡"模型,
建立种子库再扩散到难收敛点(见 §7.1)。
--- ---
## 7. 注意事项 ## 7. 注意事项
1. **种子步进回退(核心机制)**`seed_step_fallback: true`(默认开)时, 1. **种子步进回退(核心机制)**`tlusty_strategies` 链含 `seed_step` 时(默认推荐
冷启动失败的点会自动改走种子步进链`seed_step.SEED_STEP_CHAIN`): `["cold_run", "seed_step"]`),冷启动失败的点会自动回退到种子步进链:服务端在全局
把失败结果备份到 `<model>.coldfail/`,用 `find_seed` 找最近邻已收敛的 `.7` 种子库找近邻已收敛 `.7``ltgray=F + ichang=0` 热启动重跑。这是高温/He-poor/富金属区的
作种子,`LTGRAY=F + ICHANG=0` 热启动重跑。这是高温/He-poor/富金属区的
决定性破局手段(详见 §2.1)。 决定性破局手段(详见 §2.1)。
**种子跨度限制**种子与目标的参数差不能太大(logg ≤0.5/步, **种子匹配**两级匹配——exact_family(同 Teff/logg/logHe 族,CNO 用有向距离:富方向
Teff5000K/步;logCNO 一步 ≤100×)。跨度过大(如 cno-4 直接跳 cno-1 重罚 4×、贫方向 1×)与 global 加权距离(`d_teff/5000 + d_logg×2 + d_loghe×0.5 +
1000× 金属跳跃)即便种子步进也发散 → 这是真实物理极限,网格如实标注 d_cno×0.1 ≤ 3.0`),不再是早期简单的绝对跨度阈值(见 `seed_finder.rs` / `design.md §3`
**种子库建立策略**:高温区建议先冷启动算 He-rich 或低金属的"桥头堡" 跨度过大(如 cno-4 直接跳 cno-1,1000× 金属跳跃)即便种子步进也发散 → 这是真实物理
模型,建立种子库再扩散到难收敛点(如 cno-4 → cno-2 → cno-1 多步跳板) 极限,网格如实标注
**种子库建立策略**:高温区建议先冷启动算 He-rich 或低金属的"桥头堡"模型,建立种子库
再扩散到难收敛点(如 cno-4 → cno-2 → cno-1 多步跳板)。
2. **断点续算判定**`conv.json``converged=true` 的点会被跳过。假收敛 2. **断点续算判定**`conv.json``converged=true` 的点会被跳过。假收敛
atmosphere_has_nan=true)的点会被重算。 `atmosphere_has_nan=true`)的点会被重算。
3. **失败隔离**:单点失败(发散/崩溃)不中断整个网格,记入 grid_status.json 3. **失败处理**:单点失败(发散/崩溃)不中断整个网格;当前策略失败触发策略链回退,
的 error/unfinished 列表。高温难点的失败大多是真实物理极限(见 §2.1), 链耗尽则标记 failed。高温难点的失败大多是真实物理极限(见 §2.1),不强制成功。
不强制成功;如确需重试,可用 `seed_step.py` 手动从更近的种子起跳。
4. **磁盘空间**每个模型约 50-100MB(含中间文件,走种子步进的点还会留 4. **磁盘空间**节点端归档 `result_dir` 永久保留(无 LRU 淘汰),每个模型约
`<model>.coldfail/` 备份)。432 点约需 20-40GB。如不够可定期清理中间文件 2-10MB 白名单产物(`.spec/.cont/.iden/.7/.bfac/.emflux/各阶段快照/日志`)。沙盒
(保留 .spec/.cont/.7/conv.json `task_*` 工作目录结算后清理,节点重启时清理残留
5. **并行安全**:每个 worker 用独立工作目录(results/<模型名>/),fort.* 文件 5. **并行安全**:每任务独立沙盒(`data/work/task_<id>/`),fort.* 文件不冲突。可安全并行。
不冲突。可安全并行。
6. **nst 文件行长限制(已修复)**tlusty 的 nst 解析器有 ~72 字符的行宽限制。 6. **nst 文件行长限制(已修复)**tlusty 的 nst 解析器有 ~72 字符的行宽限制。
如果参数太多写在一行(如加了 IDLTE/IACC 后 >72 字符),行尾的参数会被 如果参数太多写在一行(如加了 IDLTE/IACC 后 >72 字符),行尾的参数会被
静默截断。`write_nst()` 现在把参数分两行写(line1≤64c, line2 余下参数)。 静默截断。`nst_writer` 把参数分两行写(line1≤64c, line2 余下参数)。
这是个隐蔽 bug——截断后 tlusty 不报错而是用默认值,导致"看似成功实则参数 这是个隐蔽 bug——截断后 tlusty 不报错而是用默认值,导致"看似成功实则参数
没生效"。 没生效"。
7. **fort.84 残留(已修复)**:tlusty 运行时会在工作目录写 fort.84(nst 参数的 7. **fort.84 残留(已修复)**:tlusty 运行时会在工作目录写 fort.84(nst 参数的
内部表示)。如果下一次 tlusty 运行(不同 NATOMS)读到旧的 fort.84,会报 内部表示)。如果下一次 tlusty 运行(不同 NATOMS)读到旧的 fort.84,会报
"Bad integer for item 48" 崩溃。`run_tlusty()` 现在每次运行前删除 fort.84。 "Bad integer for item 48" 崩溃。runner 每次运行前删除 fort.84。
8. **收敛可靠性(如实)**:用正确配方(无 CHMAX/ITEK, NFREAD=2000, nc NITER=10 8. **收敛可靠性(如实)**:用正确配方(无 CHMAX/ITEK, NFREAD=2000, nc NITER=10
后,20000-40000K 全区间冷启动可靠收敛;60000-80000K + He-rich/低金属用 后,20000-40000K 全区间冷启动可靠收敛;60000-80000K + He-rich/低金属用
@@ -458,4 +378,4 @@ python3 src/seed_step.py --teff 80000 --logg 6.5 --loghe -4 \
未收敛。之前版本的"8/8 边界全部成功"不准确(基于错误的 CHMAX=0.1)。 未收敛。之前版本的"8/8 边界全部成功"不准确(基于错误的 CHMAX=0.1)。
`ICRSW`Hummer & Voels 切换)在 tlusty208 **原版中是死代码** `ICRSW`Hummer & Voels 切换)在 tlusty208 **原版中是死代码**
SUBROUTINE SWITCH 从未被 CALLCRSW≡1.0),不要依赖它稳定化; SUBROUTINE SWITCH 从未被 CALLCRSW≡1.0),不要依赖它稳定化;
即便源码修复启用后实测对难点也无帮助。详见 EXPERIENCE.md §5Y 即便源码修复启用后实测对难点也无帮助。详见 EXPERIENCE.md。
+121 -37
View File
@@ -46,7 +46,7 @@
### 1.3 限流机制与错误模型 (Rate Limiting & Unified Error) ### 1.3 限流机制与错误模型 (Rate Limiting & Unified Error)
- **API 限流 (Rate Limiting)**:服务端对敏感接口(如 `/login``/node/register`)应用了基于 Governor / 漏桶算法的请求限流器。超出速率上限时返回 `429 Too Many Requests` - **API 限流 (Rate Limiting)**:服务端对敏感接口(如 `/login``/node/register`)应用了**滑动窗口**sliding window)请求限流器(`rate_limit.rs`。超出速率上限时返回 `429 Too Many Requests`
- **统一错误格式 (AppError)**:所有 RESTful API 的错误响应均格式化为标准 JSON: - **统一错误格式 (AppError)**:所有 RESTful API 的错误响应均格式化为标准 JSON:
```json ```json
{ {
@@ -103,23 +103,31 @@
### 2.2 任务规格 (`TaskSpec`) ### 2.2 任务规格 (`TaskSpec`)
服务端派发给 Worker 节点的单个计算任务定义。 服务端派发给 Worker 节点的单个计算任务定义。
- **Rust 定义** ([models.rs](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs#L98-L106)): - **Rust 定义** ([models.rs](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs#L389-L425)):
```rust ```rust
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSpec { pub struct TaskSpec {
pub task_id: Uuid, pub task_id: Uuid,
pub point_name: String, pub point_name: String,
pub params: GridPointParams, pub params: GridPointParams,
pub task_type: TaskType, // ColdRun | SeedStep pub task_type: TaskType, // ColdRun | SeedStep(兼容字段 = strategies[0]
pub seed_point_name: Option<String>,// 步进种子点名称(如适用) pub seed_point_name: Option<String>, // 步进种子点名称(如适用)
pub timeout_sec: u64, pub timeout_sec: u64,
pub workflow_name: Option<String>, // 多工作流分区键
pub wave: i32, // 难度波次
pub tlusty_config: EngineStageConfig, // TLUSTY 阶段配置(enabled/policy/strategies
pub synspec_config: EngineStageConfig, // SYNSPEC 阶段配置
pub synspec_params: Option<serde_json::Value>, // SYNSPEC 数值参数(波长范围等)
pub atmosphere_ref: Option<String>, // 显式大气来源点(仅 SYNSPEC-only 场景)
} }
```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] `EngineStageConfig`(阶段独立配置,见 `task_engine_decoupling_design.md §3`):
#[serde(rename_all = "snake_case")] ```rust
pub enum TaskType { pub struct EngineStageConfig {
ColdRun, pub enabled: bool,
SeedStep, pub policy: String, // skip_converged | force_recompute | skip_failed
pub strategies: Vec<String>, // 策略链:["cold_run","seed_step"],失败回退弹首项
} }
``` ```
@@ -127,6 +135,12 @@
```typescript ```typescript
export type TaskType = 'cold_run' | 'seed_step'; export type TaskType = 'cold_run' | 'seed_step';
export interface EngineStageConfig {
enabled: boolean;
policy: string;
strategies: string[];
}
export interface TaskSpec { export interface TaskSpec {
task_id: string; task_id: string;
point_name: string; point_name: string;
@@ -134,6 +148,12 @@
task_type: TaskType; task_type: TaskType;
seed_point_name?: string | null; seed_point_name?: string | null;
timeout_sec: number; timeout_sec: number;
workflow_name?: string | null;
wave: number;
tlusty_config: EngineStageConfig;
synspec_config: EngineStageConfig;
synspec_params?: Record<string, unknown> | null;
atmosphere_ref?: string | null;
} }
``` ```
@@ -142,7 +162,7 @@
### 2.3 任务上报报告 (`TaskReport`) ### 2.3 任务上报报告 (`TaskReport`)
Worker 节点向服务端上报的任务计算结果。 Worker 节点向服务端上报的任务计算结果。
- **Rust 定义** ([models.rs](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs#L126-L140)): - **Rust 定义** ([models.rs](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs#L496-L513)):
```rust ```rust
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskReport { pub struct TaskReport {
@@ -158,6 +178,7 @@ Worker 节点向服务端上报的任务计算结果。
pub elapsed_sec: f64, pub elapsed_sec: f64,
pub error_message: Option<String>, pub error_message: Option<String>,
pub summary_json: String, pub summary_json: String,
pub failed_stage: Option<String>, // "tlusty" | "synspec":失败阶段归因,决定弹哪条策略链
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -187,6 +208,7 @@ Worker 节点向服务端上报的任务计算结果。
elapsed_sec: number; elapsed_sec: number;
error_message?: string | null; error_message?: string | null;
summary_json: string; summary_json: string;
failed_stage?: 'tlusty' | 'synspec' | null;
} }
``` ```
@@ -237,7 +259,7 @@ Worker 节点向服务端上报的任务计算结果。
node_id: string; node_id: string;
max_slots: number; max_slots: number;
active_slots: number; active_slots: number;
status: 'online' | 'offline'; status: 'online' | 'offline' | 'pending_approval' | 'disabled' | 'rejected';
cpu_usage: number; cpu_usage: number;
memory_usage: number; memory_usage: number;
last_heartbeat: string; last_heartbeat: string;
@@ -281,9 +303,19 @@ Worker 节点向服务端上报的任务计算结果。
export interface WorkflowSummary { export interface WorkflowSummary {
name: string; name: string;
description?: string | null; description?: string | null;
status: 'idle' | 'running' | 'paused' | 'completed'; status: 'idle' | 'initializing' | 'running' | 'paused' | 'completed';
created_at: string; created_at: string;
updated_at: string; updated_at: string;
stats?: WorkflowListStats | null; // 内联网格点聚合计数(未启动为 null,见 §8.11)
}
export interface WorkflowListStats {
total: number;
converged: number;
failed: number;
running: number;
cold_run_converged: number;
seed_step_converged: number;
} }
export interface WorkflowItem { export interface WorkflowItem {
@@ -328,9 +360,12 @@ Worker 节点向服务端上报的任务计算结果。
{ {
"status": "pending_approval", "status": "pending_approval",
"message": "节点注册申请已成功提交!请在管理 Dashboard 控制台上点击【同意接入】授权该节点", "message": "节点注册申请已成功提交!请在管理 Dashboard 控制台上点击【同意接入】授权该节点",
"node_token": null "node_token": null,
"registration_secret": "<一次性凭据>"
} }
``` ```
> `registration_secret`:节点注册时下发的一次性凭据,节点须在后续
> `/api/node/check_status` 时回传,才能取走待发 token(防止仅知 node_id 的攻击者抢先冒领)。
- `200 OK` (已授权节点带专属 token 刷新配置): - `200 OK` (已授权节点带专属 token 刷新配置):
```json ```json
{ {
@@ -353,15 +388,16 @@ Worker 节点向服务端上报的任务计算结果。
### 3.2 节点心跳保活 (`POST /api/node/heartbeat`) ### 3.2 节点心跳保活 (`POST /api/node/heartbeat`)
- **处理函数**: [`heartbeat_node`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/server/src/api/node.rs#L17-L25) - **处理函数**: [`heartbeat_node`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/server/src/api/node.rs#L167-L190)
- **函数签名**: - **函数签名**:
```rust ```rust
pub async fn heartbeat_node( pub async fn heartbeat_node(
State(state): State<AppState>, State(state): State<AppState>,
Extension(auth_node): Extension<AuthenticatedNode>,
Json(req): Json<NodeHeartbeatRequest>, Json(req): Json<NodeHeartbeatRequest>,
) -> impl IntoResponse ) -> impl IntoResponse
``` ```
- **鉴权**: 是 (若配置 Token) - **鉴权**: 是Node 角色必填;`req.node_id` 必须与鉴权身份一致,否则 403
- **请求 Header**: `Content-Type: application/json` - **请求 Header**: `Content-Type: application/json`
- **请求 Body**: - **请求 Body**:
```json ```json
@@ -376,9 +412,12 @@ Worker 节点向服务端上报的任务计算结果。
- `200 OK` (成功): - `200 OK` (成功):
```json ```json
{ {
"status": "ok" "status": "ok",
"admin_max_slots": 4
} }
``` ```
> `admin_max_slots`:管理员强制的并发槽位配额(`null` = 无限制,沿用物理 `max_slots`)。
> 节点据此动态调整本地 `effective_max_slots`(见 `dynamic_cpu_slots_design.md`)。
- `200 OK` (失败): - `200 OK` (失败):
```json ```json
{ {
@@ -415,12 +454,14 @@ Worker 节点向服务端上报的任务计算结果。
"node_id": "node-worker-01", "node_id": "node-worker-01",
"status": "online", "status": "online",
"token_status": "active", "token_status": "active",
"token_issued_at": "2026-07-28T12:00:00Z" "token_issued_at": "2026-07-28T12:00:00Z",
"admin_max_slots": 4
} }
] ]
} }
``` ```
> `token_status` 取值:`active`(有效)/ `none`(无凭据记录)。token 失效靠重发覆盖 hash 实现,不存在「已吊销」中间态。 > `token_status` 取值:`active`(有效)/ `none`(无凭据记录)。token 失效靠重发覆盖 hash 实现,不存在「已吊销」中间态。
> `admin_max_slots`:管理员强制并发槽位配额(`null` = 无限制),Dashboard 据此渲染节点配额状态。
#### 2. 同意节点接入申请 (`POST /api/admin/nodes/:node_id/approve`) #### 2. 同意节点接入申请 (`POST /api/admin/nodes/:node_id/approve`)
- **权限**: Admin 角色 - **权限**: Admin 角色
@@ -459,6 +500,22 @@ Worker 节点向服务端上报的任务计算结果。
-H "Authorization: Bearer <ADMIN_TOKEN>" -H "Authorization: Bearer <ADMIN_TOKEN>"
``` ```
#### 7. 调整节点并发配额 (`POST /api/admin/nodes/:node_id/quota`)
- **权限**: Admin 角色
- **说明**: 动态调整节点并发槽位配额上限(不重启 Worker 进程,经下一次心跳响应下发生效)。传 `null` 解除限制,恢复物理 `max_slots`。详见 `dynamic_cpu_slots_design.md`
- **请求 Body**:
```json
{ "admin_max_slots": 4 }
```
- **响应**:
- `200 OK`: `{"success": true, "message": "节点配额已更新"}`
- **curl 示例**:
```bash
curl -X POST http://localhost:8090/api/admin/nodes/node-worker-01/quota \
-H "Authorization: Bearer <ADMIN_TOKEN>" -H "Content-Type: application/json" \
-d '{"admin_max_slots": 4}'
```
--- ---
## 4. 任务调度与结果上报 API (Task Processing) ## 4. 任务调度与结果上报 API (Task Processing)
@@ -470,9 +527,12 @@ Worker 节点向服务端上报的任务计算结果。
- **处理函数**: [`claim_task`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/server/src/api/task.rs#L15-L25) - **处理函数**: [`claim_task`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/server/src/api/task.rs#L15-L25)
- **函数签名**: - **函数签名**:
```rust ```rust
pub async fn claim_task(State(state): State<AppState>) -> impl IntoResponse pub async fn claim_task(
State(state): State<AppState>,
Extension(auth_node): Extension<AuthenticatedNode>,
) -> impl IntoResponse
``` ```
- **鉴权**: 是 (若配置 Token) - **鉴权**: 是(Node 角色必填;服务端据注入身份校验节点是否被停用)
- **请求 Body**: 无 - **请求 Body**: 无
- **响应 Schema**: - **响应 Schema**:
- `200 OK` (有可计算任务): - `200 OK` (有可计算任务):
@@ -533,21 +593,23 @@ Worker 节点向服务端上报的任务计算结果。
```rust ```rust
pub async fn report_task( pub async fn report_task(
State(state): State<AppState>, State(state): State<AppState>,
Extension(auth_node): Extension<AuthenticatedNode>,
mut multipart: Multipart, mut multipart: Multipart,
) -> impl IntoResponse ) -> impl IntoResponse
``` ```
- **鉴权**: 是 (若配置 Token) - **鉴权**: 是(Node 角色必填;服务端据注入身份做任务归属校验 `verify_task_claim`,防跨节点伪造)
- **请求格式**: `multipart/form-data` - **请求格式**: `multipart/form-data`
- Part `report`: JSON 字符串 (映射为 `TaskReport`) - Part `report`: JSON 字符串 (映射为 `TaskReport`)
- Part `seed_file` *(可选)*: 二进制数据 (收敛网格点的 `.7` 大气结构种子文件) - Part `seed_file` *(可选)*: 二进制数据 (收敛网格点的 `.7` 大气结构种子文件)
- **响应 Schema**: - **响应 Schema**:
- `200 OK` (成功): - `200 OK` (成功 / 幂等重放):
```json ```json
{ {
"status": "ok", "status": "ok",
"message": "上报成功" "message": "上报成功"
} }
``` ```
> **幂等重放**:已结算任务(终态守卫已吸收)的重复/迟到上报仍返回 200,并补写种子,不重复结算。
- `400 Bad Request` (缺少 `report` 字段): - `400 Bad Request` (缺少 `report` 字段):
```json ```json
{ {
@@ -562,7 +624,10 @@ Worker 节点向服务端上报的任务计算结果。
"message": "无法解析 params 或 summary_json" "message": "无法解析 params 或 summary_json"
} }
``` ```
- **说明**: 当 `converged == true``atmosphere_has_nan == false` 且包含 `seed_file` 时,服务端会将种子保存至 `seeds_dir/<point_name>/<point_name>.7` 并记入 `seeds` 表。若冷启动任务失败,服务端会自动唤醒 `GridScheduler` 触发针对该网格点的步进回退算法 (Seed-step Fallback) - `409 Conflict` (任务归属校验失败): 任务被其它节点重新领用、或队列中已无该 task_id(已被结算清理)。此时返回冲突,不写库
- **说明**:
- 当 `converged == true``atmosphere_has_nan == false` 且包含 `seed_file` 时,服务端会将种子原子写入 `seeds_dir/<point_name>/<point_name>.7` 并记入 `seeds` 表(源精度 `point_name`)。
- 失败上报(未收敛 / 失败 / 超时)且 `state_changed == true` 时,服务端才触发**策略链回退**`trigger_strategy_fallback`,按 `failed_stage` 弹 TLUSTY 或 SYNSPEC 策略链);被终态守卫吸收的重复失败(`state_changed=false`)不再触发,杜绝重复派发涡旋(2026-08-02 修复)。
- **curl 示例**: - **curl 示例**:
```bash ```bash
curl -X POST http://localhost:8090/api/task/report \ curl -X POST http://localhost:8090/api/task/report \
@@ -591,6 +656,7 @@ Worker 节点向服务端上报的任务计算结果。
- **请求格式**: `multipart/form-data` - **请求格式**: `multipart/form-data`
- Part `report`: 旧版 `conv.json` 的**原文 JSON**(映射为 `ModelSummary`,服务端解析出 `name` / `params` / `converged` / `final_max_relc` - Part `report`: 旧版 `conv.json` 的**原文 JSON**(映射为 `ModelSummary`,服务端解析出 `name` / `params` / `converged` / `final_max_relc`
- Part `seed_file`: 二进制数据(`.7` 大气种子文件;收敛点必传) - Part `seed_file`: 二进制数据(`.7` 大气种子文件;收敛点必传)
- Part `success_method` *(可选)*: 文本 `cold_run` / `seed_step`。由 `tools/import_results` 依据旧 `conv.json` 的 stages 是否含 `seed_nc` 判定后设置,决定导入点最终归因(缺省按 seed_step 统计)
- **Query 参数**: `workflow`(可选,默认 `imported`):目标工作流名,种子导入到该工作流的 `grid_points` - **Query 参数**: `workflow`(可选,默认 `imported`):目标工作流名,种子导入到该工作流的 `grid_points`
- **命名保真**: `point_name` 取旧 `conv.json``name` 字段(源精度真名,如 `t20000_g5.0_...`),**逐字符**落库(磁盘目录、`grid_points.name``seeds.point_name`),与旧版 Python `gen_input5.model_name` 完全一致。 - **命名保真**: `point_name` 取旧 `conv.json``name` 字段(源精度真名,如 `t20000_g5.0_...`),**逐字符**落库(磁盘目录、`grid_points.name``seeds.point_name`),与旧版 Python `gen_input5.model_name` 完全一致。
- **幂等**: `ON CONFLICT DO NOTHING` upsert `grid_points``conv.json``.7` 原子覆盖写,可重复运行。 - **幂等**: `ON CONFLICT DO NOTHING` upsert `grid_points``conv.json``.7` 原子覆盖写,可重复运行。
@@ -742,15 +808,19 @@ Worker 节点向服务端上报的任务计算结果。
"grid_stats": { "grid_stats": {
"total": 512, "total": 512,
"pending": 210, "pending": 210,
"queued": 60,
"running": 32, "running": 32,
"converged": 260, "converged": 260,
"failed": 10 "failed": 10,
"cold_run_converged": 200,
"seed_step_converged": 60
} }
} }
``` ```
> `grid_stats` 为**全部工作流的合计**(跨工作流全局聚合)。多工作流并发运行时,此处展示所有 > `grid_stats` 为**全部工作流的合计**(跨工作流全局聚合)。多工作流并发运行时,此处展示所有
> 工作流 grid_points 的汇总进度;如需查看单个工作流的进度,可读取该工作流各自的 grid_points 统计 > 工作流 grid_points 的汇总进度;`queued``pending` 分开计数(多工作流分区新语义),
> `Database::get_grid_summary_stats(Some(workflow_name))`)。 > `cold_run_converged` / `seed_step_converged` 为收敛手段归因;如需查看单个工作流的进度,
> 可读取该工作流各自的 grid_points 统计(`Database::get_grid_summary_stats(Some(workflow_name))`)。
- **curl 示例**: - **curl 示例**:
```bash ```bash
curl -X GET http://localhost:8090/api/status \ curl -X GET http://localhost:8090/api/status \
@@ -881,7 +951,6 @@ Worker 节点向服务端上报的任务计算结果。
"success": true, "success": true,
"message": "成功获取工作流详情", "message": "成功获取工作流详情",
"data": { "data": {
"id": 1,
"name": "sdB_cno", "name": "sdB_cno",
"description": "sdB CNO 6D Stellar Atmosphere Grid", "description": "sdB CNO 6D Stellar Atmosphere Grid",
"config_yaml": "...", "config_yaml": "...",
@@ -944,13 +1013,13 @@ Worker 节点向服务端上报的任务计算结果。
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
) -> impl IntoResponse ) -> impl IntoResponse
``` ```
- **说明**: 校验工作流,解析 YAML 中定义的所有 6 维网格坐标点,通过 `GridScheduler::initialize_grid` 展开网格点并计算保序难度 Wave,写入 SQLite 任务队列并开启节点调度 - **说明**: 以原子 CAS`transition_workflow_to_initializing`)抢占启动权,随后**异步 spawn** 网格初始化:解析 YAML 的 6 维网格点、按 policy 决定点状态(跳过收敛/打回失败)、展开并计算保序难度 Wave、把 pending 点推入 MQ 开启节点调度。接口先返回 200,后台推进为 `running`
- **响应 Schema**: - **响应 Schema**:
- `200 OK` (成功启动): - `200 OK` (成功启动,后台初始化中):
```json ```json
{ {
"success": true, "success": true,
"message": "工作流 'sdB_cno' 已成功启动并安排计算任务", "message": "工作流 'sdB_cno' 已进入后台异步建立与挂载流程",
"data": null "data": null
} }
``` ```
@@ -962,6 +1031,14 @@ Worker 节点向服务端上报的任务计算结果。
"data": null "data": null
} }
``` ```
- `409 Conflict` (并发启动,初始化抢占失败):
```json
{
"success": false,
"message": "工作流 'sdB_cno' 初始化抢占挂起异常,请稍后重试",
"data": null
}
```
- `404 Not Found`: - `404 Not Found`:
```json ```json
{ {
@@ -1028,7 +1105,6 @@ Worker 节点向服务端上报的任务计算结果。
"failed": 3, "failed": 3,
"cold_run_converged": 220, "cold_run_converged": 220,
"seed_step_converged": 41, "seed_step_converged": 41,
"imported_converged": 0,
"waves": [ "waves": [
{ "wave": 0, "total": 108, "converged": 108, "failed": 0 } { "wave": 0, "total": 108, "converged": 108, "failed": 0 }
], ],
@@ -1037,6 +1113,8 @@ Worker 节点向服务端上报的任务计算结果。
} }
} }
``` ```
> 收敛手段归因仅 `cold_run_converged` / `seed_step_converged` 两字段;**无独立
> `imported_converged`**——历史导入点统一按 seed_step 途径计入(`success_method` 语义见 §8.8)。
> `avg_point_sec` = `AVG(COALESCE(tasks.elapsed_sec, created_at→completed_at 时间戳差))`—— > `avg_point_sec` = `AVG(COALESCE(tasks.elapsed_sec, created_at→completed_at 时间戳差))`——
> 优先用 Worker 回报的精确墙钟(不含排队等待),历史无 `elapsed_sec` 的行回退时间戳差近似; > 优先用 Worker 回报的精确墙钟(不含排队等待),历史无 `elapsed_sec` 的行回退时间戳差近似;
> `eta_sec` = `avg_point_sec × (total - converged - failed) ÷ 在线节点总槽位`(并发感知; > `eta_sec` = `avg_point_sec × (total - converged - failed) ÷ 在线节点总槽位`(并发感知;
@@ -1048,14 +1126,14 @@ Worker 节点向服务端上报的任务计算结果。
- **处理函数**: `get_workflow_points``crates/server/src/api/workflow.rs` - **处理函数**: `get_workflow_points``crates/server/src/api/workflow.rs`
- **权限**: Admin - **权限**: Admin
- **用途**: 详情页"网格点明细"表与"收敛性分析"热力图、概览"最近动态"流的数据源。 - **用途**: 详情页"网格点明细"表与"收敛性分析"Parallel Sets 平行集合图)、概览"最近动态"流的数据源。
每行附带**最近一次尝试**信息(`tasks` 关联子查询取最新行,从未派发过的点 `last_*` 为 null)。 每行附带**最近一次尝试**信息(`tasks` 关联子查询取最新行,从未派发过的点 `last_*` 为 null)。
- **查询参数**(全部可选,枚举值白名单校验,非法 → `400`: - **查询参数**(全部可选,枚举值白名单校验,非法 → `400`:
| 参数 | 取值 | 默认 | | 参数 | 取值 | 默认 |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `status` | `pending`/`queued`/`running`/`converged`/`failed` | 不过滤 | | `status` | `pending`/`queued`/`running`/`converged`/`failed` | 不过滤 |
| `method` | `cold_run`/`seed_step`/`imported` | 不过滤 | | `method` | `cold_run`/`seed_step`(白名单**不含** `imported`,传入即 `400` | 不过滤 |
| `wave` | 整数波次 | 不过滤 | | `wave` | 整数波次 | 不过滤 |
| `q` | 点名子串(LIKE 通配符已转义) | 不过滤 | | `q` | 点名子串(LIKE 通配符已转义) | 不过滤 |
| `sort` | `wave`/`teff`/`max_relc`/`attempts`/`last_completed_at` | `wave` | | `sort` | `wave`/`teff`/`max_relc`/`attempts`/`last_completed_at` | `wave` |
@@ -1167,13 +1245,17 @@ Worker 节点向服务端上报的任务计算结果。
"running": 8, "converged": 261, "failed": 3 } "running": 8, "converged": 261, "failed": 3 }
], ],
"rate_per_hour": 12.5, "rate_per_hour": 12.5,
"now": "2026-07-31T09:00:00Z",
"done_rate_per_hour": 11.0,
"rate_span_hours": 23.5,
"stalled_minutes": 0.0 "stalled_minutes": 0.0
} }
} }
``` ```
> `series` 超 300 条自动降采样(首末点保留);`rate_per_hour` = 窗口首末 > `series` 超 300 条自动降采样(首末点保留);`rate_per_hour` = 窗口首末
> converged 增量 ÷ 时长(快照不足 2 条为 null);`stalled_minutes` = 终态数 > converged 增量 ÷ 时长(快照不足 2 条为 null);`now` = 服务端当前 UTC 时刻(前端锚定曲线右缘);
> converged+failed)最后一次增长至窗口末端的分钟数(前端 >10 分钟触发停滞预警)。 > `done_rate_per_hour` = 终态完成速率(用于 ETA);`rate_span_hours` = 速率统计实际时间跨度;
> `stalled_minutes` = 终态数(converged+failed)最后一次增长至窗口末端的分钟数(前端 >10 分钟触发停滞预警)。
--- ---
@@ -1213,11 +1295,13 @@ Worker 节点向服务端上报的任务计算结果。
| HTTP 状态码 | 触发场景说明 | 响应格式 | 核心原因与解决建议 | | HTTP 状态码 | 触发场景说明 | 响应格式 | 核心原因与解决建议 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| **`200 OK`** | 请求正常处理 | JSON / Binary Stream | 操作成功执行。 | | **`200 OK`** | 请求正常处理 | JSON / Binary Stream | 操作成功执行。 |
| **`400 Bad Request`** | 参数校验失败、缺失关键字段YAML 格式错误 | `application/json` / Plain Text | 检查请求 JSON 结构,验证 YAML 配置语法是否正确。 | | **`400 Bad Request`** | 参数校验失败、缺失关键字段YAML 格式错误或枚举白名单拒绝 | `application/json` / Plain Text | 检查请求 JSON 结构,验证 YAML 配置语法,确认 `method`/`sort`/`order` 等枚举在合法取值内。 |
| **`401 Unauthorized`** | 鉴权失败或缺失 Authorization Header | `application/json` | 确认环境变量配置,并在 Request Header 中包含正确的 Token。 | | **`401 Unauthorized`** | 鉴权失败或缺失 Authorization Header | `application/json` | 确认环境变量配置,并在 Request Header 中包含正确的 Token。 |
| **`403 Forbidden`** | 身份校验失败(心跳 `node_id` 与鉴权身份不匹配、跨点上报拒绝、角色权限不足) | `application/json` | 确认节点 token 与上报身份一致,检查 RBAC 角色。 |
| **`404 Not Found`** | 资源、种子文件或工作流不存在 | `application/json` | 校验请求 URL 中的资源文件名或工作流 `name` 是否拼写无误。 | | **`404 Not Found`** | 资源、种子文件或工作流不存在 | `application/json` | 校验请求 URL 中的资源文件名或工作流 `name` 是否拼写无误。 |
| **`429 Too Many Requests`** | API 请求超出速率限制(限流生效) | `application/json` | 降低请求频率或配置漏桶/令牌桶容量参数。 | | **`409 Conflict`** | 状态冲突:节点停用/启用状态不支持、start 初始化抢占失败、report 任务归属校验失败 | `application/json` | 检查目标状态是否合法,或稍后重试。 |
| **`429 Too Many Requests`** | API 请求超出速率限制(滑动窗口限流生效) | `application/json` | 降低请求频率;限流为滑动窗口实现(`rate_limit.rs`),非漏桶。 |
| **`500 Internal Server Error`** | 服务端数据库错误、I/O 打开失败或队列异常 | `application/json` | 检查服务端日志以进一步厘清 SQLite 锁冲突、磁盘空间或资源路径问题。 | | **`500 Internal Server Error`** | 服务端数据库错误、I/O 打开失败或队列异常 | `application/json` | 检查服务端日志以进一步厘清 SQLite 锁冲突、磁盘空间或资源路径问题。 |
--- ---
*文档生成于 2026-07-27 | DCTS Server 0.1.0* *文档生成于 2026-07-272026-08-04 更新以对齐阶段独立配置 / 多工作流隔离 / 节点配额 / 失败阶段归因。*
+4 -3
View File
@@ -45,7 +45,7 @@ flowchart TB
### 2.1 Master 服务端 (`server` & Web `dashboard`) ### 2.1 Master 服务端 (`server` & Web `dashboard`)
- **工作流与多任务隔离**:支持多工作流并发隔离运行(`grid_points``task_queue` 增加 `workflow_name` 复合唯一索引),调度与重置操作严格隔离在单工作流作用域内。支持旧版 SQLite 数据库启动时无缝平滑迁移。 - **工作流与多任务隔离**:支持多工作流并发隔离运行(`grid_points``task_queue` 增加 `workflow_name` 复合唯一索引),调度与重置操作严格隔离在单工作流作用域内。支持旧版 SQLite 数据库启动时无缝平滑迁移。
- **安全中间件与 RBAC**:基于角色访问控制 (Admin / Node / Public) 划分 API 权限,内嵌 Bearer Token 校验、Governor / 漏桶算法限流中间件与 CORS 跨域控制,防御侧信道攻击与暴力破解。 - **安全中间件与 RBAC**:基于角色访问控制 (Admin / Node / Public) 划分 API 权限,内嵌 Bearer Token 校验、**滑动窗口(sliding window限流中间件**与 CORS 跨域控制,防御侧信道攻击与暴力破解。
- **节点凭据生命周期管理**:支持 Worker 节点注册申请、管理员审批授权、Token 重新颁发(旧 token 失效)与节点停用/启用全生命周期管理。 - **节点凭据生命周期管理**:支持 Worker 节点注册申请、管理员审批授权、Token 重新颁发(旧 token 失效)与节点停用/启用全生命周期管理。
- **任务调度与分配**:调度器将 pending 网格点推入 `mq` 队列;Worker 以 **pull 抢占式** 领用任务(详见 [§5 任务调度模型](#5-任务调度模型-task-scheduling))。 - **任务调度与分配**:调度器将 pending 网格点推入 `mq` 队列;Worker 以 **pull 抢占式** 领用任务(详见 [§5 任务调度模型](#5-任务调度模型-task-scheduling))。
- **状态维护与心跳监测**:后台离线检测线程定期标记超时未心跳的节点为 `offline`,并能将僵挂在超时节点上的任务自动回收到队列中(Requeue)。 - **状态维护与心跳监测**:后台离线检测线程定期标记超时未心跳的节点为 `offline`,并能将僵挂在超时节点上的任务自动回收到队列中(Requeue)。
@@ -96,7 +96,7 @@ stateDiagram-v2
1. **分布式无状态 Worker & 上报阶梯退避**:Worker 节点不保存持久运行控制状态,异常宕机不会损坏主数据集。计算结果向 Master 上报时,具备多达 8 次指数阶梯容灾回退(最大间隔 60 秒,覆盖超 2 分钟断断连长窗),稳健保障长时间高密物理算单不受瞬时组网闪断或服务端短时上线切换干预。 1. **分布式无状态 Worker & 上报阶梯退避**:Worker 节点不保存持久运行控制状态,异常宕机不会损坏主数据集。计算结果向 Master 上报时,具备多达 8 次指数阶梯容灾回退(最大间隔 60 秒,覆盖超 2 分钟断断连长窗),稳健保障长时间高密物理算单不受瞬时组网闪断或服务端短时上线切换干预。
2. **零文件扫描与连接并发缩流**:底层任务分批取配、排队清洗与邻接优化(Seed-Stepping)全链线依赖常驻内存的 SQlite 主从精算并调优收敛连接池配置(主库=8,队列=4 减免本地排他写冲突并发挂断);去除了历史残存的高损及同步阻塞磁盘遍历 API,在确保零卡死响应的前提下提升查询搜索效力。 2. **零文件扫描与连接并发缩流**:底层任务分批取配、排队清洗与邻接优化(Seed-Stepping)全链线依赖常驻内存的 SQlite 主从精算并调优收敛连接池配置(主库=8,队列=4 减免本地排他写冲突并发挂断);去除了历史残存的高损及同步阻塞磁盘遍历 API,在确保零卡死响应的前提下提升查询搜索效力。
3. **任务超时与流控平稳保护 (Stale & Requeue)**:服务端后台定期向已超时死挂的 `Running` (默认 >1800 秒)作业予以强退回转为 `Pending`;此外当操作维护员发起暂停或终止工作流行为时,将仅平滑洗退待调 `Queued` 项,悉心保育在途已投的 Worker 数值演算完整出计算归表,防假命题竞合。 3. **任务超时与流控平稳保护 (Stale & Requeue)**:服务端后台定期向已超时死挂的 `Running` (默认 >1800 秒)作业予以强退回转为 `Pending`;此外当操作维护员发起暂停或终止工作流行为时,将仅平滑洗退待调 `Queued` 项,悉心保育在途已投的 Worker 数值演算完整出计算归表,防假命题竞合。
4. **多级退避与种子隔离**:若某点冷启动发散,自动隔离失败现场,依靠数据库记录寻找欧氏空间距离最匹配的热启动合拢 `.7` 气象序列;即便遇到底层强硬大步发散也不产生干扰并留存物理运行根系以便溯源 4. **策略链回退与种子匹配**:若某点当前策略失败,服务端弹出策略链首项(`trigger_strategy_fallback`),下一顺位为 `seed_step` 时在全局种子库中按**有向 CNO 距离**(富方向重罚 4×、贫方向 1×,非对称)匹配最近邻收敛 `.7` 大气作为热启动种子;无种子则保持 pending 待种子出现后自愈。失败现场经**白名单归档**(`result_dir`)保留科学产物,沙盒则在任务结算后清理(详见 `tlusty_result_artifacts.md` / `design.md`
--- ---
@@ -125,12 +125,13 @@ LIMIT 1
```rust ```rust
let active = self.active_slots.load(Ordering::Acquire); let active = self.active_slots.load(Ordering::Acquire);
if (active as usize) < self.config.max_slots { if (active as usize) < self.effective_max_slots.load(Ordering::Acquire) {
match self.claim_task().await { /* 抢任务 */ } match self.claim_task().await { /* 抢任务 */ }
} else { } else {
sleep(Duration::from_secs(2)).await; // 满载:不 claim,等槽位释放 sleep(Duration::from_secs(2)).await; // 满载:不 claim,等槽位释放
} }
``` ```
> `effective_max_slots` = `min(admin_max_slots, physical_max_slots)`,管理员配额经心跳响应下发、动态生效(见 `dynamic_cpu_slots_design.md`)。
由此分两种工况: 由此分两种工况:
+10 -7
View File
@@ -25,14 +25,16 @@ cargo build
``` ```
dcts/ dcts/
├── Cargo.toml # Workspace 根配置 ├── Cargo.toml # Workspace 根配置
├── config.yaml # 示例网格配置文件
├── crates/ ├── crates/
│ ├── common/ # [Library] 物理计算引擎、子进程管理、收敛检测与模板 │ ├── common/ # [Library] 物理计算引擎、子进程管理、收敛检测、种子匹配与归档白名单
│ ├── server/ # [Binary] Axum HTTP REST 服务端与 Scheduler │ ├── server/ # [Binary] Axum HTTP REST 服务端与 Scheduler
│ ├── node/ # [Binary] Worker 节点 Daemon 与任务抢占回路 │ ├── node/ # [Binary] Worker 节点 Daemon 与任务抢占回路
│ └── mq/ # [Library] SQLite 事务型分布式任务队列 │ └── mq/ # [Library] SQLite 事务型分布式任务队列
├── dashboard/ # [Web] ESM 模块化运维看板(Vite
├── workflows/sdB_cno.yaml # 示例网格工作流配置
├── tools/ ├── tools/
│ └── import_results/ # [Binary] 历史计算结果导入工具(旧版网格结果 → 标记已完成 + 迁移产物树 │ └── import_results/ # [Binary] 历史计算结果导入工具(旧版网格结果 → 种子/收敛入库
├── scripts/ # 结果拉取等运维脚本
└── docs/ # 分主题架构与规格技术文档 └── docs/ # 分主题架构与规格技术文档
``` ```
@@ -64,7 +66,8 @@ cargo clippy --workspace --all-targets -- -D warnings
## 4. 提交 Code Review 规范 ## 4. 提交 Code Review 规范
1. **分支策略**:从 `main` 切出特性分支,推荐命名如 `feature/seed-optimizer``fix/stale-node-leak` 1. **分支策略**:从 `main` 切出特性分支,推荐命名如 `feature/seed-optimizer``fix/stale-node-leak`
2. **Commit Message 规范**格式推荐使用 `module: succinct explanation`,如: 2. **Commit Message 规范**遵循 Conventional Commits,格式为 `<type>(<scope>): <description>`,如:
- `common: add tolerance factor check for seed finder` - `feat(common): 有向 CNO 距离种子匹配`
- `server: fix sqlite connection pool leak under heavy load` - `fix(server): 修复调度竞态与僵尸任务自愈`
3. **保持文档更新**:若修改了 API 接口定义或数据库 Schema,请同步更新 `docs/api_reference.md` `docs/database.md` - `type``feat` / `fix` / `refactor` / `docs` / `test` / `chore` / `perf` / `ci``scope` 常用 `common` / `server` / `node` / `mq` / `dashboard` / `all`
3. **保持文档更新**:若修改了 API 接口定义或数据库 Schema,请同步更新 `docs/api.md``docs/database.md`,以及物理/调度设计文档(`docs/design.md``docs/task_engine_decoupling_design.md`)。
+107 -7
View File
@@ -10,7 +10,10 @@
erDiagram erDiagram
WORKFLOWS ||--o{ GRID_POINTS : contains WORKFLOWS ||--o{ GRID_POINTS : contains
GRID_POINTS ||--o{ TASKS : logs GRID_POINTS ||--o{ TASKS : logs
WORKFLOWS ||--o{ WORKFLOW_PROGRESS_SNAPSHOTS : samples
NODES ||--o{ TASK_QUEUE : executes NODES ||--o{ TASK_QUEUE : executes
NODES ||--o| NODE_CREDENTIALS : authenticates
SEEDS }o--o{ GRID_POINTS : hot-starts
subgraph PrimaryDB ["主数据库 (dcts.db)"] subgraph PrimaryDB ["主数据库 (dcts.db)"]
WORKFLOWS { WORKFLOWS {
@@ -22,7 +25,8 @@ erDiagram
datetime updated_at datetime updated_at
} }
GRID_POINTS { GRID_POINTS {
string name PK integer id PK
string name
string workflow_name FK string workflow_name FK
double teff double teff
double logg double logg
@@ -35,16 +39,31 @@ erDiagram
string status string status
integer attempt_count integer attempt_count
string success_method string success_method
double last_elapsed_sec
} }
TASKS { TASKS {
string task_id PK string task_id PK
string point_name FK string point_name
string node_id string node_id
string task_type string task_type
string seed_point_name
string status string status
double max_relc double max_relc
boolean atmosphere_has_nan
integer retry_count integer retry_count
datetime created_at datetime created_at
datetime started_at
datetime completed_at
string error_message
string workflow_name
boolean tlusty_enabled
string tlusty_policy
text tlusty_strategies
boolean synspec_enabled
string synspec_policy
text synspec_strategies
string atmosphere_ref
double elapsed_sec
} }
NODES { NODES {
string node_id PK string node_id PK
@@ -54,8 +73,40 @@ erDiagram
double cpu_usage double cpu_usage
double memory_usage double memory_usage
datetime last_heartbeat datetime last_heartbeat
string registration_secret
integer admin_max_slots
} }
NODE_CREDENTIALS {
string node_id PK
string token_hash
datetime issued_at
integer revoked
string raw_token_pending
} }
SEEDS {
integer id PK
string point_name UK
double teff
double logg
double loghe
double logc
double logn
double logo
string file_path
boolean is_clean
}
WORKFLOW_PROGRESS_SNAPSHOTS {
integer id PK
string workflow_name
datetime ts
integer total
integer pending
integer queued
integer running
integer converged
integer failed
}
end
subgraph QueueDB ["队列库 (dcts_queue.db / mq)"] subgraph QueueDB ["队列库 (dcts_queue.db / mq)"]
TASK_QUEUE { TASK_QUEUE {
@@ -91,28 +142,76 @@ erDiagram
`name` 共同构成复合唯一约束 `UNIQUE(workflow_name, name)` `name` 共同构成复合唯一约束 `UNIQUE(workflow_name, name)`
- `teff`, `logg`, `loghe`, `logc`, `logn`, `logo` (`REAL NOT NULL`)6 维物理参数。 - `teff`, `logg`, `loghe`, `logc`, `logn`, `logo` (`REAL NOT NULL`)6 维物理参数。
- `cno_sum` (`REAL NOT NULL`)CNO 丰度之和(调度排序用)。 - `cno_sum` (`REAL NOT NULL`)CNO 丰度之和(调度排序用)。
- `wave` (`INTEGER`):按 cno_sum 分组的批次波次(调度优先级用)。 - `wave` (`INTEGER NOT NULL DEFAULT 0`):按 cno_sum 分组的批次波次(调度优先级用)。
- `status` (`VARCHAR(32)`)`pending` / `queued` / `running` / `converged` / `failed` - `status` (`VARCHAR(32)`)`pending` / `queued` / `running` / `converged` / `failed`
- `attempt_count` (`INTEGER`):失败重试计数(仅观测用)。 - `attempt_count` (`INTEGER`):失败重试计数(仅观测用)。
- `success_method` (`VARCHAR(32)`):收敛时的成功手段 (`cold_run` 冷启动成功 / `seed_step` 种子步进成功)。 - `success_method` (`VARCHAR(32)`):收敛时的成功手段 (`cold_run` 冷启动成功 / `seed_step` 种子步进成功 / `imported` 历史导入)。
- `last_elapsed_sec` (`REAL`):最近一次尝试的墙钟耗时(详情页 ETA 估算用,兼容旧数据回退)。
> **多工作流分区(per-workflow partitioning**`grid_points``task_queue` 均按 `workflow_name` 隔离。 > **多工作流分区(per-workflow partitioning**`grid_points``task_queue` 均按 `workflow_name` 隔离。
> 调度、状态更新、stale 重投、`stop_workflow` 重置都限定在单个工作流内,互不影响。 > 调度、状态更新、stale 重投、`stop_workflow` 重置都限定在单个工作流内,互不影响。
> 历史旧库(无 `workflow_name` 列)在启动时自动迁移:表重建为复合唯一结构,旧行 `workflow_name` > 历史旧库(无 `workflow_name` 列)在启动时自动迁移:表重建为复合唯一结构,旧行 `workflow_name`
> 标记为 `__legacy__`,不干扰新工作流查询。 > 标记为 `__legacy__`,不干扰新工作流查询。
### 2.3 `nodes` (计算节点心跳与状态表) ### 2.3 `tasks` (任务尝试记录表)
每次派发/尝试生成一行,记录任务的阶段配置快照与执行结果(详情页/归因/回退弹栈的数据源)。
- `task_id` (`VARCHAR(128) PRIMARY KEY`):任务 IDUUID)。
- `point_name` (`TEXT NOT NULL`):网格点权威名(源精度,非 params 重推)。
- `node_id` (`TEXT`):执行节点 ID。
- `task_type` (`VARCHAR(32)`):兼容字段,等于 `tlusty_strategies[0]``cold_run` / `seed_step`),供旧节点识别。
- `seed_point_name` (`TEXT`):种子步进时注入的近邻种子点(`GET /api/seed/<name>` 下载依据)。
- `status` (`VARCHAR(32)`)`pending` / `claimed` / `running` / `completed` / `failed` / `timeout`
- `max_relc` (`REAL`):最终最大相对变化(收敛判据量)。
- `atmosphere_has_nan` (`BOOLEAN`):最终大气是否含 >10% NaN 行(无效化标记)。
- `retry_count` (`INTEGER`):重试计数。
- `created_at` / `started_at` / `completed_at` (`DATETIME`):创建 / 开始 / 完成时间。
- `error_message` (`TEXT`):失败原因(含 synspec 错误/超时等)。
- `workflow_name` (`TEXT`):所属工作流(多工作流分区键)。
- **阶段独立配置快照(派发时落库)**`tlusty_enabled` (`BOOLEAN`)、`tlusty_policy` (`TEXT`)、
`tlusty_strategies` (`JSON`)、`synspec_enabled` (`BOOLEAN`)、`synspec_policy` (`TEXT`)、
`synspec_strategies` (`JSON`)、`atmosphere_ref` (`TEXT`,显式大气来源点)。
回退时弹 `*_strategies` 链首(见 `task_engine_decoupling_design.md §4.2`),policy/策略链取派发时快照。
- `elapsed_sec` (`REAL`):任务墙钟耗时(详情页 ETA 估算优先用此值)。
### 2.4 `nodes` (计算节点心跳与状态表)
- `node_id` (`TEXT PRIMARY KEY`):节点唯一标识(未指定 `DCTS_NODE_ID` 时自动生成 `node-<uuid>`)。 - `node_id` (`TEXT PRIMARY KEY`):节点唯一标识(未指定 `DCTS_NODE_ID` 时自动生成 `node-<uuid>`)。
- `max_slots` (`INTEGER NOT NULL`):节点声明的最大并发计算槽位数(即 `DCTS_MAX_SLOTS`,领用上限)。 - `max_slots` (`INTEGER NOT NULL`):节点声明的最大并发计算槽位数(即 `DCTS_MAX_SLOTS`,领用上限)。
- `active_slots` (`INTEGER NOT NULL DEFAULT 0`):当前正在执行的任务数,随 claim 自增、report 完结自减。 - `active_slots` (`INTEGER NOT NULL DEFAULT 0`):当前正在执行的任务数,随 claim 自增、report 完结自减。
- `status` (`VARCHAR(32) NOT NULL DEFAULT 'online'`)`pending_approval`(注册待审批)/ `online`(在线)/ `offline`(心跳超时离线)/ `disabled`(管理员手动停用,保持心跳但不再分发任务)/ `rejected` - `status` (`VARCHAR(32) NOT NULL DEFAULT 'online'`)`pending_approval`(注册待审批)/ `online`(在线)/ `offline`(心跳超时离线)/ `disabled`(管理员手动停用,保持心跳但不再分发任务)/ `rejected`
- `cpu_usage` / `memory_usage` (`REAL NOT NULL DEFAULT 0.0`):心跳上报的 CPU/内存使用率(仅观测展示用,**不参与任务分发决策**)。 - `cpu_usage` / `memory_usage` (`REAL NOT NULL DEFAULT 0.0`):心跳上报的 CPU/内存使用率(仅观测展示用,**不参与任务分发决策**)。
- `last_heartbeat` (`DATETIME NOT NULL`):最后一次心跳上报时间,后台线程据此判定 `online → offline` - `last_heartbeat` (`DATETIME NOT NULL`):最后一次心跳上报时间,后台线程据此判定 `online → offline`
- `registration_secret` (`TEXT`):节点注册时下发的一次性凭据,用于 `/node/check_status` 取走专属 token 的二次鉴权。
- `admin_max_slots` (`INTEGER`,可空):管理员强制并发槽位配额(`NULL` = 无限制,沿用物理 `max_slots`),经心跳响应下发给节点动态生效。
### 2.4 `task_queue` (分布式任务队列表 - `mq`) ### 2.5 `node_credentials` (节点 L2 鉴权凭据表)
- `node_id` (`TEXT PRIMARY KEY`):关联 `nodes.node_id`
- `token_hash` (`TEXT NOT NULL`):节点专属 token 的 SHA-256 哈希(**不存明文**)。
- `issued_at` (`DATETIME NOT NULL`):颁发时间。
- `revoked` (`INTEGER NOT NULL DEFAULT 0`):吊销标记(重新颁发 token 时旧行吊销)。
- `raw_token_pending` (`TEXT`):暂存待确认的明文 token(颁发流程过渡用,确认后清除)。
### 2.6 `seeds` (种子缓存池表)
全局共享的已收敛大气 `.7` 索引(跨工作流复用,种子步进热启动数据源)。
- `id` (`INTEGER PRIMARY KEY AUTOINCREMENT`)。
- `point_name` (`TEXT UNIQUE NOT NULL`):种子点权威名(源精度,与磁盘文件名一致)。
- `teff`, `logg`, `loghe`, `logc`, `logn`, `logo` (`REAL NOT NULL`):6 维物理参数(种子匹配距离计算用)。
- `file_path` (`TEXT NOT NULL`)`.7` 大气文件在 `seeds_dir` 的路径。
- `is_clean` (`BOOLEAN NOT NULL DEFAULT 1`):大气是否干净(无 NaN)——仅干净种子可被匹配(`reload_seed_cache` 只加载 `is_clean=1`)。
> 运行期维护内存 `seed_cache` + `seed_index`exact_family 桶索引),`find_best_seed_from_db`
> 先查桶索引(O(1)~O(小)),未命中退化为全量 global 扫描(见 `seed_finder.rs` / `design.md §3`)。
### 2.7 `workflow_progress_snapshots` (工作流进度时间序列表)
后台定期(每分钟)为每个 running 工作流记录进度计数,供详情页进度曲线与 ETA 估算。
- `id` (`INTEGER PRIMARY KEY AUTOINCREMENT`)。
- `workflow_name` (`TEXT NOT NULL`)。
- `ts` (`DATETIME NOT NULL DEFAULT (datetime('now'))`):采样时间。
- `total` / `pending` / `queued` / `running` / `converged` / `failed` (`INTEGER NOT NULL`):该时刻各状态计数。
### 2.8 `task_queue` (分布式任务队列表 - `mq`)
驱动分布式抢占与超时重试的核心表,使用 SQLite `WAL` 模式确保高吞吐并发安全(详见 [`sqlite_queue.rs`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L65))。 驱动分布式抢占与超时重试的核心表,使用 SQLite `WAL` 模式确保高吞吐并发安全(详见 [`sqlite_queue.rs`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L65))。
- `task_id` (`VARCHAR(128) PRIMARY KEY`):任务 IDUUID)。 - `task_id` (`VARCHAR(128) PRIMARY KEY`):任务 IDUUID)。
- `payload` (`TEXT NOT NULL`):序列化的 [`TaskSpec`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs)(含 point_name / params / task_type / seed_point_name / workflow_name 等)。 - `payload` (`TEXT NOT NULL`):序列化的 [`TaskSpec`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs)(含 point_name / params / task_type / seed_point_name / workflow_name / tlusty_config / synspec_config 等)。
- `status` (`TEXT NOT NULL`)`pending`(就绪待领用)/ `claimed`(已被某 node 领用,计算中)。 - `status` (`TEXT NOT NULL`)`pending`(就绪待领用)/ `claimed`(已被某 node 领用,计算中)。
- `created_at` (`DATETIME NOT NULL`):推入队列时间(同 `wave` 内 FIFO 排序键)。 - `created_at` (`DATETIME NOT NULL`):推入队列时间(同 `wave` 内 FIFO 排序键)。
- `claimed_at` (`DATETIME`):被领用的时间戳(`requeue_stale_tasks` 据此判定超时回投)。 - `claimed_at` (`DATETIME`):被领用的时间戳(`requeue_stale_tasks` 据此判定超时回投)。
@@ -129,3 +228,4 @@ erDiagram
1. **WAL (Write-Ahead Logging) 模式**SQLite 连接自动启用 `PRAGMA journal_mode=WAL;``PRAGMA busy_timeout=15000;`,解决多线程/多进程读写锁竞争。 1. **WAL (Write-Ahead Logging) 模式**SQLite 连接自动启用 `PRAGMA journal_mode=WAL;``PRAGMA busy_timeout=15000;`,解决多线程/多进程读写锁竞争。
2. **连接池机制**:借助 `r2d2` + `r2d2_sqlite` 维护异步连接池,防止高并发下数据库句柄冲突。 2. **连接池机制**:借助 `r2d2` + `r2d2_sqlite` 维护异步连接池,防止高并发下数据库句柄冲突。
3. **原子 Claim 事务**:任务抢占在单个 `IMMEDIATE` 事务内完成——先 `SELECT ... WHERE status='pending' ORDER BY wave ASC, created_at ASC LIMIT 1` 取队首,再 `UPDATE SET status='claimed', claimed_by_node_id=?`,提交后返回。事务保证同一任务不会被两个 node 同时领用;遇到 `SQLITE_BUSY/LOCKED` 最多退避重试 5 次(详见 [`pop_task`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L145))。 3. **原子 Claim 事务**:任务抢占在单个 `IMMEDIATE` 事务内完成——先 `SELECT ... WHERE status='pending' ORDER BY wave ASC, created_at ASC LIMIT 1` 取队首,再 `UPDATE SET status='claimed', claimed_by_node_id=?`,提交后返回。事务保证同一任务不会被两个 node 同时领用;遇到 `SQLITE_BUSY/LOCKED` 最多退避重试 5 次(详见 [`pop_task`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L145))。
4. **凭据零明文存储**`node_credentials.token_hash` 只存 SHA-256 哈希;节点注册的一次性 `registration_secret` 也仅在审批前短期有效,避免数据库泄漏直接泄露有效 token。
+4 -4
View File
@@ -129,8 +129,8 @@ DCTS 运行时所依赖的物理计算底座二进制文件 `assets/tlusty_stati
| `DCTS_ADMIN_TOKEN` | ** | 管理员控制台与敏感 API 鉴权令牌 | | `DCTS_ADMIN_TOKEN` | ** | 管理员控制台与敏感 API 鉴权令牌 |
| `DCTS_AUTH_TOKEN` | ** | 旧版全局令牌(兼容回退为 Admin 凭据,建议迁移到 `DCTS_ADMIN_TOKEN` | | `DCTS_AUTH_TOKEN` | ** | 旧版全局令牌(兼容回退为 Admin 凭据,建议迁移到 `DCTS_ADMIN_TOKEN` |
| `DCTS_AUTH_DISABLE` | `false` | 应急开发参数:设置为`1``true` 时跳过鉴权(仅本地调试,切勿生产) | | `DCTS_AUTH_DISABLE` | `false` | 应急开发参数:设置为`1``true` 时跳过鉴权(仅本地调试,切勿生产) |
| `DCTS_STALE_SEC` | `1800` | 任务运行超时重新放回队列的时间上限(秒) | | `DCTS_STALE_SEC` | `21600` | 任务运行超时重新放回队列的时间上限(秒)。默认 6 小时,约为单任务默认超时(7200s)的 3 倍缓冲,避免接近 timeout 的任务在上报前被重投 |
| `DCTS_NODE_STALE_SEC` | `120` | 判定 Worker 节点离线的心跳超时时间(秒) | | `DCTS_NODE_STALE_SEC` | `60` | 判定 Worker 节点离线的心跳超时时间(秒) |
### 3.2 Worker 节点环境变量表 (`node`) ### 3.2 Worker 节点环境变量表 (`node`)
@@ -141,12 +141,12 @@ DCTS 运行时所依赖的物理计算底座二进制文件 `assets/tlusty_stati
| `DCTS_MAX_SLOTS` | `4` | 本地 Worker 节点的并发计算 Slot 槽位数 | | `DCTS_MAX_SLOTS` | `4` | 本地 Worker 节点的并发计算 Slot 槽位数 |
| `DCTS_RUNTIME_DIR` | `data/runtime` | 本地 TLUSTY/SYNSPEC 可执行程序及物理谱线数据存放目录 | | `DCTS_RUNTIME_DIR` | `data/runtime` | 本地 TLUSTY/SYNSPEC 可执行程序及物理谱线数据存放目录 |
| `DCTS_WORK_DIR` | `data/work` | 本地计算沙盒工作目录 | | `DCTS_WORK_DIR` | `data/work` | 本地计算沙盒工作目录 |
| `DCTS_RESULT_DIR` | `data/result` | node 端**完整计算结果归档**目录(光谱/连续谱/各阶段大气快照等全部产物)。超过 200 个网格点子目录时按 LRU 删最旧。旧名 `DCTS_ARCHIVE_DIR` 向后兼容 | | `DCTS_RESULT_DIR` | `data/result` | node 端**完整计算结果归档**目录(光谱/连续谱/各阶段大气快照等全部产物)。**永久保留,无 LRU 淘汰**2026-08-02 撤销旧 MAX_RESULT_MODELS=200 上限,避免科学产物被淘汰丢失)。旧名 `DCTS_ARCHIVE_DIR` 向后兼容 |
| `DCTS_HEARTBEAT_SEC` | `15` | 向服务端发送心跳报告的时间间隔(秒) | | `DCTS_HEARTBEAT_SEC` | `15` | 向服务端发送心跳报告的时间间隔(秒) |
> **两目录分工**(重要,避免混淆): > **两目录分工**(重要,避免混淆):
> - **`data/seeds/`**server 端,`DCTS_SEEDS_DIR`):**种子库**。只存最小集 `conv.json` + `<name>.7`,供 `download_seed` 端点给远程 node 热启动下载。**永不清理**(种子是 SeedStep 必需资源,删除会导致已收敛点重算)。 > - **`data/seeds/`**server 端,`DCTS_SEEDS_DIR`):**种子库**。只存最小集 `conv.json` + `<name>.7`,供 `download_seed` 端点给远程 node 热启动下载。**永不清理**(种子是 SeedStep 必需资源,删除会导致已收敛点重算)。
> - **`data/result/`**node 端,`DCTS_RESULT_DIR`):**完整计算结果归档**。存全部科学产物(`.spec/.cont/.iden/.7/各阶段快照/日志`),供本地留档/排错。LRU 上限 200 > - **`data/result/`**node 端,`DCTS_RESULT_DIR`):**完整计算结果归档**。存全部科学产物(`.spec/.cont/.iden/.7/.bfac/.emflux/各阶段快照/日志`),供本地留档/排错。**永久保留,无 LRU 淘汰**2026-08-02 撤销 LRU 上限)
> >
> 多机部署下两者物理分离:seeds 在 server 机、result 在各 node 机。单机部署下都挂在 `./data` 下。 > 多机部署下两者物理分离:seeds 在 server 机、result 在各 node 机。单机部署下都挂在 `./data` 下。
+80 -35
View File
@@ -1,6 +1,9 @@
# DCTS 物理计算与流程设计 (Physics Pipeline Design) # DCTS 物理计算与流程设计 (Physics Pipeline Design)
> 本文档详细阐述 DCTS 核心物理计算引擎的设计原理、4 阶段 TLUSTY/SYNSPEC 计算链,以及冷启动与种子步进(Seed Stepping)发散回退机制。 > 本文档阐述 DCTS 核心物理计算引擎的设计原理、TLUSTY/SYNSPEC 计算链(冷启动 / 种子步进),
> 以及基于策略链(Strategy Chain)的回退机制与种子匹配算法。
> 阶段独立配置(`enabled` / `policy` / `strategies`)的完整设计见
> [`task_engine_decoupling_design.md`](./task_engine_decoupling_design.md)。
--- ---
@@ -8,7 +11,9 @@
TLUSTY 通过**完全线性化 (Complete Linearization)** 方法求解恒星大气结构的 NLTE(非局部热力学平衡)统计平衡方程与辐射转移方程。由于线性化的收敛半径有限,若初始猜想(初猜)偏离真解较远,迭代极易发生数值发散。 TLUSTY 通过**完全线性化 (Complete Linearization)** 方法求解恒星大气结构的 NLTE(非局部热力学平衡)统计平衡方程与辐射转移方程。由于线性化的收敛半径有限,若初始猜想(初猜)偏离真解较远,迭代极易发生数值发散。
DCTS 单点计算分为 **4 个渐进物理阶段** DCTS 单点计算分为 **TLUSTY 大气结构求解 + SYNSPEC 光谱合成**两个独立阶段(各自有 `enabled` 开关)。TLUSTY 阶段内再按执行策略选用不同收敛链
**冷启动链 (ColdRun`default_cold_chain`)**
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -17,52 +22,92 @@ flowchart LR
Stage3 --> Stage4["4. 合成光谱\n(synspec)\n输出 .spec / .cont"] Stage3 --> Stage4["4. 合成光谱\n(synspec)\n输出 .spec / .cont"]
``` ```
**种子步进链 (SeedStep`default_seed_chain`)**:直接以近邻已收敛大气为热启动起点,**跳过 lte 灰大气阶段**:
```mermaid
flowchart LR
Seed1["1. NLTE 连续谱热启动\n(seed_nc / tlusty)\n以种子 .7 作 fort.8 初猜"] --> Seed2["2. NLTE 完整谱线\n(nl / tlusty)\n精化收敛"]
Seed2 --> Seed3["3. 合成光谱\n(synspec)"]
```
### 各阶段物理配置与功能明细 ### 各阶段物理配置与功能明细
| 阶段 | 执行程序 | 物理含义 | 关键参数 | 典型迭代/耗时 | 阶段配置见 `common::runner::default_cold_chain` / `default_seed_chain``StageConfig`):
| 阶段 | 执行程序 | 物理含义 | 关键参数 | 迭代上限 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| **1. LTE 灰大气 (lte)** | `tlusty.exe` | 解析灰色不透明度求解温度结构,无需种子,提供合理起点。 | `T T` 模式 | 0 次迭代 / 1-3 秒 | | **1. LTE 灰大气 (lte)** | `tlusty.exe` | 解析灰色不透明度求解温度结构,无需种子,提供合理起点。 | `lte=T, ltgray=T, niter=0, ilvlin=0` | 0 次(生成初始结构) |
| **2. NLTE 连续谱 (nc)** | `tlusty.exe` | 切换到 NLTE,忽略束缚-束缚线跃迁(`ilvlin=0`),收敛基础电离平衡。 | `F F`, `ilvlin=0` | 10 次迭代 / 1-5 分钟 | | **2. NLTE 连续谱 (nc)** | `tlusty.exe` | 切换到 NLTE,忽略束缚-束缚线跃迁(`ilvlin=0`),收敛基础电离平衡。 | `lte=F, ltgray=F, ilvlin=0, niter=10` | 10 次 |
| **3. NLTE 完整谱线 (nl)** | `tlusty.exe` | 引入全部线跃迁(`ilvlin=100`),求解包含非平衡辐射场的大气结构。 | `F F`, `ilvlin=100` | 15-30 次迭代 / 5-20 分钟 | | **3. NLTE 完整谱线 (nl)** | `tlusty.exe` | 引入全部线跃迁(`ilvlin=100`),求解包含非平衡辐射场的大气结构**要求收敛**`require_converged=true`)。 | `lte=F, ltgray=F, ilvlin=100, niter=100` | 100 次 |
| **4. 合成光谱 (synspec)** | `synspec.exe` | 基于阶段 3 收敛大气结构(`.7` 文件),计算高分辨率合成光谱。 | `INPOP=35` | 3-10 | | **3'. 种子热启动 (seed_nc)** | `tlusty.exe` | 以近邻收敛大气 `.7` `fort.8` 初猜做 NLTE 连续谱收敛(跳过低温度灰大气阶段)。 | `lte=F, ltgray=F, ilvlin=0, niter=20, ichang=0` | 20 |
| **4. 合成光谱 (synspec)** | `synspec.exe` | 基于收敛大气结构(`.7`),计算高分辨率合成光谱。 | 波长窗/展宽/截断由 `synspec:` 数值参数配置 | — |
> 阶段标签由 workflow 配置保证唯一(`lte`/`nc`/`nl`/`seed_nc`),各阶段输入卡/日志/大气/收敛诊断
> 以 `<name>.<label>.<后缀>` 快照落盘归档(见 [`tlusty_result_artifacts.md`](./tlusty_result_artifacts.md))。
--- ---
## 2. 冷启动链 vs 种子步进链 (Seed-Stepping Fallback) ## 2. 冷启动链 vs 种子步进链(策略链机制)
在极端高有效温度(如 $T_{\text{eff}} \ge 50,000\text{ K}$、极低氦丰度或强金属线空白区,从 LTE 灰大气直接启动的**冷启动链 (DEFAULT_CHAIN)** 容易发散。 在极端高有效温度、极低氦丰度或强金属线空白区,从 LTE 灰大气直接启动的冷启动链容易发散。DCTS 通过 **策略链 (Strategy Chain)** 机制处理:工作流可配置 `tlusty_strategies`(如 `["cold_run", "seed_step"]`),调度器按顺序派发,失败时弹出链首、以剩余链回退重试(见 `task_engine_decoupling_design.md §4.2`
针对这一问题,DCTS 引入了**动态种子步进机制 (Seed Stepping Chain)**
```mermaid ```mermaid
flowchart TD flowchart TD
Start([开始计算指定网格点]) --> ExecCold[执行冷启动链 lte -> nc -> nl] Start([开始计算指定网格点]) --> Resolve[调度器解析策略链\nresolve_dispatchable_chain]
ExecCold --> CheckCold{nl 阶段物理收敛?} Resolve --> Exec[执行链首策略]
Exec --> Check{nl 阶段物理收敛?}
CheckCold -- "是 (Success)" --> RunSyn1[运行 Synspec 生成光谱] --> Save1[保存结果 & 汇报成功] Check -- "是 (Success)" --> RunSyn[运行 Synspec 生成光谱] --> Save[结果入库 & 汇报成功]
CheckCold -- "否 (Diverging)" --> IsolCold[清理并隔离冷启动失败现场] Check -- "否 (失败)" --> Pop[服务端弹出链首策略\ntrigger_strategy_fallback]
Pop --> Rest{剩余链非空?}
IsolCold --> FindSeed[在种子库搜索最近邻收敛 .7 种子] Rest -- "是,下一顺位 seed_step" --> FindSeed[查全局种子池找近邻收敛 .7 种子]
FindSeed --> HasSeed{找到合规邻居种子?} FindSeed --> HasSeed{找到合规种子?}
HasSeed -- "是" --> Inject[注入 seed_point_name\n重置 pending 再派发] --> Exec
HasSeed -- "" --> ExecSeed[启动种子步进链 seed_nc -> nl\nLTGRAY=F 热启动] HasSeed -- "" --> Hold[保持 pending 待种子出现后自愈\nseed_step 顺位暂存链尾)]
ExecSeed --> CheckSeed{nl 阶段物理收敛?} Rest -- "否(链耗尽)" --> Fail[标记任务彻底失败]
CheckSeed -- "是 (Success)" --> RunSyn2[运行 Synspec 生成光谱] --> Save2[标记 seed_step_used=true & 汇报成功]
CheckSeed -- "否 (Failed)" --> MarkFail[标记任务彻底失败]
HasSeed -- "否" --> MarkFail
``` ```
### 种子匹配策略 (Seed Finding Algorithm) 要点:
`common::seed_finder` 模块彻底摆脱了早期对文件系统进行同步阻塞遍历式查档的高耗延迟做法,改为经由 Master 服务端 SQLite 内存快照索表直接执行多级筛选,并使用标准化欧氏距离(Euclidean Distance)查找最佳热起邻格起点: 1. **派发门控**:链首为 `seed_step` 时必须能查到近邻种子才可派发;无种子则暂存该顺位到链尾、继续解析下一项(保证终止性,重复 seed_step 不陷入死循环)。初始派发与失败回退共用 `resolve_dispatchable_chain`
2. **种子注入**`seed_step` 派发时服务端在 `tasks` 行写入 `seed_point_name`,节点凭此下载种子大气(`GET /api/seed/<name>`),TLUSTY 以种子 `.7` 作为 `fort.8` 热启动输入,跳过灰大气阶段直接跑 `seed_nc -> nl`
3. **回退语义**:仅 `failed` 状态且状态实际迁移时触发(2026-08-02 修复,杜绝重复失败报告触发多余 seed_step);回退保留派发时的策略链快照,不修改原 `policy`
$$d(p_1, p_2) = \sqrt{ \sum_{i} w_i \left( \frac{x_{1,i} - x_{2,i}}{\sigma_i} \right)^2 }$$ ---
优先匹配有效温度 $T_{\text{eff}}$ 和表面重力 $\log g$ 变化最小的已收敛 `.7` 大气结构作为 `fort.8` 热启动输入,跳过容易发散的灰大气阶段。 ## 3. 种子匹配算法 (Seed Finding)
> [!NOTE] 种子池是**全局共享的物理资源池**`seeds` 表 + 内存缓存 + 索引,跨工作流共享)。收敛且大气干净(无 NaN)的结果上报时,`.7` 大气原子写入 `seeds_dir/<name>/<name>.7` 并入库。
> **物理现场溯源说明**:为了服务于严谨的天文理论算理复盘,Node 端的沙盒演算文件夹(`data/work/task_{uuid}`)及其中所产生成的全部迭代物理日志与 Fortran 临时数表不会触发自动入侵清除,为发生极端大气参数无解突断时的推导验证提供了长期完整的痕迹。
> ### 3.1 exact_family 桶索引
> **严谨声明校验**:在 `GridConfig` 的加载引擎中引入了非合规键位阻断(Denying Unknown Fields),有效杜绝了用户在定义参数和扩展选项(如 `niter`, `itek_fallback`, `template`, `fort55`, `linelist`)由于错拼被静默忽略而导致不可预测迭代的行为。
`(teff/5000, logg×100, loghe×100)` 量化成 `SeedBucketKey`,插入时写入 floor/floor+1 的 8 个笛卡尔积桶,查询时查 8 桶 → O(1)~O(小) 缩候选集,桶内再精算距离(量化只缩候选、不影响正确性)。
### 3.2 有向 CNO 距离(非对称,核心创新)
exact_family 判定(同物理族,仅 CNO 不同):`Δteff<5000 && Δlogg<0.01 && Δloghe<0.01`。候选按**有向 CNO 距离**排序:
```
delta = target cand(对 logc / logn / logo 各分量)
delta > 0(目标更富,坏方向)→ 惩罚 4.0 × delta
delta < 0(目标更贫,好方向)→ 惩罚 1.0 × |delta|
```
**数据标定依据**:对历史 1191 个真实 seed_step 配对的成败统计——贫金属方向(种子更富、目标往贫走)成功率 42–54%,富金属方向仅 3–11%(`he=-4` 时 54% vs 3%,差 18 倍)。物理解释:从高金属收敛解**减少**金属是稳定微扰;反过来**增加**金属时,新增紫外谱线辐射驱动会破坏已建立的辐射平衡 → 发散出现 NaN。
### 3.3 全局距离(跨 teff
exact_family 未命中时退化到全量扫描,候选需 `d ≤ 3.0`
```
d = Δteff/5000 + Δlogg×2 + Δloghe×0.5 + Δcno×0.1
```
权重物理意义:Teff 主导黑体势(÷5000 归一);logg 破坏静力学压强平衡最烈(×2);loghe 次之(×0.5);CNO 影响紫外谱线辐射驱动,方向性已由有向距离表达(×0.1)。
---
## 4. 节点端执行与现场处理
1. **种子获取**seed_step 任务凭 `seed_point_name` 下载种子 `.7`,落 `.seed_cache/`(LRU 上限 8),再复制私有副本进 slot 沙盒作为 `fort.8`
2. **沙盒生命周期**:任务算完 → 上报成功(或失败,尽力而为)→ **白名单归档**`result_dir/<name>/`,永久保留)→ **清理沙盒**;节点重启时也会**清理残留的 `task_*` 沙盒**2026-08-02 修复:避免强杀遗留工作目录写满磁盘)。归档白名单与 TLUSTY fort 单元语义详见 [`tlusty_result_artifacts.md`](./tlusty_result_artifacts.md)。
3. **节点无感知**:节点只执行 `strategies[0]` + 注入的 `seed_point_name`,对回退过程完全无感知,调度与计算解耦。
+76
View File
@@ -0,0 +1,76 @@
# 动态调整计算节点 CPU 核数(并发槽位)设计方案
> **状态:已实现**`effective_max_slots` + 心跳响应下发配额 + `/api/admin/nodes/:node_id/quota` +
> 节点详情管理弹窗)。下文为设计原文,保留作实现参照;实际实现以 `crates/node/src/worker.rs`
> `crates/server/src/api/admin.rs``dashboard/src/components/nodesTable.js` 为准。
## 1. 需求背景
目前 DCTS 的计算节点 (Worker) 在启动时通过本地配置确定最大计算槽位 (`max_slots`),并在心跳和注册时上报给服务端。如果在不重启 Worker 进程的情况下,因为设备资源被其他用户借用等原因,需要临时减少该节点分发的任务数(即缩减 CPU 核数/并发槽位)。
## 2. 现有架构分析
- **Pull 模式**:任务的领用是由 Worker 主动发起 `POST /api/task/claim` 的。Worker 内部根据自身的 `max_slots` 控制并发领用的数量。
- **状态同步**Worker 定期发送 `POST /api/node/heartbeat` 汇报当前的 `active_slots`(正在运行的任务数)和资源负载。
因为是 Pull 模式,如果在服务端强行拒绝 `claim`(例如当节点的活跃任务超过管理员设定的配额时返回空),会导致 Worker 不断进行无效的轮询(Busy Loop),浪费网络和日志资源。因此,最优解是**让 Worker 能够感知到服务端下发的配额上限,并动态调整其本地的并发控制器**。
## 3. 设计方案:基于心跳响应的配额下发机制 (Heartbeat-based Quota Sync)
### 3.1 数据库与服务端改造
1. **DB 结构更新**
在服务端的 `nodes` 表中新增一个可空字段 `admin_max_slots` (INTEGER, Nullable),用于保存管理员强制指定的并发上限。
2. **新增管理 API**
- 接口:`POST /api/admin/nodes/:node_id/quota`
- 权限:Admin 角色
- 请求体:`{"admin_max_slots": 4}`(传 `null` 表示解除限制,恢复节点的物理槽位上限)
3. **心跳响应扩展**
修改 `POST /api/node/heartbeat` 的响应结构,将 `admin_max_slots` 的值透传给 Worker
```json
{
"status": "ok",
"admin_max_slots": 4 // 如果无限制,则为 null
}
```
*注意*:还需在 `crates/common/src/models.rs` 中为 `NodeInfo` 增加 `pub admin_max_slots: Option<i32>` 字段,确保 `/api/admin/nodes` 返回配额数据供 Dashboard 渲染。
### 3.2 Worker 节点改造
1. **解析心跳响应**
目前 Worker 的心跳线程(`crates/node/src/worker.rs`)忽略了响应体。需要在 `crates/common/src/models.rs` 中定义 `NodeHeartbeatResponse` 结构体,并在 Worker 中反序列化,提取 `admin_max_slots`
2. **状态共享**
Worker 并非使用 `Semaphore` 控制并发,而是依赖 `Arc<AtomicI32>` (`active_slots`) 计数。我们需要在 `NodeWorker` 的状态中新增一个 `effective_max_slots: Arc<AtomicUsize>`,以便心跳线程和领用线程共享配额信息。
3. **动态调整并发控制逻辑**
- 心跳线程在收到心跳响应后,计算生效配额并更新:
```rust
let target_slots = match admin_max_slots {
Some(admin_limit) => std::cmp::min(admin_limit as usize, physical_max_slots),
None => physical_max_slots
};
self.effective_max_slots.store(target_slots, Ordering::Release);
```
- 任务领用(Claim)主循环的条件相应修改为:
```rust
let active = self.active_slots.load(Ordering::Acquire);
if (active as usize) < self.effective_max_slots.load(Ordering::Acquire) {
// ... 领用任务 ...
} else {
tokio::time::sleep(Duration::from_secs(2)).await;
}
```
- **平滑降级**:如果缩减时当前正在运行的槽位大于 `target_slots`,上述 `if` 判断会直接失败,Worker 自然进入 `sleep` 分支待命。无需强行中断物理计算任务,等现有任务结束后,数量便会自动回落到新配额以下,实现安全平滑降级(即便配额设为 0,也会安全地暂停接收新任务)。
### 3.3 前端控制台 (Dashboard) 改造
1. **保持表格原状**
外部节点列表的大表维持原状(“CPU 槽位”列仍然显示 `占用 / 最大`,不作修改,保持界面整洁)。
2. **重构操作列交互(收敛至统一弹窗)**
- 将“管理操作”列中原有的“启用/停用”、“重发 Token”等多个离散按钮,统一替换为一个**三个点 (More Options) 的 SVG 图标按钮**。
- 点击该按钮后,利用系统中现有的弹窗样式 (如 `.modal-card`) 弹出一个**节点详情与管理 Modal**。
3. **节点详情管理弹窗内容**
- **基础信息展示**:显示节点的详细状态、心跳时间、资源使用率等。
- **槽位占用情况展示**:如果没有配额限制,显示格式为 `16/16`(占用/最大);如果有配额限制,显示格式为 `12/12/16`(占用/配额/最大),其中“最大”代表节点 `.env``DCTS_MAX_SLOTS` 设置的运行使用上限。
- **修改配额 (CPU 核数)**:提供表单供输入新的配额数字,调用 `/api/admin/nodes/:node_id/quota` 保存;或提供“清除配额”恢复运行使用的最大核数上限。
- **节点调度控制**:提供“暂停(停用)/启动(启用)节点”的操作按钮。
- **安全管理**:提供“重新颁发 Token”的操作按钮。
## 4. 方案优势
1. **平滑降级**:不中断正在运行的 TLUSTY 任务,实现优雅缩容。
2. **网络高效**:利用现有的高频心跳链路顺带下发配置,不增加额外的 RPC 接口,且避免了服务端阻断 claim 带来的无效轮询。
3. **职责清晰**Server 依然是状态中心,Worker 仍旧掌控并发调度,逻辑解耦。
+112
View File
@@ -0,0 +1,112 @@
# Runbook:僵尸任务涡旋修复上线(2026-08-02)
## 背景
2026-08-01 晚重启工作流后发生重复派发涡旋(单点最高被算 101 次、wave3-9 积压饿死 6+ 小时)与收敛点被迟到失败报告翻黑(converged 净减少)。根因与修复见本次代码变更(三条不变量:每点至多一个活任务;converged 吸收态;stop/重启不产生僵尸)。
**当前生产状态(14:24 实时)**:工作流已暂停、槽位已排空、converged 4621 / failed 1139 / pending 3456。本 runbook 从"部署新版"开始。
## 第 1 步:部署新版服务端
```bash
cd /home/fmq/program/tlusty/tl208-s54/dcts # 本机(代码所在)
./scripts/deploy.sh -e remote -b compose -r server
```
部署期间工作流保持 paused,无影响。部署完成后确认容器正常:
```bash
ssh fmq@100.66.1.2 'cd /vol1/1000/program/dcts && docker compose ps server'
curl -s "http://100.66.1.2:8090/api/status" -H "Authorization: Bearer <admin_token>" | head -c 300
```
## 第 2 步:存量清理(维护窗口,约 2 分钟)
在 100.66.1.2 上执行。建议停容器避免 WAL 锁争用(节点心跳会短暂失败并自动恢复):
```bash
ssh fmq@100.66.1.2
cd /vol1/1000/program/dcts
docker compose stop server
# 确认队列库路径(compose 默认为 data/dcts_queue.db
ls -la data/dcts.db data/dcts_queue.db
# 备份
cp data/dcts.db data/dcts.db.bak.$(date +%s)
cp data/dcts_queue.db data/dcts_queue.db.bak.$(date +%s)
# 清理 + 修复(先审计后执行)
sqlite3 data/dcts.db <<'SQL'
ATTACH 'file:data/dcts_queue.db?mode=ro' AS q;
-- 审计 1:将删除的僵尸行数(预期数千条;= 无队列凭证的 pending 行)
SELECT COUNT(*) AS zombies FROM main.tasks
WHERE status='pending' AND workflow_name='sdB_cno'
AND task_id NOT IN (SELECT task_id FROM q.task_queue);
-- 审计 2:被翻黑的收敛点数(success_method 非空的 failed 点)
SELECT COUNT(*) AS flipped FROM grid_points
WHERE workflow_name='sdB_cno' AND status='failed' AND success_method IS NOT NULL;
-- 审计 3:从未真正获得种子救援的 failed 点数
SELECT COUNT(*) AS never_rescued FROM grid_points
WHERE workflow_name='sdB_cno' AND status='failed' AND success_method IS NULL
AND NOT EXISTS (SELECT 1 FROM tasks t
WHERE t.point_name=grid_points.name
AND t.workflow_name='sdB_cno'
AND t.task_type='seed_step');
-- 执行 1:清僵尸(claimed 在途行自动保留,NOT IN 子查询不过滤 status
DELETE FROM main.tasks
WHERE status='pending' AND workflow_name='sdB_cno'
AND task_id NOT IN (SELECT task_id FROM q.task_queue);
-- 执行 2:修复被翻黑的收敛点
UPDATE grid_points SET status='converged'
WHERE workflow_name='sdB_cno' AND status='failed' AND success_method IS NOT NULL;
-- 执行 3(可选):复活从未获救援的 failed 点,走正常 cold_run → 种子回退路径
UPDATE grid_points SET status='pending'
WHERE workflow_name='sdB_cno' AND status='failed' AND success_method IS NULL
AND NOT EXISTS (SELECT 1 FROM tasks t
WHERE t.point_name=grid_points.name
AND t.workflow_name=grid_points.workflow_name
AND t.task_type='seed_step');
SQL
docker compose up -d server
```
> 注意:执行 3 会把约百个点打回 pending,重启后它们会先跑一轮 cold_run 再触发种子回退——这是预期行为。
## 第 3 步:启动工作流
```bash
curl -s -X POST "http://100.66.1.2:8090/api/workflows/sdB_cno/start" \
-H "Authorization: Bearer <admin_token>"
```
启动时 `initialize_grid` 卫生逻辑会再清一遍残留(与第 2 步不冲突),随后 3456 个 pending 点按 wave 升序正常派发。
## 第 4 步:验证(启动后 1 小时内)
```bash
# 1. 无重复派发:最近 1 小时每个点应至多 1 个任务(预期空结果)
ssh fmq@100.66.1.2 "sqlite3 /vol1/1000/program/dcts/data/dcts.db \
\"SELECT point_name, COUNT(*) n FROM tasks WHERE created_at > datetime('now','-1 hour') GROUP BY point_name HAVING n > 1;\""
# 2. converged 单调不减、queued 以 ~100/h 下降(每 10 分钟看一次)
watch -n 600 "curl -s http://100.66.1.2:8090/api/status -H 'Authorization: Bearer <admin_token>' | head -c 200"
# 3. 历史涡旋点不再产生新任务
ssh fmq@100.66.1.2 "sqlite3 /vol1/1000/program/dcts/data/dcts.db \
\"SELECT COUNT(*) FROM tasks WHERE point_name='t55000_g5.0_he2_c-4_n-3_o-4' AND created_at > datetime('now','-1 hour');\""
# 4. 观察项(非承诺):快照 running 计数是否恢复 >0。若节点满载但 running 恒 0,
# 属独立显示问题,查 server 日志中 claim 的 "跳过 running 标记" info 行定位。
```
## 回滚预案
若新版出现异常:`docker compose stop server` → 恢复备份(`cp data/dcts.db.bak.<ts> data/dcts.db`,队列库同理)→ 部署旧镜像 → up。清理 SQL 的逆操作不需要(僵尸行清除与翻黑修复都是单向正确方向)。
+282
View File
@@ -0,0 +1,282 @@
# 收敛恒星光谱的物理正确性保证调研
> 调研日期:2026-08-04
> 范围:TLUSTY 208 + SYNSPEC 54 计算的恒星大气与光谱的**物理正确性**保证方法。
> 前置文档:`docs/task_failure_detection_analysis.md`(程序"是否崩溃/收敛"的判定,是本调研的下层)。
> 核心命题:**程序正常退出(rc=0)且 max_relc < chmax ≠ 光谱在物理上正确**。本文调研介于两者之间的静默错误来源与验证方法。
> 方法:源码级静态分析 + 天体物理方法学综述 + 三方独立复核。
---
## 0. TL;DR
当前 pipeline 对光谱正确性的保证**基本停留在"数值收敛"层面**,完全没有做物理不变量校验。三方调研发现:
1. **最高 ROI 缺口**:TLUSTY 已经无条件地把**能量守恒诊断**(`TOTAL SURFACE FLUX`、逐深度 `RAD/TOT``CON/TOT``(RAD+CON)/TOT`)写进已归档的 `.6` 文件,**却从未被任何 Rust 代码解析**。这是 Hubeny 官方推荐的 1% 判据,零成本可得,是物理正确性的第一道硬门槛。
2. **系统性的配方风险**`fort55_writer.rs` 写的字段 `idrv/ifreq` 与 SYNSPEC 实际读取的 `IDSTD/IPRIN` 错位(已核实),导致所有光谱的标准深度参考点与输出详尽度偏离预期。
3. **网格级错误传播**:通过 `converged && !nan` 门控上传的种子大气(`.7`),会把"伪收敛但物理未平衡"的点扩散给整个物理族——这是网格计算特有的静默放大机制,现有检查无法识别。
4. **完整的方法学分层**(§6):硬门槛(线上每点)→ 质量监控(抽样)→ 深度核查(线下基准比对),覆盖辐射平衡、光谱自洽、网格一致性、外部基准四个维度。
---
## 1. 当前已产出但未利用的物理诊断
> 这是后续做正确性校验的基础——很多信息 TLUSTY/SYNSPEC 已经产出,只是我们没解析。
### 1.1 能量守恒诊断(最关键,零成本可得)
TLUSTY 在 `LFIN`(最终迭代)时由 `OUTPRI` 子程序无条件写出,位于 `.6` 文件(unit 6,已归档):
```
TOTAL SURFACE FLUX 6.77712339D+12 ← 实际积分通量 TOTF
ID MASS TAUROSS TEMP NE DENS P_gas LOG(G_rad) RAD/TOT CON/TOT (RAD+CON)/TOT
1 ... 8.913E-01 0.000E+00 8.91264E-01
```
- `FLTT = SIG4P * TEFF**4`(预期通量 = σTeff⁴/4π,`SIG4P``BASICS.FOR:51`
- `RAD/TOT` = 辐射通量 / σTeff⁴
- `(RAD+CON)/TOT` = 总通量(辐射+对流)/ σTeff⁴ —— **此列应≈1.0**
- 实测样本(发散模型 max_relc≈1e21):`(RAD+CON)/TOT ≈ 0.891`,即 **11% 能量守恒违反**;收敛良好模型应≈1.0
**关键事实**`.6` 已在归档白名单(`STAGE_SNAPSHOT_SUFFIXES` 含 "6"),但 `crates/` 下无任何代码解析它。**数据已就位,只差一个 `.6` 解析器写入 conv.json**。
### 1.2 辐射平衡逐深度残差(被覆盖丢失)
TLUSTY `RECHCK` 子程序(`tlusty208.f:49854`)在 LFIN 时无条件调用,逐深度计算 `re=(吸收−发射)/发射` 写入 unit 17fort.17)。但 unit 17 随后被 SYNSPEC `OUTPRI` 覆盖写连续谱(`synspec54.f:3371`)——**成功路径下 RECHCK 输出永久丢失**,仅 SYNSPEC 失败时 `.cont` 里残留的才是 RECHCK 表(这也是 `.cont` 后缀二义性的来源)。
修复方向:仿照 `.bfac`/`.emflux` 的快照做法,在 RECHCK 后/SYNSPEC 前快照 fort.17。
### 1.3 其他未启用 / 未解析的诊断
| 诊断 | 子程序 | unit→文件 | 启用条件 | 现状 |
|------|--------|----------|---------|------|
| 能量守恒总表 | `OUTPRI` | 6→`.6` | 无条件 | **已产出,未解析**(最高 ROI |
| 辐射平衡逐深度 | `RECHCK` | 17→`.cont` | 无条件 | **被 SYNSPEC 覆盖丢失** |
| 统计平衡残差 | `CHCKSE` | 16→fort.16 | `ICHCKP≠0`nst 未设) | **未启用** |
| 冷却/加热率 | `COOLRT` | 86/87/88 | `ICOOLP≠0`nst 未设) | **未启用** |
| 出射辐射场谱 | `OUTPRI` | 13→fort.13 | 无条件 | **白名单排除**result_filter.rs:179 |
| TLUSTY 出射谱 | `OUTPRI` | 14→`.emflux` | 无条件 | **已快照归档,未解析**(可做 bolometric 积分) |
| b 因子(NLTE 偏离) | `OUTPRI` | 12→`.bfac` | 无条件 | **已快照归档,未解析** |
| SYNSPEC 谱线统计 | START/OUTPRI | 6→`.log` | 无条件 | **已归档,未解析**(含 `LINES-TOTAL`、波长范围、等效宽度) |
### 1.4 conv.json 的现状
`ModelSummary` / `StageSummary` / `ConvCheckResult``models.rs`)记录的字段**全部是数值收敛指标**max_relc, last_iter, worst_depth, synspec_rc 等),**没有任何物理正确性字段**(能量守恒、辐射平衡、统计平衡残差)。
---
## 2. 静默错误的物理来源
> "程序正常退出但光谱物理错误"的四层来源,按严重度排序。每层给出源码证据与可检测性。
### 2.1 第一层:TLUSTY 大气结构错误(光谱错误的根因)
| 错误来源 | 物理后果 | 源码证据 | 当前是否捕获 | 严重度 |
|---------|---------|---------|------------|--------|
| **NITER 截断被当收敛** | 大气未达统计平衡,布居数错误 | `tlusty208.f:14728` `LFIN=ABS(CHMX).LE.CHMAX.OR.ITER.GE.NITER`OR 短路:CHMX 再大也会因 `ITER≥NITER` 判 LFIN | 部分(`conv_check.rs` 重判 max_relc<chmax,但若截断时"刚好"<chmax 则通过) | 极高 |
| **ACCEL2/Ng 加速伪收敛** | 加速外推后下次 CHMX 人为变小,提前判收敛但平衡未达成 | `tlusty208.f:29693-29797`,无加速后发散回退检查 | 未捕获(max_relc 变小与真收敛难区分) | 高 |
| **模型截断但物理不充分** | 关键 H/He 跃迁被省略,NLTE 种群错误但程序正常 | `gen_input5.rs` 固定 H=9lev、HeI=14、HeII=14;远低于 MLEVEL=1134 上限 | 未捕获(需物理判断) | 高 |
| **ND=50 深度网格偏稀** | 大气结构分辨率不足,谱合成输入质量差 | `nst_writer.rs:6` ND=50`BASICS.FOR:9` MDEPTH=100 | 未捕获(需对比 ND=99 | 中-高 |
### 2.2 第二层:SYNSPEC 谱合成错误
| 错误来源 | 物理后果 | 源码证据 | 当前是否捕获 | 严重度 |
|---------|---------|---------|------------|--------|
| **★fort.55 字段错位** | 标准深度取最深层(IDSTD=50=ND 而非自动 2ND/3≈33)、输出详尽度偏离、Lyman 线翼开关被误设 | `fort55_writer.rs:5``imode,idrv,ifreq`SYNSPEC `:253``IMODE,IDSTD,IPRIN` —— **已核实错位** | 未捕获 | **高(系统性)** |
| **NLTE 静默降级为 LTE** | 匹配不上的 NLTE 线被 set to LTE,线强度错误但程序继续 | `synspec54.f:9114-9176`,仅写 fort.11 计数 | 未捕获(DCTS 不解析 fort.11 | 高 |
| **IBFAC=0 默认全程按 LTE 读种群** | fort.8 不含/不全含 NLTE 种群时种群保持 LTE 不替换 | `synspec54.f:1157` IBFAC=0 默认;`:11220` NUMP>IP 才替换 | 未捕获 | 高 |
| **波长网格步长固定且窗口窄** | 窄线被漏掉,谱"正常但缺线" | `fort55_writer.rs:12` SPACE=0.01;默认窗口仅 1400-1410Å(`runner.rs:522` | 未捕获 | 中-高 |
| **线表缺失(只链 fort.19** | 缺关键谱线,有计数但无"应有线却缺失"校验 | `runner.rs:540` symlink 单一线表;`:9157` NLIN0 计数 | 未捕获 | 高 |
### 2.3 第三层:数据传递与种子继承(网格级错误放大)
| 错误来源 | 物理后果 | 源码证据 | 当前是否捕获 | 严重度 |
|---------|---------|---------|------------|--------|
| **★种子继承的错误传播** | 伪收敛点的 `.7` 通过 `converged && !nan` 门控上传为种子,被相邻 SeedStep 点热启动,**错误沿网格拓扑传播**;`directed_cno_distance` 倾向选贫方向,一个坏种子可污染整个 Teff/logg 族 | `executor.rs:193` 种子门控;`seed_finder.rs` 最近邻选种 | **未捕获**(坏种子本身"通过"了所有检查) | **极高** |
### 2.4 第四层:编译/数值精度
| 错误来源 | 物理后果 | 源码证据 | 当前是否捕获 | 严重度 |
|---------|---------|---------|------------|--------|
| **生产编译无 -fcheck/-ffpe-trap** | 数组越界、IEEE 异常 → rc=0 + 静默写越界内存,产出无 NaN 标记但物理错误的大气 | `deployment.md:86,90` `gfortran -fno-automatic -O3` | 未捕获(详见 task_failure_detection_analysis.md §4 漏洞 3 | 高 |
> 详见 `task_failure_detection_analysis.md` 的 gfortran 运行时错误退出码实测表。
---
## 3. 天体物理验证方法学
> 每个方法标注:验证的物理不变量、操作步骤、容差判据、所需数据、实现难度、线上/线下。
### 3.1 大气模型层面(TLUSTY 产出)
| 方法 | 验证的物理不变量 | 容差/判据 | 所需数据 | 难度 | 时机 |
|------|----------------|----------|---------|------|------|
| **通量守恒** | 每深度 πF(rad+conv)=σTeff⁴ | `(RAD+CON)/TOT` 偏离 1 ≤1%(Hubeny 官方判据);任一深度 >5% 直接判坏 | `.6` 末表 | 低 | **线上** |
| **max_relc 收敛门槛** | 状态向量相对修正已可忽略 | < chmax(默认 10⁻³,对 sdB/早型星合理;精密线分析可收紧到 10⁻⁴) | `*_chmax*.9` | 低(已实现) | **线上** |
| **假收敛排查** | max_relc 单调下降 ≥3 个量级 | 初始/最终 max_relc ≥10³ | fort.9 全序列 | 中 | 抽样 |
| **NaN/Inf 否决(收紧)** | 所有深度点温度/密度为有限物理值 | **任意 1 行命中即否决**(当前 10% 阈值过松) | `.7` | 低 | **线上** |
| **温度结构边界** | T(τ) 落在物理合理范围 | 表层 T ≲ 2-3×Teffidstd 处 T≈(2/3)Teff;总体 10¹-10⁸ K | `.7` | 中 | 抽样 |
| **统计平衡残差(NLTE** | 跃迁平衡 R_ij·n_i=R_ji·n_j、粒子数守恒 | b 因子无 NaN/Inf;关键能级 b∈10⁻³-10³ | `.bfac` | 中 | 抽样 |
**对 TLUSTY 的已知坑**(a) fort.9 缺失分支无条件判收敛 + 虚构 `best_max_relc=0.0`(见 task_failure_detection_analysis.md 漏洞 2);(b) Kantorovich 加速的伪收敛风险;(c) 表层(worst_depth=1)最易出 NaN。
### 3.2 光谱合成层面(SYNSPEC 产出)
| 方法 | 验证的物理不变量 | 容差/判据 | 所需数据 | 难度 | 时机 |
|------|----------------|----------|---------|------|------|
| **NaN/负值/零行否决(最关键)** | 归一化谱∈[0,1],绝对流量≥0,无 NaN/Inf | **0 容忍**(任意一个即判坏) | `.spec``.cont` | 低 | **线上** |
| **Rayleigh-Jeans 尾 Planck 检验** | 长波端连续谱 → Bλ(Teff) | 斜率与 Bλ(Teff) 一致;偏离 >20% 异常 | `.cont` + Teff | 中 | 抽样 |
| **总积分流量能量守恒** | ∫Fλ dλ ≈ σTeff⁴(bolometric | 偏离 <1-2% | `.emflux`(全频率范围) | 中 | 抽样 |
| **连续谱自洽(TLUSTY vs SYNSPEC** | 同波长点 `.emflux``.cont` 水平一致 | 连续谱区差异 <1-2% | `.emflux` + `.cont` | 低 | 抽样 |
| **Balmer 线基准** | Hα/Hβ EW 随 Teff/logg 已知行为 | 线心深度∈(0,1)sdB 的 Hβ EW 约 5-15Å;高 Teff(>30kK) 不应异常强 | 光学 `.spec` | 中 | 抽样 |
| **He 线电离序列** | He II/He I 强度比随 Teff 单调 | 比值突变 = NLTE 电离平衡错 | 光学 `.spec` | 中 | 抽样 |
**归一化谱 vs 绝对流量谱**:归一化谱只验线形/线强,不能验能量守恒;绝对流量谱(`.emflux``.cont`)才能验通量守恒与 Planck。两者必须分别处理。
### 3.3 网格层面(内部一致性)
| 方法 | 验证的物理不变量 | 容差/判据 | 所需数据 | 难度 | 时机 |
|------|----------------|----------|---------|------|------|
| **相邻点平滑性(一阶差分)** | 光谱随参数连续变化 | 连续谱单步 Δ<10-15%;线 EW<30% | 全网格 `.cont`/EW | 中 | 线下 |
| **二阶差分 / 离群点检测** | 光谱无孤立异常 | r>4σ 或 \|残差\|>20% 标 outlier | 全网格光谱 | 中 | 线下 |
| **沿等参数线单调性** | Balmer/He 线随 Teff/logg 已知单调趋势 | 出现锯齿/反转 = 可疑 | 全网格光谱序列 | 低 | 线下 |
| **收敛质量地图** | max_relc 在网格上无孤立坏区 | 孤立高 max_relc 点(邻居都收敛)= 可疑 | 全网格 conv.json | 低 | 线下 |
**已知坑**:网格在 80kK+He-poor+富金属区有真实物理极限(CNO 高价离子主导不透明度,线性化无法阻尼)——这片成片失败是物理真实的,不应误判为 bug;但**孤立单点失败**(邻居都好)才需要复查。
### 3.4 外部基准比对(ground truth
| 基准 | 验证什么 | 容差/判据 | 数据来源 | 难度 | 时机 |
|------|---------|----------|---------|------|------|
| **Lanz & Hubeny 2003/2007 网格** | 与社区标准 TLUSTY/SYNSPEC 网格逐点一致 | 连续谱 <1-2%,线轮廓 <5% | tlusty.oca.euOSTAR2002/BSTAR2006,免费) | 中 | 建库时 |
| **NLTE-OBGRID** | Hubeny 更新版网格(更完整 H 原子) | 同上 | MAST archive.stsci.edu/hlsp/nlte-obgrid | 中 | 建库时 |
| **PHOENIX / CASTELLI / ATLAS9** | 与独立代码(不同物理近似)合理一致 | 光学几个百分点;UV(400nm)可达 10-20% | STScI | 中-高 | 建库时 |
| **CALSPEC 实测标准星** | 终极 ground truth | Bohlin+2020TLUSTY 白矮星网格 1500Å-30μm 一致到 **1%** | MASTGD153/GD71/G191B2B | 高 | 深度核查 |
| **Gaia FGK 基准星** | 非光谱学基本参数(干涉/角直径)校验 | gold standard | blancocuaresma.com/s/benchmarkstars | 高 | 深度核查 |
**实测比对必须考虑的系统误差**:消光(RV=3.1)、视向速度、instrumental broadening(需 ROTINS 卷积)、距离/标度因子。
---
## 4. 物理红旗清单(一票否决)
以下任一出现即光谱不可信,必须标记复查:
| 红旗 | 物理含义 | 检测方法 |
|------|---------|---------|
| 连续谱出现负值或 NaN | 辐射转移数值失败 | `.spec`/`.cont` 任意点 <0 或 NaN |
| 归一化谱线心深度 >1(流量 <0) | 线吸收过度/数值错 | 归一化谱任意点 <0 或 >1.05 |
| `.spec` 全零或行数异常少 | SYNSPEC 中途 quit 留脏数据 | 行数 <预期×0.9 或全零 |
| 总积分流量偏离 σTeff⁴ >2% | 能量不守恒 | 积分 `.emflux` |
| 通量守恒表末列偏离 1 >1%(任一深度) | 辐射平衡未达 | 读 `.6` 末表 |
| 表层温度 >>Teff(如 >3×Teff | T(τ) 结构非物理 | 读 `.7` 表层 T |
| 热星(Teff>30kKBalmer 线异常强 | NLTE 电离算错 | 测 Hα/Hβ EW 比对基准 |
| 连续谱斜率与 Teff 不符 | 温度结构错 | RJ 尾比对 Bλ(Teff) |
| 沿 Teff 序列 Balmer/He 线非单调 | 局部点 NLTE 错 | 网格级单调性检查 |
| 孤立单点与四邻插值偏离 >20% | 该点异常 | 离群点检测 |
---
## 5. fort.55 字段错位问题(已核实的系统性 bug)
这是一个影响**所有光谱**的系统性问题,值得单独记录。
### 5.1 事实
- `fort55_writer.rs:5` 生成的第一行:`imode, idrv, ifreq`(值如 `0, 50, 1`
- SYNSPEC `synspec54.f:253` 实际读取:`IMODE, IDSTD, IPRIN`
- 即 **`idrv=50` 被当作 `IDSTD=50``ifreq=1` 被当作 `IPRIN=1`**
### 5.2 影响
- **IDSTD=50**=ND,最深层):SYNSPEC 的标准深度(`synspec54.f:2155`)本应自动取 `2*ND/3≈33`,现被强制取最深层。标准深度是谱线多普勒宽度、Stark 加宽的参考层,取错层会让线翼轮廓参考错误深度。
- **IPRIN=1**:SYNSPEC 的输出详尽度开关,影响 `.log` 里打印的诊断量多少。
- **第 3 行 `IOPHLI=0`**`fort55_writer.rs:7` 第一个 0):`synspec54.f:255` 读取,IOPHLI 控制 Lyman 线翼处理,0 可能关闭相关处理。
### 5.3 处置
这是配方层面的 bug,需要单独修复(修正 `fort55_writer.rs` 的字段语义,使 IDSTD=0 自动、IPRIN/IPRIND 按需设置),并重算受影响的光谱。本调研仅记录,不含修复实现。
---
## 6. 分层验证策略建议
> 按"必须做 / 建议做 / 可选做"分层,标注每项的现状与所需投入。
### 第一层:硬门槛(线上,每个点必跑,0 容忍,失败即标记重算)
| # | 校验项 | 现状 | 所需投入 | 依据 |
|---|--------|------|---------|------|
| 1 | SYNSPEC `.spec`/`.cont` 内容校验(NaN/Inf/负值/全零/行数) | **未做**(漏洞 1 | 低 | task_failure_detection_analysis.md P0 |
| 2 | 大气 NaN 否决收紧到 0 行容忍 | 10% 阈值过松(漏洞 4 | 低 | conv_check.rs:212 |
| 3 | max_relc < chmax + is_finite + fort.9 真实存在 | 已实现,但 fort.9 缺失分支有虚构值(漏洞 2 | 低 | runner.rs:417 |
| 4 | **通量守恒 `(RAD+CON)/TOT` 偏离 1 ≤1%**(从 `.6` 提取) | **未做(数据已就位)** | 低(写一个 .6 解析器) | §1.1,Hubeny 官方判据 |
### 第二层:质量监控(线上抽样 + 线下定期)
| # | 校验项 | 现状 | 所需投入 |
|---|--------|------|---------|
| 5 | 总积分流量 vs σTeff⁴(`.emflux`,偏离 <2%) | 未做,数据已归档 | 中(bolometric 积分) |
| 6 | Rayleigh-Jeans 尾 Planck 斜率检验 | 未做 | 中 |
| 7 | TLUSTY `.emflux` 与 SYNSPEC `.cont` 连续谱自洽 | 未做 | 低 |
| 8 | 假收敛排查(max_relc 下降 ≥3 个量级) | 未做 | 中 |
| 9 | 收敛质量热图(网格级 max_relc 分布) | 未做 | 低(数据在 conv.json |
| 10 | 温度结构边界(表层 T、idstd 处 T) | 未做 | 中 |
### 第三层:深度核查(线下,建库时 + 每次配方变更时)
| # | 校验项 | 所需投入 |
|---|--------|---------|
| 11 | 与 Lanz & Hubeny 2003/2007 / NLTE-OBGRID 重叠点逐波长比对 | 中 |
| 12 | 与 PHOENIX/CASTELLI 独立代码比对 | 中-高 |
| 13 | 与 CALSPEC/SDSS 实测标准星比对 | 高 |
| 14 | Balmer/He 线序列单调性与基准 | 中 |
| 15 | 网格二阶差分离群点检测 | 中 |
| 16 | 统计平衡残差 / b 因子合理性(`.bfac` | 中 |
### 配方变更触发规则
任何以下变更必须重跑第三层深度核查:CHMAX/NITER/ND 改动、原子模型/线表(fort.19)更新、TLUSTY/SYNSPEC 重编译(尤其加 `-fcheck`/`-ffpe-trap`)、丰度网格范围调整、**fort.55 字段修复(§5**。
---
## 7. 综合判断
**当前 pipeline 在"光谱物理正确性校验"上基本没做** —— 只做了数值收敛性(max_relc)和最低限度的完整性(`.7` 无 NaN、synspec 退出码),完全没有能量守恒、辐射平衡、统计平衡这三项核心物理不变量校验。
**投入产出比排序**
1. **P0:解析 `.6` 的能量守恒诊断**(§1.1)—— 数据已就位,Hubeny 官方 1% 判据,是物理正确性的第一道硬门槛。同时修 `.spec` 内容校验(§6 第一层)。
2. **P1fort.55 字段错位修复**(§5)—— 影响所有光谱的系统性 bug,需修正后重算。
3. **P1:网格级种子传播校验**(§2.3)—— 这是网格计算特有的静默放大机制,可通过"同一物理族下游点的 max_relc/通量守恒分布异常"间接检测。
4. **P2:质量监控层**(§6 第二层)—— bolometric 积分、Planck 检验、连续谱自洽。
5. **P3:建库时基准比对**(§6 第三层)—— 与 Lanz & Hubeny 网格逐点比对是最直接的同代码 ground truth。
---
## 8. 参考资料
**官方文档**
- Hubeny 2017, "A Brief Introductory Guide to TLUSTY and SYNSPEC"arXiv:1706.01859)—— 通量守恒 1% 判据、假收敛、ND=50 B 星例、改丰度警告
- TLUSTY User's Guide IIIarXiv:1706.01937)—— fort.9 收敛日志格式、unit 6 辐射平衡表
- TLUSTY User's Guide IV, Hubeny 2021arXiv:2104.02829)—— CHMAX=10⁻⁴ 高精度例
**社区基准网格(ground truth**
- OSTAR2002 / BSTAR2006tlusty.oca.eu)—— Lanz & Hubeny 2003/2007 社区标准网格
- NLTE-OBGRIDMAST archive.stsci.edu/hlsp/nlte-obgrid)—— Hubeny 更新版
- WD-GRIDMAST archive.stsci.edu/hlsp/wd-grid)—— Bohlin+2020 白矮星 NLTE 网格,1500Å-30μm 对 CALSPEC 1% 一致
**交叉代码比对**
- Witzke+2021, MPS-ATLASA&A)—— 三代码流量差异:~400nm 最大,其他波段几个百分点
- CASTELLI/Kurucz ATLAS9STScI
**本仓库内相关文档**
- `docs/task_failure_detection_analysis.md` —— rc 不可靠性、fort.9/NaN 阈值漏洞、gfortran 退出码实测(第一层硬门槛的依据)
- `docs/tlusty_result_artifacts.md` —— fort 单元语义、`.emflux`/`.bfac` 快照、TLUSTY fort.14 与 SYNSPEC `.spec` 关系
- `docs/PIPELINE.md` —— 阶段链、nst 配方(ND=50、默认 CHMAX=0.001)、物理极限区
**源码证据**
- 能量守恒诊断:`tlusty208.f:14179-14191,14271-14273`OUTPRI);`:49854`RECHCK);实测 `hotsd/tlusty (副本)/hhe35nl.6:334-339`
- fort.55 错位:`fort55_writer.rs:5` vs `synspec54.f:253`(已核实)
- 种子传播:`executor.rs:193``seed_finder.rs`
+221
View File
@@ -0,0 +1,221 @@
# DCTS 任务计算引擎解耦与阶段独立配置架构设计方案
## 1. 背景与现状分析 (Background & Current Bottlenecks)
### 1.1 现状与概念降维问题
在 DCTS 早期版本中,任务类型通过单级平铺枚举 `TaskType`(如 `ColdRun`, `SeedStep`)定义。它将 TLUSTY 阶段的“初始猜想策略”、SYNSPEC 阶段的执行与否,以及全局任务的生命周期强行绑定在一起,导致概念降维与严重耦合。
### 1.2 组合爆炸风险
随着扩展能力(如 `Interpolation`, `LineListScan`)的加入,平铺枚举会导致 $N \times M$ 的组合爆炸,引发代码中大量的硬编码匹配分支。
### 1.3 `runner.rs``executor.rs` 的硬编码耦合问题
现存节点端代码将执行顺序与文件依赖硬编码,例如必须检查 `TaskType::SeedStep` 才能下载大气文件,无法灵活应对只运行光谱合成的场景。
---
## 2. 独立阶段配置架构设计 (Stage-Based Independent Configuration)
为了实现极高的灵活性并彻底避免组合爆炸,摒弃全局单一的 `TaskType` 枚举绑定,将 TLUSTY 和 SYNSPEC 视为平级的**独立阶段 (Stage)**。
对每一个计算阶段,均提供正交的三个配置维度:
1. **启用开关 (Enabled)**:布尔值,决定当前任务流是否要执行该阶段。
2. **执行策略 (Execution Policy)**:启动工作流时对历史终态点的处理逻辑(跳过收敛/重试失败、强制重算、跳过收敛及失败)。注意:策略只决定**启动时**对终态点的处理;失败后的策略链回退由策略链(§4.2)独立驱动,不受策略门控(2026-08-04 语义修正)。
3. **计算策略 (Compute Strategy)**:该阶段所采用的具体科学计算方法。
### 2.1 独立阶段正交模型
**阶段一:TLUSTY 大气结构求解**
- **Enabled**: `true` / `false`
- **Policy**: `SkipConverged` (默认:跳过收敛·重试失败) / `ForceRecompute` (全量重算) / `SkipFailed` (跳过收敛及失败)
- **Strategy**: `ColdRun` (冷启动) / `SeedStep` (种子推演)(系统已预留扩展其他策略如网格内插的接口)
**阶段二:SYNSPEC 光谱合成**
- **Enabled**: `true` / `false`
- **Policy**: `SkipConverged` / `ForceRecompute` / `SkipFailed`
- **Strategy**: `Standard` (标准)(系统已预留扩展如多分辨率展宽、线表扫描等策略接口)
### 2.2 典型场景映射
此设计允许前端灵活组装出任意复杂度的科研计算流:
- **场景 A(新网格生成)**TLUSTY [启用, 增量, ["cold_run", "seed_step"]] + SYNSPEC [启用, 增量, ["standard"]]
- **场景 B(仅更新光谱)**TLUSTY [关闭] + SYNSPEC [启用, 强制重算, ["standard"]]
- **场景 C(强制更新大气)**TLUSTY [启用, 强制重算, ["seed_step"]] + SYNSPEC [关闭]
---
## 3. 数据模型与阶段配置结构设计 (Domain Model)
在底层核心结构 (`TaskSpec`) 中,我们将原有的单级平铺类型彻底废弃,转而采用**嵌套式单阶段配置模型 (`EngineStageConfig`)**(注:为避免与 `common::config::StageConfig` 迭代步进参数同名冲突,阶段配置统命名为 `EngineStageConfig`)。
- **`EngineStageConfig` 结构说明**
每一个阶段(无论是 TLUSTY 还是 SYNSPEC)都会拥有一个独立的 `EngineStageConfig` 对象,包含以下三个核心字段:
1. `enabled`: 布尔值。是否在当前计算流中启用该阶段。
2. `policy`: 枚举。决定**启动工作流时**对历史终态点的处理,可选值为:
- `SkipConverged`: 跳过已收敛、重试已失败(启动时把失败点打回 pending 重试,收敛点保留)。
- `ForceRecompute`: 强制重算(启动时收敛 + 失败全部打回 pending,无视历史状态与产物)。
- `SkipFailed`: 跳过收敛及失败(启动时收敛和失败点都保留,只算从未计算过的点)。
- 失败后的策略链回退不受策略门控,只由 `strategies` 链(回退优先级排序)驱动。
3. `strategies`: 策略链队列。存储该阶段的计算策略组合,例如 `[ColdRun, SeedStep]`
- **`TaskSpec` 根模型重构**
原有的 `task_type` 被标记为向后兼容保留字段,新增了:
- `tlusty_config`: 对应 TLUSTY 阶段的 `EngineStageConfig` 实例。
- `synspec_config`: 对应 SYNSPEC 阶段的 `EngineStageConfig` 实例。
- 保留旧版 `task_type` 作为废弃兼容字段,主要用于 Serde 反序列化历史遗留任务或在途 MQ 消息,并在 `normalize_compat()` 方法中自动映射到新的嵌套结构。
---
## 4. 数据库与流转设计 (Database & Scheduler Lifecycle)
### 4.1 数据库结构升级
任务表 (`tasks`) 需要打平阶段配置,彻底移除阶段绑定关系。
- **新增阶段控制字段**
- 为 TLUSTY 新增 `tlusty_enabled` (布尔)、`tlusty_policy` (文本)、`tlusty_strategies` (JSON 数组,默认 `["cold_run"]`)。
- 为 SYNSPEC 新增 `synspec_enabled` (布尔)、`synspec_policy` (文本)、`synspec_strategies` (JSON 数组,默认 `["standard"]`)。
- 新增 `atmosphere_ref` 用于显式关联大气网格点。
- **移除冗余旧字段**
- 在数据库层面移除旧的 `task_type` 列(或保留为 NULL 兼容列)。注:原数据库并无 `pipeline_scope` 列,无需清理。
### 4.2 Strategy Chain 自动链式回退机制 (Scheduler-Level)
原本硬编码在 `config.yaml` 中的 `seed_step_fallback` 逻辑,在本次重构中**升级为更为通用的“策略链 (Strategy Chain)”机制 (`tlusty_strategies`)**。用户可以选择一个或多个策略并排序,形成执行队列。
它的工作流如下:
1. **节点执行当前策略**:Node 接收到任务后,总是读取并执行队列中的第一个策略(例如 `tlusty_strategies[0]``ColdRun`)。
2. **失败上报**:当该策略执行失败时,Node 向 Server 报告任务失败 (`Failed`)。
3. **调度介入弹出队列**:Server 接收到失败报告后,检查 `tasks` 表中的 `tlusty_strategies` 数组。
- 若数组中只有一个元素,说明策略链耗尽,任务彻底失败。
- 若数组中还有后续元素(如 `[ColdRun, SeedStep]`),Server 会将失败的 `ColdRun` 从队列中弹出。
4. **生成新任务指令**Server 将该网格点重置为 `Pending` 状态,并为其生成**下一顺位策略**的任务指令:
- `tlusty_strategies: ["seed_step"]` (剩下的策略链)
- **不修改**原有的 `tlusty_policy` 字段(保持用户的初始配置,避免状态污染)。策略链回退只由策略链驱动(2026-08-04 语义修正:移除策略门控);若用户不想重试失败点,用单策略链(如 `["cold_run"]`+ `SkipFailed` 表达。
- `seed_point_name: 'xxx'` (如果是 SeedStep,Server 在此时负责在全局网格中寻找已收敛邻居点并注入)
5. **节点无感知执行**:Node 再次拉取到任务时,继续单纯地执行当前队列头部的 `SeedStep` 策略即可。Node 在准备运行新策略前,会自动清理该网格点上一轮策略留下的故障标记;节点对“回退过程”完全无感知,实现了全局调度与局部计算的完美解耦。
注:`synspec_strategies` 的自动弹栈与回退机制与 TLUSTY 保持完全一致(若配置了多策略链)。
---
## 5. 节点 Runner 与沙箱执行逻辑 (Node Executor)
在 DCTS 架构中,Node 采用**单任务独立沙箱 (`data/work/task_<task_id>`)** 执行流,并在完成后清理沙箱 (`cleanup_slot_work_dir`)。Server 端的 Scheduler 在生成 `TaskSpec` 时已依据网格数据库状态计算好当前任务指令,Node 仅需依指令执行:
1. **TLUSTY 阶段沙箱执行**
- 首先判断 `tlusty_config.enabled` 开关。若为 `false` 则跳过 TLUSTY 阶段。
- 若开启,Node 读取 `tlusty_config.strategies` 数组的第一项策略。
- 若策略为 `SeedStep`Node 凭 `seed_point_name` 自动向 Server API 异步下载种子 `.7` 大气文件并放入沙箱工作目录,随后调用 `ExecutionRunner` 启动 TLUSTY 迭代计算。
2. **SYNSPEC 阶段沙箱执行**
- 独立判断 `synspec_config.enabled` 开关。若为 `false` 则跳过。
- 若开启,Node 校验当前沙箱或关联的大气产物是否存在:若 TLUSTY 阶段刚完成则直接复用沙箱内 `.7` 文件;若为单独运行 SYNSPEC 场景(TLUSTY 关闭),Node先查看本地result目录是否有.7文件并复制到沙箱,如果没有再 凭 `atmosphere_ref`(或 `point_name`)自动向 Server/存储拉取目标 `.7` 文件放入沙箱。
- 读取 `synspec_config.strategies` 的第一项策略(如 `Standard`),调用 SYNSPEC 二进制进程合成光谱,并按照白名单将结果归档至result。
这种沙箱无状态机制完美切合节点端的生命周期,既保证了节点的干脆利落,又实现了与服务端全局调度的彻底解耦。
---
## 6. 前端 Dashboard 设计 (UI Design)
针对恒星大气网格工作流的主页 UI 进行大幅精简与重构。
### 6.1 移除冗余组件
- **移除卡片下方操作按钮**:彻底删除每个工作流卡片下方的“YAML配置”、“启动”、“暂停”等老旧按钮,工作流的启动配置将收敛至全新的内嵌配置面板。
- **移除收敛手段归因图表**:删除工作流首页中缺乏实际参考价值的“收敛手段归因”模块,为全新的任务调度引擎配置面板腾出空间。
### 6.2 工作流内嵌启动面板 (Embedded Task Configuration Panel)
不再使用弹窗 (Modal) 形式,而是直接将任务阶段独立配置面板**内嵌**在工作流详情页或首页的卡片内部,给予用户最直观且最大化的自由配置能力。
**2026-08 补充**:面板**默认折叠成一行**(标题 + 状态 + 操作按钮:YAML 配置 / 导出 / 暂停 / 删除 / 保存),点「启动配置 ▾」展开——折叠态下主按钮即「启动配置」(展开核对 TLUSTY/SYNSPEC 设置后再点真正的「启动」;工作流运行中该按钮退化为「配置 ▾」,且「启动」仅在展开态出现、避免误触发)。展开后 TLUSTY/SYNSPEC 两张阶段卡**左右并排**(宽屏两列,窄屏自动退化为单列),高度比纵向叠放减半。折叠状态跨轮询保持,展开中的编辑不因折叠丢失。
UI 布局草图如下:
```
+-------------------------------------------------------------------------+
| Workflow Execution Engine: o_star_grid_v1 |
+-------------------------------------------------------------------------+
| |
| [x] TLUSTY Atmosphere Computing Stage |
| Policy: [ Skip Converged (Incremental) v ] |
| Strategy Chain (Ordered by fallback priority): |
| 1. [ Cold Run v ] (X) |
| 2. [ Seed Step v ] (X) |
| + Add Fallback Strategy |
| |
| --------------------------------------------------------------------- |
| |
| [x] SYNSPEC Spectrum Synthesis Stage |
| Policy: [ Force Recompute v ] |
| Strategy Chain: |
| 1. [ Standard v ] (X) |
| |
| [ Save Config ] [ Start Grid ] |
+-------------------------------------------------------------------------+
```
前端 Payload 发送格式:
```json
{
"tlusty_config": {
"enabled": true,
"policy": "skip_converged",
"strategies": ["cold_run", "seed_step"]
},
"synspec_config": {
"enabled": true,
"policy": "force_recompute",
"strategies": ["standard"]
}
}
```
---
## 7. 兼容与部署限制(2026-08 审查补充)
### 7.1 滚动升级约束:阶段开关需全集群新节点
`task_type` 兼容字段只向后兼容 TLUSTY 策略(`cold_run` / `seed_step`)——旧节点仍能据此
区分冷启动与种子步进。但 **`enabled` 阶段开关无法传达给旧节点**
- TLUSTY-only 工作流(`synspec_stage.enabled: false`)在旧节点上仍会执行光谱合成;
- SYNSPEC-only 工作流(`tlusty.enabled: false`)在旧节点上被当作普通 cold_run+synspec 任务,
语义退化(需自带种子/大气才能成功)。
因此启用**阶段开关**(非默认双开)的工作流,须确保集群内全部节点已升级到支持阶段配置的
版本,否则节点行为与工作流意图不符。纯默认配置(双阶段启用)不受影响。
### 7.2 policy 决策取派发时快照(不随 YAML 漂移)
策略链回退(§4.2)的**策略链**与回退任务继承的 policy 均取**派发时落库的 DB 快照**
`tasks` 表的 `*_policy` / `*_strategies` 列,见 `FallbackSnapshot`),与 §4.2「不修改原有
policy,保持用户初始配置」语义一致。2026-08-04 起策略不再门控回退(回退只由策略链驱动),
但 policy 仍随重试任务落库,供审计与下次启动时的策略重置逻辑消费。数值参数
`synspec: {wstart/wend/...}``timeout_sec`)仍取当前 YAML(用户编辑意图优先),形成
「旧链 / 旧 policy + 新数值参数」的混合口径——这是有意取舍:策略链与 policy 是任务级
回退语义记录必须用派发时的,运行参数允许跟随最新配置。
### 7.3 完整 YAML 编辑器(数值参数编辑通道)已恢复
内嵌面板(§6.2)只编辑阶段三维配置(enabled / policy / strategies);`synspec:` 数值参数块
(波长范围、展宽、截断等)不在面板编辑范围。**2026-08 补充**:引擎面板新增「YAML 配置」按钮,
恢复原 `yamlEditor.js` 的完整 YAML 查看/编辑弹窗(语法高亮 + 行号 + 脏状态守卫 + 运行中锁定 +
服务端校验错误内联横幅),数值参数等完整配置均可在此查看与修改后直接保存(PUT
/api/workflows/:name)。该弹窗与内嵌面板的保存互相独立、以各自编辑内容为准——二者维度正交:
面板管执行语义(阶段开关/策略),弹窗管完整配置(含数值参数)。
+360
View File
@@ -0,0 +1,360 @@
# 任务失败判定逻辑调研:Tlusty&Synspec收敛性判断LUSTY / SYNSPEC 退出码可靠性与兜底机制
> 调研日期:2026-08-04
> 范围:`crates/common/src/runner.rs``crates/node/src/reporter.rs``crates/common/src/conv_check.rs`
> 的任务成败判定逻辑,对照 TLUSTY 208 / SYNSPEC 54 源码(`tlusty/tlusty208.f``synspec/synspec54.f`
> 与 gfortran 运行时退出码行为。
> 方法:源码级静态分析 + gfortran 实测(退出码、运行时错误)+ 三方独立复核。
> 本文仅记录调研结论,不含修复实现。
---
## 0. TL;DR
调度系统通过子进程退出码 `rc` 判定 TLUSTY / SYNSPEC 成败,但 **gfortran 下裸 `STOP` 默认返回 rc=0**,而两个 Fortran 程序的几乎所有错误路径用的都是裸 `STOP``call quit`(内部亦为裸 `stop`)。因此 **rc 本身不可靠**
当前判定能"基本不出错",靠的是 rc 之外的兜底:
- **TLUSTY**`rc==0 && fort.7 存在` 才进入 `check_fort9`max_relc < chmax+ `atmosphere_has_nan` 双重否决。这三重条件对"显式发散 STOP"和"硬崩溃(信号)"覆盖可靠。
- **SYNSPEC****仅靠 `synspec_rc==0`,下游零内容校验**,是当前系统最大的科学正确性风险。
复核(含 gfortran 运行时错误实测)发现两类此前未点出的风险:
1. **gfortran 运行时错误默认返回 rc=0 且静默**(实数 IEEE 异常、数组越界),生产编译未启用 `-fcheck`/`-ffpe-trap`
2. **fort.9 缺失分支无条件判收敛**,且写入虚构的 `best_max_relc=0.0`,会污染种子选择并向相邻网格点扩散。
修复优先级收敛为:SYNSPEC `.spec` 内容校验 > 收紧 `atmosphere_has_nan` 阈值 > 修正 fort.9 缺失分支 > 补齐 SOLVES/RYBSOL 发散路径归因。
---
## 1. 现状:失败判定的四层结构
node 端任务失败判定从底向上分四层:
```
execute_task (executor.rs) ← 前置 IO 失败 → 直接返回 Err
└─ run_model_with_timeout ← 计算失败 → 产出 ModelSummary(converged 字段)
└─ derive_report_status ← 半失败判定 → Completed / Failed
└─ infer_failed_stage ← 失败归因 → tlusty / synspec
```
### 第一层:`execute_task`executor.rs:11)—— 前置 IO 失败
直接返回 `Err`、进不到计算:
| 失败情形 | 处理 | 行号 |
| -------------------------------------- | ----------------------------------- | ------------------- |
| 原子数据文件拉取失败 | `warn` 但**继续**(非致命) | executor.rs:46-50 |
| 种子大气下载失败(HTTP 非 2xx / 网络) | `bail!` 返回 Err | executor.rs:114-137 |
| 沙盒目录创建失败 | `?` 返回 Err | executor.rs:57 |
返回 Err 时,worker.rs:456 转为 `String`reporter 走 `Err(e)` 分支(reporter.rs:78-89):
`status=Failed, converged=false, error_message=e`
### 第二层:`run_model_with_timeout`runner.rs:232)—— 计算过程收敛判定
核心,`final_converged` 在此累积,分三块:
**① TLUSTY 阶段链收敛判定(runner.rs:402-469**
每个阶段执行后,只有 `rc==0 && fort.7 存在` 才进入收敛检查,否则该阶段 `converged=false`
```rust
// runner.rs:402
if rc == 0 && fort7.is_file() {
if fort9.is_file() {
let res = check_fort9(&fort9, eff_chmax);
stage_summary.converged = res.converged; // max_relc < chmax
} else {
// NITER=0 grey start without fort.9 ← 见 §4 漏洞 2
stage_summary.converged = true;
stage_summary.best_max_relc = Some(0.0);
}
} else {
stage_summary.note = Some(format!("tlusty rc={} or missing fort.7", rc));
}
```
`check_fort9` 的收敛判据(conv_check.rs:163):
```
converged = max_relc.is_finite() && max_relc < chmax
```
其中 `max_relc` 是 fort.9 **最后一次迭代**中所有深度点里**最大的相对修正绝对值**。关键防御:
- `chmax ≤ 0 或非有限` → 直接判失败并报错(conv_check.rs:58,防误配)
- fort.9 无有效迭代数据 → `max_relc=Infinity`,判失败(conv_check.rs:127
- 发散值(NaN/Inf/无-E 记数法 `-1.35+118`)→ `is_finite()=false` → 判失败(conv_check.rs:160
- fort.9 缺失但 rc=0 → 视为"NITER=0 grey start"**判收敛**runner.rs:419)—— 见 §4 漏洞 2
阶段间联动:若某阶段 `require_converged=true` 且未收敛 → `break`,中止后续所有阶段(runner.rs:463)。
`final_converged` 取最后执行阶段的值。
**② 大气 NaN 强制否决(runner.rs:488-491**
```rust
let atmo_has_nan = atmosphere_has_nan(&final_7);
if atmo_has_nan { final_converged = false; }
```
`atmosphere_has_nan`conv_check.rs:190)检测大气文件里 `NaN`/`Inf`/`********`Fortran 字段溢出),
**超 10% 行命中**即判无效。此步会推翻前面收敛结论 —— 见 §4 漏洞 3 的阈值盲区。
**③ SYNSPEC 阶段(runner.rs:498-603**
SYNSPEC 不直接改 `final_converged`,记录到 `synspec_rc` / `synspec_error`
- `fort.8`(大气输入)复制失败 → 记 `synspec_error`,跳过(runner.rs:508
- `fort.55`(控制卡)写入失败 → 记 `synspec_error`,跳过(runner.rs:531
- synspec 进程 rc≠0 或超时(上限 `min(600, timeout_sec)`)→ 记 `synspec_rc`runner.rs:568-575
**仅 SYNSPEC 场景**tlusty 关闭)的收敛重判(runner.rs:611-616):
`final_converged = (synspec_rc == 0)`
### 第三层:`derive_report_status`reporter.rs:38)—— 半失败 = 失败
```rust
let synspec_failed = synspec_error.is_some() || matches!(synspec_rc, Some(rc) if rc != 0);
if s.converged && !synspec_failed { TaskStatus::Completed } else { TaskStatus::Failed }
```
旧逻辑 `if converged { Completed }` 只看大气收敛,TLUSTY 收敛但 SYNSPEC 失败时 `converged` 仍为 true
→ 误报成功 → 服务端吸收为终态。新逻辑:**大气好 + 光谱坏 = 整体失败**,让服务端弹 synspec 链重试。
`converged` 字段仍按大气真实状态上报(为 true 时服务端仍存 .7 作种子)。
### 第四层:`infer_failed_stage`reporter.rs:15)—— 失败归因
| 条件 | 归因 |
| ---------------------------------------- | -------------------- |
| `converged=false` + tlusty 启用 | `tlusty`(含级联) |
| `converged=false` + tlusty 关闭 | `synspec` |
| `converged=true` 但 synspec 出错/rc≠0 | `synspec` |
| 全部成功 | `None` |
---
## 2. `rc==0` 的真实含义与不可靠性
### 2.1 gfortran 退出码实测(实证)
`/tmp` 用最小 Fortran 程序实测(gfortran 15.2.0`-fno-automatic -O3` 等价于生产编译环境):
| Fortran 语句 / 错误类型 | 触发方式 | 退出码 rc | runner 能否抓住 |
| ---------------------------------- | ------------- | --------------------------- | ----------------------- |
| 裸`STOP` | `stop` | **0** | ❌ 靠 fort.9 兜 |
| `STOP 'msg'` | `stop 'x'` | **0** | ❌ 靠 fort.9 兜 |
| `STOP 1`(带数字) | `stop 1` | 1 | ✅(TLUSTY 未用此形式) |
| 自然`END` | 主程序结束 | 0 | — |
| 实数除零`1.0/0.0` | IEEE 默认 | **0** | ❌ 产 Inf,无陷阱 |
| `0.0/0.0` | IEEE 默认 | **0** | ❌ 产 NaN |
| 实数溢出`1e30*1e30` | IEEE 默认 | **0** | ❌ 产 Inf |
| `sqrt(-1)` / `log(0)` | IEEE 默认 | **0** | ❌ 产 NaN/-Inf |
| **整数除零** `1/0` | SIGFPE | **136**128+8 | ✅ |
| **数组越界(默认)** | 无`-fcheck` | **0,静默写越界内存** | ❌ |
| 数组越界(`-fcheck=bounds`) | 启用检查 | 2 | ✅ |
| READ 遇 EOF(无`end=` 守卫) | 运行时 | **2** | ✅ |
| READ 类型不匹配(无`err=` 守卫) | 运行时 | **2** | ✅ |
| 栈溢出 / 段错误 | SIGSEGV | **139**128+11 | ✅ |
> 生产编译命令(deployment.md:86,90)为 `gfortran -fno-automatic -O3`**未启用** `-fcheck=bounds`
> `-ffpe-trap=invalid,zero,overflow`。因此表中所有 rc=0 的静默失败在生产二进制中真实存在。
### 2.2 核心矛盾
**Fortran 的 `STOP` 语句默认返回 0**,而 TLUSTY/SYNSPEC 几乎所有错误处理用的都是裸 `STOP``call quit`(内部裸 `stop`)。导致**进程异常终止的退出码 = 正常完成的退出码 = 0**。
- TLUSTY `subroutine quit`tlusty208.f:29946-29955)末行裸 `stop`
- SYNSPEC `subroutine quit`synspec54.f:12014-12021)末行裸 `stop`
- 两者全文 80+ 处 `STOP`/`call quit`,错误路径几乎全走裸 `STOP`
---
## 3. 源码级失败路径分析
### 3.1 TLUSTY:三重兜底对"显式发散"覆盖可靠
**主循环结构(tlusty208.f:1-62):**
```fortran
10 ITER=ITER+1
CALL RESOLV ← 形式解;内部 CALL OUTPUT(:3807/:3828) 每次迭代写 fort.7
IF(LFIN) GO TO 20 ← 上一轮设了 LFIN 就跳出
IF(IACC.GT.0) CALL ACCEL2
IF(IFRYB.EQ.0) THEN
IF(NN.GT.MSMX) THEN
CALL SOLVE ← 含发散 STOP(:14703)
ELSE
CALL SOLVES ← 含发散 STOP(:15050)
END IF
ELSE
CALL RYBSOL ← 含发散 STOP(:47585-47590) ← 修正点:第三条路径
END IF
GO TO 10
20 STOP ← 主程序正常结束,裸 STOP → rc=0
END
```
**关键事实:**
- **fort.7 每次迭代都写**`CALL OUTPUT`tlusty208.f:3807, 3828)在 RESOLV 内,每次迭代执行。
- **发散 STOP 在三处平行求解器内**(不止 SOLVE):
- `SOLVE` :14701-14706`CHMX > 1.D16` → 裸 STOP
- `SOLVES` :15047-15051(同逻辑)
- `RYBSOL`/RYBCHN :47585-47590(同逻辑,`IFRYB≠0` 时走此路)
三处发散检查都在 `PRCHAN`/RYBCHN 写完 fort.9 **之后**,故发散 STOP 时 fort.7 与 fort.9 均已落盘。
- **达到 NITER 未收敛**`LFIN=ABS(CHMX).LE.CHMAX.OR.ITER.GE.NITER`(:14728),走主程序正常 ENDrc=0fort.9 含未收敛的 max_relc。
**TLUSTY 失败场景对照:**
| 场景 | Fortran 处理 | rc | 写 fort.7 | 写 fort.9 | runner 判定 | 是否合适 |
| --------------------- | --------------------------------- | ----------- | --------- | --------- | ---------------------------------------- | -------- |
| 正常收敛 | 主程序 END | 0 | ✅ | ✅ | check_fort9 → converged | ✅ |
| 达到 NITER 未收敛 | `LFIN`END | 0 | ✅ | ✅ | check_fort9 → max_relc≥chmax → 未收敛 | ✅ |
| 数值发散 CHMX>1e16 | SOLVE/SOLVES/RYBSOL 内裸 STOP | **0** | ✅ | ✅ | check_fort9 → 发散值未收敛 | ✅ |
| 输入错误(call quit | 裸 stop | 0 | ❌ | ❌ | rc=0 但 fort.7 缺失 → 失败 | ✅ |
| temp 越界 | `stop 'partf; temp...'`(:44624) | **0** | ❌ | ❌ | fort.7 缺失 → 失败 | ✅ |
**结论**rc 虽不可靠,但 `rc==0 + fort.7 存在 + check_fort9 + atmosphere_has_nan` 四重条件对"显式 STOP 类发散"和"硬崩溃(信号)"覆盖可靠 —— 这是一个**巧合但有效**的设计。
### 3.2 SYNSPEC:仅靠 rc,基本裸奔
**主程序结构(synspec54.f:1-174):** 主循环 `:10` 标签到 `:150 IF(IBLANK.LT.NBLANK) GO TO 10`
`CALL OUTPRI`:143)写谱输出(unit 7 谱 / 17 连续谱 / 16 部分等宽 / 6 诊断)且**在循环内、多次追加写**(无 REWIND)。
主程序以自然 `END`:174)结束,正常路径 rc=0。
**call quit / STOP 相对 OUTPRI 的时序分类(复核产出):**
| 归类 | 数量 | 代表行号 | 风险 |
| ------------------------------------------------------------------ | -------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------ |
| **OUTPRI 之前**(START 阶段输入校验) | 多数 | :726, :808, :937, :1062, :1856, :2229, :12168, :12224, :12252, :12323, :12324, :16745, :19455, :19601 | 低 —— 谱未生成,靠"文件缺失"兜得住 |
| **OUTPRI 之后 / 之间**(主循环内 RESOLV/RESOLW/OPAC 调用链) | **6 处** | **:4203, :8208, :9935, :18863, :18865, :19891** | 高 —— 脏数据风险 |
| 死代码 / 坏码 | 3 处 | :17414`stop23`(坏码)、:17851 CHCKAB(调用被注释)、:18383 MOLSET(从未调用) | 无 |
**脏数据风险的精确触发条件**:仅在 **NBLANK>1 或分子/集合重处理**synspec54.f:152-162)导致前序迭代已写 fort.7 之后,后续迭代在 INISET/OPAC/RESOLW/SETRAY 等处中途 `call quit` → 残缺谱已落盘 + rc=0 → 被误判成功。**单集合单次运行(NBLANK=1)所有 quit 都在首次 OUTPRI 之前,无脏数据**。
**SYNSPEC 失败场景对照:**
| 场景 | Fortran 处理 | rc | runner 判定 | 是否合适 |
| ---------------------------------------- | ------------------ | ----------- | --------------------------------------------------------------- | --------------- |
| 正常完成 | 主程序 END:174 | 0 | synspec_rc=0 → 成功 | ✅ |
| 输入数据错误(OUTPRI 前 quit | 裸 stop | **0** | 谱未生成 → .spec 缺失(runner.rs:579 is_file 不成立)→ 未产谱 | ⚠️ 靠文件缺失 |
| 计算中途错误(OUTPRI 后 quit,多轮场景) | 裸 stop | **0** | synspec_rc=0 →**误判成功** | ❌ 严重 |
---
## 4. 复核发现的漏洞(按严重度排序)
> 以下为源码级静态分析与 gfortran 实测、经三方独立复核确认的风险。
### 漏洞 1(最严重):SYNSPEC 产出的 `.spec` 全链路零内容校验
runner 对 SYNSPEC 产出(runner.rs:578-599**只有 `is_file()` 存在性检查 + rename/copy**,无任何内容校验:
```rust
if model_dir.join("fort.7").is_file() {
let _ = tokio::fs::rename(..., ... ".spec").await; // 不查内容
}
```
下游全部不拦截:
- `executor.rs:191-215`:只读 `.7`(种子),从不碰 `.spec`
- `result_filter.rs:64-110`:纯文件名后缀白名单,`spec` 在白名单内(result_filter.rs:28),0 字节 `.spec` 与干净的 50MB `.spec` **同等归档**
- `derive_report_status`reporter.rs:38-45):只看 `synspec_rc``synspec_error`,不看产物。
完整穿透链:SYNSPEC `call quit`rc=0+ 写出含 NaN/Inf/0 字节的 `.spec``is_file()` 成立 → rename 成 `.spec`
`final_converged = rc==0`runner.rs:612)→ `synspec_failed=false`reporter.rs:39)→ `Completed`
脏谱归档、服务端吸收为终态、不重试。**没有任何一道防线检查 `.spec` 内容。**
### 漏洞 2(严重):fort.9 缺失分支无条件判收敛 + 虚构 best_max_relc=0.0
runner.rs:417-421,当 `rc==0 && fort.7 存在` 但 fort.9 **不存在**时:
```rust
} else {
// NITER=0 grey start without fort.9
stage_summary.converged = true; // 无条件判收敛
stage_summary.best_max_relc = Some(0.0); // 虚构"完美收敛"值
stage_summary.note = Some("NITER=0 grey start".to_string());
}
```
- 注释"NITER=0 grey start"是**断言而非检查**,代码未校验本次 stage 的 `niter` 配置是否真为 0。
- `best_max_relc = Some(0.0)` 进入 `final_max_relc`runner.rs:458-459)→ 上报(reporter.rs:73)。若服务端用 max_relc 选种子,会把误判模型当"完美收敛"优先选作下一轮种子,**污染向相邻网格点扩散**。
- 合法 grey start`lte`/`nc` 阶段,`require_converged=false`runner.rs:23/38)与致命崩溃共用同一段无校验代码,无法区分。
**触发条件**rc==0gfortran 裸 STOP 或运行时静默错误)+ fort.7 已写 + fort.9 缺失。严重度比漏洞 1 低,因为多数发散情况 fort.9 会写出含发散值的行,反而触发 `check_fort9``is_finite()=false` 判不收敛。
### 漏洞 3(严重):gfortran 运行时错误默认 rc=0 且静默
见 §2.1 实测表。在生产编译(无 `-fcheck`/`-ffpe-trap`)下:
- **实数 IEEE 异常**(除零、溢出、NaN 产生)→ rc=0,无陷阱,静默产出 NaN/Inf。
- **数组越界**(无 `-fcheck=bounds`)→ rc=0,**静默写越界内存**,可能产出物理上错误但无 NaN 标记的大气。
这两类若发生在 RESOLV 写完 fort.7tlusty208.f:3828)之后、SOLVE 写 fort.9 之前 → fort.7 在、fort.9 不在 → 命中漏洞 2 → 误判收敛。`atmosphere_has_nan` 能否兜住取决于越界是否恰好产生 NaN/Inf 字面值;若只是静默改写相邻内存,此防线无效。
### 漏洞 4(中):`atmosphere_has_nan` 的 10% 阈值对物理产物过松
conv_check.rs:212`(bad_lines as f64) > (total_lines as f64 * 0.1)`,严格大于。
典型 `.7` 文件 60-100 个深度行,阈值 6-10 行:
| 场景 | bad/total | 判定 | 物理正确性 |
| --------------------------- | --------- | ----------------------- | -------------- |
| 全 NaN(彻底发散) | 100% | true → 否决 | 正确 |
| 6/100 行 NaN(表层发散) | 6% | **false → 放过** | **错误** |
| 5/60 行 NaN(典型深度网格) | 8.3% | **false → 放过** | **错误** |
| 单行 NaN | 1% | **false → 放过** | **错误** |
物理上**任意一个深度点的温度/密度是 NaN,整个大气就不可用**(积分会传播 NaN)。10% 是统计学语义,与大气可用性的布尔语义不匹配。表层(深度浅、温度高)最易数值发散,往往只有最外几层出 NaN —— 正好落在盲区。
补充盲区:regex `(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})`conv_check.rs:199)只匹配字面 `NaN`/`Inf`/`***`;若 Fortran 把发散值写成接近 f64 上限但未溢出的普通科学记数法(如 `1.0E+308`),不命中任何模式。
### 漏洞 5(低):`infer_failed_stage` / 日志归因对 RYBSOL 路径不完整
发散 STOP 在 SOLVE / SOLVES / RYBSOL 三处(§3.1),但失败 note 统一记 `tlusty rc=0 or missing fort.7`runner.rs:429),丢失"为何未收敛"的线索。对走 RYBSOL`IFRYB≠0`)的发散,归因信息不完整。
### 漏洞 6(低):仅 SYNSPEC 场景 final_converged 重判只看 rc
runner.rs:611-616TLUSTY 关闭的纯复算场景,`final_converged = (synspec_rc == 0)`,与漏洞 1 同源,场景更窄。
---
## 5. 被排除的伪风险
**超时 / shutdown 后半成品 fort.7 进 check_fort9 误判收敛 —— 不存在。**
`run_child_async_with_timeout` 在超时/shutdown 时返回 `Err`runner.rs:151, 156)→ 上层 match `rc = -1`
runner.rs:380-383 / 568-574)。于是 `rc==0 && fort.7 存在` 前置门不成立 → 进 `else` 分支 →
`converged` 保持 false → `check_fort9` 根本不调用。SYNSPEC 超时同理 → `synspec_rc=-1` → 判失败。
**超时路径是可靠的。**
---
## 6. 修复优先级(仅建议,不含实现)
| 优先级 | 漏洞 | 建议 | 改动范围 |
| ------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| P0 | 漏洞 1 | SYNSPEC`.spec` 内容校验:NaN/Inf/`***` + 行数下限 + 非全零,命中则置 `synspec_rc` 为非零或设 `synspec_error` | runner.rsSYNSPEC 产出后,runner.rs:585 附近) |
| P1 | 漏洞 4 | 收紧物理产物 NaN 阈值:产物准入用 0 行容忍(任意一行 NaN 即否决),而非 10% | conv_check.rs:212 或新增严格版函数 |
| P1 | 漏洞 2 | runner.rs:417-421:至少把`best_max_relc``Some(0.0)` 改为 `None`(消除污染扩散);理想是校验 stage 的 `niter` 配置才走该分支 | runner.rs:417-421 |
| P2 | 漏洞 5 | 补齐 SOLVES / RYBSOL 发散路径的归因,失败 note 区分求解器类型 | runner.rs:429 |
| P3 | 漏洞 3 | 生产编译加`-fcheck=bounds -ffpe-trap=invalid,zero,overflow`,让数组越界与 IEEE 异常返回 rc≠0(性能换正确性) | deployment.md 编译命令 |
---
## 7. 参考资料
- 源码:
- `tlusty/tlusty208.f`(主程序 :1-62;发散 STOP :14703/:15050/:47585quit :29946-29955
PRCHAN :22876-22944RYBCHN :47445-47598RESOLV :3724-3903OUTPUT :14055 起)
- `synspec/synspec54.f`(主程序 :1-174OUTPRI :3343-3458quit :12014-12021
脏数据风险点 :4203/:8208/:9935/:18863/:18865/:19891
- 判定逻辑:
- `crates/common/src/runner.rs`run_model_with_timeout :232-682;漏洞 1/2/5/6 所在)
- `crates/node/src/reporter.rs`derive_report_status :38-45infer_failed_stage :15-27
- `crates/common/src/conv_check.rs`check_fort9 :54-175atmosphere_has_nan :190-213
- 编译配置:`docs/deployment.md:86,90`(确认生产编译无 `-fcheck`/`-ffpe-trap`
- 相关设计:`docs/task_engine_decoupling_design.md`(阶段独立配置、半失败判定语义)
+195
View File
@@ -0,0 +1,195 @@
# TLUSTY 结果产物分析:Node 归档白名单与 fort 单元快照
> 本文档记录对 TLUSTY/SYNSPEC 源码的单元号(fort.N)语义分析,以及据此确定的
> Node 端结果归档(`result_dir`)白名单与 TLUSTY b 因子/出射谱快照的设计依据。
> 源码路径:TLUSTY 位于 `/home/fmq/program/SpectraRust/tlusty/``tlusty208.f`),
> SYNSPEC 位于 `/home/fmq/program/SpectraRust/synspec/``synspec54.f`)。
> 归档逻辑实现于 `crates/node/src/executor.rs``crates/common/src/runner.rs`
> `crates/common/src/result_filter.rs`
## 1. 背景与问题
Node 每个任务算完后,把沙盒 `work_dir/task_<id>/<name>/` 内的产物拷贝到持久归档
`result_dir/<name>/`。归档采用**白名单**策略(`is_result_worthy`),只保留有语义价值
的产物,丢弃 Tlusty/Synspec 运行时的中间工作单元(旧版 catch-all 会把它们一并搬进
归档,每个模型浪费约 2MB / 4.8MB)。
需要回答的核心问题:
1. 当前归档到底保留了哪些文件?
2. TLUSTY 的 `fort.12`b 因子)、`fort.14`(出射谱)、`fort.69`(计时日志)是否有
保存价值?—— 需以 TLUSTY 源码为准。
3. TLUSTY 出射谱(fort.14)与 SYNSPEC 输出的 `.spec` 有什么区别,是否冗余?
## 2. Node 结果归档机制(现状)
- **归档路径**`result_dir/<name>/``result_dir` 由 node 配置指定,默认为 `data/result/`)。
- **触发时机**:上报成功后(上报失败也归档,用于排错),随后清理沙盒。
- **原子写入**:先拷为 `.result.tmp.<uuid>` 再 rename,防止半截文件。
- **无 LRU 上限**:所有已算网格点产物永久保留(2026-08-02 撤销此前 200 目录上限)。
- **白名单判定**`common::result_filter::is_result_worthy(fname, model_name)`
### 2.1 保留的文件(白名单)
`model_name` 为网格点权威名,如 `t20000_g5.0_he-2_c-4_n-4_o-4`
**裸名保留**(不以 model_name 为前缀,有独立语义):
| 文件 | 语义 |
|------|------|
| `conv.json` | 收敛摘要 |
| `fort.8` | SYNSPEC 输入大气 |
| `fort.55` | SYNSPEC 控制卡 |
**科学核心** `<name>.<后缀>`
| 后缀 | 来源 | 语义 |
|------|------|------|
| `.7` | TLUSTY/SYNSPEC | 最终大气 |
| `.spec` | SYNSPEC fort.7 | 合成光谱(高分辨率) |
| `.cont` | SYNSPEC fort.17 | 连续谱 |
| `.iden` | SYNSPEC fort.12 | 谱线证认表 |
| `.log` | SYNSPEC | SYNSPEC 日志 |
| `.bfac` | TLUSTY fort.12 快照 | 最终模型 b 因子(非 LTE 偏离因子) |
| `.emflux` | TLUSTY fort.14 快照 | 最终模型出射谱(波长 Å + Fλ) |
**阶段快照** `<name>.<label>.<后缀>`label ∈ `lte`/`nc`/`nl`/`seed_nc`):
- `.5`(输入卡)、`.6`(输出日志)、`.err`(错误日志)、`.nst`(控制卡)、`.7`(该阶段大气)
**收敛诊断** `<name>.<label>_chmax*.9`**唯一保留的 .9 形态**(裸 `<name>.<label>.9`
`_chmax*.9` 内容重复,runner 源头已停止写出)。
### 2.2 丢弃的内容
- 所有 Tlusty 中间工作单元:`fort.1/2/3/10/11/13/14/18/22/42/44/50/57/69/82/95`
- `fort.84`NATOMS 崩溃缓存)、`.tmp` 残留
- 符号链接(`data``fort.19` 等共享 runtime 资源)、子目录
- 不以 `<name>.` 开头且不在裸名列表的文件、未知后缀/未知阶段标签
- `fort.12` / `fort.17`:被 runner 消费(见 §4.1)后删除
## 3. TLUSTY fort 单元语义(源码分析)
TLUSTY 每次运行到达最终迭代(`LFIN=.TRUE.`)时,经 `OUTPRI`tlusty208.f:14156,调用点
:3889)一次性写出多个单元。各单元语义与行号:
| 单元 | 内容 | 源码位置 | 判定 |
|------|------|----------|------|
| `fort.12` | **b 因子(非 LTE 偏离因子表)**:头 2I5 + 每深度 TEMP/ELEC/DENS/BFAC,格式 701/702/703 | :14344 | 科学价值高,需快照 |
| `fort.13` | 出射辐射场(FREQ, FLUX=H(ν), FH=Eddington 因子),格式 602 | :14185 | 与 fort.14 同源,未快照 |
| `fort.14` | **出射谱(波长 Å + Fλ)**,格式 614 `F15.3,1pe15.3` | :14188 | 科学价值高,需快照 |
| `fort.17` | 每深度布居数 POPUL(格式 503 | :14124 | SYNSPEC 覆盖后转存 `.cont` |
| `fort.20` | b 因子 BFAC(另一分支) | :14119 | 未保留 |
| `fort.22` | 绝对 b 因子 BFAB | :14346 | 未保留 |
| `fort.69` | **迭代计时日志**`TIMING` 子程序写 IP/MOD/TIME/DT/ROUTFORMAL SOLUTION / LINEARIZATION),格式 600 | :29937 | 纯性能诊断,丢弃 |
> 注意:`OUTPRI` 仅在最终迭代调用,故每个单元内保存的是**最后一次迭代**的最终值。
> 收敛链上最后一次 TLUSTY 运行即最终模型。
## 4. SYNSPEC 对 unit 12/14 的覆盖与丢失风险
### 4.1 单元号复用导致覆盖
TLUSTY 与 SYNSPEC **同目录串行执行**,且两个程序复用相同的单元号:
| 单元 | TLUSTY 写 | SYNSPEC 写 |
|------|-----------|-----------|
| 12 | b 因子 | 谱线证认表(synspec54.f:9719,格式 603 |
| 14 | 出射谱 | 谱线数据(synspec54.f:9852,格式 601 |
由于 TLUSTY 先跑、SYNSPEC 后跑,SYNSPEC 会把 `fort.12`/`fort.14` 覆盖。现有 runner
流程:
1. TLUSTY 写 b 因子 + 出射谱 → **被 SYNSPEC 覆盖**
2. SYNSPEC 写 `fort.7`(谱)、`fort.17`(连续谱)、`fort.12`(谱线证认表)
3. runner 将 `fort.7``.spec``fort.17``.cont``fort.12``.iden`,随后删除 `fort.12`/`fort.17`
**结论**:归档里的 `.iden` 保存的是 SYNSPEC 的谱线证认表(覆盖后的 fort.12),
**TLUSTY 的 b 因子与出射谱在覆盖前无任何快照,会被静默丢失** —— 这是真实的数据损失点。
### 4.2 快照决策
- `fort.69`:纯计时日志,**不保存**(无科学内容)。
- `fort.12`b 因子)与 `fort.14`(出射谱):**需快照**。
## 5. 实现:快照 b 因子 + 出射谱
### 5.1 快照命名与白名单
| 源文件 | 快照名 | 白名单后缀 |
|--------|--------|-----------|
| `fort.12` | `<name>.bfac` | `bfac`(加入 `SCIENCE_SUFFIXES` |
| `fort.14` | `<name>.emflux` | `emflux`(加入 `SCIENCE_SUFFIXES` |
### 5.2 插入点
`runner::run_model_with_timeout`:**收敛链循环结束后、SYNSPEC 启动前**调用
`snapshot_tlusty_outputs(&model_dir, name)`。此时 fort.12/14 是最后一次 TLUSTY 运行
(最终模型)的产物,且尚未被 SYNSPEC 覆盖。仅 SYNSPEC 场景(tlusty 未运行)下
fort.12/14 不存在,函数按文件存在性静默跳过。
### 5.3 变更文件
- `crates/common/src/runner.rs`:新增 `snapshot_tlusty_outputs()` + 调用 + 2 个测试
- `crates/common/src/result_filter.rs``SCIENCE_SUFFIXES``bfac`/`emflux` + 文档 + 测试
- `crates/node/src/executor.rs`:归档文档、`test_save_result_artifacts` 同步 + 修正
`fort.13` 注释(原误标为"NLTE 跃迁频率网格",实为出射辐射场 FREQ/FLUX/FH
## 6. TLUSTY 出射谱(fort.14vs SYNSPEC `.spec`
两者**转换公式完全相同、物理量一致**,差异在网格密度、线不透明度处理、覆盖范围与用途。
### 6.1 相同点
- 公式一字不差:`Fλ = H(ν) × ν² / c`
- TLUSTY:14188):`FLAM=FLUX(IJP)*FREQ(IJP)*FREQ(IJP)/2.997925E18`
- SYNSPEC:3365):`FLAM=FLUX(IJ)*FREQ(IJ)*FREQ(IJ)*CAS``CAS=1./2.997925D18`
- `FLUX` 都是表面出射 Eddington 通量 H(ν)erg/cm²/s/sterad/HzOUTPRI 注释明确
"precisely the second moment H(freq) at the surface")。
- 同一波长点的连续谱水平应当一致。
### 6.2 差异点
| 维度 | TLUSTY fort.14 | SYNSPEC `.spec` |
|------|---------------|-----------------|
| 网格密度 | 模型自身频率网格(NFREQ 个点,数百~数千,非均匀) | 以每条谱线为中心的细网格(`FR0=FREQ0(IL0)`SPACE/CUTOFF/DOPSTD 采样线轮廓,密度高几个量级) |
| 线不透明度 | 模型求解时的粗/近似线处理(不透明度采样/ODF/超能级),只体现最强线 | 完整线表(fort.19)逐细频点重解辐射转移,得到分辨开的真实线轮廓 |
| 波长覆盖 | 整个模型宽带频率范围 | 仅 fort.55 指定窗口(dcts 默认 14001410 Å) |
| FLUX 来源 | 模型迭代过程的自洽辐射场解(顺带用于通量守恒检查 TOTF) | 用已收敛大气在细网格重新解 RT |
| 用途/下游 | 模型副带品,快速看谱/校验 | 科学产品,注释明说 "serves as input to ROTINS"(转动/仪器展宽卷积),用于与观测对比 |
### 6.3 实际含义
两者是**同一物理量(表面 Fλ)在不同分辨率和线处理下的版本**,非两种东西。叠画时
连续谱重合,`.spec` 显示被分辨开的精细谱线轮廓,fort.14 中弱线基本不可见。归档同时
保留二者不构成冗余:`.emflux` 代表模型自身辐射场(宽带、粗,可独立校验),`.spec`
代表最终高分辨率合成谱(窄窗、细,与观测对比的产品)。
## 7. 完整归档文件清单(快照实施后)
```
result_dir/<name>/
├── conv.json
├── fort.8 # SYNSPEC 输入大气(裸名保留)
├── fort.55 # SYNSPEC 控制卡(裸名保留)
├── <name>.7 # 最终大气
├── <name>.spec # SYNSPEC 合成光谱
├── <name>.cont # SYNSPEC 连续谱
├── <name>.iden # SYNSPEC 谱线证认表
├── <name>.log # SYNSPEC 日志
├── <name>.bfac # TLUSTY 最终 b 因子(快照 fort.12
├── <name>.emflux # TLUSTY 最终出射谱(快照 fort.14
├── <name>.<label>.5 # 阶段输入卡快照
├── <name>.<label>.6 # 阶段输出日志快照
├── <name>.<label>.err # 阶段错误日志快照
├── <name>.<label>.nst # 阶段控制卡快照
├── <name>.<label>.7 # 阶段大气快照
└── <name>.<label>_chmax*.9 # 阶段收敛诊断(唯一保留的 .9)
```
## 8. 后续可选优化
- `fort.13`(出射辐射场,含 Eddington 因子 FH)未快照;如需保留,在
`snapshot_tlusty_outputs` 中加 `("fort.13", "emrad")` 并在白名单加 `emrad` 后缀即可。
- `fort.20`b 因子)、`fort.22`(绝对 b 因子)同属 b 因子家族;如需保留可在 TLUSTY
阶段结束后一并快照,但 `fort.12` 已覆盖相对 b 因子,绝对 b 因子可由 POPUL/POPLTE 重算。
+5 -5
View File
@@ -9,12 +9,12 @@
### 现象 1`nl` 阶段出现 `DIVD ... BIG``NITER` 达到上限发散 ### 现象 1`nl` 阶段出现 `DIVD ... BIG``NITER` 达到上限发散
- **原因**:初猜大气与当前网格点的真实 NLTE 大气物理状态相差过大,线性化半径无法收敛。 - **原因**:初猜大气与当前网格点的真实 NLTE 大气物理状态相差过大,线性化半径无法收敛。
- **排查步骤** - **排查步骤**
1. 查看节点运行目录中的 `fort.6``conv.json` 1. 查看归档的 `conv.json`(节点端在 `DCTS_RESULT_DIR/<model_name>/conv.json`,服务端在 `DCTS_SEEDS_DIR/<model_name>/conv.json`
```bash ```bash
cat results/<model_name>/conv.json cat data/seeds/<model_name>/conv.json
``` ```
2. 检查 `coldfail` 现场保存:冷启动失败后,系统会自动保存过程数据到 `<model>.coldfail/` 2. 失败任务的各阶段输入/日志/收敛诊断(`<name>.<label>.5/.6/.err/.nst/.7``_chmax*.9`)经白名单归档到节点 `result_dir`,供排错;沙盒 `task_*` 在结算后清理
3. **解决方案**:确保 Master 的 `results/` 目录下存有相近 $T_{\text{eff}}$ / $\log g$ 的已收敛 `.7` 大气文件。系统将在下次重试时自动触发 **Seed-Stepping** 种子步进算法 3. **解决方案**:确保服务端 `DCTS_SEEDS_DIR`(默认 `data/seeds`下存有相近 $T_{\text{eff}}$ / $\log g$ 的已收敛 `.7` 种子。若 `tlusty_strategies``seed_step`,调度器会在失败回退时自动注入近邻种子重试(**Seed-Stepping** 种子步进
### 现象 2`lte` 阶段报错或瞬间终止 ### 现象 2`lte` 阶段报错或瞬间终止
- **原因**:输入的物理参数超出了灰色大气基本假设或基础原子数据(原子能级/光致电离截面)损坏缺失。 - **原因**:输入的物理参数超出了灰色大气基本假设或基础原子数据(原子能级/光致电离截面)损坏缺失。
@@ -43,7 +43,7 @@
### 现象 3:网络抖动或后端滚动重配下提示 `向服务端上报任务 ... 结果失败` ### 现象 3:网络抖动或后端滚动重配下提示 `向服务端上报任务 ... 结果失败`
- **原因**:在较长时效(如1-2小时)的运算完结回传一瞬间,Server 恰遇热更重启或遭遇防火墙短暂会话剔除。 - **原因**:在较长时效(如1-2小时)的运算完结回传一瞬间,Server 恰遇热更重启或遭遇防火墙短暂会话剔除。
- **容灾机制**:Worker 内置了超强的 8 轮指数级自适应退避长跳上报防护(跨度可自 1s 到 60s 顺次延迟,支撑 2 分钟以上的长时断裂耐受窗口);若由于连天硬件故障真正超出了总重试界限,亦可在本地非清理型数据栈(`data/work/task_{uuid}`)的目录直接调出本套算法终极收敛物并执行手工打标还原 - **容灾机制**:Worker 内置了超强的 8 轮指数级自适应退避长跳上报防护(跨度可自 1s 到 60s 顺次延迟,支撑 2 分钟以上的长时断裂耐受窗口);上报失败时产物仍会**尽力归档**到本地 `DCTS_RESULT_DIR/<model_name>/`(白名单保留科学产物),沙盒随后清理。若真正超出了总重试界限,可从 `result_dir/<model_name>/` 直接调出最终收敛物(`.7`/`.spec` 等)手工补录
--- ---
+19
View File
@@ -7,6 +7,25 @@
> 原则:**数据已经在库里大半,缺口主要是 HTTP 暴露 + 少量持久化 + 全新前端 UI。** > 原则:**数据已经在库里大半,缺口主要是 HTTP 暴露 + 少量持久化 + 全新前端 UI。**
> 零新增前端依赖(不引入图表库),沿用现有 ESM + 原生 DOM + 设计令牌风格。 > 零新增前端依赖(不引入图表库),沿用现有 ESM + 原生 DOM + 设计令牌风格。
> ## ⚠️ 实现状态(2026-08 更新,与当前 dashboard 实现的差异)
>
> 本文为**设计文档**,部分设计已被后续实现与 `task_engine_decoupling_design.md`
> 引擎面板设计覆盖,按现状对齐如下:
>
> - **已实现**:详情页 4 个 Tab(概览 overview / 逐点表 pointsTable / 平行集合分析
> parSets / 点详情面板 pointPanel)、进度曲线与 ETA、停滞检测横幅、CSV 导出、
> `workflow_progress_snapshots` 表、YAML 编辑器(yamlEditor,双模式/语法高亮/运行中
> 锁定/脏状态守卫)、引擎配置面板(wfEnginePanelTLUSTY/SYNSPEC 双阶段卡、enabled/
> policy/strategies 编辑、折叠跨轮询保持)。
> - **已变更(设计被覆盖)**:首页卡片操作按钮与详情页顶部控制条按钮**已全部移除**,
> 操作收敛至详情页内嵌引擎面板(见 `task_engine_decoupling_design.md §6`);Tab 3 由
> **2D 热力矩阵改为 Parallel Sets 平行集合图**(§2.2 Tab 3 描述过时);Tab 1 方法归因
> 行已移除(cold/seed 计数在点表与 parSets 中可用)。
> - **未实现**:§2.2/§2.4 的「重试失败点」按钮与 `POST .../points/retry` 端点
> (前后端均未实现)。
> - **方法归因**`imported` 不再作为独立收敛途径(导入点按 seed_step 统计),
> `success_method` 过滤白名单仅 `cold_run` / `seed_step`
--- ---
## 1. 现状盘点(带代码证据) ## 1. 现状盘点(带代码证据)
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env bash
# ==============================================================================
# fetch_results.sh — 从全部计算节点同步 data/result 完整科学产物到本地备份
# ==============================================================================
# 背景:node 端 result 目录永久保留全部网格点的完整产物(.spec/.cont/.iden/
# 各阶段快照/conv.json/日志等,撤销了早期的 MAX_RESULT_MODELS=200 LRU 上限)。
# 为做异地备份、以及便于失败归因与收敛验证,把各节点 result 目录整树拉回本地,
# 按节点分目录存放。
#
# 用法:
# ./scripts/fetch_results.sh # 无参数 → 交互式向导选择节点
# ./scripts/fetch_results.sh -i # 显式进入交互式向导
# ./scripts/fetch_results.sh -n node-fmq-dckj-02 # 非交互:仅同步指定节点(DCTS_NODE_ID)
# ./scripts/fetch_results.sh -d /backup/dcts-salvage # 覆盖备份根目录
# ./scripts/fetch_results.sh --with-work # 连带拉取 data/work 沙盒残留
# ./scripts/fetch_results.sh --include-local # 把本机节点也复制进备份树
# ./scripts/fetch_results.sh --force # 忽略完成标记,强制重新同步
#
# 传输策略:双方均可用 rsync → 增量同步(断点续传、幂等);否则退化为 scp -r 全量
# 拷贝(Windows 节点一般无 rsync,自动走 scp)。SSH 复用 deploy.sh 的 ControlMaster
# 连接复用,多次执行不重复输密码。
# ==============================================================================
set -eo pipefail
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# 定位至项目根目录(与 deploy.sh 一致)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${WORK_DIR}"
# =============================================================================
# 默认配置(可被命令行参数覆盖)
# =============================================================================
SSH_CONTROL_DIR="${SSH_CONTROL_DIR:-$HOME/.ssh/cm}"
BACKUP_ROOT="${BACKUP_ROOT:-${WORK_DIR}/data/salvage}"
ONLY_NODE=""
WITH_WORK=false
INCLUDE_LOCAL=false
FORCE=false
INTERACTIVE=false
HAD_ARGS=false
SELECTED_PROFILES=()
# =============================================================================
# 解析命令行参数
# =============================================================================
while [[ $# -gt 0 ]]; do
HAD_ARGS=true
case "$1" in
-n|--node) ONLY_NODE="$2"; shift 2 ;;
-d|--dest) BACKUP_ROOT="$2"; shift 2 ;;
-i|--interactive) INTERACTIVE=true; shift ;;
--with-work) WITH_WORK=true; shift ;;
--include-local) INCLUDE_LOCAL=true; shift ;;
--force) FORCE=true; shift ;;
-h|--help)
sed -n '2,32p' "$0"
exit 0 ;;
*) echo -e "${RED}未知参数: $1${NC}"; exit 1 ;;
esac
done
mkdir -p "${BACKUP_ROOT}"
# =============================================================================
# 工具函数
# =============================================================================
# 从 profile 文件读取键值(避免 source 造成变量污染;写法对齐 deploy.sh)
read_profile_var() { # $1=profile $2=键 $3=默认值
local p="$1" k="$2" d="${3:-}"
local v
v=$(grep -E "^${k}=" "$p" 2>/dev/null | head -n1 | cut -d'=' -f2- | tr -d '"' | tr -d "'" || true)
[ -n "$v" ] && printf '%s' "$v" || printf '%s' "$d"
}
# rsync 远端路径转义:含空格目录需单引号包住(现有路径均无空格,此处兜底)
rsync_remote_path() { # $1 = 远端绝对路径
local p="$1"
case "$p" in
*\ *) printf "'%s'" "$p" ;;
*) printf '%s' "$p" ;;
esac
}
# 传输一个目录树(rsync 优先,退化 scp)
sync_dir() { # $1=user $2=ip $3=port $4=远端绝对目录 $5=本地目标
local u="$1" ip="$2" port="$3" rdir="$4" dest="$5"
local ssh_opts="-o ControlMaster=auto -o ControlPath=${SSH_CONTROL_DIR}/cm-${u}@${ip}:${port} -o ControlPersist=1800"
mkdir -p "$(dirname "${dest}")"
# 完成标记:已成功同步过且未 --force 时跳过
if [ -f "${dest}/.fetch-complete" ] && [ "${FORCE}" != "true" ]; then
echo -e " ${CYAN}[i]${NC} 已有完成标记 ${dest}/.fetch-complete,跳过(--force 可重拉)"
return 0
fi
# 连通性探测(BatchMode=no 允许交互输密码)。
# 注意探测命令必须用 `echo ok` 而非 `true`Windows OpenSSH 默认 shell 是
# cmd/PowerShell,没有 Unix 的 true,会导致"密码正确但仍判连接失败"。
if ! ssh -p "${port}" -o ConnectTimeout=8 ${ssh_opts} "${u}@${ip}" "echo ok" >/dev/null 2>&1; then
echo -e " ${RED}[!] 无法连接 ${u}@${ip},跳过本节点${NC}"
return 1
fi
# rsync 可用性(本机 + 远端)
local rs_ok=false
if command -v rsync >/dev/null 2>&1; then
if ssh -p "${port}" ${ssh_opts} "${u}@${ip}" "command -v rsync" >/dev/null 2>&1; then
rs_ok=true
fi
fi
if [ "${rs_ok}" = "true" ]; then
echo -e " ${BLUE}[→]${NC} rsync 增量同步: ${u}@${ip}:${rdir}/ → ${dest}/"
rsync -a --partial --info=progress2 \
-e "ssh -p ${port} ${ssh_opts}" \
"${u}@${ip}:$(rsync_remote_path "${rdir}")/" "${dest}/"
else
echo -e " ${YELLOW}[→]${NC} 远端无 rsync,退化 scp 全量拷贝: ${u}@${ip}:${rdir}/ → ${dest}/"
scp -P "${port}" ${ssh_opts} -r "${u}@${ip}:${rdir}/" "${dest}/"
fi
touch "${dest}/.fetch-complete"
}
# 备份单个节点
backup_node() { # $1 = profile 文件
local p="$1" node_id role env_mode u ip port dir
node_id=$(read_profile_var "$p" DCTS_NODE_ID)
role=$(read_profile_var "$p" DEPLOY_ROLE node)
env_mode=$(read_profile_var "$p" DEPLOY_ENV "")
u=$(read_profile_var "$p" REMOTE_USER "fmq")
ip=$(read_profile_var "$p" REMOTE_IP "")
port=$(read_profile_var "$p" REMOTE_PORT 22)
dir=$(read_profile_var "$p" REMOTE_DIR "")
# 跳过 server 角色(server 的 result 不是计算产物,且 seeds 另行备份)
if [ "${role}" = "server" ]; then
echo -e "${YELLOW}== ${p##*/}: 角色 server,跳过(seed 备份不属本脚本职责)${NC}"
return 0
fi
# 本地节点:result 就在本机 ./data/result,默认不重复备份
if [ "${env_mode}" = "local" ]; then
if [ "${INCLUDE_LOCAL}" = "true" ]; then
[ -n "${node_id}" ] || node_id="node-local"
else
echo -e "${YELLOW}== ${p##*/}: 本地节点(${node_id:-localhost})result 已在 ./data/result,跳过(--include-local 可纳入备份树)${NC}"
return 0
fi
fi
# 按节点过滤
if [ -n "${ONLY_NODE}" ] && [ "${node_id}" != "${ONLY_NODE}" ]; then
return 0
fi
if [ -z "${node_id}" ]; then
node_id="${u}@${ip}"
fi
local loc=""
if [ "${env_mode}" = "local" ]; then
loc="(本机)"
else
loc=" ${u}@${ip}:${dir}"
fi
echo -e "\n${GREEN}== ${p##*/} → 节点 ${node_id}${NC} ${loc}"
local dest_rc="${BACKUP_ROOT}/${node_id}/result"
if [ "${env_mode}" = "local" ]; then
# 本机直接拷贝,不走网络
echo -e " ${BLUE}[→]${NC} 本机复制: ./data/result → ${dest_rc}"
mkdir -p "${dest_rc}"
cp -a ./data/result/. "${dest_rc}/"
touch "${dest_rc}/.fetch-complete"
else
sync_dir "${u}" "${ip}" "${port}" "${dir}/data/result" "${dest_rc}" || return 1
fi
echo -e " ${GREEN}[√]${NC} result 已同步,共 $(ls "${dest_rc}" | grep -cv '^\.fetch-complete$' || true) 个网格点子目录"
# 可选:一并拉取 data/work 沙盒残留(未清理的计算现场,含完整过程文件)
if [ "${WITH_WORK}" = "true" ]; then
local dest_wk="${BACKUP_ROOT}/${node_id}/work"
if [ "${env_mode}" = "local" ]; then
echo -e " ${BLUE}[→]${NC} 本机复制: ./data/work → ${dest_wk}"
mkdir -p "${dest_wk}"
cp -a ./data/work/. "${dest_wk}/" 2>/dev/null || true
else
sync_dir "${u}" "${ip}" "${port}" "${dir}/data/work" "${dest_wk}" || return 1
fi
fi
}
# =============================================================================
# 交互式选择(无参数或 -i 时进入,风格对齐 deploy.sh 的 Profile 向导)
# =============================================================================
interactive_select() {
# 构建可同步列表:非 *.example 且 DEPLOY_ROLE=node 的 profileserver 不在菜单)
local menu=()
for p in deploy.env.d/*.env; do
[ -f "$p" ] || continue
[[ "$p" != *.example ]] || continue
if [ "$(read_profile_var "$p" DEPLOY_ROLE node)" = "node" ]; then
menu+=("$p")
fi
done
if [ ${#menu[@]} -eq 0 ]; then
echo -e "${RED}错误: deploy.env.d/ 下没有可用的 node profile${NC}"
exit 1
fi
echo -e "${BLUE}==============================================================${NC}"
echo -e "${CYAN} 🚀 DCTS 计算结果产物下载控制台 ${NC}"
echo -e "${BLUE}==============================================================${NC}"
echo -e "${YELLOW}【节点选择】检测到 ${#menu[@]} 个可用的计算节点 Profile 文件:${NC}"
local i p node_id env_mode ip user
i=1
for p in "${menu[@]}"; do
node_id=$(read_profile_var "$p" DCTS_NODE_ID)
env_mode=$(read_profile_var "$p" DEPLOY_ENV "")
ip=$(read_profile_var "$p" REMOTE_IP "")
user=$(read_profile_var "$p" REMOTE_USER "fmq")
[ -n "$node_id" ] || node_id="${user}@${ip:-localhost}"
local tag="远程" ipshow="$ip"
if [ "${env_mode}" = "local" ]; then tag="本机"; ipshow="(本机)"; fi
printf " %d) %s (%s) %s@%s [%s]\n" "$i" "$node_id" "${p##*/}" "$user" "$ipshow" "$tag"
((i++))
done
echo -e " a) [全部节点] 依次同步上述全部 ${#menu[@]} 个节点"
read -r -p "请选择节点 [序号如 1,2 或 1-2; 全部如 a; 默认 0=退出]: " CHOICE
CHOICE=${CHOICE:-0}
SELECTED_PROFILES=()
if [[ "${CHOICE,,}" == "a" ]] || [[ "${CHOICE,,}" == "all" ]]; then
SELECTED_PROFILES=("${menu[@]}")
elif [[ "${CHOICE}" =~ ^[0-9] ]]; then
local part from to n
IFS=',' read -r -a ADDR <<< "${CHOICE}"
for part in "${ADDR[@]}"; do
if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]; then
from="${BASH_REMATCH[1]}"; to="${BASH_REMATCH[2]}"
for ((n=from; n<=to; n++)); do
if [ "$n" -ge 1 ] && [ "$n" -le "${#menu[@]}" ]; then
SELECTED_PROFILES+=("${menu[$((n-1))]}")
fi
done
elif [[ "$part" =~ ^[0-9]+$ ]]; then
if [ "$part" -ge 1 ] && [ "$part" -le "${#menu[@]}" ]; then
SELECTED_PROFILES+=("${menu[$((part-1))]}")
fi
fi
done
fi
if [ ${#SELECTED_PROFILES[@]} -eq 0 ]; then
echo -e "${YELLOW}未选择任何节点,退出。${NC}"
exit 0
fi
# 是否连带同步 data/work 沙盒残留
if [ "${WITH_WORK}" != "true" ]; then
read -r -p "是否连带同步 data/work 沙盒残留(未清理的计算现场,含完整过程文件)? [y/N]: " WK_CHOICE
[[ "${WK_CHOICE,,}" == "y" || "${WK_CHOICE,,}" == "yes" ]] && WITH_WORK=true
fi
# 用户显式勾选本机节点 → 自动纳入备份树(否则 backup_node 会跳过本机)
local p2 env2
for p2 in "${SELECTED_PROFILES[@]}"; do
env2=$(read_profile_var "$p2" DEPLOY_ENV "")
[ "${env2}" = "local" ] && INCLUDE_LOCAL=true
done
echo -e "${CYAN}将同步 ${#SELECTED_PROFILES[@]} 个节点 → ${BACKUP_ROOT}${NC}"
}
if [ "${HAD_ARGS}" = "false" ] || [ "${INTERACTIVE}" = "true" ]; then
interactive_select
fi
# =============================================================================
# 主流程:遍历 deploy.env.d/*.env(排除 *.example
# =============================================================================
echo -e "${CYAN}备份根目录: ${BACKUP_ROOT}${NC}"
echo -e "${CYAN}SSH 复用目录: ${SSH_CONTROL_DIR}${NC}"
mkdir -p "${SSH_CONTROL_DIR}"
profiles=()
for p in deploy.env.d/*.env; do
[ -f "$p" ] || continue
[[ "$p" != *.example ]] || continue
profiles+=("$p")
done
if [ ${#profiles[@]} -eq 0 ]; then
echo -e "${RED}错误: deploy.env.d/ 下没有可用的 node profile${NC}"
exit 1
fi
# 交互式模式下仅同步用户勾选的节点
if [ ${#SELECTED_PROFILES[@]} -gt 0 ]; then
profiles=("${SELECTED_PROFILES[@]}")
fi
for p in "${profiles[@]}"; do
backup_node "$p" || true
done
echo -e "\n${GREEN}===== 全部节点处理完成,备份位于 ${BACKUP_ROOT} =====${NC}"
+37 -9
View File
@@ -14,11 +14,12 @@ use tracing::{debug, info, warn};
/// DCTS 历史计算结果导入工具。 /// DCTS 历史计算结果导入工具。
/// ///
/// 双职责: /// 双职责:
/// 1. **入库 + 种子库**:经 `/api/admin/import_seed` 把旧版 `run_grid.py` 的收敛结果 /// 1. **完整产物树迁移**:把旧目录的全部产物按**新版 node result 格式 + workflow 配置
/// `conv.json` + `.7` 大气)上传服务端,标记 grid_points 为 converged,避免重算。
/// 2. **完整产物树迁移**:把旧目录的全部产物按**新版 node result 格式 + workflow 配置
/// 的小数精度命名**,拷贝到本地 `data/result/<name>/`(与 node 的 `DCTS_RESULT_DIR` /// 的小数精度命名**,拷贝到本地 `data/result/<name>/`(与 node 的 `DCTS_RESULT_DIR`
/// 同卷,docker-compose 里 server/node 都挂载 `./data:/app/data`)。 /// 同卷,docker-compose 里 server/node 都挂载 `./data:/app/data`)。
/// 2. **入库 + 种子库**(可选):经 `/api/admin/import_seed` 把旧版 `run_grid.py` 的收敛结果
/// `conv.json` + `.7` 大气)上传服务端,标记 grid_points 为 converged,避免重算。
/// 仅做本地产物整理时加 `--no-upload` 跳过入库。
/// ///
/// 命名以 **workflow 配置为准**`GridConfig::from_yaml_str` 保留源小数,如 `logg: 5.0` /// 命名以 **workflow 配置为准**`GridConfig::from_yaml_str` 保留源小数,如 `logg: 5.0`
/// → `g5.0`),而非旧 conv.json 里的 name —— 配置是当前项目权威。 /// → `g5.0`),而非旧 conv.json 里的 name —— 配置是当前项目权威。
@@ -49,7 +50,7 @@ struct Args {
result_dir: PathBuf, result_dir: PathBuf,
/// Master 服务端 API 地址(默认: http://127.0.0.1:8090)。 /// Master 服务端 API 地址(默认: http://127.0.0.1:8090)。
/// 若服务端未运行或不想入库,可指向不可达地址,工具会继续完成本地产物树迁移 /// 仅入库步骤使用;加 `--no-upload` 后此参数无效
#[arg(short, long, default_value = "http://127.0.0.1:8090")] #[arg(short, long, default_value = "http://127.0.0.1:8090")]
server: String, server: String,
@@ -62,6 +63,12 @@ struct Args {
/// 服务端 Admin 鉴权令牌(/api/admin/import_seed 需 Admin 角色)。 /// 服务端 Admin 鉴权令牌(/api/admin/import_seed 需 Admin 角色)。
#[arg(short, long)] #[arg(short, long)]
token: Option<String>, token: Option<String>,
/// 仅处理本地文件(扫描 + 产物树迁移),不向服务端 /api/admin/import_seed 入库。
/// 用于"先保全/整理文件、暂不标记 grid_points=converged"的场合;之后如需入库,
/// 去掉该标志重跑即可(服务端 DB 幂等,只补未入库点)。
#[arg(long)]
no_upload: bool,
} }
/// 一个经校验的待导入历史结果点。 /// 一个经校验的待导入历史结果点。
@@ -117,7 +124,16 @@ async fn main() -> Result<()> {
); );
// 2. 扫描旧版结果目录,匹配到配置里的网格点。 // 2. 扫描旧版结果目录,匹配到配置里的网格点。
let candidates = scan_dir_for_results(&args.dir, &name_map).await?; let mut candidates = scan_dir_for_results(&args.dir, &name_map).await?;
// 按 cno_sum 升序排序后再上传(2026-08-04 wave 修复配套):服务端 import_seed 按
// "该工作流已有点中 cno_sum 严格小于本点的去重值个数"计算 wave。升序导入保证即使是
// 全新工作流,后导入的高 cno 点 rank 也自然递增、无需回头改前面的点,最终波次与
// initialize_grid 完全一致(不再依赖文件系统遍历顺序)。
candidates.sort_by(|a, b| {
candidate_cno_sum(a)
.partial_cmp(&candidate_cno_sum(b))
.unwrap_or(std::cmp::Ordering::Equal)
});
info!( info!(
"扫描完成,共找到 {} 个经校验无 NaN 且物理收敛、且命中配置网格点的合格结果!", "扫描完成,共找到 {} 个经校验无 NaN 且物理收敛、且命中配置网格点的合格结果!",
candidates.len() candidates.len()
@@ -146,6 +162,11 @@ async fn main() -> Result<()> {
info!("产物树迁移完成:{}/{}", migrated, candidates.len()); info!("产物树迁移完成:{}/{}", migrated, candidates.len());
// 4. 经 /api/admin/import_seed 批量入库(标记 converged,避免重算)。 // 4. 经 /api/admin/import_seed 批量入库(标记 converged,避免重算)。
if args.no_upload {
info!("已指定 --no-upload:跳过服务端入库,仅完成本地产物树迁移。");
info!("=== 本地文件处理完成(迁移 {migrated} 个点,未入库)===");
return Ok(());
}
info!("=== 启动 HTTP 结果入库(→ grid_points=converged==="); info!("=== 启动 HTTP 结果入库(→ grid_points=converged===");
let ingested = upload_results_to_server( let ingested = upload_results_to_server(
&args.server, &args.server,
@@ -222,6 +243,16 @@ fn param_fingerprint(k: &[f64; 6]) -> String {
s s
} }
/// 从候选结果的 conv.json 提取 `cno_sum`(用于按难度升序排序,保证服务端逐点 rank
/// 计算 wave 与 initialize_grid 一致)。解析失败兜底 `f64::MAX`(排到最后,服务端仍会
/// 按现有库的 rank 计算其 wave,只是顺序不保证升序)。
fn candidate_cno_sum(c: &CandidateResult) -> f64 {
serde_json::from_str::<ModelSummary>(&c.summary_json)
.ok()
.map(|s| s.params.cno_sum())
.unwrap_or(f64::MAX)
}
/// 从 workflow 配置派生收敛链最后阶段标签。 /// 从 workflow 配置派生收敛链最后阶段标签。
/// ///
/// 旧版 run_one.py 每阶段覆盖同名 .5/.6/.err/nst,只留最后阶段的版本。迁移时需把这些 /// 旧版 run_one.py 每阶段覆盖同名 .5/.6/.err/nst,只留最后阶段的版本。迁移时需把这些
@@ -333,10 +364,7 @@ async fn scan_dir_for_results(
// 判定该历史点的收敛途径(供服务端写入 success_method,让导入点融入冷启动/种子步进统计): // 判定该历史点的收敛途径(供服务端写入 success_method,让导入点融入冷启动/种子步进统计):
// 旧 run_grid.py 流程——冷启动失败才改用 seed_step 链重跑,种子链首段 label 为 "seed_nc"。 // 旧 run_grid.py 流程——冷启动失败才改用 seed_step 链重跑,种子链首段 label 为 "seed_nc"。
// 故 stages 含 seed_nc → seed_step(种子步进救活);否则 → cold_run(冷启动一次收敛)。 // 故 stages 含 seed_nc → seed_step(种子步进救活);否则 → cold_run(冷启动一次收敛)。
let has_seed_nc_stage = summary let has_seed_nc_stage = summary.stages.iter().any(|s| s.label == "seed_nc");
.stages
.iter()
.any(|s| s.label == "seed_nc");
let success_method = if has_seed_nc_stage { let success_method = if has_seed_nc_stage {
"seed_step" "seed_step"
} else { } else {
+24 -9
View File
@@ -46,24 +46,19 @@ chain:
- {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0} - {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}
- {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10} - {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100} - {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}
itek_fallback: [] # 留空:ITEK 默认值最稳;非空会重试(实测无效)
niter: 100 # 每个 tlusty 运行的默认最大迭代数 (nst NITER)nc 阶段在 chain 内覆盖为 10
# ---- 种子步进回退(NEW---- # ---- 种子步进回退(旧字段,保留兼容;新配置见下方 tlusty 块----
# 冷启动失败时,自动用已收敛的邻居模型作种子,用 LTGRAY=F 热启动重试。 # 冷启动失败时,自动用已收敛的邻居模型作种子,用 LTGRAY=F 热启动重试。
# 这是高温 He-poor / 富金属区收敛的关键(见 EXPERIENCE.md §5Y)。 # 这是高温 He-poor / 富金属区收敛的关键(见 EXPERIENCE.md §5Y)。
# 失败的冷启动结果会备份到 <model>.coldfail/ 目录。 # 失败的冷启动结果会备份到 <model>.coldfail/ 目录。
seed_step_fallback: true seed_step_fallback: true
# ---- 执行参数 ---- # ---- 执行参数 ----
nworkers: 16 # 并行 worker 数(每次 tlusty 运行是单线程的;
# 想用更多核心就调大;每个 worker 需要独立工作目录)
timeout_sec: 7200 # 单模型墙钟时间上限(120分钟) timeout_sec: 7200 # 单模型墙钟时间上限(120分钟)
resume: true # 跳过已完成的模型(conv.json 中 converged=true
# ---- 光谱合成 SYNSPEC 控制参数 ---- # ---- 光谱合成 SYNSPEC 控制参数(旧字段,数值参数;启用开关见下方 synspec_stage----
synspec: synspec:
wstart: 30000.0 # 光谱波长起始点 (Å) wstart: 3000.0 # 光谱波长起始点 (Å)
wend: 7000.0 # 光谱波长终止点 (Å) wend: 7000.0 # 光谱波长终止点 (Å)
imode: 0 imode: 0
idrv: 50 idrv: 50
@@ -71,4 +66,24 @@ synspec:
rel_cutoff: 0.0001 rel_cutoff: 0.0001
abs_cutoff: 0.01 abs_cutoff: 0.01
results: data/seeds # ====================================================================
# 阶段独立配置(见 docs/task_engine_decoupling_design.md §2-§3
#
# TLUSTY / SYNSPEC 视为正交独立阶段,每阶段三维:
# enabled : 是否执行该阶段
# policy : skip_converged(增量) | force_recompute(强制重算) | skip_failed(忽略失败)
# strategies : 策略链队列,按回退优先级排序;节点执行 strategies[0],失败后服务端弹出首项
#
# 此处显式声明与旧 seed_step_fallback/synspec 块等价的默认配置,便于前端面板编辑。
# 旧字段(seed_step_fallback / synspec)保留作回退兜底,resolve 方法优先用本块。
# ====================================================================
tlusty:
enabled: true
policy: skip_converged
strategies: [cold_run, seed_step]
synspec_stage:
enabled: true
policy: skip_converged
strategies: [standard]