goal-task-model.js 8.57 KB
/**
 * 目标·任务两级栏的纯逻辑(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;
}