goal-task-model.js
8.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
/**
* 目标·任务两级栏的纯逻辑(27-P3C §七)。
*
* 为什么单独一个 .js:.vue 里的逻辑没人跑得到。
* 状态映射 / 排序 / 默认选中这三件事一旦错了都是**静默错**(画错颜色、选错任务、切 task 不清栈),
* 所以全部抽到这里,由 `server/tests/unit/goal-task-model.test.js` 跑真测试
* (**不要**再写一个 .check.js —— 那种文件根 vitest.config.js 的 include 覆盖不到、
* package.json 也没有脚本引它,写了等于没人跑;测试直接 import 本文件即可,
* 手法同 `graph-console-node-signals.test.js` 之于 node-signals.js)。
*
* 色板**沿用 WorkGraphCanvas 的 .dot 与 GraphRunPane 的 .run-status**,一个新颜色都不造:
* 左栏那个点和画布上那个点说的是同一件事的两级,颜色对不上会让人以为是两套状态。
* running #0069d9 / success #1a9c4b / fail #d33 / blocked #e08a00 / ready #7aa7dd / pending #bbb
*/
/** task 状态 → 圆点样式 + 中文标签。hollow = 空心圈(草稿:还没有任何东西发生) */
export const TASK_STYLE = {
draft: { color: '#bbb', hollow: true, label: '草稿' },
// queued 是批 3(调度器)的状态,这里先给好样式:批 3 落地时列表不用再改一遍颜色。
queued: { color: '#7aa7dd', hollow: false, label: '排队中' },
running: { color: '#0069d9', hollow: false, label: '执行中', pulse: true },
completed: { color: '#1a9c4b', hollow: false, label: '已完成' },
failed: { color: '#d33', hollow: false, label: '失败' },
blocked: { color: '#e08a00', hollow: false, label: '挂起' },
cancelled: { color: '#bbb', hollow: false, label: '已取消' },
};
/** goal 状态 → 圆点样式 + 中文标签。planning 借 running 的蓝并且脉冲:它是个真会持续几十秒的状态 */
export const GOAL_STYLE = {
draft: { color: '#bbb', hollow: true, label: '草稿' },
planning: { color: '#0069d9', hollow: false, label: '拆分中', pulse: true },
active: { color: '#0069d9', hollow: false, label: '进行中' },
done: { color: '#1a9c4b', hollow: false, label: '已完成' },
failed: { color: '#d33', hollow: false, label: '失败' },
cancelled: { color: '#bbb', hollow: false, label: '已取消' },
archived: { color: '#bbb', hollow: true, label: '已归档' },
};
const FALLBACK = { color: '#bbb', hollow: true, label: '—' };
/** 认不出的状态一律退到灰空心 + 原文,绝不 undefined —— 后端加了新状态时列表照样画得出来 */
export function taskStyle(status) {
return TASK_STYLE[status] || { ...FALLBACK, label: String(status || '—') };
}
export function goalStyle(status) {
return GOAL_STYLE[status] || { ...FALLBACK, label: String(status || '—') };
}
/** 终态:不再变化,墙钟停在 finishedAt */
export const TASK_TERMINAL = ['completed', 'failed', 'cancelled'];
/**
* 墙钟:startedAt → (finishedAt | now)。没开跑返回 ''(第二排就少一段,不显示 0min)。
* 负数 / 解析不出来也返回 '' —— 宁可少一个字段,不要显示「-3min」。
*/
export function elapsedLabel(task, nowMs = Date.now()) {
if (!task || !task.startedAt) return '';
const t0 = Date.parse(task.startedAt);
if (!Number.isFinite(t0)) return '';
const t1 = task.finishedAt ? Date.parse(task.finishedAt) : nowMs;
const ms = (Number.isFinite(t1) ? t1 : nowMs) - t0;
if (!Number.isFinite(ms) || ms < 0) return '';
const m = Math.floor(ms / 60000);
return m < 60 ? `${m}min` : `${Math.floor(m / 60)}h${m % 60}min`;
}
/**
* task 行第二排:`auto · 3/7 · 12min`。
* progress 为 null = 还没起 run 或 run 还没有节点行 → 整段不出现(后端就是这么约定的:
* 「据此画未开始,而不是 0/0」),不要自己补一个 0/0 出来。
*/
export function taskSubtitle(task, nowMs = Date.now()) {
if (!task) return '';
const parts = [];
if (task.mode) parts.push(task.mode);
if (task.progress && task.progress.total) parts.push(`${task.progress.done || 0}/${task.progress.total}`);
const el = elapsedLabel(task, nowMs);
if (el) parts.push(el);
return parts.join(' · ');
}
/** goal 行右侧:`3/5 任务完成`。零 task 的 goal(拆分中 / 空草稿)不显示这段 */
export function goalProgressLabel(goal) {
const tasks = (goal && goal.tasks) || [];
if (!tasks.length) return '';
const done = tasks.filter((t) => t.status === 'completed').length;
return `${done}/${tasks.length} 任务完成`;
}
/**
* `⇢` 的 hover 文案:「等 T1 完成」。
* 只认同 goal 内的兄弟 task;引用到已归档 / 已删的 id 时原样显示 id,不静默吞掉 ——
* 「依赖一个不存在的 task」正是 dispatch 会 409 的原因,藏起来只会让人查不到。
*/
export function dependsLabel(task, siblings = []) {
const deps = (task && task.dependsOn) || [];
if (!deps.length) return '';
const byId = new Map(siblings.map((t) => [t.taskId, t]));
const names = deps.map((id) => {
const t = byId.get(id);
if (!t) return id;
return `#${t.seq} ${t.title || id}`;
});
return `等 ${names.join('、')} 完成`;
}
/**
* 默认选中:最近一条 running 的 task,没有就最近创建的 task。
* 「最近」按 createdAt 倒序;goals 列表本身已经是 created_at DESC,但不能依赖调用方的顺序 ——
* 轮询回来的数组顺序变了就会让选中态莫名其妙跳一次。
*/
export function pickDefaultSelection(goals = []) {
const all = [];
for (const g of goals) for (const t of g.tasks || []) all.push({ goal: g, task: t });
if (!all.length) return null;
const byNewest = (a, b) => String(b.task.createdAt || '').localeCompare(String(a.task.createdAt || ''));
const running = all.filter((x) => x.task.status === 'running').sort(byNewest);
const pick = running[0] || all.slice().sort(byNewest)[0];
return { goalId: pick.goal.goalId, taskId: pick.task.taskId };
}
/** 在整棵树里按 taskId 找一条 task(连它的 goal 一起给回来,选中态两个都要) */
export function findTask(goals = [], taskId) {
if (!taskId) return null;
for (const g of goals) {
for (const t of g.tasks || []) if (t.taskId === taskId) return { goal: g, task: t };
}
return null;
}
/**
* run 收敛态 → task 状态:**逐字复述** `server/services/task-settle.js` 的 `RUN_TO_TASK_STATUS`。
*
* 为什么这里必须是「逐字」而不是「差不多」:前端复述一份后端的取值域、还自作主张多写一条,
* 正是 `canDispatch` 那次事故的形态(前端多认一个状态 → 公司失败一次后再也下不了任务)。
* 后端表改了而这里没跟,浏览器就会把一个已经收敛的 task 画成认不出的状态,而且没有任何报错。
* 所以两张表的一致性由 `server/tests/unit/goal-task-model.test.js` 直接 diff 钉住 ——
* 后端改表这条测试会红,并报出差在哪一条。
*/
export const RUN_TERMINAL_TO_TASK_STATUS = {
completed: 'completed',
failed: 'failed',
stopped: 'cancelled', // 人工停止不是执行失败,照抄 'stopped' 会画成认不出的状态
blocked: 'blocked',
};
/**
* 前端实际用的表 = 后端那 4 条 + `running`。
*
* 多出来的这条**不是复述后端**,而是前端独有的需求,所以显式拼在这里而不是混进上面那张表:
* 后端的 settleRootRun 只在 run 收敛时被调用,它没有理由认识 `running`;而 SSE 是全程推的,
* 一条正在跑的 run 也要能把行画成「执行中」(库里那行确实在 launchTaskRun 时就写成 running 了,
* 这条目前只是兜底,但推流早于轮询到达时它就是唯一的信息来源)。
*/
const RUN_TO_TASK_STATUS = { ...RUN_TERMINAL_TO_TASK_STATUS, running: 'running' };
/**
* SSE 快照 → 选中 task 行的增量补丁(§七.3「有 SSE 就只更新那一行」)。
*
* 只回**看得出来的那几个字段**:状态走上面那张表,进度用节点行数算。
* run 还在 ready/planning 时不给 status —— 那时 task 行在库里是 running,回一个 'ready' 会把
* 一个正在跑的任务画成认不出的状态。
*/
export function runStateToTaskPatch(state) {
if (!state || !state.run) return null;
const patch = {};
const mapped = RUN_TO_TASK_STATUS[state.run.run_status];
if (mapped) patch.status = mapped;
const nodes = state.nodes || [];
if (nodes.length) {
patch.progress = {
total: nodes.length,
done: nodes.filter((n) => n.status === 'success' || n.status === 'skipped').length,
};
}
return Object.keys(patch).length ? patch : null;
}