feat(all): 数据库模块化拆分与版本化迁移、任务引擎命名体系收敛、物理输出校验加固与用户配置接通

- server/db: 拆 4929 行 db.rs 单体为 db/ 目录,migrations.rs 引入 PRAGMA user_version
    版本化迁移运行器(M1~M13)
  - 任务引擎 Phase 6/7b/7c 改名收敛:EngineStageConfig→PhaseConfig、StagePolicy→ResumePolicy、
    Converged→Completed、删除 task_type 列、success_method 拆 tlusty_/synspec_ 双列、
    新增 tlusty_status/synspec_status 半失败阶段守卫
  - 科学正确性加固:conv_check 任意行 NaN/Inf/溢出判无效(0 行容忍)、新增 spec_is_valid
    校验 SYNSPEC 脏谱、itek_history 逐次迭代全量保真、fmt_abn powf 溢出饱和
  - 用户配置真正接通:tlusty_chain/tlusty_input 由死字段经 调度器→TaskSpec→executor→runner
    透传生效;config 加载期 validate + deny_unknown_fields + 解析失败记 warn
  - 调度修复:H1 活锁(pending_strategies 跳过已失败策略)、种子查找错误不再静默降级冷启动
  - dashboard: 阶段配置面板 tlusty_stage/synspec_stage、"已完成"标签、迭代诊断展示
  - docs: 新增 database_refactor_design.md,同步 database/api/PIPELINE/workflow_detail
