goal-task-model.js
29.1 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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
/**
* 目标·任务两级栏的纯逻辑(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'];
// ────────────────────────────────────────────────────────────────────────────
// 队列(27-P3C 批 3)
// ────────────────────────────────────────────────────────────────────────────
/**
* 排队位次徽标。
*
* **位次不在这里算**:口径(priority → seq → created_at)全仓库只有
* `server/services/task-scheduler.js#listQueue` 一个持有者,前端再排一遍就是第二个持有者
* —— `canDispatch` 那次事故就是这么来的。所以这里只读服务端随 taskView 吐回来的
* `queuePosition`(1-based),不读兄弟节点、不数数组下标。
*
* 读不到时显示 `排队 #?` 而不是悄悄退成「排队中」:那说明服务端这次没给位次(端点没吐 /
* 这条已经不在队里了),是个要有人看见的缺口,藏起来正是要消灭的那类静默。
*
* @returns {{text:string,title:string}|null} 非 queued 返回 null
*/
export function queueBadge(task) {
if (!task || task.status !== 'queued') return null;
const n = Number(task.queuePosition);
if (Number.isInteger(n) && n > 0) {
return { text: `排队 #${n}`, title: `队列第 ${n} 位(位次由服务端按 priority → seq → created_at 算)` };
}
return {
text: '排队 #?',
title: '服务端这次没有返回 queuePosition。位次口径只归调度器一处,前端不自己排,所以只能显示未知。',
};
}
/**
* 「上游已经不可能满足了」这个 blocked 的原因前缀 ——
* **逐字复述** `server/services/work-graph-db.js` 的 `UPSTREAM_BLOCK_PREFIX`。
*
* 为什么必须复述:`blocked_reason` 是一条编码过的字符串,要从里面取出「死掉的是哪个上游」
* 才能给出「强制放行」(删掉那条依赖)这个操作,也才能把 id 翻成人看得懂的任务名。
* 复述就必须有人钉住一致 —— `server/tests/unit/goal-task-model.test.js` 直接 diff 后端那个常量。
* 这条红了说明后端改了前缀:症状是界面上 blocked 的原因退成一串原文、「强制放行」永久置灰,
* 线上不会报任何错。
*/
export const UPSTREAM_BLOCK_PREFIX = 'upstream_failed:';
/**
* 拆 `blocked_reason`。认不出的原因**原样带走**(raw),绝不吞:
* 预算耗尽、入队做了一半(enqueue_incomplete:…)都会走到这里,把它们显示成空白等于
* 让一条卡住的任务看起来毫无理由地卡着。
*/
export function parseBlockedReason(reason) {
const raw = String(reason || '');
if (!raw) return null;
if (raw.startsWith(UPSTREAM_BLOCK_PREFIX)) {
return { kind: 'upstream', upstreamId: raw.slice(UPSTREAM_BLOCK_PREFIX.length), raw };
}
return { kind: 'other', upstreamId: '', raw };
}
/**
* blocked 行第二排的原因文案:`上游 #1 搭骨架 未完成`。
* 上游不在同 goal 的兄弟里(跨 goal / 已归档)就显示原始 id —— 同 dependsLabel 的理由。
* 没写原因的 blocked 返回 '',调用方退回状态标签(「挂起」)。
*/
export function blockedLabel(task, siblings = []) {
const p = parseBlockedReason(task && task.blockedReason);
if (!p) return '';
if (p.kind !== 'upstream') return p.raw;
const dep = siblings.find((t) => t.taskId === p.upstreamId);
return dep ? `上游 #${dep.seq} ${dep.title || dep.taskId} 未完成` : `上游 ${p.upstreamId} 未完成`;
}
/**
* 任务级重跑走 `POST /:cid/tasks/:taskId/rerun`(27-P3C §六.3,批 3 落地)。
*
* **刻意不叫 retry**:`POST /api/graph-runs/:id/nodes/:nodeId/retry` 是**节点级**重试
* (同一条 run 内把某个节点打回 ready)。任务级是新起一条 run、整张图从头来。
* 界面文案也按这条分:任务行的「重跑这个任务」vs 节点详情里的「重试这一步」。
*/
export const RERUN_ENDPOINT = (cid, taskId) => `/api/company/${cid}/tasks/${taskId}/rerun`;
/**
* task 行 `⋯` 菜单:**按状态只给做得成的操作**。
*
* 判据全部来自后端已经吐回来的字段(status / runId / blockedReason),不猜、不复述取值域。
* 做不成的那两个(重跑 / 重跑上游)以置灰 + title 形式留下,理由见 RERUN_UNAVAILABLE ——
* 「不给会报错的入口」指的是不给能点却必然 4xx 的按钮,不是把缺口藏起来。
*
* @returns {{key:string,label:string,title:string,danger?:boolean,disabled?:boolean}[]}
*/
export function taskActions(task) {
const t = task || {};
const acts = [];
if (t.status === 'running') {
acts.push({
key: 'stop',
label: '停止',
danger: true,
disabled: !t.runId, // 没有 run 就没有 /stop 可打,点了必 404
title: t.runId
? 'POST /api/graph-runs/:id/stop —— 软信号:不打断手头那个节点,到节点边界才落 stopped'
: '这条任务标着执行中却没有 runId,停不了;刷新后仍如此就是数据不一致,去看服务端日志',
});
}
if (t.status === 'queued') {
// manual 的任务**只有人点得动**(§五.2bis:调度器故意不碰它们,否则它会占着坑停在
// 第一个节点上等 /next,把后面所有任务无限期堵死)。auto 的也留着这个入口 ——
// 「我现在就要它跑」是合理诉求,抢不到坑服务端会回 409 + 当前位次,不会假装成功。
acts.push({
key: 'start',
label: '立即开始',
// **不在这里置灰**:能不能开始取决于「上游做完没」和「坑空没空」,两者都不在这一行的
// 数据里(兄弟任务的状态、别的 goal 占没占坑)。照前端手上这点信息去猜,就是又一个
// 复述服务端取值域的持有者。服务端会回 409 + 挡我的是谁 + 当前位次,比猜准得多。
title: 'POST /:cid/tasks/:id/start —— 抢一个执行坑。上游没做完 / 坑被占着会如实回 409,不会假装已开始',
});
acts.push({
key: 'cancel',
label: '取消排队',
danger: true,
title: 'POST /:cid/tasks/:id/cancel —— run 从 queued 撤成 stopped,任务收成「已取消」。'
+ '它还没开跑,所以不会有半截产物;行还在,归档才会从列表里消失',
});
}
if (t.status === 'blocked') {
const p = parseBlockedReason(t.blockedReason);
const upstream = p && p.kind === 'upstream' ? p.upstreamId : '';
acts.push({
key: 'rerun-upstream',
label: '重跑上游',
// 上游 id 从 blocked_reason 里解出来;解不出来(预算耗尽那种 blocked)就没有重跑对象。
// 置灰而不是藏起来:藏起来会让下一个人以为这里本来就没有这个操作
upstreamId: upstream,
disabled: !upstream,
title: upstream
? `重跑上游任务 ${upstream}。它一 completed,这条就自己回队列(blocked 是每轮 tick 重算的,不是不可逆状态)`
: `卡住的原因不是上游失败(${(p && p.raw) || '服务端没写原因'}),没有上游可重跑`,
});
acts.push({
key: 'release',
label: '强制放行',
// 判据 = **后端此刻真做得到的事**:`PATCH /:cid/tasks/:id {depends_on}` 的闸门在
// `work-graph-db.updateTask`,它对 draft / queued / blocked 都放行 —— 而这个动作只在
// blocked 时才出现,所以「状态」这一维恒成立,剩下的唯一条件就是「有一条依赖可删」。
// (批 3 复核抓到的原状:那道闸门只认 draft/queued,于是这个按钮在真正需要它的那一格
// 一定是亮的、请求一定 409 —— 「不给能点却必然 4xx 的入口」当时只是一句注释。)
disabled: !upstream || !(t.dependsOn || []).includes(upstream),
title: !upstream
? `卡住的原因不是上游失败(${(p && p.raw) || '服务端没写原因'}),没有可删的依赖 —— 放行不适用`
: (t.dependsOn || []).includes(upstream)
? `删掉「依赖 ${upstream}」这条边。服务端改完依赖会立刻重算一次队列,这条随即回到排队中`
: `原因指向 ${upstream},但这条任务的依赖里已经没有它了 —— 没什么可删的。`
+ '这种「原因过期」的 blocked 由服务端每轮调度自动解开(最迟 60s 的兜底扫描),等一下刷新即可',
});
// **保底出路**:上游被归档 / 被删号时,「重跑上游」打过去是 404、「强制放行」也可能无依赖可删,
// 于是这一行会一个能点的按钮都没有。取消对 blocked 一定走得通(服务端按 run 的真实状态
// 分三条路收尾),所以它必须在这里,否则界面上就是一条谁也动不了的死任务。
acts.push({
key: 'cancel',
label: '取消任务',
danger: true,
title: 'POST /:cid/tasks/:id/cancel —— 放弃这条挂起的任务,收成「已取消」。'
+ '行还留在列表里(归档才会消失),之后随时可以「重跑」',
});
}
if (t.status === 'draft' || TASK_TERMINAL.includes(t.status)) {
if (TASK_TERMINAL.includes(t.status)) {
acts.push({
key: 'rerun',
label: '重跑',
title: '新起一条 run,整张图从头来。旧 run 连同它的节点行留在历史里,随时点得开',
});
}
acts.push({ key: 'archive', label: '归档', danger: true, title: '从列表里移走(只盖 archived_at,不删数据)' });
}
return acts;
}
// ────────────────────────────────────────────────────────────────────────────
// 执行位(批 4:一家公司可以同时跑 max_parallel_tasks 条 root run)
// ────────────────────────────────────────────────────────────────────────────
/**
* 服务端给的「执行位」快照 → `{ max, used, free }`,给不出就是 `null`。
*
* **形状逐字沿用服务端唯一那一份**:`server/routes/graph-run.js#slotState` 返回
* `{ max, used, free, holders }`(409 `run-slot-busy` 的 body 与 `routes/company.js`
* 的 409 都从它取)。这里连 `free` 都**读**服务端的、不自己 `max - used` ——
* 一做减法就是第二个持有者,而 `slotState` 那边的 free 还带着「max 刚改小」的截断语义。
*
* 校验从严、给不出就返回 null:`max` 是 per-company 配置(`company.json.max_parallel_tasks`),
* 服务端没吐、吐了个字符串,都不能让界面凭空说出一个「3 个执行位」来。
* null 时所有文案退回**批 3 的原样**(只说在跑几个、不提总数),少说一句好过说错一句。
*
* ⚠️ **今天还没有任何端点把它吐给前端**:`slotState` 只喂 409 的 body,
* `GET /:cid/goals` 与 `GET /:cid/tasks` 都还没带 `slots`。所以现在走的恒是 null 那一支
* = 与批 3 完全一致。服务端补的时候直接把 `slotState(companyId)` 的返回值挂上去即可
* (holders 可留可去,这里不读),别在那条路上写第二份取值域。
*/
export function readSlots(raw) {
const s = raw || {};
// **只认真正的 number**,不走 Number() 转换:`Number(null)` / `Number('')` 都是 0,
// 于是一个字段缺失的响应会被读成「0 个执行位在用」并原样显示出去 —— 那正是本函数要挡的
// 「凭空说出一个数」。字符串数字同理:服务端吐 '3' 说明那一头的形状已经不对了,
// 该让文案退回批 3 原样并被人看见,而不是替它纠正。
const { max, used, free } = s;
if (![max, used, free].every(Number.isInteger)) return null;
if (max < 1 || used < 0 || free < 0) return null;
return { max, used, free };
}
/**
* 全公司在途统计,喂「新目标」表单那句提示。
* 只数后端给的 status,不推断位次 —— 一条**还没建出来**的任务排第几,谁都算不准
* (它的 priority 会决定它插在队伍的哪一段),说「排在第 M 位」就是编一个数字。
*/
export function queueStat(goals = []) {
const stat = { running: 0, queued: 0, blocked: 0 };
for (const g of goals) {
for (const t of g.tasks || []) {
if (t.status in stat) stat[t.status] += 1;
}
}
return stat;
}
/**
* 「新目标 · 全部下达」按钮上方那句话。
*
* 这是本批要消解的那个问题在界面上的落点:批 3 之前公司里只要有一条任务在跑,下达就是一个
* **死路**(按钮灰 / 409),用户唯一能做的是盯着等。现在下达=入队,所以这里要说清两件事:
* 队伍里现在有谁,以及「点下去之后它会怎么样」。
*
* **不编位次**:一条还没建出来的任务排第几谁都算不准(它的 priority 决定它插在队伍哪一段,
* 而 priority 只有服务端的 listQueue 说了算)。所以这里只报 running / queued / blocked 三个
* **服务端给的**数,位次留给下达之后每一行自己显示(queueBadge 读 queuePosition)。
* 写死一个「将排在第 M 位」就是前端自造取值域 —— canDispatch 那次事故正是这么开始的。
*
* blocked 单独报一句:它是「有东西卡住了、要人动手」,混在「在排队」里会让人以为它自己会好。
*
* @returns {string} 队里空空如也时返回 ''(没有队伍就没什么好说的)
*/
export function queueNoteText(stat, rawSlots) {
const s = stat || {};
const running = Number(s.running) || 0;
const queued = Number(s.queued) || 0;
const blocked = Number(s.blocked) || 0;
const slots = readSlots(rawSlots);
if (!running && !queued && !blocked && !(slots && slots.used)) return '';
const parts = [];
// 有 slots 就报「几分之几个执行位」——「1 个任务在跑」在 3 坑的公司里读起来像「满了」,
// 而它其实还空着 2 个,这是批 4 之后这句话唯一会骗人的地方。
//
// **两个数各归各的来源,不互相换算**:执行位用服务端的 slots.used(数的是活跃 root run,
// 含 ready/paused),在跑几条任务用 stat.running(数的是 task 行)。两者绝大多数时候相等,
// 但不是同一件事 —— 拿一个去顶另一个就是又一次「前端自己推导后端的取值域」。
if (slots) parts.push(`${slots.used}/${slots.max} 个执行位在用`);
else if (running) parts.push(`${running} 个任务在跑`);
// 两个数对不上时**两个都报**:藏掉一个就等于替服务端断言它们该相等
if (slots && running && running !== slots.used) parts.push(`${running} 个任务在跑`);
if (queued) parts.push(`${queued} 个在排队`);
if (blocked) parts.push(`${blocked} 个卡在上游`);
// 尾巴分两支:知道还有几个空位时就说「有空位」(这才是用户此刻真正在问的「我要等吗」),
// 不知道就退回批 3 原样。**不预测本次会不会立刻开跑**:那取决于 priority / 依赖 / 执行模式,
// 三样都只有服务端算得准,写死一句「马上开跑」就是编一个承诺。
const tail = slots && slots.free > 0
? `还空着 ${slots.free} 个执行位。「全部下达」照常可点:本次的任务会进同一个队列,`
+ '有空位就会被调度器直接拉起来 —— 确切位次由服务端算,下达后显示在左栏每一行上。'
: '「全部下达」照常可点:本次的任务会进同一个队列,'
+ '排在它们后面,坑一空就自动开跑 —— 确切位次由服务端算,下达后显示在左栏每一行上。';
return `当前${parts.join('、')}。${tail}`;
}
/**
* 选中的这条 task 有没有一张**画得出来**的 run。
*
* `queued` 的 task 在库里是有 `current_run_id` 的(出队认领要靠 `WHERE current_run_id=?` 决胜),
* 但那条 run 还是 `run_status='queued'`:它一个节点都没点亮,控制按钮一个都不可用,
* 面板也不在轮询名单里。把它交给 GraphRunPane 画出来的是一块「看着活着、其实全灰、还不刷新」
* 的死面板,没有任何一处能告诉人「它在排队」。所以排队中一律退回 org graph **定义**视图,
* 由 pendingRunHint 说清它排在第几 —— 这才是用户此刻唯一想知道的事。
*/
export function hasStartedRun(task) {
const t = task || {};
return Boolean(t.runId) && t.status !== 'queued';
}
/**
* 从 dispatch 响应里认出「把选中态挪到哪条 task」。
*
* 批 3 之后一次下达会把多条 task 一起入队,响应形状由服务端定;这里**只认它明确给出的
* taskId**,三种都认不出就返回 '',调用方保持选中态不动。绝不从别处猜一条出来:
* 猜错就是用户点完「下达」看到的是另一条任务的图,而且没有任何报错。
*/
export function dispatchedTaskId(d) {
const r = d || {};
return (r.started && r.started.taskId)
|| (Array.isArray(r.queued) && r.queued.length && (r.queued[0].taskId || r.queued[0]))
|| r.taskId
|| '';
}
/**
* 选中一条还没起过 run 的 task 时,org 栏顶上那句话。
* 排队中的那条要说清「前面还有几个」—— 这是「为什么点了下达却什么都没发生」的唯一答案。
*
* 批 4 之后位次单独一个数**答不了「我还要等吗」**:3 个执行位的公司里排第 3 位是「马上就跑」,
* 1 个执行位的公司里排第 2 位就是「要等一整轮」。所以 slots 给得出来时把空位一起说了。
* `slots` 给不出(服务端还没吐这个字段)就退回批 3 原样 —— 见 readSlots 上那段。
*
* **manual 的排队任务单独一句**:调度器只出队 `execution_mode==='auto'` 的
* (`server/services/task-scheduler.js#tickInner`,§五.2bis 明写它故意不碰 manual),
* 所以对一条 manual 任务说「有空位就自动开跑」是纯粹的谎 —— 它会一直排在那里,
* 直到有人点 ⋯ 菜单里的「立即开始」。这一条与并行无关,是批 3 就在的,批 4 顺手补上。
*/
export function pendingRunHint(task, rawSlots) {
const t = task || {};
const tail = '下面是 org graph 定义本身,节点状态一律 pending。';
if (t.status !== 'queued') return `还没有起过 run(${taskStyle(t.status).label}),${tail}`;
const slots = readSlots(rawSlots);
const free = slots ? slots.free : null;
// 手动模式:空位有多少都不会让它自己动起来,先把这件事说清楚
if (t.mode === 'manual') {
return '排队中。这条是「手动」模式,调度器不会自动拉起它'
+ `(它只出队自动模式的任务${free ? `,${free} 个执行位空着也一样` : ''});`
+ `用任务行的 ⋯ 菜单「立即开始」抢一个执行位,${tail}`;
}
const n = Number(t.queuePosition);
if (!Number.isInteger(n) || n <= 0) return `排队中(服务端没给位次),${tail}`;
if (slots && n <= free) {
return `排在第 ${n} 位,${slots.used}/${slots.max} 个执行位在用、还空着 ${free} 个 ——`
+ ` 下一轮调度就会被拉起来(最迟 60s 的兜底扫描),${tail}`;
}
if (slots) {
// 「还要等几个」= 还差几个执行位腾出来(n - free),不是「前面还有几个任务」(n-1):
// 空位会先被前 free 条占掉,剩下的每腾出一个坑才轮下去一位。free=0 时这句就是「等 n 个」
return `排在第 ${n} 位,${slots.used}/${slots.max} 个执行位在用、没有空位 ——`
+ ` 还要等 ${n - free} 个执行位腾出来才轮到(按服务端的队列口径),${tail}`;
}
if (n === 1) return `排在队首,前面的任务一收敛就自动开跑,${tail}`;
return `排队中,前面还有 ${n - 1} 个任务,${tail}`;
}
/**
* 墙钟: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,但不能依赖调用方的顺序 ——
* 轮询回来的数组顺序变了就会让选中态莫名其妙跳一次。
*
* 批 4(一家公司可以同时跑多条)之后 running 会有 N 条,这里挑「最新那条」。
* **不会因此抖动**:排序只看 createdAt,与谁先跑完无关;而且调用方只在选中态失效时才问一次
* (`GraphConsole.ensureSelection`),选中之后 20s 轮询不会重挑。
* 上限:N 条 createdAt 完全相同时顺序由 localeCompare 的稳定性决定 —— 那种并列本身就没有
* 「更该选哪条」的答案,不值得为它引入第二个排序键。
*/
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;
}