/** * 目标·任务两级栏的纯逻辑(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: '已取消' }, // 46 号文档 Phase 4:父任务被 subtask-planning 拆出子任务后,自己那条完整 org graph // 被取消,终态改由子任务汇总决定——业务语义上父任务仍"活着",只是执行体换了, // 复用 running 同款蓝色+脉冲,不新造一个颜色。 decomposed:{ color: '#0069d9', hollow: false, label: '拆分执行中', pulse: true }, }; /** 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} 任务完成`; } /** * 46 号文档 Phase 5:`decomposed` 状态的父任务旁边显示"N/M 子任务完成"——只读展示, * 不做交互(认领/拖拽留给以后)。`siblings` 传同一个 goal 下的兄弟任务(跟 * `parentTitle`/`blockedLabel` 同款用法),子任务在物化时就是同一个 goal_id, * 不需要跨 goal 查。 */ export function decomposedProgressLabel(task, siblings = []) { if (!task || task.status !== 'decomposed') return ''; const children = siblings.filter((t) => t.parentTaskId === task.taskId && t.subtaskKind === 'decomposed_work'); if (!children.length) return ''; const done = children.filter((t) => t.status === 'completed').length; return `${done}/${children.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; }