mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
记录页、画布上找得到被驳回的运行:分段、计数、搜索词、遥测、缩略条、运行胶囊
为什么:被驳回的显示码 rejected 不在 RUN_STATUS_ORDER 里,也不在计数的 COUNTED 里,搜索词表里没有「驳回」。点「已取消」 分段时服务端交回了它,前端按显示码一筛又筛掉了;以前它算在「失败」里,现在哪个计数里都没有;搜「驳回」不当状态词认; 详情的状态格没有副标题,缩略条没有结束标记。画布的运行胶囊更糟:相位是 cancelled,写「已取消」,一句话写「张工 取消了 本次运行 · 停在「研究协作团队」」——驳回的人并没有停过它。 做了什么: - lib/status:RUN_STATUS_ORDER 在已取消后面加上 rejected(分段、搜索词表都从它生成),查询时照旧换回 cancelled。 - 计数加上被驳回;搜索词另认「驳回」「已驳回」;缩略条和已取消共用一个结束标记(同一个剪影和颜色)。 - 遥测的状态格:副标题写谁在哪一步驳回、备注(REJECTED_TEXT.sub,和时间线那一行同一句);run.cancelled 还没取到时 按驳回的那一步说。 - 运行胶囊:run.cancelled 带 reason=rejected 或运行记录 end_reason=rejected 时,状态写「被驳回」,一句话写 「张工 驳回了「主管审批」:口径不对」,不再接「停在…」;回放到中途照旧说那一刻的相位。手动停止照旧。 检查:check-runs 加一节(伪造被驳回、手动停止、失败各一条):分段和计数里有被驳回、已取消和失败的计数都不含它、点被驳回 只留它、点已取消只留手动停止、搜「驳回」「被驳回」都筛得出、失败页签里没有它、详情显示码和状态格。check-canvas-fx 加 一节被驳回的胶囊。把这几处改动退回去回跑,check-runs 4 项、check-canvas-fx 3 项不通过;改完都通过。
This commit is contained in:
@@ -183,10 +183,11 @@ export function statusLabel(status: string | null | undefined, opts?: StatusOpts
|
|||||||
/**
|
/**
|
||||||
* 运行列表的状态筛选用:按显示顺序排好的运行状态。
|
* 运行列表的状态筛选用:按显示顺序排好的运行状态。
|
||||||
* 这是显示用的码,不能直接当查询参数——waiting / held 在后端都是 interrupted,
|
* 这是显示用的码,不能直接当查询参数——waiting / held 在后端都是 interrupted,
|
||||||
* 查询前用 serverStatusOf 换回去。
|
* rejected 在后端是 cancelled(+ end_reason=rejected),查询前用 serverStatusOf 换回去。
|
||||||
|
* 被驳回单独一项、紧跟在已取消后面:不在这里的码,分段、计数、搜索词表里都没有它,被驳回的运行只剩「全部」里看得到
|
||||||
*/
|
*/
|
||||||
export const RUN_STATUS_ORDER: StatusCode[] = [
|
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:按它查回来的是两种混在一起,
|
* waiting、held、suspended 在后端都是 interrupted:按它查回来的是两种混在一起,
|
||||||
* 要再用 resolveStatus(run.status, {pendingApproval: hasPendingApproval(approvals,
|
* 要再用 resolveStatus(run.status, {pendingApproval: hasPendingApproval(approvals,
|
||||||
* run.id)}) 在前端分开。节点才有的状态(idle / skipped / blocked / unreached)
|
* run.id)}) 在前端分开。rejected 同理查 cancelled,回来按 end_reason 分开(被驳回 / 手动停止)。节点才有的状态(idle / skipped / blocked / unreached)
|
||||||
* 不是运行状态,返回 null。
|
* 不是运行状态,返回 null。
|
||||||
*/
|
*/
|
||||||
export function serverStatusOf(code: StatusCode): RunStatus | null {
|
export function serverStatusOf(code: StatusCode): RunStatus | null {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { type StatusCode } from '../../lib/status'
|
|||||||
import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace'
|
import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace'
|
||||||
import { useRunClock } from '../../run/useRunClock'
|
import { useRunClock } from '../../run/useRunClock'
|
||||||
import type { Approval, Run } from '../../types'
|
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 {
|
import {
|
||||||
LONG_WAIT_MS, ageMs, cancelReason, runScope, runTiming, type RunTiming,
|
LONG_WAIT_MS, ageMs, cancelReason, runScope, runTiming, type RunTiming,
|
||||||
} from './model'
|
} from './model'
|
||||||
@@ -116,6 +116,10 @@ export function Telemetry({ run, trace, phase, code, streaming, pending, labelOf
|
|||||||
sub = '断点完好,可继续运行'
|
sub = '断点完好,可继续运行'
|
||||||
} else if (code === 'cancelled') {
|
} else if (code === 'cancelled') {
|
||||||
sub = cancelNote(run.error, 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
|
const nodesTotal = proj?.nodesTotal ?? 0
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ for (const [word, codes] of [
|
|||||||
['挂起', ['held']], ['中断', ['held']], ['已中断', ['held']], ['可继续运行', ['held']],
|
['挂起', ['held']], ['中断', ['held']], ['已中断', ['held']], ['可继续运行', ['held']],
|
||||||
['interrupted', ['waiting', 'held']],
|
['interrupted', ['waiting', 'held']],
|
||||||
['取消', ['cancelled']], ['canceled', ['cancelled']],
|
['取消', ['cancelled']], ['canceled', ['cancelled']],
|
||||||
|
// 被驳回(审批人的决定,不是失败):屏幕上写「被驳回」,人也会只搜「驳回」
|
||||||
|
['驳回', ['rejected']], ['已驳回', ['rejected']],
|
||||||
['出错', ['failed']], ['报错', ['failed']], ['error', ['failed']],
|
['出错', ['failed']], ['报错', ['failed']], ['error', ['failed']],
|
||||||
['排队', ['queued']], ['执行中', ['running']], ['进行中', ['running']],
|
['排队', ['queued']], ['执行中', ['running']], ['进行中', ['running']],
|
||||||
] as [string, StatusCode[]][]) addWord(word, codes)
|
] as [string, StatusCode[]][]) addWord(word, codes)
|
||||||
@@ -217,7 +219,7 @@ export type ShapeKind = 'active' | 'wait' | 'other' | 'unknown' | 'live'
|
|||||||
export interface RunShape {
|
export interface RunShape {
|
||||||
/** 各段占墙钟的比例,加起来是 1;空数组 = 连墙钟都不知道 */
|
/** 各段占墙钟的比例,加起来是 1;空数组 = 连墙钟都不知道 */
|
||||||
segs: { kind: ShapeKind; frac: number; open?: boolean }[]
|
segs: { kind: ShapeKind; frac: number; open?: boolean }[]
|
||||||
/** 收在哪种结局上:只给要注意的几种,已完成不标 */
|
/** 收在哪种结局上:只给要注意的几种,已完成不标。被驳回和已取消同一个结束标记(同 lib/status 的剪影和颜色) */
|
||||||
end: 'failed' | 'cancelled' | 'suspended' | null
|
end: 'failed' | 'cancelled' | 'suspended' | null
|
||||||
title: string
|
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
|
: code === 'held' || code === 'suspended' ? 'suspended' : null
|
||||||
if (t.wallMs == null || t.wallMs <= 0) {
|
if (t.wallMs == null || t.wallMs <= 0) {
|
||||||
return { segs: [], end, title: t.activeMs != null ? timingTitle(t) : '没有时长记录' }
|
return { segs: [], end, title: t.activeMs != null ? timingTitle(t) : '没有时长记录' }
|
||||||
|
|||||||
@@ -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
|
const COUNT_LIMIT = 200
|
||||||
|
|
||||||
export interface StatusCounts {
|
export interface StatusCounts {
|
||||||
|
|||||||
+28
-13
@@ -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 { formatCost, formatDuration, formatLapse, formatOffset, formatSpan, formatTokens, NONE } from '../lib/format'
|
||||||
import { isTypingTarget, matchShortcut } from '../lib/keys'
|
import { isTypingTarget, matchShortcut } from '../lib/keys'
|
||||||
import { statusMeta, STATUS, type StatusCode } from '../lib/status'
|
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 { lastStampOf, projectSettled, useDock, waitedMs } from '../canvas/RunTimeline'
|
||||||
import { structureSig, useStudio } from '../store/studio'
|
import { structureSig, useStudio } from '../store/studio'
|
||||||
import type { RunEvent } from '../types'
|
import type { RunEvent } from '../types'
|
||||||
@@ -111,29 +111,35 @@ export function phaseLabel(phase: RunPhase): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 这次运行是怎么取消的:run.cancelled 带着谁放弃的(actor)和一句说明(放弃等审批、
|
* 这次运行是怎么取消的:run.cancelled 带着谁放弃的(actor)和一句说明(放弃等审批、
|
||||||
* 挂起的运行时才有,如「放弃了这次运行,1 条待审批一并关闭」)。按事件数组缓存
|
* 挂起的运行时才有,如「放弃了这次运行,1 条待审批一并关闭」)。按事件数组缓存。
|
||||||
|
*
|
||||||
|
* 被人驳回结束的也是 run.cancelled(reason=rejected,05-已知遗留 (a)):rejected 为真,note 写谁在哪一步驳回、备注
|
||||||
|
* (REJECTED_TEXT.sub,同时间线那一行)。以前一律写「张工 取消了本次运行」,驳回的人并没有停过它
|
||||||
*/
|
*/
|
||||||
const cancelCache = new WeakMap<RunEvent[], string>()
|
const cancelCache = new WeakMap<RunEvent[], { note: string; rejected: boolean }>()
|
||||||
function cancelNote(events: RunEvent[]): string {
|
function cancelNote(events: RunEvent[]): { note: string; rejected: boolean } {
|
||||||
const hit = cancelCache.get(events)
|
const hit = cancelCache.get(events)
|
||||||
if (hit != null) return hit
|
if (hit != null) return hit
|
||||||
let note = ''
|
let out = { note: '', rejected: false }
|
||||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
const e = events[i]
|
const e = events[i]
|
||||||
if (!String(e.type).startsWith('run.')) continue
|
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 who = typeof e.data?.actor === 'string' ? e.data.actor.trim() : ''
|
||||||
const what = typeof e.data?.message === 'string' ? e.data.message.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
|
break
|
||||||
}
|
}
|
||||||
cancelCache.set(events, note)
|
cancelCache.set(events, out)
|
||||||
return note
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function headlineOf(t: Trace, phase: RunPhase, states: Record<string, NodeState>, at: number,
|
function headlineOf(t: Trace, phase: RunPhase, states: Record<string, NodeState>, 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')
|
const running = Object.keys(states).filter((id) => states[id] === 'running')
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case 'queued':
|
case 'queued':
|
||||||
@@ -167,6 +173,8 @@ function headlineOf(t: Trace, phase: RunPhase, states: Record<string, NodeState>
|
|||||||
case 'succeeded':
|
case 'succeeded':
|
||||||
return t.issuance?.tier ? `运行完成 · ${issuanceLabel(t.issuance.tier)}` : '运行完成'
|
return t.issuance?.tier ? `运行完成 · ${issuanceLabel(t.issuance.tier)}` : '运行完成'
|
||||||
case 'cancelled': {
|
case 'cancelled': {
|
||||||
|
// 被驳回:那一句已经写了在哪一步(「张工 驳回了「付款」:…」),不再接「停在「付款」」
|
||||||
|
if (rejected) return cancelled || REJECTED_TEXT.title
|
||||||
const stopped = Object.keys(states).filter((id) => states[id] === 'cancelled')
|
const stopped = Object.keys(states).filter((id) => states[id] === 'cancelled')
|
||||||
const where = stopped.length ? `停在「${label(stopped[0])}」` : '已取消'
|
const where = stopped.length ? `停在「${label(stopped[0])}」` : '已取消'
|
||||||
return cancelled ? `${cancelled} · ${where}` : where
|
return cancelled ? `${cancelled} · ${where}` : where
|
||||||
@@ -187,7 +195,9 @@ export function useRunGlance(active: boolean): RunGlance {
|
|||||||
const runError = useStudio((s) => s.run?.error)
|
const runError = useStudio((s) => s.run?.error)
|
||||||
const nodes = useStudio((s) => s.nodes)
|
const nodes = useStudio((s) => s.nodes)
|
||||||
const edges = useStudio((s) => s.edges)
|
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 limitMs = useRunLimit()
|
||||||
const ticking = active && replayAt == null && isActivePhase(phaseLive)
|
const ticking = active && replayAt == null && isActivePhase(phaseLive)
|
||||||
const now = useRunClock(ticking)
|
const now = useRunClock(ticking)
|
||||||
@@ -229,6 +239,11 @@ export function useRunGlance(active: boolean): RunGlance {
|
|||||||
const remainingMs = limitMs != null && phase === 'running' && drive && drive[1] == null
|
const remainingMs = limitMs != null && phase === 'running' && drive && drive[1] == null
|
||||||
? Math.max(0, limitMs - (at - drive[0])) : 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[] = []
|
const attention: Attention[] = []
|
||||||
for (const c of cells) {
|
for (const c of cells) {
|
||||||
if (c.state !== 'failed') continue
|
if (c.state !== 'failed') continue
|
||||||
@@ -251,9 +266,9 @@ export function useRunGlance(active: boolean): RunGlance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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,
|
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),
|
executing: phase === 'running' && cells.some((c) => c.state === 'running' && !trace.nodes[c.id]?.looping),
|
||||||
cells, done, visited, parallelNow: proj.parallelNow, series: trace.parallelSeries,
|
cells, done, visited, parallelNow: proj.parallelNow, series: trace.parallelSeries,
|
||||||
tokens, cost, unpriced, remainingMs, limitMs, attention,
|
tokens, cost, unpriced, remainingMs, limitMs, attention,
|
||||||
|
|||||||
@@ -706,6 +706,21 @@ await settleCase('取消:清除、回放', [
|
|||||||
ev(9, 'run.cancelled', null, { timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }, 9),
|
ev(9, 'run.cancelled', null, { timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }, 9),
|
||||||
], { text: '已取消', actions: ['清除', '回放'] })
|
], { 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('服务重启挂起:接着跑', [
|
await settleCase('服务重启挂起:接着跑', [
|
||||||
ev(9, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关闭时这次运行仍在进行,已挂起;重启后可从断点继续运行' }, 9),
|
ev(9, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关闭时这次运行仍在进行,已挂起;重启后可从断点继续运行' }, 9),
|
||||||
], {
|
], {
|
||||||
|
|||||||
@@ -2441,6 +2441,85 @@ await section('生效记录面板', async () => {
|
|||||||
fakes.delete('GET /api/actions')
|
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 () => {
|
await section('收尾', async () => {
|
||||||
// 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 POST
|
// 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 POST
|
||||||
// (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有
|
// (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有
|
||||||
|
|||||||
Reference in New Issue
Block a user