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:
+3
-10
@@ -9,16 +9,9 @@
|
||||
<!-- 字重精简: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>
|
||||
/* 首帧前落主题,避免 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>
|
||||
|
||||
@@ -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 不可用时回退媒体查询 */ }
|
||||
})();
|
||||
@@ -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(详情页数据源) =====
|
||||
|
||||
@@ -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() {
|
||||
// ===== 刷新与挂载 =====
|
||||
|
||||
/** 把一次拉取的详情数据填入 panelState(fetch + 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 }) {
|
||||
// mount(forceConfig=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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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}%`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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/canceled(7c 由 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>`;
|
||||
}
|
||||
|
||||
/** 网格点收敛途径徽章 HTML(success_method:cold_run / seed_step / 策略名)。 */
|
||||
/** 整体归因 = TLUSTY 阶段策略 ?? 光谱阶段策略(阶段归因列为空时退回另一侧)。
|
||||
* 正常双阶段点 → cold_run/seed_step;synspec-only 点 → 光谱策略(如 standard)。 */
|
||||
export function overallMethod(p) {
|
||||
return p.tlusty_success_method ?? p.synspec_success_method;
|
||||
}
|
||||
|
||||
/** 网格点收敛途径徽章 HTML(method:cold_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>';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -58,19 +58,29 @@ export function restorePsCache(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态轴档位(收敛拆冷启动/种子步进,物理意义不同:稳定区 vs 救回区)。
|
||||
/** 状态轴档位(收敛拆冷启动/种子步进/SYNSPEC,物理意义不同:稳定区 vs 救回区 vs 光谱重算)。
|
||||
* 顺序即图例/轴布局/每值内分段/色带绘制层叠的唯一来源,语义约定:
|
||||
* 已收敛(最受关注)置顶 → 进行中按任务进展方向(running→queued→pending)→ 失败沉底。
|
||||
* 色带绘制按此顺序:收敛色带先画(底层),失败最后画(最上层,数量少且颜色醒目)。 */
|
||||
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('');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + 阶段链诊断)
|
||||
/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + TLUSTY 收敛链诊断)
|
||||
*
|
||||
* 从点表「查看」按钮、Parallel Sets 点名列表 / 单点 drill-down 进入。
|
||||
* 面板 DOM 挂在 ctx.pointPanelEl(unmount 由控制器调用 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_json(ModelSummary)推导尝试方法徽标(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>
|
||||
|
||||
@@ -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-only:tlusty 禁用,服务端映射 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 ?? synspec(P9 拆分后派生值,
|
||||
// 已含收敛归因且覆盖 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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -117,6 +117,41 @@ test('triggerNow() 立即触发一次并续排', async () => {
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('triggerNow() 与在途 schedule 不并发执行 fn(inFlight 守卫)', 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(); // 触发首次 schedule(fn 在途,停在 await)
|
||||
// 让 start 的 schedule 进入 fn()——通过一次微任务边界。
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// 此时 fn 在途(calls=1,active=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 不得并发触发第二次 fn(inFlight 守卫)');
|
||||
assert.equal(maxActive, 1, 'fn 不得并发执行(maxActive 应为 1)');
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('退避上限 maxMs 生效(长时间连续失败不无限增长)', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
|
||||
@@ -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 存在
|
||||
|
||||
@@ -17,17 +17,17 @@ import {
|
||||
} from '../src/utils/yamlStage.js';
|
||||
|
||||
test('defaultStage:tlusty 默认 [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('extractTopBlock:key 含正则元字符按字面匹配,`.` 不通配(防御性转义)', () => {
|
||||
// 用不会真实出现的 `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('extractTopBlock:key 含 `+` 等其它元字符仍按字面匹配', () => {
|
||||
// `+` 在正则里是量词;未转义会抛 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('parseStageFromYaml:enabled 行内注释', () => {
|
||||
|
||||
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('serializeStageBlock:flow 序列输出格式', () => {
|
||||
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, '往返保真');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user