This commit is contained in:
fmq
2026-08-06 20:51:21 +08:00
parent cd370d88e7
commit d16b3d3cdc
61 changed files with 10268 additions and 5881 deletions
+3 -10
View File
@@ -9,16 +9,9 @@
<!-- 字重精简:Inter 去掉未使用的 300Space 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>
/* 首帧前落主题,避免 prefers-color-scheme 先绘制导致深/浅闪变 (FOUC) */
(function () {
try {
var saved = localStorage.getItem('dcts_theme');
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) { /* localStorage 不可用时回退媒体查询 */ }
})();
</script>
<!-- 主题初始化移为外链(public/theme-init.js):使 CSP 可移除 script-src 'unsafe-inline'。
同步脚本在首帧前执行,保持防 FOUC 语义。 -->
<script src="/theme-init.js"></script>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
+11
View File
@@ -0,0 +1,11 @@
/* 首帧前落主题,避免 prefers-color-scheme 先绘制导致深/浅闪变 (FOUC)。
* 独立为外链文件(public/ 原样拷贝到 dist/),使 CSP 可移除 script-src 'unsafe-inline'
* (见 crates/server/src/main.rs security_headers_middleware)。
* 必须以同步任 script 在 <head> 中于首帧前执行(Vite 保留此标签,不做打包)。 */
(function () {
try {
var saved = localStorage.getItem('dcts_theme');
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) { /* localStorage 不可用时回退媒体查询 */ }
})();
+4 -8
View File
@@ -32,11 +32,7 @@ const TOKEN_KEY = 'dcts_admin_token';
* @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]
* @property {object} [grid_stats] - { pending, queued, running, completed, failed }
*/
/**
@@ -69,7 +65,7 @@ const TOKEN_KEY = 'dcts_admin_token';
* @property {number} pending
* @property {number} queued
* @property {number} running
* @property {number} converged
* @property {number} completed
* @property {number} failed
* @property {number} [eta_sec]
* @property {Array<{label:string,count:number}>} [waves]
@@ -250,8 +246,8 @@ export async function deleteWorkflowApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`, { method: 'DELETE' });
}
export async function getWorkflowDetailApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`);
export async function getWorkflowDetailApi(name, opts = {}) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`, opts);
}
// ===== 工作流执行观测 API(详情页数据源) =====
+34 -31
View File
@@ -61,8 +61,8 @@ const panelState = {
status: 'idle',
description: null, // 工作流描述(回显保存,避免 null 覆盖既有描述)
configYaml: '', // 原始 YAML(未经编辑)
tlusty: defaultStage('tlusty'),
synspec: defaultStage('synspec'),
tlusty_stage: defaultStage('tlusty_stage'),
synspec_stage: defaultStage('synspec_stage'),
wired: false,
dirty: false,
collapsed: true, // 默认折叠成一行(标题+操作按钮),点「配置 ▾」展开阶段卡
@@ -80,7 +80,7 @@ function policyOptions(selected) {
}
function strategyOptions(kind, selected) {
const opts = kind === 'tlusty' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
const opts = kind === 'tlusty_stage' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
return opts.map(o =>
`<option value="${o.value}"${o.value === selected ? ' selected' : ''}>${escapeHtml(o.label)}</option>`
).join('');
@@ -88,7 +88,7 @@ function strategyOptions(kind, selected) {
/** 单个阶段卡片(TLUSTY 或 SYNSPEC)。 */
function stageCardHtml(kind, label, stage) {
const idPrefix = kind === 'tlusty' ? 'tlusty' : 'synspec';
const idPrefix = kind === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const stratRows = stage.strategies.map((s, i) => {
const opts = strategyOptions(kind, s);
return `
@@ -125,7 +125,7 @@ function stageCardHtml(kind, label, stage) {
${ICONS.plus({ size: 11 })} 添加回退策略
</button>
</div>
${kind === 'synspec'
${kind === 'synspec_stage'
? '<p class="text-hint engine-stage-note">光谱数值参数(波长范围、展宽等)在 YAML 中配置,请用「导出 YAML」编辑后重新导入。</p>'
: ''}
</div>
@@ -159,8 +159,8 @@ function panelHtml() {
<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)}
${stageCardHtml('tlusty_stage', 'TLUSTY 大气结构计算', panelState.tlusty_stage)}
${stageCardHtml('synspec_stage', 'SYNSPEC 光谱合成', panelState.synspec_stage)}
</div>
<div class="wf-engine-actions">
<button type="button" class="btn btn-secondary btn-sm" data-engine-action="yaml" title="查看 / 编辑完整 YAML 配置">
@@ -210,8 +210,8 @@ function showError(msg) {
/** 重新渲染某阶段的策略链列表(增删/排序后调用)。 */
function rerenderStrategyList(kind) {
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const idPrefix = kind === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty_stage' ? panelState.tlusty_stage : panelState.synspec_stage;
const idPrefix = kind === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const ul = document.querySelector(`[data-strat-list="${idPrefix}"]`);
if (!ul) return;
ul.innerHTML = stage.strategies.map((s, i) => {
@@ -236,9 +236,9 @@ 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;
// strat-kind 在 enabled/policy 用 tlusty_stage/synspec_stage;在 strat*
const kind = kindRaw === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const stage = kind === 'tlusty_stage' ? panelState.tlusty_stage : panelState.synspec_stage;
if (field === 'enabled') {
stage.enabled = t.checked;
@@ -265,9 +265,9 @@ function handleClick(e) {
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 kind = btn.getAttribute('data-strat-kind') === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const stage = kind === 'tlusty_stage' ? panelState.tlusty_stage : panelState.synspec_stage;
const opts = kind === 'tlusty_stage' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
// 默认添加白名单首项(若已含则添加下一项)。
const next = opts.find(o => !stage.strategies.includes(o.value)) || opts[0];
stage.strategies.push(next.value);
@@ -275,8 +275,8 @@ function handleClick(e) {
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 kind = btn.getAttribute('data-strat-kind') === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const stage = kind === 'tlusty_stage' ? panelState.tlusty_stage : panelState.synspec_stage;
const idx = parseInt(btn.getAttribute('data-strat-idx'), 10);
if (field === 'strat-remove') {
stage.strategies.splice(idx, 1);
@@ -328,12 +328,12 @@ async function saveConfig() {
}
// 阶段配置合法性校验(与服务端 save_workflow 同口径,修复审查 #4/#5):
// 双阶段全关、或启用阶段空策略链 → 拦截保存,避免保存出必然失败的任务配置。
const validationErr = validateStageConfigs(panelState.tlusty, panelState.synspec);
const validationErr = validateStageConfigs(panelState.tlusty_stage, panelState.synspec_stage);
if (validationErr) {
showError(validationErr);
return;
}
const newYaml = applyStageBlocks(panelState.configYaml, panelState.tlusty, panelState.synspec);
const newYaml = applyStageBlocks(panelState.configYaml, panelState.tlusty_stage, panelState.synspec_stage);
showError('');
try {
// 回显既有描述(修复审查 #6:旧实现硬编码 description:null 会清空工作流描述)。
@@ -415,7 +415,7 @@ async function doDelete() {
}
function exportYaml() {
const yaml = applyStageBlocks(panelState.configYaml, panelState.tlusty, panelState.synspec);
const yaml = applyStageBlocks(panelState.configYaml, panelState.tlusty_stage, panelState.synspec_stage);
if (!yaml.trim()) {
showToast('配置内容为空,无法导出', 'warning');
return;
@@ -440,9 +440,11 @@ function exportYaml() {
// ===== 刷新与挂载 =====
/** 把一次拉取的详情数据填入 panelStatefetch + parse 的公共逻辑,不含渲染)。
* mount 与 poll 共用此逻辑;mount 调用后强制重渲染,poll 仅在状态变化时更新按钮。 */
async function fetchAndApplyDetail({ forceConfig }) {
const res = await getWorkflowDetailApi(panelState.name);
* mount 与 poll 共用此逻辑;mount 调用后强制重渲染,poll 仅在状态变化时更新按钮。
* signal 可选:透传给 getWorkflowDetailApi,用于详情页卸载时中断在途响应
* (避免 unmount 后异步响应仍改写 panelState)。 */
async function fetchAndApplyDetail({ forceConfig, signal } = {}) {
const res = await getWorkflowDetailApi(panelState.name, signal ? { signal } : undefined);
const json = await res.json();
if (!json.success || !json.data) return false;
const prevStatus = panelState.status;
@@ -453,13 +455,13 @@ async function fetchAndApplyDetail({ forceConfig }) {
// mountforceConfig=true)或非编辑中(dirty=false)时同步服务端配置到本地。
if (forceConfig || !panelState.dirty) {
panelState.configYaml = json.data.config_yaml || '';
// TLUSTY 生效配置:无 `tlusty:` 块时按旧 `seed_step_fallback` 推断(与服务端
// TLUSTY 生效配置:无 `tlusty_stage:` 块时按旧 `seed_step_fallback` 推断(与服务端
// resolve_tlusty_config 同口径),避免保存时把 `seed_step_fallback: false` 静默改写。
panelState.tlusty = resolveTlustyFromYaml(panelState.configYaml);
panelState.tlusty_stage = resolveTlustyFromYaml(panelState.configYaml);
const s = parseStageFromYaml(panelState.configYaml, 'synspec_stage');
// 无 synspec_stage 块 → 兜底默认(与服务端 resolve_synspec_config 同口径),
// 避免面板停留在上一次的旧值。
panelState.synspec = s || defaultStage('synspec');
panelState.synspec_stage = s || defaultStage('synspec_stage');
}
if (prevStatus !== panelState.status) {
updateActionButtons();
@@ -474,10 +476,11 @@ async function fetchAndApplyDetail({ forceConfig }) {
}
/** 拉取最新工作流详情,更新面板状态与操作按钮可用性(不改编辑中的字段值)。
* 供详情页轮询调用:仅在非 dirty 时同步配置,状态变化时更新按钮可用性。 */
export async function refreshEnginePanel() {
* 供详情页轮询调用:仅在非 dirty 时同步配置,状态变化时更新按钮可用性。
* signal 可选:透传给 fetchAndApplyDetail,卸载时中断在途响应(防 unmount 后改 panelState)。 */
export async function refreshEnginePanel(signal) {
try {
await fetchAndApplyDetail({ forceConfig: false });
await fetchAndApplyDetail({ forceConfig: false, signal });
} catch (err) {
// 静默:详情页主轮询会处理 404。
}
@@ -536,8 +539,8 @@ export async function mountEnginePanel(name, container) {
panelState.status = 'idle';
panelState.description = null;
panelState.configYaml = '';
panelState.tlusty = defaultStage('tlusty');
panelState.synspec = defaultStage('synspec');
panelState.tlusty_stage = defaultStage('tlusty_stage');
panelState.synspec_stage = defaultStage('synspec_stage');
panelState.dirty = false;
panelState.wired = false;
panelState.collapsed = true;
+2 -2
View File
@@ -144,9 +144,9 @@ export function renderWorkflows(workflows) {
// 内联进度(来自列表接口内联 stats;未启动的工作流 stats=null → 不渲染)
const s = wf.stats;
const hasProgress = s && s.total > 0;
const pct = hasProgress ? Math.min(100, Math.max(0, Math.round((s.converged / s.total) * 100))) : 0;
const pct = hasProgress ? Math.min(100, Math.max(0, Math.round((s.completed / s.total) * 100))) : 0;
const countsText = hasProgress
? `${s.converged}/${s.total} 收敛 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行`
? `${s.completed}/${s.total} 完成 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行`
: '';
let card = existingCards.get(name);
+2 -4
View File
@@ -31,7 +31,7 @@ grid:
logn: [-4.0]
logo: [-4.0]
chain:
tlusty_chain:
- {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: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}
@@ -41,7 +41,7 @@ nworkers: 4
timeout_sec: 3600
resume: true
synspec:
synspec_input:
wstart: 3000.0
wend: 7000.0
imode: 0
@@ -49,8 +49,6 @@ synspec:
ifreq: 1
rel_cutoff: 0.0001
abs_cutoff: 0.01
results: data/seeds
`;
function showLoginError(msg) {
+11 -9
View File
@@ -114,11 +114,13 @@ export function updateUI(data) {
const stats = data.grid_stats || {};
// 后端已将 queued 从 pending 拆出(详情页需区分"未入队/排队中");
// 首页"待计算网格点"保持传统合并口径 = pending + queued。
const pending = (stats.pending ?? data.pending_points ?? 0) + (stats.queued ?? 0);
const running = stats.running ?? data.running_points ?? 0;
const converged = stats.converged ?? data.converged_points ?? 0;
const failed = stats.failed ?? data.failed_points ?? 0;
const completed = converged + failed;
const pending = (stats.pending ?? 0) + (stats.queued ?? 0);
const running = stats.running ?? 0;
// 7c 改名:后端权威键为 stats.completed(原 converged)。doneOk=成功完成,failed=失败,
// doneTotal=两者合计(所有已终止点)。
const doneOk = stats.completed ?? 0;
const failed = stats.failed ?? 0;
const doneTotal = doneOk + failed;
const valPendingTasks = document.getElementById('val-pending-tasks');
const valRunningTasks = document.getElementById('val-running-tasks');
@@ -130,13 +132,13 @@ export function updateUI(data) {
const valCompletedTasks = document.getElementById('val-completed-tasks');
const valCompletionRate = document.getElementById('val-completion-rate');
const convergedProgressFill = document.getElementById('converged-progress-fill');
if (valCompletedTasks) valCompletedTasks.textContent = completed.toLocaleString();
if (valCompletedTasks) valCompletedTasks.textContent = doneTotal.toLocaleString();
if (valCompletionRate) {
valCompletionRate.textContent = `${converged.toLocaleString()} 收敛 / ${failed.toLocaleString()}收敛`;
valCompletionRate.textContent = `${doneOk.toLocaleString()} 完成 / ${failed.toLocaleString()}完成`;
}
if (convergedProgressFill) {
const totalModels = pending + running + completed;
const pct = totalModels > 0 ? Math.min(100, Math.max(0, Math.round((converged / totalModels) * 100))) : 0;
const totalModels = pending + running + doneTotal;
const pct = totalModels > 0 ? Math.min(100, Math.max(0, Math.round((doneOk / totalModels) * 100))) : 0;
convergedProgressFill.style.width = `${pct}%`;
}
+17
View File
@@ -135,6 +135,8 @@
--accent-blueprint: light-dark(var(--color-blue-700), #38bdf8);
--accent-cyan: var(--color-blue-600);
--accent-cyan: light-dark(var(--color-blue-600), #22d3ee);
--accent-teal: #0d9488;
--accent-teal: light-dark(#0d9488, #2dd4bf);
--accent-purple: var(--color-purple-700);
--accent-purple: light-dark(var(--color-purple-700), #a78bfa);
--accent-emerald: var(--color-emerald-700);
@@ -1236,6 +1238,18 @@ body::before {
.activity-result.ok { color: var(--color-success); }
.activity-result.fail { color: var(--color-danger); }
/* 尝试历史:失败行的 synspec 错误摘要(Phase 1,解析 summary_json */
.attempt-synspec-err {
margin-top: 2px;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
color: var(--color-danger);
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-elapsed {
color: var(--text-secondary);
white-space: nowrap;
@@ -1361,6 +1375,7 @@ body::before {
/* 状态配色(与 method-badge/status-badge 配色族一致) */
.ps-cold { fill: var(--color-success); }
.ps-seed { fill: var(--accent-purple); }
.ps-synspec { fill: var(--accent-teal); }
.ps-failed { fill: var(--color-danger); }
.ps-running { fill: var(--color-warning); }
.ps-queued { fill: var(--color-info); }
@@ -1368,6 +1383,7 @@ body::before {
/* 色带默认 fill-opacity 已定,色块需不透明 → 用 stroke 不可,故色块状态类同时设 fill */
.ps-seg.ps-cold { fill: var(--color-success); }
.ps-seg.ps-seed { fill: var(--accent-purple); }
.ps-seg.ps-synspec { fill: var(--accent-teal); }
.ps-seg.ps-failed { fill: var(--color-danger); }
.ps-seg.ps-running { fill: var(--color-warning); }
.ps-seg.ps-queued { fill: var(--color-info); }
@@ -1387,6 +1403,7 @@ body::before {
/* 状态轴取值标签染色 = 自带图例(与色块/色带配色一致) */
.ps-vlabel-cold { fill: var(--color-success); font-weight: 600; }
.ps-vlabel-seed { fill: var(--accent-purple); font-weight: 600; }
.ps-vlabel-synspec { fill: var(--accent-teal); font-weight: 600; }
.ps-vlabel-failed { fill: var(--color-danger); font-weight: 600; }
.ps-vlabel-running { fill: var(--color-warning); font-weight: 600; }
.ps-vlabel-queued { fill: var(--color-info); font-weight: 600; }
+14 -5
View File
@@ -8,8 +8,9 @@
* 解析前需规范化为 ISO所有时间解析函数共用 parseIso/parseTsMs
*
* 语义与后端契约对齐crates/server/src/db.rs
* - 网格点状态pending/queued/running/converged/failed/canceled
* - success_method收敛途径cold_run / seed_step
* - 网格点状态pending/queued/running/completed/failed/canceled7c converged 改名
* - 阶段归因列tlusty_success_method大气策略/ synspec_success_method光谱策略
* 整体归因 overallMethod(p) = tlusty ?? synspec
*/
/**
@@ -134,7 +135,9 @@ export function statusBadge(status) {
/** 网格点状态映射:[中文标签, badge class]。与后端 grid_points.status 对齐。 */
export const POINT_STATUS_MAP = {
converged: ['已收敛', 'online'],
// Phase 7a/7c'completed' 是 7c 改名后的权威值(原 'converged' TLUSTY-first 残留)——
// 实为"管线完成"(大气收敛 + 光谱合成),展示层统一标"已完成"。
completed: ['已完成', 'online'],
failed: ['失败', 'danger'],
running: ['运行中', 'warning'],
queued: ['排队中', 'info'],
@@ -147,11 +150,17 @@ export function pointStatusBadge(status) {
return `<span class="status-badge ${cls}">${cn}</span>`;
}
/** 网格点收敛途径徽章 HTMLsuccess_methodcold_run / seed_step / 策略名)。 */
/** = TLUSTY ?? 退
* 正常双阶段点 cold_run/seed_stepsynspec-only 光谱策略 standard */
export function overallMethod(p) {
return p.tlusty_success_method ?? p.synspec_success_method;
}
/** 网格点收敛途径徽章 HTMLmethodcold_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")原样展示,让光谱归因可见。
// 其它策略名(如 synspec-only 归因的 "standard")原样展示,让光谱归因可见。
if (method) return `<span class="method-badge syn">${escapeHtml(method)}</span>`;
return '<span class="text-hint">—</span>';
}
+10 -1
View File
@@ -28,6 +28,11 @@ export function createPoller(fn, { baseMs = 5000, maxMs = 60000, maxFails = 4 }
let running = false;
let failCount = 0;
let onVisibility = null;
// 在途守卫:避免 triggerNow / visibility 与正在执行的 schedule 并发调用 fn()。
// schedule 是 async,若上一轮仍停在 await fn() 时被再次调用,两个 schedule 会
// 并发跑 fn(),导致请求翻倍且响应到达顺序不确定。已在途则直接 return——
// 在途 schedule 结束时会自己排下一轮 timer,不会漏调度。
let inFlight = false;
function clearTimer() {
if (timer) {
@@ -37,8 +42,10 @@ export function createPoller(fn, { baseMs = 5000, maxMs = 60000, maxFails = 4 }
}
async function schedule() {
if (inFlight) return;
inFlight = true;
clearTimer();
if (document.hidden) return;
if (document.hidden) { inFlight = false; return; }
let ok = true;
try {
@@ -46,6 +53,8 @@ export function createPoller(fn, { baseMs = 5000, maxMs = 60000, maxFails = 4 }
ok = !!r;
} catch (_) {
ok = false;
} finally {
inFlight = false;
}
if (ok) {
+20 -17
View File
@@ -1,4 +1,4 @@
/* 工作流阶段配置(tlusty / synspec_stage的轻量 YAML 解析与序列化
/* 工作流阶段配置(tlusty_stage / synspec_stage的轻量 YAML 解析与序列化
*
* 无依赖纯函数模块刻意从 wfEnginePanel.js 拆出使其可在 Node 原生 test
* 运行器下被单测覆盖wfEnginePanel.js 导入了浏览器侧的 toast/modal/api无法
@@ -10,9 +10,9 @@
* flow 序列与设计文档示例一致
*/
/** stage 配置默认值。 */
/** stage 配置默认值。kind 与 YAML 顶层键同名(tlusty_stage / synspec_stage)。 */
export function defaultStage(kind) {
if (kind === 'tlusty') {
if (kind === 'tlusty_stage') {
return { enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'] };
}
return { enabled: true, policy: 'skip_converged', strategies: ['standard'] };
@@ -22,14 +22,14 @@ export function defaultStage(kind) {
* - 至少一个阶段启用双关 无任何计算可执行任务必失败
* - 启用的阶段必须配置 1 个策略空链 调度无顺位可派任务必失败
* 返回错误文案校验通过返回 null */
export function validateStageConfigs(tlusty, synspec) {
if (!tlusty.enabled && !synspec.enabled) {
export function validateStageConfigs(tlusty_stage, synspec_stage) {
if (!tlusty_stage.enabled && !synspec_stage.enabled) {
return 'TLUSTY 与 SYNSPEC 阶段均被禁用:至少应启用一个计算阶段';
}
if (tlusty.enabled && (!tlusty.strategies || tlusty.strategies.length === 0)) {
if (tlusty_stage.enabled && (!tlusty_stage.strategies || tlusty_stage.strategies.length === 0)) {
return 'TLUSTY 阶段已启用但策略链为空:至少需要 1 个策略(如 cold_run';
}
if (synspec.enabled && (!synspec.strategies || synspec.strategies.length === 0)) {
if (synspec_stage.enabled && (!synspec_stage.strategies || synspec_stage.strategies.length === 0)) {
return 'SYNSPEC 阶段已启用但策略链为空:至少需要 1 个策略(如 standard';
}
return null;
@@ -38,12 +38,15 @@ export function validateStageConfigs(tlusty, synspec) {
/** `^key:`
*
* 顶层注释 0 `#`无缩进必须终止块它是独立的顶层结构不属于上一块
* 否则 `tlusty:` `synspec_stage:` 之间的顶层注释会被吞进 tlusty
* 否则 `tlusty_stage:` `synspec_stage:` 之间的顶层注释会被吞进 tlusty_stage
* 随后 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));
// 转义 key 中的正则元字符:key 作为导出纯函数的入参,未来可能含 `.`、`[` 等,
// 直接拼接会误匹配(如 key=`te.lusty` 的 `.` 会匹配任意字符)。按字面匹配更稳健。
const keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const startIdx = lines.findIndex(l => new RegExp(`^${keyRe}:\\s*(\\S.*)?$`).test(l));
if (startIdx < 0) return null;
// 块的缩进为 0(顶层键);块体为后续缩进 > 0 的连续行。
const blockLines = [lines[startIdx]];
@@ -98,16 +101,16 @@ export function parseStageFromYaml(yaml, key) {
return { enabled, policy, strategies };
}
/** TLUSTY **** `tlusty:`
/** TLUSTY **** `tlusty_stage:`
* `seed_step_fallback` 推断与服务端 `GridConfig::resolve_tlusty_config` 同口径
* - `seed_step_fallback: false` `[cold_run]`不回退种子步进
* - `true` / 缺省 默认链 `[cold_run, seed_step]`
*
* 用途审查 #2 修复面板编辑保存会物化 `tlusty:` 若无此推断而直接回退到
* 用途审查 #2 修复面板编辑保存会物化 `tlusty_stage:` 若无此推断而直接回退到
* `defaultStage` `[cold_run, seed_step]`会把旧 `seed_step_fallback: false`
* 的工作流静默改写为启用种子回退 */
export function resolveTlustyFromYaml(yaml) {
const block = parseStageFromYaml(yaml, 'tlusty');
const block = parseStageFromYaml(yaml, 'tlusty_stage');
if (block) return block;
// 行级查找顶层键(`^\s*seed_step_fallback` 天然排除 `#` 注释行)。
const hit = (yaml || '').split('\n')
@@ -115,7 +118,7 @@ export function resolveTlustyFromYaml(yaml) {
if (hit && /false\b/i.test(hit)) {
return { enabled: true, policy: 'skip_converged', strategies: ['cold_run'] };
}
return defaultStage('tlusty');
return defaultStage('tlusty_stage');
}
/** 把 stage 配置序列化为 YAML 块文本(flow 序列风格)。 */
@@ -126,12 +129,12 @@ export function serializeStageBlock(key, stage) {
return `${key}:\n enabled: ${stage.enabled}\n policy: ${stage.policy}\n strategies: ${strat}`;
}
/** tlusty/synspec_stage config_yaml
/** tlusty_stage/synspec_stage config_yaml
* 已存在该块 替换不存在 追加到末尾 */
export function applyStageBlocks(yaml, tlusty, synspec) {
export function applyStageBlocks(yaml, tlusty_stage, synspec_stage) {
let out = yaml;
out = replaceOrAppendBlock(out, 'tlusty', serializeStageBlock('tlusty', tlusty));
out = replaceOrAppendBlock(out, 'synspec_stage', serializeStageBlock('synspec_stage', synspec));
out = replaceOrAppendBlock(out, 'tlusty_stage', serializeStageBlock('tlusty_stage', tlusty_stage));
out = replaceOrAppendBlock(out, 'synspec_stage', serializeStageBlock('synspec_stage', synspec_stage));
return out;
}
+22 -23
View File
@@ -8,7 +8,7 @@
*/
import {
escapeHtml, parseTsMs, fmtTime, fmtDuration, statusBadge, pointMethodBadge,
escapeHtml, parseTsMs, fmtTime, fmtDuration, statusBadge, pointMethodBadge, overallMethod,
} from '../../utils/format.js';
import { fetchWorkflowPointsApi, fetchWorkflowProgressApi } from '../../api.js';
import { isAbortError, logError } from '../../utils/errors.js';
@@ -16,7 +16,7 @@ import { ICONS } from '../../utils/icons.js';
/** 分段进度条档位:顺序与计数行一致。五段常驻(空档宽度 0),原位改 width 复用 CSS 过渡。 */
const SEG_DEFS = [
['converged', 'seg-converged', '收敛'],
['completed', 'seg-converged', '完成'],
['failed', 'seg-failed', '失败'],
['running', 'seg-running', '运行'],
['queued', 'seg-queued', '排队'],
@@ -57,10 +57,10 @@ export function renderStrip(s) {
const countsEl = document.getElementById('wf-strip-counts');
if (countsEl) {
const pct = s.total ? Math.round((s.converged / s.total) * 100) : 0;
const pct = s.total ? Math.round((s.completed / s.total) * 100) : 0;
const parts = [
`${s.total} 网格点`,
`${s.converged} 收敛${s.total ? ` (${pct}%)` : ''}`,
`${s.completed} 完成${s.total ? ` (${pct}%)` : ''}`,
`${s.running} 运行`,
`${s.queued} 排队`,
`${s.pending} 待定`,
@@ -94,7 +94,7 @@ function overviewSkeleton() {
<span class="metric-sub">6 维参数笛卡尔积展开</span>
</div>
<div class="metric-card card">
<span class="metric-title">收敛</span>
<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>
@@ -144,9 +144,9 @@ function setOvText(id, v) {
/** 轮询原位刷新概览数值(指标卡 + 归因徽章 + 波次)。 */
function updateOverview(ctx, s) {
const pct = s.total ? Math.round((s.converged / s.total) * 100) : 0;
const pct = s.total ? Math.round((s.completed / s.total) * 100) : 0;
setOvText('ov-m-total', s.total);
setOvText('ov-m-converged', s.converged);
setOvText('ov-m-converged', s.completed);
setOvText('ov-m-converged-sub', `完成率 ${pct}%`);
setOvText('ov-m-failed', s.failed);
setOvText('ov-m-runqueue', s.running + s.queued);
@@ -161,19 +161,19 @@ 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('|');
const sig = waves.map(w => `${w.wave}:${w.completed}/${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;
const wp = w.total ? Math.round((w.completed / 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>
<span class="wave-count tabular-num">${w.completed}/${w.total}${w.failed ? ` · ${w.failed} 失败` : ''}</span>
</div>`;
}).join('')
: '<p class="text-hint">尚无波次数据(启动工作流后按难度分批生成)</p>';
@@ -206,16 +206,15 @@ async function refreshActivityFeed(ctx) {
}
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);
const ok = p.status === 'completed';
// 归因徽章用整体归因 overallMethod= tlusty 阶段策略 ?? 光谱阶段策略,能正确显示
// synspec-only 的 "standard")。失败点整体归因为空 → 显示 —。
const method = pointMethodBadge(overallMethod(p));
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-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>`;
@@ -286,7 +285,7 @@ export function renderSparkline(ctx, prog) {
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 areaPts = `${x(0).toFixed(2)},${H} ${line('completed')} ${x(n - 1).toFixed(2)},${H}`;
// 时间轴标签:窗口起点 / 中点 / 终点
const fmtAxisTime = (ms) => {
@@ -296,7 +295,7 @@ export function renderSparkline(ctx, prog) {
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}`;
const sig = `${n}|${tsMs[0]}|${tsMs[n - 1]}|${series[n - 1].completed}|${series[n - 1].failed}`;
if (sig === ctx.lastSparkSig && ctx.sparkBuilt) {
ctx.sparkHoverCtx = { tsMs, tStart, span, series, n, pctOf, x };
return;
@@ -338,7 +337,7 @@ export function renderSparkline(ctx, prog) {
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}`;
readout.textContent = `${p.ts} · 收敛 ${p.completed}/${p.total} (${c.pctOf(p, 'completed').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
};
const hideCrosshair = () => {
crosshair.setAttribute('visibility', 'hidden');
@@ -399,7 +398,7 @@ export function renderSparkline(ctx, prog) {
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'));
if (lineConverged) lineConverged.setAttribute('points', line('completed'));
wrap.querySelectorAll('.spark-axis-label').forEach((el, i) => {
if (el.textContent !== axisLabels[i]) el.textContent = axisLabels[i];
@@ -423,13 +422,13 @@ export function renderRateLine(ctx, prog) {
: `(近 ${Math.max(1, Math.round(span * 60))} 分钟平均)`;
}
parts.push(`经验速率 ≈ +${Number(rate).toFixed(1)} 点/小时${spanTxt}`);
// ETA 用终态处理速率(converged+failed 的近 2h 平均):与「剩余 = total converged
// ETA 用终态处理速率(completed+failed 的近 2h 平均):与「剩余 = total completed
// − 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;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.completed - ctx.latestStats.failed : 0;
if (etaRate > 0 && remaining > 0) {
parts.push(`按当前处理速率剩余 ${remaining} 点约需 ${fmtDuration((remaining / etaRate) * 3600)}`);
}
@@ -441,7 +440,7 @@ export function renderRateLine(ctx, prog) {
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 remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.completed - 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 =
+20 -9
View File
@@ -58,19 +58,29 @@ export function restorePsCache(ctx) {
}
}
/** / vs
/** //SYNSPEC vs vs
* 顺序即图例/轴布局/每值内分段/色带绘制层叠的唯一来源语义约定
* 已收敛最受关注置顶 进行中按任务进展方向runningqueuedpending 失败沉底
* 色带绘制按此顺序收敛色带先画底层失败最后画最上层数量少且颜色醒目 */
const PS_STATUS = ['cold', 'seed', 'running', 'queued', 'pending', 'failed'];
* 色带绘制按此顺序收敛色带先画底层失败最后画最上层数量少且颜色醒目
* synspec synspec_success_method 非空 synspec-only 任务的光谱策略 "standard"
* 属已收敛但由光谱合成阶段产出需与大气收敛区分 */
const PS_STATUS = ['cold', 'seed', 'synspec', 'running', 'queued', 'pending', 'failed'];
const PS_STATUS_LABEL = {
cold: '冷启动收敛', seed: '种子步进', failed: '失败',
cold: '冷启动收敛', seed: '种子步进', synspec: 'SYNSPEC 合成', failed: '失败',
running: '运行', queued: '排队', pending: '未开始',
};
/** 点 → 状态档位 key。 */
/** key
* 阶段归因列拆分P9后可直接区分TLUSTY 策略看 tlusty_success_method
* SYNSPEC-only 点看 synspec_success_method不再需要猜策略名 SYNSPEC_METHODS hack */
function psSlot(p) {
if (p.status === 'converged') return p.success_method === 'seed_step' ? 'seed' : 'cold';
if (p.status === 'completed') {
if (p.tlusty_success_method === 'seed_step') return 'seed';
if (p.tlusty_success_method === 'cold_run') return 'cold';
// 光谱阶段收敛(synspec-only/双阶段光谱归因落库)→ SYNSPEC 合成档。
if (p.synspec_success_method) return 'synspec';
return 'cold';
}
if (p.status === 'failed') return 'failed';
if (p.status === 'running') return 'running';
if (p.status === 'queued') return 'queued';
@@ -361,8 +371,9 @@ function renderPsConclusion(ctx, pts) {
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 cold = pts.filter(p => p.status === 'completed' && p.tlusty_success_method === 'cold_run').length;
const seed = pts.filter(p => p.status === 'completed' && p.tlusty_success_method === 'seed_step').length;
const synspec = pts.filter(p => psSlot(p) === 'synspec').length;
const failed = pts.filter(p => p.status === 'failed').length;
const baseFail = failed / total;
@@ -385,7 +396,7 @@ function renderPsConclusion(ctx, pts) {
});
findings.sort((a, b) => b.score - a.score);
const lines = [
`冷启动 ${cold} · 种子步进 ${seed} · 失败 ${failed} / 共 ${total}`,
`冷启动 ${cold} · 种子步进 ${seed} · SYNSPEC ${synspec} · 失败 ${failed} / 共 ${total}`,
...findings.slice(0, 3).map(f => f.text),
];
el.innerHTML = lines.map(l => `<span class="ps-concl-line">${escapeHtml(l)}</span>`).join('');
+56 -9
View File
@@ -1,4 +1,4 @@
/* · +
/* · + TLUSTY
*
* 从点表查看按钮Parallel Sets 点名列表 / 单点 drill-down 进入
* 面板 DOM 挂在 ctx.pointPanelElunmount 由控制器调用 closePointPanel 清理
@@ -8,7 +8,7 @@ import { fetchPointDetailApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import { setupFocusTrap, releaseFocusTrap } from '../../components/modal.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
escapeHtml, pointStatusBadge, pointMethodBadge, overallMethod,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs,
} from '../../utils/format.js';
import { isAbortError } from '../../utils/errors.js';
@@ -88,7 +88,7 @@ export async function openPointPanel(ctx, pointName) {
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)}`;
badges.innerHTML = `${pointStatusBadge(point.status)} ${pointMethodBadge(overallMethod(point))}`;
body.innerHTML = '';
body.appendChild(buildAttemptSection(attempts));
body.appendChild(buildConvSection(conv));
@@ -98,6 +98,48 @@ export async function openPointPanel(ctx, pointName) {
}
}
/** 阶段徽标:failed_stage = "tlusty"/"synspec";旧数据 NULL → 兜底 TLUSTY。 */
function attemptStageBadge(stage) {
const isSyn = stage === 'synspec';
const cls = isSyn ? 'syn' : 'cold';
const label = isSyn ? 'SYNSPEC' : 'TLUSTY';
return `<span class="method-badge ${cls}">${label}</span>`;
}
/**
* summary_jsonModelSummary推导尝试方法徽标Phase 6 起无 task_type 字段
* ModelSummary.seed 存在 种子步进热启动否则冷启动synspec-only 任务无 seed
* 标冷启动已知兜底Phase 7a 将改用策略派生口径精化
*/
function attemptMethodBadge(a) {
try {
const obj = a.summary_json ? JSON.parse(a.summary_json) : null;
if (obj && obj.seed) return '<span class="method-badge seed">种子步进</span>';
} catch {
/* 解析失败 → 按冷启动处理 */
}
return '<span class="method-badge cold">冷启动</span>';
}
/**
* summary_json 提取 synspec 错误摘要容错
* - 正常路径ModelSummary JSON synspec_error
* - 错误路径`{"error": ...}`reporter 失败上报 error 文本
* - 解析失败 / 无错误返回 null不展示
*/
function summarySynspecError(summaryJson) {
if (!summaryJson) return null;
try {
const obj = JSON.parse(summaryJson);
if (!obj || typeof obj !== 'object') return null;
if (obj.error) return String(obj.error);
if (obj.synspec_error) return String(obj.synspec_error);
} catch {
/* 解析失败 → 不展示摘要 */
}
return null;
}
function buildAttemptSection(attempts) {
const sec = document.createElement('section');
sec.className = 'point-section';
@@ -107,13 +149,18 @@ function buildAttemptSection(attempts) {
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 synErr = summarySynspecError(a.summary_json);
const errLine = (!ok && synErr)
? `<div class="attempt-synspec-err" title="${escapeHtml(synErr)}">synspec: ${escapeHtml(synErr.length > 48 ? synErr.slice(0, 48) + '…' : synErr)}</div>`
: '';
const resultHtml = ok
? '<span class="activity-result ok">成功</span>'
: `<span class="activity-result fail">${escapeHtml(a.status)}</span>`;
: `<span class="activity-result fail">${escapeHtml(a.status)}</span>${errLine}`;
return `
<tr>
<td class="tabular-num">${i + 1}</td>
<td>${pointMethodBadge(a.task_type)}</td>
<td>${attemptMethodBadge(a)}</td>
<td>${attemptStageBadge(a.failed_stage)}</td>
<td>${seedHtml}</td>
<td class="tabular-num">${fmtRelc(a.max_relc)}</td>
<td>${resultHtml}</td>
@@ -121,13 +168,13 @@ function buildAttemptSection(attempts) {
<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>';
}).join('') || '<tr><td colspan="9" 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>
<tr><th scope="col">#</th><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>
@@ -149,7 +196,7 @@ function buildConvSection(conv) {
sec.className = 'point-section';
if (!conv) {
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<h3 class="point-section-title">TLUSTY 收敛链诊断</h3>
<p class="text-hint">诊断文件不可用conv.json 缺失或解析失败</p>`;
return sec;
}
@@ -177,7 +224,7 @@ function buildConvSection(conv) {
? `<span class="activity-result fail" title="${escapeHtml(conv.synspec_error)}">${escapeHtml(conv.synspec_error)}</span>`
: '—';
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<h3 class="point-section-title">TLUSTY 收敛链诊断</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>
+12 -8
View File
@@ -7,7 +7,7 @@
import { fetchWorkflowPointsApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
escapeHtml, pointStatusBadge, pointMethodBadge, overallMethod,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs, csvEscape,
} from '../../utils/format.js';
import { isAbortError, logError } from '../../utils/errors.js';
@@ -41,13 +41,15 @@ export function renderPointsTab(ctx) {
<option value="pending">未开始</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="converged">收敛</option>
<option value="completed">完成</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>
<!-- 光谱专用点过滤器synspec-onlytlusty 禁用服务端映射 tlusty IS NULL AND synspec IS NOT NULL -->
<option value="synspec_only">SYNSPEC 专用</option>
</select>
<select data-wf-filter="wave" aria-label="按波次过滤">
<option value="">全部波次</option>${waveOpts}
@@ -125,14 +127,14 @@ function renderPointsRows(tbody, points) {
const updated = [];
points.forEach(p => {
const name = escapeHtml(p.name);
const sig = `${p.status}|${p.success_method || ''}|${p.attempt_count}`;
const sig = `${p.status}|${overallMethod(p) || ''}|${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 rescued = p.status === 'completed' && 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>
@@ -141,7 +143,7 @@ function renderPointsRows(tbody, points) {
<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>${pointMethodBadge(overallMethod(p))}</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>
@@ -215,11 +217,13 @@ export async function exportPointsCsv(ctx) {
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'];
// CSV 的"收敛方法"列导出整体归因 overallMethod = tlusty ?? synspecP9 拆分后派生值,
// 已含收敛归因且覆盖 synspec-only;列名 overall_method 避免重新合并两阶段值域)。
const header = ['name','teff','logg','loghe','logc','logn','logo','cno_sum','wave','status','overall_method','attempt_count','last_max_relc','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.status, overallMethod(p) ?? '', p.attempt_count ?? '',
p.last_max_relc ?? '', 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');
+2 -1
View File
@@ -78,7 +78,8 @@ function startScopedPolling() {
poller = createPoller(async () => {
const statsOk = await refreshStats(ctx.name);
// 同步引擎面板的操作按钮状态(启动/暂停可用性随工作流状态变化)。
refreshEnginePanel();
// 透传 ctx.abortCtl.signal:卸载后中断在途详情响应,避免改写已丢弃的 panelState。
refreshEnginePanel(ctx.abortCtl?.signal);
if (ctx.activeTab === 'points') await refreshPoints(ctx);
return statsOk; // stats 拉取成败决定退避
}, { baseMs: 5000, maxMs: 60000 });
+19 -4
View File
@@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
import {
escapeHtml, parseIso, parseTsMs, fmtTime, fmtDateTime, fmtFullTs,
fmtDuration, fmtDur, fmtRelc, csvEscape, fmtClock,
statusBadge, pointStatusBadge, pointMethodBadge,
statusBadge, pointStatusBadge, pointMethodBadge, overallMethod,
} from '../src/utils/format.js';
// ===== escapeHtml =====
@@ -142,7 +142,7 @@ test('statusBadge 覆盖全部后端工作流状态', () => {
});
test('pointStatusBadge 覆盖网格点状态', () => {
assert.equal(pointStatusBadge('converged'), '<span class="status-badge online">已收敛</span>');
assert.equal(pointStatusBadge('completed'), '<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>');
@@ -150,10 +150,10 @@ test('pointStatusBadge 覆盖网格点状态', () => {
assert.equal(pointStatusBadge('???'), '<span class="status-badge secondary">未知</span>');
});
test('pointMethodBadge 识别收敛途径(与后端 success_method 契约对齐)', () => {
test('pointMethodBadge 识别收敛途径(与后端整体归因契约对齐)', () => {
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")原样展示
// 其它策略名(synspec-only 归因的光谱策略,如 "standard")原样展示
assert.equal(pointMethodBadge('standard'), '<span class="method-badge syn">standard</span>');
// 含 HTML 元字符须转义(XSS 防护)
assert.equal(
@@ -163,3 +163,18 @@ test('pointMethodBadge 识别收敛途径(与后端 success_method 契约对
assert.equal(pointMethodBadge(null), '<span class="text-hint">—</span>');
assert.equal(pointMethodBadge(''), '<span class="text-hint">—</span>');
});
test('overallMethod 派生整体归因(tlusty 阶段优先,synspec-only 退回光谱策略)', () => {
// 正常双阶段点:TLUSTY 阶段策略
assert.equal(overallMethod({ tlusty_success_method: 'seed_step' }),
'seed_step');
// synspec-only 点:tlusty 为 NULL,退回光谱策略
assert.equal(overallMethod({ tlusty_success_method: null, synspec_success_method: 'standard' }),
'standard');
// 双阶段都落库时优先大气侧
assert.equal(overallMethod({ tlusty_success_method: 'cold_run', synspec_success_method: 'standard' }),
'cold_run');
// 两者皆空 → null`??` 保留右侧 null
assert.equal(overallMethod({ tlusty_success_method: null, synspec_success_method: null }),
null);
});
+35
View File
@@ -117,6 +117,41 @@ test('triggerNow() 立即触发一次并续排', async () => {
stub.restore();
});
test('triggerNow() 与在途 schedule 不并发执行 fninFlight 守卫)', async () => {
const stub = installDocStub();
const timers = captureTimers();
let active = 0; // 当前正在执行的 fn 数(应恒 ≤1)
let maxActive = 0;
let calls = 0;
// fn 是慢异步:用 gate 控制其完成时机,确保 triggerNow 落在它在途时。
let resolveFn = null;
const fn = () => {
active++;
maxActive = Math.max(maxActive, active);
calls++;
return new Promise((resolve) => { resolveFn = resolve; });
};
const p = createPoller(fn, { baseMs: 5000 });
const startP = p.start(); // 触发首次 schedulefn 在途,停在 await
// 让 start 的 schedule 进入 fn()——通过一次微任务边界。
await Promise.resolve();
await Promise.resolve();
// 此时 fn 在途(calls=1active=1)。triggerNow 应受 inFlight 守卫直接 return
// 不再起一个新的 schedule 并发跑 fn。
const triggerP = p.triggerNow();
await Promise.resolve();
await Promise.resolve();
// 完成 fn(在途的 schedule 才会推进)。
resolveFn(true);
await Promise.all([startP, triggerP]);
active = 0; // fn 已完成
assert.equal(calls, 1, 'triggerNow 不得并发触发第二次 fninFlight 守卫)');
assert.equal(maxActive, 1, 'fn 不得并发执行(maxActive 应为 1');
p.stop();
timers.restore();
stub.restore();
});
test('退避上限 maxMs 生效(长时间连续失败不无限增长)', async () => {
const stub = installDocStub();
const timers = captureTimers();
+1 -1
View File
@@ -30,7 +30,7 @@ function ctx(name, points = [], ts = null) {
test('savePsCache 写入 JSON 并可由 restorePsCache 恢复', () => {
store.clear();
const before = Date.now();
const c = ctx('sdB_cno', [{ name: 'p1', teff: 20000, status: 'converged' }]);
const c = ctx('sdB_cno', [{ name: 'p1', teff: 20000, status: 'completed' }]);
savePsCache(c);
// 持久化 key 存在
+57 -30
View File
@@ -17,17 +17,17 @@ import {
} from '../src/utils/yamlStage.js';
test('defaultStagetlusty 默认 [cold_run, seed_step]synspec 默认 [standard]', () => {
assert.deepEqual(defaultStage('tlusty'), {
assert.deepEqual(defaultStage('tlusty_stage'), {
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
});
assert.deepEqual(defaultStage('synspec'), {
assert.deepEqual(defaultStage('synspec_stage'), {
enabled: true, policy: 'skip_converged', strategies: ['standard'],
});
});
test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块(审查 #4)', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged',
'# 这是顶层注释,应终止 tlusty 块',
@@ -35,7 +35,7 @@ test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块
' enabled: false',
' policy: force_recompute',
].join('\n');
const t = extractTopBlock(yaml, 'tlusty');
const t = extractTopBlock(yaml, 'tlusty_stage');
assert.equal(t.lines.length, 3, 'tlusty 块应只含自身 3 行,不含顶层注释与后续块');
assert.ok(!t.lines.some(l => l.includes('synspec_stage')), '不吞入 synspec_stage');
@@ -46,24 +46,51 @@ test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块
test('extractTopBlock:空行属块体,缩进注释属块体', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
'',
' # 缩进注释,属块体',
' policy: skip_converged',
].join('\n');
const t = extractTopBlock(yaml, 'tlusty');
const t = extractTopBlock(yaml, 'tlusty_stage');
assert.equal(t.lines.length, 5);
});
test('extractTopBlockkey 含正则元字符按字面匹配,`.` 不通配(防御性转义)', () => {
// 用不会真实出现的 `te.lusty` 验证 `.` 被转义:若未转义,`teXlusty:` 会被误匹配。
const yaml = [
'teXlusty:',
' enabled: true',
'te.lusty:',
' enabled: false',
].join('\n');
// 字面匹配 te.lusty → 命中第三行,而非被通配成 teXlusty。
const t = extractTopBlock(yaml, 'te.lusty');
assert.ok(t, '含 `.` 的 key 应能定位其自身块');
assert.equal(t.startIdx, 2, '应命中字面 te.lusty: 行,而非被通配成 teXlusty:');
assert.ok(!t.lines.some(l => l.includes('teXlusty')), '不应误匹配通配目标 teXlusty');
});
test('extractTopBlockkey 含 `+` 等其它元字符仍按字面匹配', () => {
// `+` 在正则里是量词;未转义会抛 SyntaxError 或误匹配。此处验证按字面定位。
const yaml = [
'a+b:',
' enabled: true',
].join('\n');
const t = extractTopBlock(yaml, 'a+b');
assert.ok(t, '含 `+` 的 key 应按字面匹配而不抛错');
assert.equal(t.startIdx, 0);
assert.equal(t.lines.length, 2);
});
test('parseStageFromYaml:行内注释不污染 policy 解析(审查 #5', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged # 旧值 force_recompute 已废弃',
' strategies: [cold_run, seed_step]',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
const t = parseStageFromYaml(yaml, 'tlusty_stage');
assert.equal(t.policy, 'skip_converged', '应取真值而非注释里的 force_recompute');
assert.equal(t.enabled, true);
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
@@ -71,12 +98,12 @@ test('parseStageFromYaml:行内注释不污染 policy 解析(审查 #5',
test('parseStageFromYaml:整行注释里的 policy 不被当真值(审查 #5', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' # policy: force_recompute <- 注释掉的旧值',
' policy: skip_failed',
' strategies: [cold_run]',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
const t = parseStageFromYaml(yaml, 'tlusty_stage');
assert.equal(t.policy, 'skip_failed', '应跳过整行注释,取真值 skip_failed');
});
@@ -93,20 +120,20 @@ test('parseStageFromYamlenabled 行内注释', () => {
test('parseStageFromYaml:块式 strategies 列表', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged',
' strategies:',
' - cold_run',
' - seed_step',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
const t = parseStageFromYaml(yaml, 'tlusty_stage');
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
});
test('parseStageFromYaml:块不存在返回 null', () => {
const yaml = 'grid:\n teff: [20000]\n';
assert.equal(parseStageFromYaml(yaml, 'tlusty'), null);
assert.equal(parseStageFromYaml(yaml, 'tlusty_stage'), null);
});
test('resolveTlustyFromYaml:无 tlusty 块时按 seed_step_fallback 推断(审查 #2 修复)', () => {
@@ -124,16 +151,16 @@ test('resolveTlustyFromYaml:无 tlusty 块时按 seed_step_fallback 推断(
// seed_step_fallback: true → 默认链 [cold_run, seed_step]。
const yamlTrue = 'seed_step_fallback: true\n';
assert.deepEqual(resolveTlustyFromYaml(yamlTrue), defaultStage('tlusty'));
assert.deepEqual(resolveTlustyFromYaml(yamlTrue), defaultStage('tlusty_stage'));
// 缺省(无该字段)→ 默认链。
const yamlAbsent = 'grid:\n teff: [20000]\n';
assert.deepEqual(resolveTlustyFromYaml(yamlAbsent), defaultStage('tlusty'));
assert.deepEqual(resolveTlustyFromYaml(yamlAbsent), defaultStage('tlusty_stage'));
});
test('resolveTlustyFromYaml:有 tlusty 块时优先块(不受旧字段干扰)', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: force_recompute',
' strategies: [cold_run]',
@@ -146,7 +173,7 @@ test('resolveTlustyFromYaml:有 tlusty 块时优先块(不受旧字段干扰
test('resolveTlustyFromYaml:注释行不误判 seed_step_fallback', () => {
const yaml = '# seed_step_fallback: false(注释,非真实配置)\ngrid:\n teff: [20000]\n';
assert.deepEqual(resolveTlustyFromYaml(yaml), defaultStage('tlusty'),
assert.deepEqual(resolveTlustyFromYaml(yaml), defaultStage('tlusty_stage'),
'注释行被忽略,按缺省推断默认链');
});
@@ -155,7 +182,7 @@ test('applyStageBlocks:替换已存在块,保留其他配置(往返保真
'grid:',
' teff: [20000]',
'timeout_sec: 7200',
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged',
' strategies: [cold_run]',
@@ -172,22 +199,22 @@ test('applyStageBlocks:替换已存在块,保留其他配置(往返保真
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\]/);
assert.match(out, /tlusty_stage:\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:/);
const out = applyStageBlocks(yaml, defaultStage('tlusty_stage'), defaultStage('synspec_stage'));
assert.match(out, /tlusty_stage:/);
assert.match(out, /synspec_stage:/);
assert.match(out, /teff: \[20000\]/, '原配置保留');
});
test('applyStageBlocks:两块间有顶层注释时不互相破坏(审查 #4 回归)', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged',
' strategies: [cold_run, seed_step]',
@@ -200,29 +227,29 @@ test('applyStageBlocks:两块间有顶层注释时不互相破坏(审查 #4
// 修改 tlusty 不应破坏 synspec_stage(旧实现会把注释+synspec 吞进 tlusty 块后覆盖丢失)。
const out = applyStageBlocks(yaml,
{ enabled: false, policy: 'skip_converged', strategies: ['cold_run'] },
defaultStage('synspec'),
defaultStage('synspec_stage'),
);
assert.match(out, /synspec_stage:\n enabled: true/, 'synspec_stage 块必须存活');
assert.match(out, /tlusty:\n enabled: false/, 'tlusty 块已更新');
assert.match(out, /tlusty_stage:\n enabled: false/, 'tlusty 块已更新');
});
test('serializeStageBlockflow 序列输出格式', () => {
const s = serializeStageBlock('tlusty', {
const s = serializeStageBlock('tlusty_stage', {
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]');
assert.equal(s, 'tlusty_stage:\n enabled: true\n policy: skip_converged\n strategies: [cold_run, seed_step]');
});
test('端到端往返:解析 → 序列化 → 再解析 保持语义', () => {
const orig = [
'tlusty:',
'tlusty_stage:',
' 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');
const parsed = parseStageFromYaml(orig, 'tlusty_stage');
const serialized = serializeStageBlock('tlusty_stage', parsed);
const reparsed = parseStageFromYaml(serialized, 'tlusty_stage');
assert.deepEqual(parsed, reparsed, '往返保真');
});