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:
@@ -1466,7 +1466,8 @@ async fn test_import_seed_admin_endpoint() {
|
||||
|
||||
// 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 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
|
||||
.oneshot(
|
||||
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 conv = make_python_legacy_conv_json(name);
|
||||
let body_bytes =
|
||||
make_import_multipart("boundaryL", &conv, b"FAKE_ATMOS_7", &format!("{name}.7"), "cold_run");
|
||||
let body_bytes = make_import_multipart(
|
||||
"boundaryL",
|
||||
&conv,
|
||||
b"FAKE_ATMOS_7",
|
||||
&format!("{name}.7"),
|
||||
"cold_run",
|
||||
);
|
||||
let res = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1667,6 +1673,7 @@ async fn test_node_disable_enable_flow() {
|
||||
timeout_sec: 60,
|
||||
workflow_name: Some("wf_test".to_string()),
|
||||
wave: 0,
|
||||
..Default::default()
|
||||
};
|
||||
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(®).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,绝不能静默当作「清除配额」
|
||||
// (审查修复 M1:Option<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) =====
|
||||
|
||||
/// 测试专用:走真实写路径派发并回报一个网格点任务。
|
||||
@@ -1907,6 +2140,7 @@ async fn dispatch_and_report(
|
||||
timeout_sec: 7200,
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave: 0,
|
||||
..Default::default()
|
||||
};
|
||||
db.insert_task(&spec).await.unwrap();
|
||||
let report = common::models::TaskReport {
|
||||
@@ -1929,6 +2163,7 @@ async fn dispatch_and_report(
|
||||
Some("nl stage diverged".to_string())
|
||||
},
|
||||
summary_json: "{}".to_string(),
|
||||
failed_stage: None,
|
||||
};
|
||||
db.record_task_report(&report, wf).await.unwrap();
|
||||
}
|
||||
@@ -2844,3 +3079,340 @@ async fn test_wf_progress_endpoint() {
|
||||
.unwrap();
|
||||
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(®).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(¶ms, 0, "wf_flip").await.unwrap();
|
||||
db.insert_seed_named(&name, ¶ms, "/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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user