mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:41:58 +00:00
合入 W1-b 已知遗留:人工驳回记成已取消 + end_reason=rejected(不重试、不被出错时继续吞掉),各处照实写「被驳回」;个人渠道「我发起的实例完成 / 失败」生效;导入清单如实记「是否认证」;使用者选人按候选池、开登录时服务端核对;自由形蓝图按导入的图列「谁来做」;tick 按 id 断言
This commit is contained in:
+76
-3
@@ -9,10 +9,11 @@
|
||||
// 不认识的组件写「此组件需要更新前端」,不报错、不空白;服务端解析出错的组件(problems)列在页面顶上;
|
||||
// 有视图在生成时自动重取;
|
||||
// - 入口页:必填没填本地拦下、错误挂到字段下面;服务端 422 的 {errors:[{path, code, message}]} 按 path 挂回字段,
|
||||
// 对不上字段的列在表单顶上;⌘/Ctrl+Enter 发起;成功后进实例页;需升级时不让发起;
|
||||
// 对不上字段的列在表单顶上;⌘/Ctrl+Enter 发起;成功后进实例页;需升级时不让发起;使用者在选人控件里边输入边按
|
||||
// 候选池取候选(GET /api/directory/candidates,只有名字),不在池里的当场说清,不去取整份名录(05-已知遗留 (b));
|
||||
// - 实例页:状态(服务端给的说法:结果未知写「等待核实写入结果」)、印章、进度(结果未知的那一步不写「完成」)、
|
||||
// 本实例的待办卡(就地处理);不是处理人的待办表单只画这一步的状态、不去取待办;动作状态(哈希前 8 位、环境;
|
||||
// 结果未知的经过记一笔,不当失败原因);
|
||||
// 结果未知的经过记一笔,不当失败原因);被驳回的实例照服务端的说法写「「付款」未确认(被驳回)」,不写失败(05-已知遗留 (a));
|
||||
// - 窄屏(390px)不出横向滚动;
|
||||
// - 页面上直接改(normal-user-apps N6):有 manage 的人点「编辑」进编辑状态(地址记 edit=1),按草稿解析;点组件是选中、
|
||||
// 不打开出处;小工具条改文字只给目录标了 x-text 的属性;隐藏、上移、删除都自动存草稿(PATCH 带整份草稿)后按草稿
|
||||
@@ -340,6 +341,15 @@ async function open({ width = 1280, height = 900, state }) {
|
||||
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
|
||||
}
|
||||
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
|
||||
// 使用者选人的候选池(05-已知遗留 (b)):按段里给的 pool 答;整份名录只给搭建者,使用者取了就是 403
|
||||
if (p === '/api/directory/candidates') {
|
||||
(state.candidateCalls ??= []).push(Object.fromEntries(url.searchParams))
|
||||
return route.fulfill({ json: state.pool ? state.pool(url.searchParams.get('q') ?? '') : { users: [], groups: [], truncated: false } })
|
||||
}
|
||||
if (p === '/api/directory') {
|
||||
state.directoryHits = (state.directoryHits ?? 0) + 1
|
||||
return route.fulfill({ status: 403, json: { detail: '需要搭建者权限' } })
|
||||
}
|
||||
if (p === '/api/apps') return route.fulfill({ json: { items: APPS } })
|
||||
// 应用详情:默认不给草稿(不进编辑状态);页面上直接改的段落给 manage 的详情
|
||||
if (p === '/api/apps/app_weekly') return route.fulfill({ json: state.detail ?? { ...APPS[0], published: DETAIL.published } })
|
||||
@@ -360,7 +370,7 @@ async function open({ width = 1280, height = 900, state }) {
|
||||
if (p === '/api/apps/app_weekly/pages/detail') return route.fulfill({ json: { ...HOME, page: { id: 'detail', title: '门店明细' }, tree: [] } })
|
||||
if (p === '/api/apps/app_claim/pages/home') return route.fulfill({ json: state.claimHome ?? CLAIM_HOME })
|
||||
if (/^\/api\/apps\/[^/]+\/instances$/.test(p)) return route.fulfill({ json: { items: p.includes('app_claim') ? [INSTANCE] : [] } })
|
||||
if (p === '/api/apps/app_claim/instances/run_claim_1') return route.fulfill({ json: INSTANCE })
|
||||
if (p === '/api/apps/app_claim/instances/run_claim_1') return route.fulfill({ json: state.instance ?? INSTANCE })
|
||||
if (p === `/api/runs/${RUN}/evidence/metrics/kpi/gmv`) {
|
||||
state.chainHits = (state.chainHits ?? 0) + 1
|
||||
return route.fulfill({ json: state.traced ? METRIC_CHAIN_TRACED : METRIC_CHAIN })
|
||||
@@ -555,6 +565,46 @@ await section('入口:本地校验、422 挂回字段、发起后进实例页'
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('入口:使用者选人只出候选池里的人,不在池里当场说清(05-已知遗留 (b))', async () => {
|
||||
const people = [{ username: 'fay', display_name: '费伊' }, { username: 'fin', display_name: '樊宁' }]
|
||||
const apply = CLAIM_HOME.forms.apply
|
||||
const state = {
|
||||
writes: [],
|
||||
me: { mode: 'local', username: 'zhang', display_name: '张三', role: 'user', groups: [], verified: true },
|
||||
claimHome: { ...CLAIM_HOME, forms: { apply: { ...apply, 'x-order': [...apply['x-order'], 'approver'],
|
||||
properties: { ...apply.properties, approver: { type: 'string', title: '审批人', 'x-widget': 'principal' } } } } },
|
||||
pool: (q) => ({ users: people.filter((u) => !q || u.username.startsWith(q) || u.display_name.startsWith(q)),
|
||||
groups: [], truncated: false }),
|
||||
}
|
||||
const { page } = await open({ state })
|
||||
await page.goto(`${WEB}/apps/app_claim/new/apply`)
|
||||
const field = page.locator('[data-schema-form] [data-field="approver"]')
|
||||
await field.waitFor({ timeout: 15000 })
|
||||
const input = field.locator('input.field')
|
||||
await input.fill('费')
|
||||
await page.waitForFunction(() => !!document.querySelector('[data-field="approver"] datalist option'), null, { timeout: 5000 })
|
||||
.catch(() => {})
|
||||
const options = await field.locator('datalist option').evaluateAll((els) => els.map((e) => `${e.getAttribute('value')}|${e.textContent}`))
|
||||
check('边输入边按开头取候选:列表里是候选池给的人,显示名作说明', options.join(',') === 'fay|费伊', options.join(','))
|
||||
const asked = (state.candidateCalls ?? []).at(-1) ?? {}
|
||||
check('……按这个入口、这个字段问(purpose=form)', asked.purpose === 'form' && asked.app_id === 'app_claim'
|
||||
&& asked.entry === 'apply' && asked.field === 'approver' && asked.q === '费', JSON.stringify(asked))
|
||||
check('……不去取整份名录(使用者读不到)', !state.directoryHits, String(state.directoryHits ?? 0))
|
||||
await input.fill('wangwu')
|
||||
await input.press('Enter')
|
||||
const refused = field.locator('[data-principal-refused="hard"]')
|
||||
await refused.waitFor({ timeout: 5000 }).catch(() => {})
|
||||
check('不在池里的名字当场说清,不收', (await refused.count()) === 1
|
||||
&& (await refused.innerText()).includes('「wangwu」不在可选的人里') && (await field.locator('[data-principal]').count()) === 0)
|
||||
await input.fill('fay')
|
||||
await input.press('Enter')
|
||||
const chip = field.locator('[data-principal="user"] span[title]')
|
||||
await chip.waitFor({ timeout: 5000 }).catch(() => {})
|
||||
check('选中的人写显示名,悬停给用户名', (await chip.count()) === 1 && (await chip.innerText()) === '费伊'
|
||||
&& (await chip.getAttribute('title')) === 'fay')
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('入口:需升级时不让发起', async () => {
|
||||
const state = { writes: [], claimHome: { ...CLAIM_HOME, app: { ...CLAIM_HOME.app, needs_upgrade: true } } }
|
||||
const { page } = await open({ state })
|
||||
@@ -566,6 +616,29 @@ await section('入口:需升级时不让发起', async () => {
|
||||
|
||||
// ---------------------------------------------------------------- 实例
|
||||
|
||||
await section('实例页:被驳回写人的决定,不写失败(05-已知遗留 (a))', async () => {
|
||||
// 服务端:动作确认被驳回,运行收成 cancelled + end_reason=rejected;实例状态、进度的说法都是服务端给的(instance_state)
|
||||
const said = { status: 'cancelled', status_code: 'cancelled', status_text: '「付款」未确认(被驳回)' }
|
||||
const view = { ...INSTANCE.view, sources: {}, tree: [
|
||||
{ id: 'inst_header', type: 'instance_header', props: {}, data: { ...INSTANCE.view.tree[0].data, ...said, pending_tasks: 0,
|
||||
finished_at: ago(60_000) } },
|
||||
{ id: 'inst_timeline', type: 'instance_timeline', props: {}, data: { state: 'ok', ...said, steps: [
|
||||
{ node: 'input', title: '报销单', state: 'done', text: '完成', at: ago(1790_000) },
|
||||
{ node: 'pay', title: '付款', state: 'rejected', text: '未确认(被驳回)', at: ago(60_000) },
|
||||
] } },
|
||||
] }
|
||||
const state = { writes: [], instance: { ...INSTANCE, ...said, pending_tasks: 0, finished_at: ago(60_000), view } }
|
||||
const { page } = await open({ state })
|
||||
await page.goto(`${WEB}/apps/app_claim/i/run_claim_1`)
|
||||
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
|
||||
const header = page.locator('[data-component="instance_header"]')
|
||||
check('实例状态写「「付款」未确认(被驳回)」', (await header.innerText()).includes('「付款」未确认(被驳回)'),
|
||||
(await header.innerText()).replace(/\n/g, ' '))
|
||||
check('……不写「失败」', !(await page.locator('[data-instance-page]').innerText()).includes('失败'))
|
||||
check('被驳回的那一步写未确认(被驳回)', (await page.locator('[data-timeline-step="rejected"]').innerText()).includes('未确认(被驳回)'))
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('实例页:状态、进度、待办卡、动作状态', async () => {
|
||||
const state = { writes: [] }
|
||||
const { page } = await open({ state })
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
// - 卡片:卡头标状态(缺按服务端检查、待确认、已确认),对到数据的项写表、中文名、粒度、业务日期、为什么;「修改」发 bp_set
|
||||
// (纯值),确认只改状态,「确认全部」一次带上整张卡片待确认的几项,加一项发 bp_add(纯值);缺项时「用真数试一次」置灰并写清缺什么;
|
||||
// - 写回卡片:目标能力没登记查询时摆「结果不明时谁去核实」(服务端 reconciler_needed),缺时只挡发布,选人发 bp_set;
|
||||
// - 自由形的「谁来做」(05-已知遗留 (d)):按服务端的 derived_people 一行一行摆(岗位、核实人能选人,发起人、表达式的
|
||||
// 只读),不能加减步骤、不改会签;没绑的照服务端的 bp.people_unbound 标「缺」;选人发 bp_set 到那一行的 path;
|
||||
// - 反问:助手的 bp_ask 画成可点的选项,「这一轮在等你回答」常显,输入框提示回答;点选项就是把它作为下一轮发出去(回答记在
|
||||
// 问题上、这一轮落进蓝图对话都是服务端做的,前端不另记);
|
||||
// - 关节:只列生成关节,按卡片填的汇总一句;「让助手填」走 fill-joints-stream,逐组写进度,组之间并行;
|
||||
@@ -77,7 +79,8 @@ const TEMPLATES = [
|
||||
cards: [{ card: 'purpose', note: '必填' }, { card: 'fields', note: '必填:发起时填什么' }, { card: 'people', note: '必填:谁审批' },
|
||||
{ card: 'writeback', note: '可选:通过后写回哪个系统' }, { card: 'access', note: '' }] },
|
||||
{ id: 'freeform', version: 1, label: '自由形', description: '从已有的运行或工作流导入', applies_when: '', tags: [], personal: '只读部分',
|
||||
cards: [{ card: 'purpose', note: '' }, { card: 'cadence', note: '' }, { card: 'access', note: '' }] },
|
||||
cards: [{ card: 'purpose', note: '' }, { card: 'cadence', note: '' },
|
||||
{ card: 'people', note: '可选:只定每一步由谁做;会签、时限、超时在画布上改' }, { card: 'access', note: '' }] },
|
||||
]
|
||||
|
||||
const item = (value, source = 'assistant', status, why = null) =>
|
||||
@@ -550,6 +553,55 @@ await section('写回:结果不明时谁去核实(能力没登记查询才
|
||||
&& await third.page.locator('[data-bp-reconciler-hint="unused"]').count() === 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 自由形:谁来做
|
||||
|
||||
/** 从已发布的「财务审批(岗位)+ 写回(没登记查询)」工作流导入的自由形蓝图:服务端按图列出的「谁来做」(freeform.people_rows) */
|
||||
const FREE_ROWS = [
|
||||
{ kind: 'slot', slot: 'finance', label: '「财务审批」', detail: '「财务审批」处理', path: '/people/0/who', who: null,
|
||||
uses: [{ node_id: 'review', title: '财务审批', role: 'assignee' }] },
|
||||
{ kind: 'reconciler', slot: 'reconciler', label: '写入结果不明时谁去核实', detail: '「写回报销单」写入结果不明时去核实',
|
||||
path: '/writeback/reconciler', who: null, uses: [{ node_id: 'pay', title: '写回报销单', role: 'reconcile' }] },
|
||||
{ kind: 'initiator', slot: null, label: '「本人确认」', detail: '由发起人自己处理', path: null, who: null,
|
||||
uses: [{ node_id: 'me', title: '本人确认', role: 'assignee' }] },
|
||||
]
|
||||
const unboundAt = (path, message) => ({ code: 'bp.people_unbound', level: 'error', path, message, slot: 'x',
|
||||
plain: { title: '有一步还没定谁来处理', why: '流程走到这一步就没人接手', who: 'you', action: '在「谁来做」里选人、组或岗位。' } })
|
||||
const FREE = () => ({ ...clone(BP), template_id: 'freeform',
|
||||
spec: { ...clone(SPEC), template: { id: 'freeform', version: 1 }, data: [], people: [] },
|
||||
joints: {}, derived_people: clone(FREE_ROWS),
|
||||
issues: [unboundAt('/people/0/who', '「财务审批」还没定谁来处理(「财务审批」处理)'),
|
||||
unboundAt('/writeback/reconciler', '写入结果不明时谁去核实还没定')] })
|
||||
|
||||
await section('自由形:「谁来做」按导入的图列出,只定由谁做(05-已知遗留 (d))', async () => {
|
||||
const state = { bp: FREE() }
|
||||
const { page } = await open({ state })
|
||||
await page.goto(`${WEB}/blueprints/bp_week`)
|
||||
const card = page.locator('[data-card="people"]')
|
||||
await card.waitFor({ timeout: 15000 })
|
||||
check('按服务端给的行摆:岗位、核实人、只读的发起人', (await card.locator('[data-bp-derived]').evaluateAll(
|
||||
(els) => els.map((e) => e.getAttribute('data-bp-derived')))).join(',') === 'slot,reconciler,initiator')
|
||||
check('行名、用途照服务端写(节点在图上的名字)', (await card.locator('[data-bp-derived="slot"]').innerText()).includes('「财务审批」处理'))
|
||||
check('没绑的标「缺」,卡头也是缺', await card.locator('[data-bp-item="/people/0/who"]').getAttribute('data-item-status') === 'missing'
|
||||
&& await card.locator('[data-bp-item="/writeback/reconciler"]').getAttribute('data-item-status') === 'missing'
|
||||
&& await card.getAttribute('data-card-status') === 'missing')
|
||||
check('卡片上照白话写缺什么、怎么改(核实人那一条也归在这张卡片上)',
|
||||
(await card.locator('[data-card-issues]').getAttribute('data-card-issues')) === '2')
|
||||
check('发起人的那一步只读:没有「修改」', await card.locator('[data-bp-derived="initiator"] [data-bp-edit]').count() === 0)
|
||||
check('不能加减步骤:没有「加一步」、没有删除', await card.locator('[data-bp-add="/people"]').count() === 0
|
||||
&& await card.locator('[data-bp-remove]').count() === 0)
|
||||
const row = card.locator('[data-bp-item="/people/0/who"]')
|
||||
await row.locator('[data-bp-edit]').click()
|
||||
const editor = row.locator('[data-bp-editor]')
|
||||
await editor.locator('[data-principal-picker] input').fill('zhang')
|
||||
await editor.locator('[data-principal-picker] input').press('Enter')
|
||||
await row.locator('[data-bp-save]').click()
|
||||
check('选人:bp_set 到那一行的 path,值是一组人或组', await until(() => (state.patches ?? []).length === 1)
|
||||
&& JSON.stringify(state.patches[0].ops) === JSON.stringify([
|
||||
{ op: 'bp_set', path: '/people/0/who', value: [{ kind: 'user', name: 'zhang' }] }]),
|
||||
JSON.stringify(state.patches?.[0]))
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 反问
|
||||
|
||||
await section('反问:选项可点、「这一轮在等你回答」常显、回答记在问题上', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// 口径卡、快照对出来(显示文字按显示规则重新渲染后逐字比对)不通过,原因点名正文显示的字和引用的值,报告标题旁的徽标
|
||||
// 跟着不通过;
|
||||
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
|
||||
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后
|
||||
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、被驳回结束(写「被驳回」、写明取自运行记录)、先失败后
|
||||
// 经继续运行跑到成功的早期运行、两条出具
|
||||
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列(卡的节点完成事件只给哈希)、同一张卡配上证据
|
||||
// 目录里写着号码的老文档(文档只给哈希、页面写明原因)、设了遮罩的数据源上按地区汇总的查询、每种显示
|
||||
@@ -611,7 +611,7 @@ await section('样本页:各种形状', async () => {
|
||||
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
|
||||
return
|
||||
}
|
||||
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 17, Object.keys(samples).join('、'))
|
||||
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 18, Object.keys(samples).join('、'))
|
||||
|
||||
await sample('base', async (s, page) => {
|
||||
check('base:有无法核对的项,总览是中性的「可核对的项全部通过,另有 N 项无法核对」,不是绿色的通过', neutral(s),
|
||||
@@ -734,6 +734,15 @@ await section('样本页:各种形状', async () => {
|
||||
await sample('base', async (_, page) => {
|
||||
check('成功结束的运行不提重新封存', await page.locator('#seal-failed').isHidden())
|
||||
})
|
||||
// 被审批人驳回结束(cancelled + end_reason=rejected):写「被驳回」,不写成手动停止的「已取消」;封存里只核对得到
|
||||
// 「运行取消」这一半,旁边写明被驳回取自运行记录。不是失败,不提重新封存
|
||||
await sample('rejected', async (s, page) => {
|
||||
check('rejected:运行结果写「被驳回」,与运行取消事件对上', s.meta.status?.result === 'pass'
|
||||
&& s.meta.status.text.includes('被驳回') && !s.meta.status.text.includes('已取消'), JSON.stringify(s.meta.status))
|
||||
check('rejected:写明被驳回取自运行记录', s.meta.status?.text.includes('取自运行记录')
|
||||
&& hasItem(s, 'meta', 'pass', '取自运行记录'), JSON.stringify(s.meta.status))
|
||||
check('rejected:不提重新封存', await page.locator('#seal-failed').isHidden())
|
||||
})
|
||||
// 早期运行先失败、经继续运行跑到成功:范围里那条运行失败之后运行又继续了,它不是运行的结束;成功结束的那条在范围外
|
||||
await sample('continued', async (s) => {
|
||||
check('continued:总览中性,运行结果无法核对(成功结束的那条不在封存范围内),不拿中途那条失败去对',
|
||||
|
||||
@@ -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),
|
||||
], {
|
||||
|
||||
+58
-1
@@ -198,6 +198,15 @@ const GUARD_CONVS = {
|
||||
const FIX_CONVS = {
|
||||
fixZ: conv('c0fixz', '会话 Z:参数定义有误(后端现在的说法)', 1, 60 * 24 * 27),
|
||||
}
|
||||
/**
|
||||
* 只在「被驳回」那段出现(05-已知遗留 (a)):一轮的运行被审批人驳回结束(服务端 cancelled + end_reason=rejected,
|
||||
* 落库时 meta.outcome 是 cancelled、meta.rejected 记着那一句)。rejR 会打开、重新运行一次;rejS 不打开,状态只能从
|
||||
* 列表接口的 last_status 来
|
||||
*/
|
||||
const REJECT_CONVS = {
|
||||
rejR: conv('c0rejr', '会话:退款那一问', 1, 60 * 24 * 35, { last_status: 'rejected', last_run_id: 'run-rej2' }),
|
||||
rejS: conv('c0rejs', '会话:没打开过的那一问', 1, 60 * 24 * 36, { last_status: 'rejected' }),
|
||||
}
|
||||
/** 只在「助手反问」那段出现:库里存着一轮以反问收尾的(answer 列里是助手问的那句话,meta.asked 标着) */
|
||||
const ASKBACK_CONVS = {
|
||||
askBack: conv('c0askbk', '助手反问过的会话', 1, 60 * 24 * 34),
|
||||
@@ -340,6 +349,9 @@ const DETAIL = {
|
||||
// 要改的是工具库里的参数定义,不在这一轮的流程里:先去改,再接着跑(没记 fix 的老写法,按原话认)
|
||||
c0fixy: [turn('ty1', '查一下订单状态', { status: 'error', error: BROKEN_TOOL, graph: GRAPH, run_id: 'run-badtool' })],
|
||||
c0fixz: [turn('tfz1', '查一下订单状态', { status: 'error', error: BROKEN_TOOL_NOW, graph: GRAPH, run_id: 'run-badtool-now' })],
|
||||
c0rejr: [turn('trj1', '这个月的退款有多少', { status: 'error', error: '已取消', graph: GRAPH, run_id: 'run-rej2',
|
||||
meta: { v: 1, runId: 'run-rej2', runStatus: 'cancelled', outcome: 'cancelled', rejected: { sub: '张工 驳回了「查数」:数字对不上' } } })],
|
||||
c0rejs: [],
|
||||
}
|
||||
/**
|
||||
* 成果带逐段证据的一轮(报告撰写节点 + _evidence)。夹具是后端 compose_doc 真跑出来的,只用
|
||||
@@ -364,6 +376,7 @@ const RUNS = {
|
||||
'run-trash-live': { status: 'running', output: {}, error: null },
|
||||
'run-badtool': { status: 'failed', output: {}, error: BROKEN_TOOL },
|
||||
'run-badtool-now': { status: 'failed', output: {}, error: BROKEN_TOOL_NOW },
|
||||
'run-rej2': { status: 'cancelled', end_reason: 'rejected', output: {}, error: '人工驳回:数字对不上' },
|
||||
}
|
||||
const SOURCES = [
|
||||
{ id: 'ds-mig', name: 'shop', kind: 'mysql', description: '' },
|
||||
@@ -404,6 +417,8 @@ const ctl = {
|
||||
sourcesFail: false,
|
||||
/** 数据源列表里多一个别处刚加的库 */
|
||||
extraSource: false,
|
||||
/** 列表里多摆「被驳回」那段的两个会话(REJECT_CONVS) */
|
||||
rejectConvs: false,
|
||||
}
|
||||
/** 会话的归档 / 删除状态。各段各自 resetDb,免得上一段删掉的会话影响下一段 */
|
||||
const db = { archived: new Set(), purged: new Set() }
|
||||
@@ -415,7 +430,7 @@ resetDb()
|
||||
const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : []),
|
||||
...(ctl.guardConvs ? Object.values(GUARD_CONVS) : []), ...(ctl.askConvs ? Object.values(ASK_CONVS) : []),
|
||||
...(ctl.askBack ? Object.values(ASKBACK_CONVS) : []),
|
||||
...(ctl.fixConvs ? Object.values(FIX_CONVS) : [])]
|
||||
...(ctl.fixConvs ? Object.values(FIX_CONVS) : []), ...(ctl.rejectConvs ? Object.values(REJECT_CONVS) : [])]
|
||||
.filter((c) => !db.purged.has(c.id))
|
||||
.map((c) => ({ ...c, archived: db.archived.has(c.id) }))
|
||||
let runSeq = 0
|
||||
@@ -1446,6 +1461,48 @@ for (const theme of THEMES) {
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('rejected', '被驳回:补救区和列表写「被驳回」,重新运行后「之前的尝试」不写「已手动停止」', async () => {
|
||||
ctl.rejectConvs = true
|
||||
try {
|
||||
const { page, ctx, errors } = await open(theme)
|
||||
await goto(page, 'c0rejr')
|
||||
await shows(page, '这个月的退款有多少')
|
||||
await page.waitForTimeout(500)
|
||||
const turn0 = (await chatState(page)).byConversation.c0rejr?.[0]
|
||||
check('刷新之后照样认成被驳回(相位 cancelled,带着驳回的那一句)', turn0?.phase === 'cancelled'
|
||||
&& turn0?.rejected?.sub === '张工 驳回了「查数」:数字对不上', JSON.stringify({ phase: turn0?.phase, rejected: turn0?.rejected }))
|
||||
const card = page.locator('[data-remedy="cancelled"]')
|
||||
const cardText = (await card.innerText().catch(() => '')).replace(/\n/g, ' / ')
|
||||
check('补救区说是审批人驳回、谁在哪一步驳回,不说「你已停止本轮」', cardText.includes('被审批人驳回')
|
||||
&& cardText.includes('张工 驳回了「查数」:数字对不上') && !cardText.includes('你已停止本轮'), cardText)
|
||||
check('被驳回的没有「继续运行」', !(await card.getByRole('button', { name: '继续运行' }).count()))
|
||||
const aside = page.locator('aside[aria-label="对话列表"]')
|
||||
const rowText = async (t) => (await aside.locator('div.group', { hasText: t }).innerText().catch(() => '')).replace(/\n/g, ' / ')
|
||||
// 标题里不带「被驳回」:标出来的是状态,不是标题里的字
|
||||
check('列表里打开过的:标「被驳回」', (await rowText('会话:退款那一问')).includes('被驳回'), await rowText('会话:退款那一问'))
|
||||
check('列表里没打开过的:按 last_status 标「被驳回」,不标「失败」', (await rowText('没打开过的那一问')).includes('被驳回')
|
||||
&& !(await rowText('没打开过的那一问')).includes('失败'), await rowText('没打开过的那一问'))
|
||||
|
||||
const startsBefore = log.runStarts.length
|
||||
await card.getByRole('button', { name: '重新运行本轮' }).click()
|
||||
check('重新运行:留下一次尝试', await page.waitForFunction(
|
||||
() => window.__chat.getState().byConversation.c0rejr.at(-1).attempts?.length === 1, null, { timeout: 8000 })
|
||||
.then(() => true, () => false))
|
||||
check('按原图重新发起了运行', log.runStarts.length > startsBefore, `${log.runStarts.length - startsBefore} 次`)
|
||||
const [attempt] = (await chatState(page)).byConversation.c0rejr.at(-1).attempts ?? []
|
||||
check('那一次记成被驳回、写谁在哪一步驳回', attempt?.outcome === 'rejected'
|
||||
&& attempt?.summary === '被驳回:张工 驳回了「查数」:数字对不上', JSON.stringify(attempt))
|
||||
const history = page.locator('[aria-label="之前的尝试"]')
|
||||
const historyText = (await history.innerText().catch(() => '')).replace(/\n/g, ' / ')
|
||||
check('「之前的尝试」写「被驳回」,不写「已手动停止」', historyText.includes('第 1 次尝试')
|
||||
&& historyText.includes('被驳回:张工 驳回了「查数」') && !historyText.includes('已手动停止'), historyText)
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await ctx.close()
|
||||
} finally {
|
||||
ctl.rejectConvs = false
|
||||
}
|
||||
})
|
||||
|
||||
await section('fixfirst', '要改的东西在工具库里:先去改参数定义,「接着跑」退成次要', async () => {
|
||||
ctl.fixConvs = true
|
||||
try {
|
||||
|
||||
@@ -2724,5 +2724,32 @@ await section('读入附件 file.extracted:读入多少字、全文多少字
|
||||
&& odd[0]?.title === '读入附件' && odd[2]?.title === '附件抽不出文字', odd.map((s) => `${s.title}|${s.sub ?? ''}`).join(' ; '))
|
||||
})
|
||||
|
||||
await section('人的驳回 run.cancelled reason=rejected(05-已知遗留 (a)):终态是「被驳回」,副标题写谁、哪一步、备注;手动停止照旧「已取消」', async () => {
|
||||
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: seq, data })
|
||||
const head = [E(1, 'run.started', null, { nodes: 2 }), E(2, 'node.started', 'pay', { label: '付款' }),
|
||||
E(3, 'human.requested', 'pay', { title: '确认发送:付款' }), E(4, 'human.resolved', 'pay', { approved: false, note: '金额不对', actor: '张工' }),
|
||||
E(5, 'node.failed', 'pay', { error: '「付款」未确认(被驳回),没有发送:金额不对' })]
|
||||
const ended = (data) => flatten(mod.decodeRun([...head, E(6, 'run.cancelled', null, data)]))
|
||||
.find((s) => s.kind === 'lifecycle' && s.id === 'rc-6')
|
||||
const rejected = ended({ reason: 'rejected', node_id: 'pay', label: '付款', kind: 'action_confirm', decision: 'reject',
|
||||
actor: '张工', note: '金额不对' })
|
||||
check('标题写「被驳回」,不写「已取消」「失败」', rejected?.title === '被驳回' && rejected?.status === 'cancelled',
|
||||
`${rejected?.title} / ${rejected?.status}`)
|
||||
check('副标题写谁在哪一步驳回、备注', rejected?.sub === '张工 驳回了「付款」:金额不对', rejected?.sub)
|
||||
check('指向驳回的那一步(点了画布取景过去)', rejected?.nodeId === 'pay', rejected?.nodeId)
|
||||
const anonymous = ended({ reason: 'rejected', node_id: 'pay', label: '付款', decision: 'reject', actor: null, note: '' })
|
||||
check('没署名、没备注:「「付款」被驳回」,不出 null、undefined', anonymous?.sub === '「付款」被驳回', anonymous?.sub)
|
||||
const expired = ended({ reason: 'rejected', node_id: 'pay', label: '付款', decision: 'expired_reject', actor: null,
|
||||
note: '超过时限,按设置驳回' })
|
||||
check('超时驳回:写按设置驳回,备注不重复', expired?.sub === '「付款」超过时限,按设置驳回', expired?.sub)
|
||||
const phase = mod.decodePhase([...head, E(6, 'run.cancelled', null, { reason: 'rejected' })])
|
||||
check('相位收成 cancelled(不是 failed、不给继续运行)', phase === 'cancelled', phase)
|
||||
const stopped = ended({ reason: 'stopped', actor: '李工' })
|
||||
check('手动停止照旧「已取消」,写谁停的', stopped?.title === '已取消' && stopped?.sub === '李工 取消了本次运行',
|
||||
`${stopped?.title} / ${stopped?.sub}`)
|
||||
const legacy = ended({})
|
||||
check('老事件(没有 reason)照旧「已取消」', legacy?.title === '已取消', legacy?.title)
|
||||
})
|
||||
|
||||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
|
||||
process.exit(failed ? 1 : 0)
|
||||
|
||||
+49
-2
@@ -11,7 +11,8 @@
|
||||
// - 不是派给我的(can_respond 为假):只读,写明原因;同意过、还在等别人的写「你已处理:同意」,不写「没有派给你」;
|
||||
// - 升级之后:升级加派的那一份不算进会签人数,另写升级后的规则(升级给谁、其中一人说了算),名单里标「升级」;
|
||||
// 待办箱一行写所属实例,同标题的分得开;
|
||||
// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;
|
||||
// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;表单里的选人控件按这条待办的候选池取候选(使用者读不到
|
||||
// 整份名录),不在池里的服务端 422(code=principal)挂回那个字段(05-已知遗留 (b));
|
||||
// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」;
|
||||
// - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有
|
||||
// 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那
|
||||
@@ -133,6 +134,13 @@ const TASKS = {
|
||||
{ id: 'bp_daily_mine', owner: '审批人甲', status: 'trialed', app_id: null }] },
|
||||
people: { zhangsan: '张三' },
|
||||
},
|
||||
// 补充输入里选复核人(选人控件):使用者按这条待办的候选池选
|
||||
pick: {
|
||||
...base, id: 'pick', approval_id: 'ap8', kind: 'input', title: '指定复核人',
|
||||
form: { format: 'agentlab.form/1', type: 'object', required: ['reviewer'], properties: {
|
||||
reviewer: { type: 'string', title: '复核人', 'x-widget': 'principal' } } },
|
||||
payload: { kind: 'human_node', message: '请指定复核人' },
|
||||
},
|
||||
input: {
|
||||
...base, id: 'input', approval_id: 'ap5', kind: 'input', title: '补填发票信息',
|
||||
form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: {
|
||||
@@ -162,6 +170,10 @@ async function open(state) {
|
||||
return route.fulfill({ status: 422, json: { detail: '表单没有通过校验', code: 'form_invalid', errors: [
|
||||
{ path: 'invoice', code: 'max_length', message: '最多 20 个字' }] } })
|
||||
}
|
||||
if (m[1] === 'pick' && body?.values?.reviewer !== 'fin') {
|
||||
return route.fulfill({ status: 422, json: { detail: '表单没有通过校验', code: 'form_invalid', errors: [
|
||||
{ path: 'reviewer', code: 'principal', message: `「${body?.values?.reviewer}」不在可选的人里,请从候选中选择` }] } })
|
||||
}
|
||||
const t = TASKS[m[1]]
|
||||
// 会签:这一票记下了,还没凑齐
|
||||
const resolved = m[1] !== 'signoff'
|
||||
@@ -171,7 +183,17 @@ async function open(state) {
|
||||
if (p === '/api/blueprints/bp_mine/copy') return route.fulfill({ json: { id: 'bp_mine_copy', scope: 'shared', owner: '审批人甲' } })
|
||||
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
|
||||
}
|
||||
if (p === '/api/auth/me') return route.fulfill({ json: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
|
||||
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
|
||||
if (p === '/api/directory/candidates') {
|
||||
const q = new URL(r.url()).searchParams
|
||||
;(state.candidateCalls ??= []).push(Object.fromEntries(q))
|
||||
const people = [{ username: 'fin', display_name: '樊宁' }, { username: 'fay', display_name: '费伊' }]
|
||||
return route.fulfill({ json: { users: people.filter((u) => u.username.startsWith(q.get('q') ?? '')), groups: [], truncated: false } })
|
||||
}
|
||||
if (p === '/api/directory') {
|
||||
state.directoryHits = (state.directoryHits ?? 0) + 1
|
||||
return route.fulfill({ status: 403, json: { detail: '需要搭建者权限' } })
|
||||
}
|
||||
// 进了副本蓝图之后的页面不在这里查:蓝图一律答「不存在」,不打到真实服务端
|
||||
if (p.startsWith('/api/blueprints/')) return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } })
|
||||
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: Object.keys(TASKS).length, apps: Object.keys(TASKS).length, other: 0 } })
|
||||
@@ -306,6 +328,31 @@ await section('补充输入:表单校验、422 挂回字段', async () => {
|
||||
check('提交的是表单的值,decision 是 submit', sent?.body?.decision === 'submit' && sent?.body?.values?.invoice === 'FP-001', JSON.stringify(sent?.body))
|
||||
})
|
||||
|
||||
await section('补充输入里选人:按待办的候选池取候选,不在池里 422 挂回字段(05-已知遗留 (b))', async () => {
|
||||
const state = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
|
||||
const page = await open(state)
|
||||
await page.goto(`${WEB}/apps/inbox/pick`)
|
||||
const field = page.locator('[data-schema-form] [data-field="reviewer"]')
|
||||
await field.waitFor({ timeout: 15000 })
|
||||
const input = field.locator('input.field')
|
||||
await input.fill('f')
|
||||
await page.waitForFunction(() => document.querySelectorAll('[data-field="reviewer"] datalist option').length > 1, null,
|
||||
{ timeout: 5000 }).catch(() => {})
|
||||
const asked = (state.candidateCalls ?? []).at(-1) ?? {}
|
||||
check('按这条待办、这个字段问候选(purpose=form、task_id)', asked.purpose === 'form' && asked.task_id === 'pick'
|
||||
&& asked.field === 'reviewer' && !asked.app_id, JSON.stringify(asked))
|
||||
check('……列出池里的人(只有名字),不去取整份名录', (await field.locator('datalist option').count()) === 2 && !state.directoryHits)
|
||||
// 候选接口一时取不到时先收下(提交时服务端判):这里绕过前端的提前核对,直接看服务端 422 怎么挂回来
|
||||
await page.route('**/api/directory/candidates**', (route) => route.abort())
|
||||
await input.fill('wangwu')
|
||||
await input.press('Enter')
|
||||
await page.locator('[data-task-approve]').click()
|
||||
await field.locator('[data-field-error="principal"]').waitFor({ timeout: 5000 }).catch(() => {})
|
||||
check('服务端 422(code=principal)挂回选人字段', (await field.locator('[data-field-error="principal"]').count()) === 1
|
||||
&& (await field.locator('[data-field-error="principal"]').innerText()).includes('不在可选的人里'))
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('审核个人应用:取副本去发布、打开我的副本、退回', async () => {
|
||||
const state = { writes: [] }
|
||||
const page = await open(state)
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
// - 「刷新」写明今天还剩几次(服务端在详情和刷新回包里给):点一次发 POST /refresh,剩余跟着回包变;用完了按钮置灰、
|
||||
// 写「今天的刷新次数已用完」;服务端回 429 时照实说服务端的原话,不当成网络错误;
|
||||
// - 「提交审核」:没选人不让提交;选一位搭建者或一个组、附一句说明,POST /submit-review 带 {to, note};提交后写
|
||||
// 「审核中」和交给了谁,不再给提交按钮;
|
||||
// 「审核中」和交给了谁,不再给提交按钮;选审核的人按候选池取(purpose=review:启用的搭建者和管理员,只有名字),
|
||||
// 不在池里的当场说清(05-已知遗留 (b));
|
||||
// - 审核状态以服务端为准(详情的 personal,形状同服务端 blueprints/personal.personal_state):审核待办还在等写「审核中」;
|
||||
// 结论是已发布写「已有正式版本:去看看」并链到那个应用;
|
||||
// 详情没给时看蓝图的状态(in_review 写「审核中」);
|
||||
@@ -134,6 +135,13 @@ async function open({ state, width = 1280, height = 900 }) {
|
||||
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
|
||||
}
|
||||
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? USER })
|
||||
// 提交审核选人的候选池(purpose=review):启用的搭建者和管理员,只有名字
|
||||
if (p === '/api/directory/candidates') {
|
||||
(state.candidateCalls ??= []).push(Object.fromEntries(url.searchParams))
|
||||
const builders = [{ username: 'lisi', display_name: '李四' }, { username: 'root', display_name: '管理员甲' }]
|
||||
return route.fulfill({ json: { users: builders.filter((u) => u.username.startsWith(url.searchParams.get('q') ?? '')),
|
||||
groups: [], truncated: false } })
|
||||
}
|
||||
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
|
||||
if (p === '/api/tasks') return route.fulfill({ json: { items: [] } })
|
||||
if (p === '/api/apps') {
|
||||
@@ -240,8 +248,15 @@ await section('提交审核:选人才能交、带说明;提交后写「审
|
||||
await dlg.locator('[data-personal-review-dialog]').waitFor({ timeout: 5000 })
|
||||
check('写明搭建者在副本上发布、个人应用不会被改动', /副本/.test(await dlg.innerText()) && /不会被改动/.test(await dlg.innerText()))
|
||||
check('没选人不让提交', await dlg.locator('[data-personal-review-submit]').isDisabled())
|
||||
await dlg.getByRole('textbox', { name: '交给谁审核' }).fill('wangwu')
|
||||
await dlg.getByRole('button', { name: '添加' }).click()
|
||||
await dlg.locator('[data-principal-refused="hard"]').waitFor({ timeout: 5000 }).catch(() => {})
|
||||
check('不是搭建者的名字当场说清,不收', /「wangwu」不在可选的人里/.test(await dlg.locator('[data-principal-refused]').innerText().catch(() => '')))
|
||||
check('……候选按提交审核的池问(purpose=review)', (state.candidateCalls ?? []).some((c) => c.purpose === 'review'),
|
||||
JSON.stringify(state.candidateCalls ?? []))
|
||||
await dlg.getByRole('textbox', { name: '交给谁审核' }).fill('lisi')
|
||||
await dlg.getByRole('button', { name: '添加' }).click()
|
||||
await dlg.locator('[data-principal="user"]').waitFor({ timeout: 5000 }).catch(() => {})
|
||||
await dlg.locator('#personal-review-note').fill('门店店长都想看')
|
||||
await dlg.locator('[data-personal-review-submit]').click()
|
||||
await page.locator('[data-personal-review="in_review"]').waitFor({ timeout: 5000 }).catch(() => {})
|
||||
|
||||
@@ -2461,6 +2461,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
|
||||
// (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有
|
||||
|
||||
@@ -360,6 +360,10 @@ await section('按后端原文匹配的地方:失败归类认得新旧两种
|
||||
['engine/nodes/multi.py', '协作团队用完 {max_rounds} 轮仍未完成:'],
|
||||
['engine/nodes/human.py', '修复时出现了原文没有的值:'],
|
||||
['engine/nodes/human.py', 'f"人工驳回:'],
|
||||
// 存量 failed+驳回的另三种原话(05-已知遗留 (a):新的驳回结束在 cancelled,界面按 end_reason 说,这几条只留给老运行)
|
||||
['engine/nodes/tools.py', '未确认(被驳回),没有发送'],
|
||||
['engine/nodes/tools.py', '的调用已被驳回'],
|
||||
['engine/nodes/tools.py', '"代码执行已被驳回"'],
|
||||
['core/errors.py', '鉴权失败{code}:服务方拒绝了当前 API Key'],
|
||||
['core/errors.py', '"请求过于频繁或额度已用完(429)"'],
|
||||
['core/errors.py', '"无法连接服务:网络不通或服务未启动"'],
|
||||
@@ -412,6 +416,12 @@ await section('按后端原文匹配的地方:失败归类认得新旧两种
|
||||
'结构化校验未通过:修复时出现了原文没有的值:total_count=0', '结构化校验没通过:修复时出现了原文没有的值:total_count=0',
|
||||
'校验修复未采用:修复结果中出现了原文没有的值', false, 'canvas'],
|
||||
['人工驳回(整改前有一条路径写「人工拒绝」)', '人工驳回:数字对不上', '人工拒绝:数字对不上', '人工审批驳回,运行终止', false, undefined],
|
||||
// 存量的另三种驳回原话:都不给「继续运行」(接着跑只会读到同一个驳回答复)
|
||||
['动作确认被驳回(存量)', '「付款」未确认(被驳回),没有发送:金额不对', '「付款」未确认(被驳回),没有发送',
|
||||
'人工审批驳回,运行终止', false, undefined],
|
||||
['工具调用审批被驳回(存量)', '工具 calculator 的调用已被驳回:不能算', '工具 calculator 的调用已被驳回',
|
||||
'人工审批驳回,运行终止', false, undefined],
|
||||
['代码执行审批被驳回(存量)', '代码执行已被驳回:不安全', '代码执行已被驳回', '人工审批驳回,运行终止', false, undefined],
|
||||
['模型鉴权失败', '鉴权失败(401):服务方拒绝了当前 API Key', '鉴权没通过(401):对方拒绝了这把密钥', '模型鉴权失败', true, 'settings'],
|
||||
['限流或额度用完', '请求过于频繁或额度已用完(429)', '请求太频繁,或者额度用完了(429)', '请求过于频繁,或额度已用完', true, undefined],
|
||||
['连不上外部服务', '无法连接服务:网络不通或服务未启动', '连不上对方的服务:网络不通,或者服务没有启动', '无法连接外部服务', true, undefined],
|
||||
|
||||
Reference in New Issue
Block a user