diff --git a/frontend/src/lib/status.ts b/frontend/src/lib/status.ts index 96a6eb16..b91c04fa 100644 --- a/frontend/src/lib/status.ts +++ b/frontend/src/lib/status.ts @@ -183,10 +183,11 @@ export function statusLabel(status: string | null | undefined, opts?: StatusOpts /** * 运行列表的状态筛选用:按显示顺序排好的运行状态。 * 这是显示用的码,不能直接当查询参数——waiting / held 在后端都是 interrupted, - * 查询前用 serverStatusOf 换回去。 + * rejected 在后端是 cancelled(+ end_reason=rejected),查询前用 serverStatusOf 换回去。 + * 被驳回单独一项、紧跟在已取消后面:不在这里的码,分段、计数、搜索词表里都没有它,被驳回的运行只剩「全部」里看得到 */ export const RUN_STATUS_ORDER: StatusCode[] = [ - 'running', 'waiting', 'held', 'failed', 'succeeded', 'cancelled', 'queued', + 'running', 'waiting', 'held', 'failed', 'succeeded', 'cancelled', 'rejected', 'queued', ] /** @@ -194,7 +195,7 @@ export const RUN_STATUS_ORDER: StatusCode[] = [ * * waiting、held、suspended 在后端都是 interrupted:按它查回来的是两种混在一起, * 要再用 resolveStatus(run.status, {pendingApproval: hasPendingApproval(approvals, - * run.id)}) 在前端分开。节点才有的状态(idle / skipped / blocked / unreached) + * run.id)}) 在前端分开。rejected 同理查 cancelled,回来按 end_reason 分开(被驳回 / 手动停止)。节点才有的状态(idle / skipped / blocked / unreached) * 不是运行状态,返回 null。 */ export function serverStatusOf(code: StatusCode): RunStatus | null { diff --git a/frontend/src/pages/runs/Telemetry.tsx b/frontend/src/pages/runs/Telemetry.tsx index e6b691ba..bbbd93f6 100644 --- a/frontend/src/pages/runs/Telemetry.tsx +++ b/frontend/src/pages/runs/Telemetry.tsx @@ -10,7 +10,7 @@ import { type StatusCode } from '../../lib/status' import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace' import { useRunClock } from '../../run/useRunClock' import type { Approval, Run } from '../../types' -import { BUNDLE_TEXT, COST_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms' +import { BUNDLE_TEXT, COST_TEXT, REJECTED_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms' import { LONG_WAIT_MS, ageMs, cancelReason, runScope, runTiming, type RunTiming, } from './model' @@ -116,6 +116,10 @@ export function Telemetry({ run, trace, phase, code, streaming, pending, labelOf sub = '断点完好,可继续运行' } else if (code === 'cancelled') { sub = cancelNote(run.error, cancelled) + } else if (code === 'rejected') { + // 谁在哪一步驳回、备注:和时间线那一行同一句(REJECTED_TEXT.sub)。run.cancelled 还没取到时按驳回的那一步说 + sub = cancelled?.reason === 'rejected' ? REJECTED_TEXT.sub(cancelled) + : REJECTED_TEXT.sub({ label: labelOf(run.error_node_id) }) } const nodesTotal = proj?.nodesTotal ?? 0 diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 9236665c..703c5fd5 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -93,6 +93,8 @@ for (const [word, codes] of [ ['挂起', ['held']], ['中断', ['held']], ['已中断', ['held']], ['可继续运行', ['held']], ['interrupted', ['waiting', 'held']], ['取消', ['cancelled']], ['canceled', ['cancelled']], + // 被驳回(审批人的决定,不是失败):屏幕上写「被驳回」,人也会只搜「驳回」 + ['驳回', ['rejected']], ['已驳回', ['rejected']], ['出错', ['failed']], ['报错', ['failed']], ['error', ['failed']], ['排队', ['queued']], ['执行中', ['running']], ['进行中', ['running']], ] as [string, StatusCode[]][]) addWord(word, codes) @@ -217,7 +219,7 @@ export type ShapeKind = 'active' | 'wait' | 'other' | 'unknown' | 'live' export interface RunShape { /** 各段占墙钟的比例,加起来是 1;空数组 = 连墙钟都不知道 */ segs: { kind: ShapeKind; frac: number; open?: boolean }[] - /** 收在哪种结局上:只给要注意的几种,已完成不标 */ + /** 收在哪种结局上:只给要注意的几种,已完成不标。被驳回和已取消同一个结束标记(同 lib/status 的剪影和颜色) */ end: 'failed' | 'cancelled' | 'suspended' | null title: string } @@ -266,7 +268,7 @@ export function runShape( } } - const end: RunShape['end'] = code === 'failed' ? 'failed' : code === 'cancelled' ? 'cancelled' + const end: RunShape['end'] = code === 'failed' ? 'failed' : code === 'cancelled' || code === 'rejected' ? 'cancelled' : code === 'held' || code === 'suspended' ? 'suspended' : null if (t.wallMs == null || t.wallMs <= 0) { return { segs: [], end, title: t.activeMs != null ? timingTitle(t) : '没有时长记录' } diff --git a/frontend/src/pages/runs/useRunsData.ts b/frontend/src/pages/runs/useRunsData.ts index e96a3a4a..0513ecfb 100644 --- a/frontend/src/pages/runs/useRunsData.ts +++ b/frontend/src/pages/runs/useRunsData.ts @@ -127,8 +127,11 @@ export function useRunList(filters: ListFilters, enabled: boolean): RunList { // 计数 // ------------------------------------------------------------------------- -/** 数的是"需要看一眼"的几类;已完成不数——它是常态,数字只会分散注意力 */ -const COUNTED: StatusCode[] = ['running', 'queued', 'waiting', 'held', 'failed', 'cancelled'] +/** + * 数的是"需要看一眼"的几类;已完成不数——它是常态,数字只会分散注意力。被驳回以前记成失败、算在「失败」里,现在单独 + * 一类,不数就哪个计数里都没有它 + */ +const COUNTED: StatusCode[] = ['running', 'queued', 'waiting', 'held', 'failed', 'cancelled', 'rejected'] const COUNT_LIMIT = 200 export interface StatusCounts { diff --git a/frontend/src/run/RunHud.tsx b/frontend/src/run/RunHud.tsx index 024c808e..f18e3837 100644 --- a/frontend/src/run/RunHud.tsx +++ b/frontend/src/run/RunHud.tsx @@ -8,7 +8,7 @@ import { confirmDialog, Kbd, Spinner, StatusBadge, toast } from '../components/u import { formatCost, formatDuration, formatLapse, formatOffset, formatSpan, formatTokens, NONE } from '../lib/format' import { isTypingTarget, matchShortcut } from '../lib/keys' import { statusMeta, STATUS, type StatusCode } from '../lib/status' -import { COST_TEXT, issuanceLabel, runClassLabel, runCostText } from '../lib/terms' +import { COST_TEXT, REJECTED_TEXT, issuanceLabel, runClassLabel, runCostText } from '../lib/terms' import { lastStampOf, projectSettled, useDock, waitedMs } from '../canvas/RunTimeline' import { structureSig, useStudio } from '../store/studio' import type { RunEvent } from '../types' @@ -111,29 +111,35 @@ export function phaseLabel(phase: RunPhase): string { /** * 这次运行是怎么取消的:run.cancelled 带着谁放弃的(actor)和一句说明(放弃等审批、 - * 挂起的运行时才有,如「放弃了这次运行,1 条待审批一并关闭」)。按事件数组缓存 + * 挂起的运行时才有,如「放弃了这次运行,1 条待审批一并关闭」)。按事件数组缓存。 + * + * 被人驳回结束的也是 run.cancelled(reason=rejected,05-已知遗留 (a)):rejected 为真,note 写谁在哪一步驳回、备注 + * (REJECTED_TEXT.sub,同时间线那一行)。以前一律写「张工 取消了本次运行」,驳回的人并没有停过它 */ -const cancelCache = new WeakMap() -function cancelNote(events: RunEvent[]): string { +const cancelCache = new WeakMap() +function cancelNote(events: RunEvent[]): { note: string; rejected: boolean } { const hit = cancelCache.get(events) if (hit != null) return hit - let note = '' + let out = { note: '', rejected: false } for (let i = events.length - 1; i >= 0; i -= 1) { const e = events[i] if (!String(e.type).startsWith('run.')) continue - if (e.type === 'run.cancelled') { + if (e.type === 'run.cancelled' && e.data?.reason === 'rejected') { + out = { note: REJECTED_TEXT.sub(e.data), rejected: true } + } else if (e.type === 'run.cancelled') { const who = typeof e.data?.actor === 'string' ? e.data.actor.trim() : '' const what = typeof e.data?.message === 'string' ? e.data.message.trim() : '' - note = what ? `${who ? `${who} ` : ''}${what}` : who ? `${who} 取消了本次运行` : '' + out = { note: what ? `${who ? `${who} ` : ''}${what}` : who ? `${who} 取消了本次运行` : '', rejected: false } } break } - cancelCache.set(events, note) - return note + cancelCache.set(events, out) + return out } function headlineOf(t: Trace, phase: RunPhase, states: Record, at: number, - label: (id: string) => string, errorText: string | null | undefined, cancelled = ''): string { + label: (id: string) => string, errorText: string | null | undefined, cancelled = '', + rejected = false): string { const running = Object.keys(states).filter((id) => states[id] === 'running') switch (phase) { case 'queued': @@ -167,6 +173,8 @@ function headlineOf(t: Trace, phase: RunPhase, states: Record case 'succeeded': return t.issuance?.tier ? `运行完成 · ${issuanceLabel(t.issuance.tier)}` : '运行完成' case 'cancelled': { + // 被驳回:那一句已经写了在哪一步(「张工 驳回了「付款」:…」),不再接「停在「付款」」 + if (rejected) return cancelled || REJECTED_TEXT.title const stopped = Object.keys(states).filter((id) => states[id] === 'cancelled') const where = stopped.length ? `停在「${label(stopped[0])}」` : '已取消' return cancelled ? `${cancelled} · ${where}` : where @@ -187,7 +195,9 @@ export function useRunGlance(active: boolean): RunGlance { const runError = useStudio((s) => s.run?.error) const nodes = useStudio((s) => s.nodes) const edges = useStudio((s) => s.edges) - const cancelled = useStudio((s) => (s.runPhase === 'cancelled' ? cancelNote(s.events) : '')) + const ending = useStudio((s) => (s.runPhase === 'cancelled' ? cancelNote(s.events) : null)) + // 被驳回只看服务端说的(run.cancelled 的 reason,或运行记录的 end_reason),不按报错原文猜 + const runRejected = useStudio((s) => s.run?.end_reason === 'rejected') const limitMs = useRunLimit() const ticking = active && replayAt == null && isActivePhase(phaseLive) const now = useRunClock(ticking) @@ -229,6 +239,11 @@ export function useRunGlance(active: boolean): RunGlance { const remainingMs = limitMs != null && phase === 'running' && drive && drive[1] == null ? Math.max(0, limitMs - (at - drive[0])) : null + // 结局是被驳回:只在没拖游标、停在最后时这么说(回放到中途说的是那一刻的相位) + const rejectedEnd = replayAt == null && phase === 'cancelled' && (!!ending?.rejected || runRejected) + const cancelled = ending?.note ?? '' + const code: StatusCode = rejectedEnd ? 'rejected' : phase + const attention: Attention[] = [] for (const c of cells) { if (c.state !== 'failed') continue @@ -251,9 +266,9 @@ export function useRunGlance(active: boolean): RunGlance { } return { - phase, code: phase, label: phaseLabel(phase), replay: replayAt != null, at, + phase, code, label: code === 'rejected' ? STATUS.rejected.label : phaseLabel(phase), replay: replayAt != null, at, elapsedMs: proj.elapsedMs, activeMs: proj.activeMs, waitMs: proj.waitMs, - headline: headlineOf(trace, phase, states, at, label, runError, replayAt == null ? cancelled : ''), + headline: headlineOf(trace, phase, states, at, label, runError, replayAt == null ? cancelled : '', rejectedEnd), executing: phase === 'running' && cells.some((c) => c.state === 'running' && !trace.nodes[c.id]?.looping), cells, done, visited, parallelNow: proj.parallelNow, series: trace.parallelSeries, tokens, cost, unpriced, remainingMs, limitMs, attention, diff --git a/scripts/check-canvas-fx.mjs b/scripts/check-canvas-fx.mjs index 1c8fb6a3..b124585b 100644 --- a/scripts/check-canvas-fx.mjs +++ b/scripts/check-canvas-fx.mjs @@ -706,6 +706,21 @@ await settleCase('取消:清除、回放', [ ev(9, 'run.cancelled', null, { timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }, 9), ], { text: '已取消', actions: ['清除', '回放'] }) +// 人的驳回也是 run.cancelled(reason=rejected,05-已知遗留 (a)):胶囊写「被驳回」、谁在哪一步驳回,不写「张工 取消了本次运行」 +await settleCase('被驳回:胶囊写被驳回和谁在哪一步驳回', [ + ev(9, 'run.cancelled', null, { reason: 'rejected', node_id: 'review', label: '主管审批', kind: 'approve', decision: 'reject', + actor: '张工', note: '口径不对', timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }, 9), +], { + text: '被驳回', actions: ['清除', '回放'], + more: async (page) => { + const cap = await capText(page) + check('胶囊不写「已取消」', !cap.includes('已取消'), cap) + const title = await capsule(page).locator('.sf-cap-main').getAttribute('title') ?? '' + check('一句话写谁在哪一步驳回、备注,不写「取消了本次运行」', title.includes('张工 驳回了「主管审批」:口径不对') + && !title.includes('取消了本次运行'), title) + }, +}) + await settleCase('服务重启挂起:接着跑', [ ev(9, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关闭时这次运行仍在进行,已挂起;重启后可从断点继续运行' }, 9), ], { diff --git a/scripts/check-runs.mjs b/scripts/check-runs.mjs index 7e82526f..beea64c1 100644 --- a/scripts/check-runs.mjs +++ b/scripts/check-runs.mjs @@ -2441,6 +2441,85 @@ await section('生效记录面板', async () => { fakes.delete('GET /api/actions') }) +await section('被驳回:分段、计数、搜索、详情都找得到(05-已知遗留 (a))', async () => { + // 人的驳回结束在 cancelled + end_reason=rejected,显示码是 rejected。以前它记成失败,算在「失败」里;只在显示上分出来、 + // 分段和计数里没有它的话,点「已取消」被筛掉、搜「驳回」认不出,被驳回的运行只剩「全部」里看得到 + if (!base) { check('沙箱里有一条成功的运行', false); return } + const t = Date.now() / 1000 - 3600 + const at = (sec) => new Date(sec * 1000).toISOString() + const ended = (id, status, endReason, error, back) => ({ ...base, id, status, end_reason: endReason, error, + error_node_id: status === 'failed' || endReason === 'rejected' ? 'gate' : null, workflow_id: null, + created_at: at(t - back), started_at: at(t - back), finished_at: at(t - back + 6), usage: { wall_ms: 6000, active_ms: 6000, wait_ms: 0 } }) + const REJ = 'fake0rej0rejected' + const rows = [ + ended(REJ, 'cancelled', 'rejected', '「付款」未确认(被驳回),没有发送:金额不对', 0), + ended('fake0rej0stopped', 'cancelled', 'stopped', '用户取消', 60), + ended('fake0rej0failed', 'failed', null, '查询超时', 120), + ] + fakes.set('GET /api/runs', (url) => { + const want = (url.searchParams.get('status') ?? '').split(',').filter(Boolean) + return { status: 200, json: want.length ? rows.filter((r) => want.includes(r.status)) : rows } + }) + const graph = { nodes: [ + { id: 'start', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {} } }, + { id: 'gate', type: 'human', position: { x: 200, y: 0 }, data: { label: '付款', config: { mode: 'approve', stop_on_reject: true } } }, + ], edges: [{ source: 'start', target: 'gate' }] } + fakes.set(`GET /api/runs/${REJ}`, () => ({ status: 200, json: rows[0] })) + fakes.set(`GET /api/runs/${REJ}/graph`, () => ({ status: 200, json: { graph, workflow_id: null, version: null } })) + fakes.set(`GET /api/runs/${REJ}/events`, () => ({ status: 200, json: [ + { seq: 1, type: 'run.started', node_id: null, ts: t, data: { nodes: 2 } }, + { seq: 2, type: 'node.started', node_id: 'start', ts: t + 0.1, data: {} }, + { seq: 3, type: 'node.finished', node_id: 'start', ts: t + 0.2, data: { duration_ms: 100 } }, + { seq: 4, type: 'node.started', node_id: 'gate', ts: t + 0.3, data: {} }, + { seq: 5, type: 'node.failed', node_id: 'gate', ts: t + 5.9, data: { error: rows[0].error } }, + { seq: 6, type: 'run.cancelled', node_id: null, ts: t + 6, data: { reason: 'rejected', node_id: 'gate', label: '付款', + kind: 'approve', decision: 'reject', actor: '张工', note: '金额不对', timing: { wall_ms: 6000, active_ms: 6000, wait_ms: 0 } } }, + ] })) + try { + await page.goto(`${WEB}/runs`, { waitUntil: 'networkidle' }) + await page.waitForTimeout(300) + const seg = page.locator('[data-segment=rejected]') + check('「全部」的分段里有「被驳回」', await seg.count() === 1 && (await seg.innerText()).includes('被驳回')) + check('计数里有被驳回的那一条', (await seg.locator('.tnum').innerText().catch(() => '')) === '1', + await seg.innerText().catch(() => '')) + check('「已取消」的计数不含被驳回的', (await page.locator('[data-segment=cancelled] .tnum').innerText().catch(() => '')) === '1') + check('「失败」的计数不含被驳回的', (await page.locator('[data-segment=failed] .tnum').innerText().catch(() => '')) === '1') + const row = page.locator(`[data-runs-list] [data-run-id="${REJ}"]`) + check('列表行写「被驳回」和原话,状态码是 rejected', await row.getAttribute('data-status') === 'rejected' + && (await row.innerText()).includes('被驳回'), (await row.innerText().catch(() => '')).replace(/\n/g, ' / ')) + check('缩略条收在和已取消同一个结束标记上', await row.locator('[data-shape-end=cancelled]').count() === 1) + + await listAfter(() => seg.click(), (p) => p.get('status') === 'cancelled') + check('点「被驳回」:查 cancelled、只留被驳回的', (await ids()).join(',') === REJ, (await ids()).join(',')) + check('分段写进地址 ?status=rejected', new URL(page.url()).searchParams.get('status') === 'rejected') + // 两段查的都是 cancelled:先回「全部」,不然这一次不会重查 + await listAfter(() => page.locator('[data-segment=all]').click(), (p) => !p.get('status')) + await listAfter(() => page.locator('[data-segment=cancelled]').click(), (p) => p.get('status') === 'cancelled') + check('点「已取消」:只留手动停止的', (await ids()).join(',') === 'fake0rej0stopped', (await ids()).join(',')) + await page.locator('[data-segment=all]').click() + await settle() + for (const word of ['驳回', '被驳回']) { + await search(word, (p) => p.get('status') === 'cancelled') + check(`搜「${word}」按状态筛出被驳回的`, (await ids()).join(',') === REJ + && (await page.locator('[data-runs-hint]').innerText()).includes(word), (await ids()).join(',')) + await search('', (p) => !p.get('status')) + } + await page.locator('[role=tab][data-tab=failed]').click() + await settle() + check('「失败」页签里没有被驳回的(不是故障)', (await ids()).join(',') === 'fake0rej0failed', (await ids()).join(',')) + + await page.goto(`${WEB}/runs/${REJ}`, { waitUntil: 'networkidle' }) + await page.locator('[data-run-detail]').waitFor() + await page.waitForTimeout(400) + check('详情的显示码是 rejected', await page.locator('[data-run-detail]').getAttribute('data-run-code') === 'rejected') + const tele = (await page.locator('[data-run-telemetry]').innerText()).replace(/\n/g, ' / ') + check('状态格写「被驳回」,副标题写谁在哪一步驳回、备注', tele.includes('被驳回') && tele.includes('张工 驳回了「付款」:金额不对') + && !tele.includes('已取消'), tele.slice(0, 160)) + } finally { + for (const k of ['GET /api/runs', `GET /api/runs/${REJ}`, `GET /api/runs/${REJ}/graph`, `GET /api/runs/${REJ}/events`]) fakes.delete(k) + } +}) + await section('收尾', async () => { // 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 POST // (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有