From c6a29bfd7cca33cbefd0c3f458ef172974b6ad55 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 14 Oct 2026 23:57:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=8D=A1=E6=8C=89=E5=AE=A1=E6=89=B9=E5=BD=93=E5=9C=BA=E6=8B=BC?= =?UTF-8?q?=E5=87=BA=E5=BE=85=E5=8A=9E=EF=BC=88=E4=B8=8D=E5=86=8D=E4=B8=BA?= =?UTF-8?q?=E4=B8=80=E5=BC=A0=E5=8D=A1=E5=8F=96=E4=B8=80=E6=AC=A1=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=EF=BC=8C=E6=94=B6=E5=88=B0=E4=B8=AD=E6=96=AD=E5=B0=B1?= =?UTF-8?q?=E5=87=BA=E5=8D=A1=EF=BC=9B=E6=9C=89=20task=5Fid=20=E8=B5=B0?= =?UTF-8?q?=E5=BE=85=E5=8A=9E=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E8=B5=B0=E8=80=81=E7=9A=84=E5=AE=A1=E6=89=B9=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E7=9F=AE=E7=AA=97=E5=8F=A3=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E4=B8=A4=E6=A1=A3=E9=AB=98=E5=BA=A6=E4=B8=8D=E5=86=8D=E5=8F=A0?= =?UTF-8?q?=E5=8A=A0=EF=BC=8C480px=20=E6=94=BE=E5=BE=97=E4=B8=8B=E4=B9=9D?= =?UTF-8?q?=E9=A1=B9=EF=BC=9B=E5=BE=85=E5=8A=9E=E6=95=B0=E9=9A=8F=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=90=AF=E5=8A=A8=E6=97=B6=E5=B0=B1=E5=8F=96=E3=80=81?= =?UTF-8?q?=E5=A4=96=E5=A3=B3=E8=AE=A2=E9=98=85=E6=97=B6=E5=BD=93=E5=9C=BA?= =?UTF-8?q?=E8=AE=B0=E5=BA=95=EF=BC=8C=E6=B4=BE=E7=BB=99=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E7=9A=84=E6=96=B0=E5=BE=85=E5=8A=9E=E9=80=9A=E7=9F=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=BC=8F=EF=BC=9B=E5=A4=B4=E4=B8=80=E5=9B=9E=E5=8F=96?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=9A=84=E5=8D=A1=E4=BD=8F=E4=BB=8E=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E7=AE=97=E8=B5=B7=EF=BC=9B=E5=A4=96=E5=A3=B3=E6=A8=AA?= =?UTF-8?q?=E5=90=91=E8=A3=81=E6=8E=89=E6=82=AC=E5=81=9C=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E7=9A=84=E5=8D=A0=E4=BD=8D=EF=BC=9B=E8=AF=81=E6=8D=AE=E6=8A=BD?= =?UTF-8?q?=E5=B1=89=E5=90=8C=E4=B8=80=E6=8C=87=E6=A0=87=E5=9C=A8=E5=8F=96?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E5=8F=A6=E5=8F=91=E8=AF=B7=E6=B1=82=EF=BC=9B?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=8E=BB=E6=8E=89=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E4=B8=AA=20main=E3=80=81=E6=A3=80=E6=9F=A5=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=8F=AA=E8=B7=91=E4=B8=80=E9=81=8D=EF=BC=9B=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E9=A1=B5=E5=AF=B9=E4=B8=8D=E4=B8=8A=E5=BE=85=E5=8A=9E=E7=9A=84?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E7=85=A7=E6=97=A7=E7=94=BB=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=8D=A1=EF=BC=8C=E5=BE=85=E5=8A=9E=E8=A1=8C=E5=86=99=20runNam?= =?UTF-8?q?e=20=E5=92=8C=E8=8A=82=E7=82=B9=E5=90=8D=EF=BC=9B=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E8=84=9A=E6=9C=AC=E6=8C=89=E6=96=B0=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=AD=E8=A8=80=EF=BC=88=E6=B2=99=E7=AE=B1?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=BE=85=E5=8A=9E=E6=97=B6=E7=94=A8=E5=90=88?= =?UTF-8?q?=E6=88=90=E7=9A=84=E4=B8=A4=E6=9D=A1=E3=80=81=E6=9C=AA=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=E5=B7=B2=E5=88=A0=E9=99=A4=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=88=86=E5=BC=80=E8=AE=A4=E3=80=81=E7=94=9F=E6=95=88?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=A1=B5=E7=AD=BE=E3=80=81=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=AD=90=E9=A1=B5=E7=AD=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 32 ++++-- frontend/src/apps/EvidenceDrawer.tsx | 18 +++- frontend/src/dev/taskOf.ts | 35 ++---- frontend/src/lib/terms.ts | 2 - frontend/src/pages/apps/edit/AppEditor.tsx | 5 +- .../src/pages/apps/edit/ReviewSections.tsx | 7 +- frontend/src/pages/runs/RunDetailView.tsx | 15 ++- frontend/src/pages/runs/RunList.tsx | 10 +- frontend/src/pages/runs/RunTasks.tsx | 53 ++++++--- frontend/src/run/RunPanel.tsx | 101 ++++++++---------- frontend/src/run/TaskCard.tsx | 29 +++-- frontend/src/run/approvalTask.ts | 48 +++++++++ frontend/src/store/catalog.ts | 15 ++- scripts/check-app-editor.mjs | 6 +- scripts/check-canvas-fx.mjs | 6 +- scripts/check-capabilities.mjs | 11 +- scripts/check-publish.mjs | 9 +- scripts/check-runs.mjs | 83 +++++++++++--- scripts/check-shell.mjs | 12 ++- scripts/check-studio.mjs | 3 +- scripts/check-ui.mjs | 9 +- 21 files changed, 355 insertions(+), 154 deletions(-) create mode 100644 frontend/src/run/approvalTask.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 02322d42..d217bb49 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -126,7 +126,9 @@ export default function App() { if (booting) return setOfflineOk(true)} /> return ( -
+ // 横向裁掉:导航项的悬停提示(不可见时也占位)在窄屏上伸出视口,整页就能左右滚出一块空白。clip 不建滚动容器, + // 粘性定位和纵向都照旧 +
- {name || '—'} - {t.node_id && <> · 节点「{t.node_id}」} - {name && dupNames.has(name) && {idTail(t.run_id)}} + {name || '—'} + {t.node_id && <> · 节点「{t.node_label ?? t.node_id}」} + {name && !unsaved && dupNames.has(name) && {idTail(t.run_id)}}
{TASK_KIND_LABEL[t.kind]} diff --git a/frontend/src/pages/runs/RunTasks.tsx b/frontend/src/pages/runs/RunTasks.tsx index 947d26ef..fbc98011 100644 --- a/frontend/src/pages/runs/RunTasks.tsx +++ b/frontend/src/pages/runs/RunTasks.tsx @@ -5,6 +5,8 @@ import { EmptyState, ErrorState, Skeleton } from '../../components/ui' import { formatDateTime } from '../../lib/format' import { ACTION_ENV_LABEL, ACTION_LOG_TEXT, ACTION_STATE_LABEL, ACTION_TEXT } from '../../lib/terms' import { TaskCard } from '../../run/TaskCard' +import { ApprovalCard } from '../../run/RunPanel' +import type { Approval } from '../../types' import type { ActionRecordRow, Task } from '../../typesApp' /* @@ -12,41 +14,66 @@ import type { ActionRecordRow, Task } from '../../typesApp' * * 待办卡:这次运行待处理的待办,一条一张 TaskCard。以前按待审批画 ApprovalCard,可核实结果未知的动作、等外部系统 * 回调这两种待办没有审批,时间线上就没有地方处理它们。卡片挂 data-approval-card(有审批的写审批 id):等待横幅的 - * 「去处理」按它定位。 + * 「去处理」按它定位。待审批里对不上待办的(开待办出过错、老服务端)照旧按审批画审批卡,不能让等着的审批无处处理。 * * 生效记录:这次运行对外部系统的每次写入(权威记录在服务端 action_records),状态、环境、哈希前 8 位、回执。 * 时间线上的 action.* 事件只是它的影子:核实、重新发送都改这里的记录 */ -/** 这次运行待处理的待办。sig 变了(待审批列表变了、运行状态变了)就重新取 */ -export function useRunTasks(runId: string, sig: string): { tasks: Task[] | null; reload: () => void } { - const [tasks, setTasks] = useState(null) +/** + * 这次运行待处理的待办。sig 变了(待审批列表变了、运行状态变了)就重新取。 + * + * asked:取这一次时手上有哪几条待审批。待审批里对不上待办的只在这几条里认——刚来的审批,它的待办还在路上, + * 等这次取回来再说,不先按审批画一张、取回来又换成待办卡 + */ +export function useRunTasks(runId: string, sig: string, pendingIds: string[] = []): { + tasks: Task[] | null; asked: ReadonlySet; reload: () => void +} { + const [got, setGot] = useState<{ tasks: Task[]; asked: ReadonlySet } | null>(null) const [epoch, setEpoch] = useState(0) useEffect(() => { let alive = true + const asked = new Set(pendingIds) api.tasks.list({ run_id: runId, assigned: 'all', status: 'pending' }) - .then(({ items }) => { if (alive) setTasks(items.filter((t) => t.status === 'pending' || t.status === 'resolving')) }) - .catch(() => { if (alive) setTasks([]) }) + .then(({ items }) => { + if (alive) setGot({ tasks: items.filter((t) => t.status === 'pending' || t.status === 'resolving'), asked }) + }) + .catch(() => { if (alive) setGot({ tasks: [], asked }) }) return () => { alive = false } - }, [runId, sig, epoch]) - return { tasks, reload: useCallback(() => setEpoch((n) => n + 1), []) } + }, [runId, sig, epoch]) // eslint-disable-line react-hooks/exhaustive-deps + return { tasks: got?.tasks ?? null, asked: got?.asked ?? NONE, reload: useCallback(() => setEpoch((n) => n + 1), []) } } -export function RunTaskCards({ tasks, onResolved }: { +const NONE: ReadonlySet = new Set() + +const CARD_STYLE = { borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))' } + +export function RunTaskCards({ tasks, approvals = [], onResolved, onApprovalResolved }: { tasks: Task[] + /** + * 待审批里对不上待办的那几条:服务端开待办时出过错还没补上(老接口 /decide 照旧能处理)、老服务端没有待办接口。 + * 按审批画审批卡(run/RunPanel 的 ApprovalCard,当场按审批拼出待办),不让等待横幅的「去审批卡」落空 + */ + approvals?: Approval[] /** 回复记下了。resolved:凑齐了、运行会恢复(调用方重新接流);没凑齐只刷新这几张卡 */ onResolved: (task: Task, resolved: boolean) => void | Promise + /** 按审批画的那几张:运行恢复了(审批卡只在凑齐时回调) */ + onApprovalResolved?: (approval: Approval) => void | Promise }) { - if (!tasks.length) return null + if (!tasks.length && !approvals.length) return null return ( -
+
{tasks.map((t) => (
+ className="relative overflow-hidden rounded-lg border" style={CARD_STYLE}> onResolved(r.task, r.resolved)} />
))} + {approvals.map((a) => ( +
+ onApprovalResolved?.(a)} /> +
+ ))}
) } diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx index ffead8af..13f991d9 100644 --- a/frontend/src/run/RunPanel.tsx +++ b/frontend/src/run/RunPanel.tsx @@ -1,14 +1,13 @@ -import { useEffect, useState } from 'react' +import { useMemo } from 'react' import { api } from '../api/client' import { useStudio } from '../store/studio' import { useCatalog } from '../store/catalog' import { NODE_DEFS } from '../canvas/nodeDefs' -import { Skeleton, StatusDot } from '../components/ui' -import { errorMessage } from '../lib/errors' -import { TASK_LOG_TEXT } from '../lib/terms' +import { StatusDot } from '../components/ui' import { TaskCard } from './TaskCard' +import { taskFromApproval } from './approvalTask' import type { Approval } from '../types' -import type { Task } from '../typesApp' +import type { Task, TaskRespond } from '../typesApp' // 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在 // 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的 @@ -17,72 +16,60 @@ import type { Task } from '../typesApp' /** * 审批卡:现在只是一层包装(X4)。审批卡的内容——报告核对概况、反方审稿、双盲复算、质询、动作确认的哈希卡、 - * 核实选项、会签进度——都搬进了 run/TaskCard,所有人的决定都经待办服务(/api/tasks/{id}/respond)处理。 + * 核实选项、签批人——都在 run/TaskCard。 * - * 这里只做两件事:按审批找到它对应的那条待办(一次中断对应一条 Approval 和一条 Task),以及回复之后的接流—— - * onResolved 给了就听它的(对话页、运行页各自重新接流),没给就是画布:恢复后由 studio 重新接上事件流。 - * 只有凑齐了(resolved)运行才会恢复;会签里记下一票时运行还停着,不接流。 + * 这里做三件事: + * - 按审批当场拼出待办(run/approvalTask):待办上给人看的内容都原样来自中断内容,审批上全有,收到中断就出卡, + * 不为一张卡再去取一次待办。审批带着 task(预览的桩数据)就照用; + * - 回复走哪里:审批上有 task_id(服务端在审批列表上给)就经待办服务(/api/tasks/{id}/respond);没有(老服务端、 + * 合成样例)照旧走 /api/approvals/{id}/decide,「始终允许」照旧走 /runs/{id}/resume 带 always; + * - 回复之后的接流:onResolved 给了就听它的(对话页、运行页各自重新接流),没给就是画布:恢复后由 studio 重新接上 + * 事件流。只有凑齐了(resolved)运行才会恢复;会签里记下一票时运行还停着,不接流。 */ -export function ApprovalCard({ approval, onResolved }: { +export function ApprovalCard({ approval, onResolved, showWorkflow = true }: { approval: Approval | any onResolved?: (runId: string) => void | Promise - /** 老调用方传的:卡头写不写工作流名现在由 TaskCard 自己定(待办上带着工作流名),这里不再用它 */ + /** 画布上工作流就是眼前这张,卡头不用再说一遍 */ showWorkflow?: boolean }) { const refreshApprovals = useCatalog((s) => s.refreshApprovals) const attachRun = useStudio((s) => s.attachRun) - const found = useApprovalTask(approval) - if (found.state === 'loading') return
- if (found.state === 'error') { - return ( -
- {TASK_LOG_TEXT.loadFailed}{found.message ? `:${found.message}` : ''} - -
- ) - } + const given: Task | undefined = approval.task + const task = useMemo(() => given ?? taskFromApproval(approval), [approval, given]) + const send = approval.task_id ? undefined : (body: TaskRespond) => decideLegacy(approval, task, body) return ( -
- { - await refreshApprovals() - if (!result.resolved) return - if (onResolved) await onResolved(approval.run_id) - else await attachRun(approval.run_id) - }} /> -
+ { + await refreshApprovals() + if (!result.resolved) return + if (onResolved) await onResolved(approval.run_id) + else await attachRun(approval.run_id) + }} /> ) } -type ApprovalTask = - | { state: 'loading' } - | { state: 'ok'; task: Task } - | { state: 'error'; message: string; reload: () => void } - /** - * 一条审批对应的待办。审批带着 task(预览的桩数据)就直接用;带着 task_id(服务端在审批列表上给,D-r)按 id 取那一条; - * 都没有(老服务端)才按运行取待办、按 approval_id 对上。升级时服务端为存量的待审批补建了待办,找不到说明服务端还没建 - * (旧版本)或者已经处理掉了 + * 老的审批接口:服务端没给 task_id 时用。请求体照 /approvals/{id}/decide:approved、note,补充输入、改稿、双盲裁决和 + * 表单带 value;「始终允许」那里没有 always,走 /runs/{id}/resume 并带上 approval_id 指明是哪一条。老接口只当一票、 + * 一票就定,回成「已处理」 */ -function useApprovalTask(approval: Approval & { task?: Task }): ApprovalTask { - const [state, setState] = useState(approval.task ? { state: 'ok', task: approval.task } : { state: 'loading' }) - const [epoch, setEpoch] = useState(0) - useEffect(() => { - if (approval.task) { setState({ state: 'ok', task: approval.task }); return } - let alive = true - const reload = () => setEpoch((n) => n + 1) - const found = approval.task_id - ? api.tasks.get(approval.task_id) - : api.tasks.list({ run_id: approval.run_id, assigned: 'all' }) - .then(({ items }) => items.find((t) => t.approval_id === approval.id) ?? null) - found - .then((task) => { - if (!alive) return - setState(task ? { state: 'ok', task } : { state: 'error', message: '', reload }) - }) - .catch((e) => { if (alive) setState({ state: 'error', message: errorMessage(e), reload }) }) - return () => { alive = false } - }, [approval.id, approval.run_id, approval.task, approval.task_id, epoch]) - return state +async function decideLegacy(approval: Approval, task: Task, body: TaskRespond): Promise<{ task: Task; resolved: boolean }> { + const approved = body.decision === 'approve' || body.decision === 'submit' + const values = body.values ?? {} + const note = body.note ?? '' + if (approved && values.always === true) { + await api.runs.resume(approval.run_id, { approved: true, always: true, note }, approval.id) + } else { + const keys = Object.keys(values) + const value = keys.length === 1 && keys[0] === 'value' ? values.value : keys.length ? values : undefined + await api.approvals.decide(approval.id, { + approved, note, ...(approved && value !== undefined && task.kind !== 'tool' ? { value } : {}), + ...(task.kind === 'tool' && values.args && typeof values.args === 'object' ? { args: values.args } : {}), + }) + } + return { + task: { ...task, status: 'resolved', can_respond: false, outcome: { decision: body.decision } }, + resolved: true, + } } // Timeline / OutputView / describe / EVENT_META / IssuanceBanner / RawEvents diff --git a/frontend/src/run/TaskCard.tsx b/frontend/src/run/TaskCard.tsx index 56fd7e76..3f6220ef 100644 --- a/frontend/src/run/TaskCard.tsx +++ b/frontend/src/run/TaskCard.tsx @@ -49,6 +49,13 @@ export interface TaskCardProps { onResolved: (result: { task: Task; resolved: boolean }) => void /** 列表里用:不画「批了会怎样」的说明、签批人那一行和底部的链接 */ compact?: boolean + /** + * 把回复发出去。默认 POST /api/tasks/{id}/respond;审批卡在老服务端(审批上没有 task_id)上改走老的审批接口, + * 按这里的形状交回结果 + */ + send?: (body: TaskRespond) => Promise<{ task: Task; resolved: boolean }> + /** 卡头写不写应用或工作流名。画布上工作流就是眼前这张,不用再说一遍 */ + showOwner?: boolean } const KIND_ICON: Record = { @@ -59,7 +66,7 @@ const KIND_ICON: Record = { /** 一天没人处理就用琥珀色写等了多久,同审批卡 */ const STALE_MS = 24 * 3_600_000 -export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) { +export function TaskCard({ task, onResolved, compact = false, send, showOwner = true }: TaskCardProps) { const toast = useToast() const payload: Record = task.payload ?? {} const kind = task.kind @@ -71,6 +78,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) { const [errors, setErrors] = useState([]) const [values, setValues] = useState>(() => (task.form ? withDefaults(task.form, {}) : {})) const [text, setText] = useState(() => (payload.draft != null ? String(payload.draft) : '')) + const post = (body: TaskRespond) => (send ? send(body) : api.tasks.respond(task.id, body)) // 运行类别服务端在待办上给(run_class);老服务端不给时:应用里的运行一律是固定版本的正式运行,别处的看中断内容 // 自己带的运行类别,都没有就不画那一行 @@ -89,7 +97,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) { if (busy) return setBusy(true) try { - const res = await api.tasks.respond(task.id, { ...body, ...(note.trim() && !body.note ? { note: note.trim() } : {}) }) + const res = await post({ ...body, ...(note.trim() && !body.note ? { note: note.trim() } : {}) }) setErrors([]) void useCatalog.getState().refreshApprovals() toast(res.resolved ? done : TASK_TEXT.doneVote, 'ok') @@ -135,7 +143,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) { style={{ color: pending ? 'var(--warn)' : 'var(--text-dim)' }}> {task.title || TASK_TEXT.untitled}
- + {!pending && } {(task.assignments.length > 1 || task.policy.kind !== 'any') && } @@ -153,7 +161,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) { {kind === 'dual_check' && ( { - const res = await api.tasks.respond(task.id, ok + const res = await post(ok ? { decision: 'submit', values: { value: reply }, note: reply.note } : { decision: 'reject', note: reply.note }) void useCatalog.getState().refreshApprovals() @@ -217,7 +225,8 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) { {trustKey && ( )} @@ -238,6 +247,12 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) { {binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint}
)} + {/* 「始终允许」会改工具的信任档、影响之后的运行:常显同一句旁注,不只藏在悬停里 */} + {trustKey && ( +
+ {TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)} +
+ )} )} {pending && !task.can_respond && kind !== 'signal' && ( @@ -263,7 +278,7 @@ function asApproval(task: Task, runClass: 'formal' | 'exploratory' | null): Appr // ---------------------------------------------------------------- 卡头 /** 种类 · 应用或工作流 · 节点 · 什么时候发起、等了多久 · 时限 · 升级 */ -function TaskMeta({ task }: { task: Task }) { +function TaskMeta({ task, showOwner }: { task: Task; showOwner: boolean }) { const now = useTicker(60_000) const created = parseServerTime(task.created_at) const age = created ? now - created.getTime() : undefined @@ -271,7 +286,7 @@ function TaskMeta({ task }: { task: Task }) { // 节点的名字:服务端在待办上给(node_label),老的中断内容里也可能带着 const label = task.node_label ?? task.payload?.node_label const where = typeof label === 'string' && label ? label : task.node_id - const owner = task.app_name ? task.app_name : task.workflow_name ? runName(task) : null + const owner = !showOwner ? null : task.app_name ? task.app_name : task.workflow_name ? runName(task) : null return (
{TASK_KIND_LABEL[task.kind] ?? task.kind} diff --git a/frontend/src/run/approvalTask.ts b/frontend/src/run/approvalTask.ts new file mode 100644 index 00000000..31914737 --- /dev/null +++ b/frontend/src/run/approvalTask.ts @@ -0,0 +1,48 @@ +/** + * 审批 → 待办卡要的 Task 形状(应用平台 X4)。一次中断对应一条 Approval 和一条 Task,待办上给人看的内容(标题、中断内容、 + * 表单)都原样来自中断内容,审批上全有:审批卡(RunPanel 的 ApprovalCard)当场按审批拼出来渲染,不再为一张卡多发一次请求 + * 去取待办——收到中断就得出卡,预览和老服务端也没有待办可取。 + * + * 种类、标题、表单的认法同服务端 process/tasks.open_task:人工节点按 mode(派给外部系统的是 signal),工具审批、代码审批、 + * 双盲复算、动作确认按中断内容的 kind。派给谁、时限这些只在待办上有,这里按「任何人、无时限」拼——画布、问数据里的运行 + * 不属于应用,服务端派的也是任何人;真要按会签、时限看,去记录页或待办箱看服务端的待办。 + */ +import type { Approval } from '../types' +import type { Task, TaskKind } from '../typesApp' + +const KIND_OF: Record = { + tool_approval: 'tool', code_approval: 'code', dual_check: 'dual_check', action_confirm: 'action_confirm', +} + +/** 中断内容 → 待办种类,同服务端 process/tasks.kind_of */ +export function taskKindOf(payload: Record | null | undefined, mode?: string): TaskKind { + const p = payload ?? {} + if (p.kind === 'human_node') { + if (p.assignee && typeof p.assignee === 'object' && p.assignee.kind === 'hook') return 'signal' + const m = String(p.mode ?? mode ?? 'approve') + return m === 'input' || m === 'edit' ? m : 'approve' + } + return KIND_OF[String(p.kind)] ?? 'approve' +} + +/** + * 按审批拼一条待办。id 给审批上的 task_id(服务端在审批列表上给);老服务端没有就用审批 id 占位——这时回复不走 + * /api/tasks,由调用方改走老的审批接口 + */ +export function taskFromApproval(a: Approval, id: string = a.task_id || a.id, patch: Partial = {}): Task { + const p: Record = a.payload ?? {} + const pending = a.status === 'pending' + return { + id, approval_id: a.id, run_id: a.run_id, node_id: a.node_id || null, node_label: a.node_label ?? null, + run_class: a.run_class ?? null, workflow_id: a.workflow_id ?? null, workflow_name: a.workflow_name ?? null, + app_id: null, app_version: null, kind: taskKindOf(p, a.mode), title: a.title || String(p.title ?? ''), + form: p.form && typeof p.form === 'object' && !Array.isArray(p.form) ? p.form : null, + policy: { kind: 'any' }, status: pending ? 'pending' : 'resolved', due_at: null, escalation_level: 0, payload: p, + assignments: [{ + id: `as-${a.id}`, principal_kind: 'anyone', principal: '', source: 'anyone', status: pending ? 'pending' : 'responded', + responded_by: a.resolved_by ?? null, responded_at: a.resolved_at ?? null, verified: false, + }], + can_respond: pending, outcome: pending ? null : (a.response ?? {}), resolved_by: a.resolved_by ?? null, + resolved_at: a.resolved_at ?? null, created_at: a.created_at ?? new Date().toISOString(), ...patch, + } +} diff --git a/frontend/src/store/catalog.ts b/frontend/src/store/catalog.ts index d65dc2ca..404729f8 100644 --- a/frontend/src/store/catalog.ts +++ b/frontend/src/store/catalog.ts @@ -150,6 +150,14 @@ const FETCHERS: Record Promise> = { export const useCatalog = create((set, get) => { let backoffStep = 0 + // 待办数:启动时随目录取一次、之后每拍心跳取。后发的请求先回来时,先发的那份晚到了不再盖掉它 + let countReq = 0 + let countApplied = 0 + const applyCounts = (seq: number, counts: TaskCounts) => { + if (seq < countApplied) return + countApplied = seq + set({ taskCounts: counts }) + } const markDown = (error: string) => { // 已经断着再失败一次,就把下一次重试往后推一档。所以"发现断开"的各条路 @@ -235,6 +243,10 @@ export const useCatalog = create((set, get) => { }) } } + // 待办数和目录一起取一次:导航上「应用」「记录」的徽标、新待办通知的记底都等它,不该等到第一拍心跳(4 秒后)。 + // 不算进清单:取不到时徽标退回按待审批数,下一拍心跳再取 + const countSeq = ++countReq + void api.tasks.count(catalogOpts).then((c) => applyCounts(countSeq, c)).catch(() => undefined) const t0 = performance.now() await Promise.all(keys.map(track)) if (!get().loaded) set({ loaded: true }) @@ -260,11 +272,12 @@ export const useCatalog = create((set, get) => { // 留空,「记录」的徽标退回按待审批数 const builder = atLeast(useAuth.getState().me, 'builder') const seq = ++fetchSeq + const countSeq = ++countReq const [approvals, counts] = await Promise.allSettled([ builder ? api.approvals.list('pending', catalogOpts) : Promise.resolve(null), api.tasks.count(catalogOpts), ]) - if (counts.status === 'fulfilled') set({ taskCounts: counts.value }) + if (counts.status === 'fulfilled') applyCounts(countSeq, counts.value) // 心跳测的是这一拍的往返;使用者不取待审批,看待办数那一个请求 const probe = builder ? approvals : counts if (probe.status === 'fulfilled') set({ latencyMs: Math.round(performance.now() - t0), lastOkAt: Date.now() }) diff --git a/scripts/check-app-editor.mjs b/scripts/check-app-editor.mjs index 5cdcc5b0..5e76e81c 100644 --- a/scripts/check-app-editor.mjs +++ b/scripts/check-app-editor.mjs @@ -285,11 +285,13 @@ await section('检查:和发布同一套规则,点一条定位到组件', as check('列出错误和提醒', await waitFor(page, async () => await issues.count() === 3)) await page.locator('[data-app-issue="app.binding_target_missing"] [data-app-issue-locate]').click() await page.waitForURL('**/edit/pages') - check('点「定位」:跳到页面一节、选中那个组件(点号加下标的 path)', await page.locator('[data-app-inspector="gmv"]').count() === 1) + // 页面一节要等组件目录到了才画页面树和属性面板:等它出来再判,别读到骨架屏 + const shows = (sel) => page.locator(sel).waitFor({ timeout: 4000 }).then(async () => await page.locator(sel).count() === 1, () => false) + check('点「定位」:跳到页面一节、选中那个组件(点号加下标的 path)', await shows('[data-app-inspector="gmv"]')) await page.goto(`${WEB}/apps/app-ae/edit/check`, { waitUntil: 'networkidle' }) await page.locator('[data-app-issue="app.expr_arith"] [data-app-issue-locate]').click() await page.waitForURL('**/edit/pages') - check('JSON 指针的 path 也认得', await page.locator('[data-app-inspector="warn"]').count() === 1) + check('JSON 指针的 path 也认得', await shows('[data-app-inspector="warn"]')) }) await section('发布:固定版本升了版,必须为每一项选处置', async () => { diff --git a/scripts/check-canvas-fx.mjs b/scripts/check-canvas-fx.mjs index 3f65ef57..1c8fb6a3 100644 --- a/scripts/check-canvas-fx.mjs +++ b/scripts/check-canvas-fx.mjs @@ -945,9 +945,11 @@ await section('正式运行:入口看 published_version;运行期间画布 await page.waitForTimeout(400) const pop = (await page.locator('.sf-pop').innerText()).replace(/\s+/g, ' ') check('提示不含画布改动', pop.includes('不含画布改动'), pop.slice(0, 80)) + // 发起表单统一用 SchemaForm 画(X2):字段按名字挂 data-field,id 是 run-formal-<字段名> check('表单字段取已发布版本的(topic),不取画布的(q)', - await page.locator('.sf-pop textarea#run-field-topic').count() === 1 - && await page.locator('.sf-pop textarea#run-field-q').count() === 0) + await page.locator('.sf-pop [data-launch-form] [data-field="topic"] #run-formal-topic').count() === 1 + && await page.locator('.sf-pop [data-field="q"]').count() === 0, + (await page.locator('.sf-pop [data-field]').evaluateAll((els) => els.map((e) => e.getAttribute('data-field')))).join(',')) const formalPop = await marks('.sf-pop') check('正式运行浮层的标题和确认按钮都是实心圆点', formalPop.formal === 2 && formalPop.exploratory === 0 && formalPop.shield === 0, JSON.stringify(formalPop)) diff --git a/scripts/check-capabilities.mjs b/scripts/check-capabilities.mjs index 3f80a3ef..5da834b3 100644 --- a/scripts/check-capabilities.mjs +++ b/scripts/check-capabilities.mjs @@ -191,13 +191,17 @@ const browser = await chromium.launch({ executablePath: CHROME }) // ================================================================ await section('能力列表:效果、筛选、同步', async () => { const { page, state, errors } = await open('/tools/capabilities') - check('工具页有「能力」页签', await page.locator('[role=tab]', { hasText: '能力' }).count() === 1) + // 「能力」页签里还有「全部能力 / 待审阅」两个子页签(D-i),按名字整字匹配 + check('工具页有「能力」页签', await page.getByRole('tab', { name: '能力', exact: true }).count() === 1, + (await page.locator('[role=tab]').allInnerTexts()).join(' | ')) check('列出全部能力', await page.locator('[data-capability]').count() === CAPS.length) const pay = page.locator('[data-capability="erp_pay"] [data-capability-effect]') check('治理后的效果和同步得出的不一致时标出来', (await pay.innerText()).includes('写入') && (await pay.innerText()).includes('*')) check('没治理过的按同步得出的效果显示', (await page.locator('[data-capability="erp_query"] [data-capability-effect]').innerText()).includes('只读')) await page.locator('[data-capability="mcp:crm/find"]').click() - check('能力名带斜杠也能打开(地址转义)', await page.locator('[data-capability-editor="mcp:crm/find"]').count() === 1) + // 右栏的治理设置是点开之后另取的:等它出来再判 + check('能力名带斜杠也能打开(地址转义)', await waitFor(page, async () => + await page.locator('[data-capability-editor="mcp:crm/find"]').count() === 1), page.url()) await page.locator('[data-capability-sync]').click() check('立即同步', await waitFor(page, async () => state.syncs === 1)) check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) @@ -273,7 +277,8 @@ await section('能力:待审阅复用数据目录的审阅队列,成批确 check('冲突的那项照实标出(说的是能力,不是表)', await waitFor(page, async () => (await page.locator('[data-review-summary]').innerText()).includes('所在能力刚被修改过'))) await rows.first().locator('[data-review-open]').click() - check('点开一项:右栏是这个能力的治理设置,标出那个位置', await page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1) + check('点开一项:右栏是这个能力的治理设置,标出那个位置', await waitFor(page, async () => + await page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1), page.url()) check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) }) diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index 5ae29b2f..cd269546 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -1994,14 +1994,17 @@ await section('应用平台:发布成功做成应用;已发布版本的使 }) await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(usage) })) - await page.keyboard.press('Alt+h') + // 编排页一进来焦点在助手输入框里,那时的 ⌥H 是打字:先把焦点放回页面(同 check-studio) + await page.evaluate(() => document.activeElement?.blur?.()) + await page.keyboard.press('Alt+KeyH') const sheet = page.locator('[role="dialog"][aria-label="版本历史"]') await sheet.waitFor() check('版本列表标出已退役的版本', await sheet.locator('[data-version-retired-chip]').count() === 1) await sheet.locator('ol button', { hasText: 'v4' }).first().click() const cur = sheet.locator('[data-version-lifecycle="4"]') - check('展开当前发布版:写使用情况', await waitFor(page, async () => await cur.count() === 1) - && (await cur.innerText()).includes('被 1 个应用固定')) + // 使用情况是展开之后另取的(账本接口):等它回来再读,别读到占位的「…」 + check('展开当前发布版:写使用情况', await waitFor(page, async () => await cur.count() === 1 + && (await cur.innerText().catch(() => '')).includes('被 1 个应用固定')), await cur.innerText().catch(() => '')) check('……当前发布版的退役按钮不可点,写明原因', await cur.locator('[data-version-retire]').isDisabled() && (await cur.innerText()).includes('当前发布版不能退役')) await sheet.locator('ol button', { hasText: 'v3' }).first().click() diff --git a/scripts/check-runs.mjs b/scripts/check-runs.mjs index 91754783..7e82526f 100644 --- a/scripts/check-runs.mjs +++ b/scripts/check-runs.mjs @@ -131,6 +131,28 @@ const listTop = await getJson('/runs?limit=50') const wfHost = (await getJson('/workflows'))[0] const base = allRuns.find((r) => r.status === 'succeeded' && !r.workflow_id) +/** + * 合成的两条待处理待办(沙箱里一条都没有时用):挂在沙箱里真实的运行上——最近的一条,和排在第 100 名之后的一条 + * (原来只取 100 条时找不到的那种)。一条等了 8 天,一条刚来 + */ +function syntheticTasks() { + const picks = [allRuns[0], allRuns[Math.min(allRuns.length - 1, 120)]].filter((r, i, a) => r && a.indexOf(r) === i) + return picks.map((r, i) => { + const at = new Date(Date.now() - (i ? 8 * 86400_000 : 3 * 60_000)).toISOString() + return { id: `fake0task0list${i}`, approval_id: `fake0appr0list${i}`, run_id: r.id, node_id: 'gate', node_label: '人工审批', + run_class: r.run_class ?? 'exploratory', workflow_id: r.workflow_id ?? null, workflow_name: r.workflow_name || '订单日报', + app_id: null, app_version: null, kind: i ? 'input' : 'approve', title: i ? '补一下预算上限' : '这份报表可以发出吗?', form: null, + policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0, payload: {}, can_respond: true, + assignments: [{ id: `fake0as${i}`, principal_kind: 'anyone', principal: '', source: 'anyone', status: 'pending', + responded_by: null, responded_at: null, verified: false }], + outcome: null, resolved_by: null, resolved_at: null, created_at: at } + }) +} +const syntheticApprovals = (tasks) => tasks.map((t) => ({ id: t.approval_id, run_id: t.run_id, node_id: t.node_id, + mode: t.kind === 'input' ? 'input' : 'approve', title: t.title, payload: { kind: 'human_node' }, status: 'pending', response: {}, + created_at: t.created_at, workflow_id: t.workflow_id, workflow_name: t.workflow_name, node_label: t.node_label, + run_status: 'interrupted', run_class: t.run_class, task_id: t.id })) + // ------------------------------------------------------------------ 页签 await section('页签与地址', async () => { @@ -182,30 +204,50 @@ await section('待办页签', async () => { ws.send(JSON.stringify({ seq: lastSeq + 2, type: 'node.started', node_id: target.node_id, ts: t + 0.05, data: { resumed: true } })) }) } + // 沙箱是开发库的拷贝:手上的待办刚好都处理完时一条都没有,列表这几项就只剩空态可看。那时拿两条合成的待办来验列表 + // (挂在沙箱里真实的运行上,一条排在最近 100 条之外);点进详情、别处批掉这几段要真待审批,照旧只在有的时候跑 + const synthetic = !pendingTasks.length && allRuns.length > 0 + const listTasks = synthetic ? syntheticTasks() : pendingTasks + if (synthetic) { + console.log(' · 沙箱里没有待处理的待办:列表这几项用合成的 2 条') + fakes.set('GET /api/tasks', (url) => ({ status: 200, json: { items: url.searchParams.get('run_id') ? [] : listTasks } })) + fakes.set('GET /api/approvals', (url) => ({ status: 200, json: url.searchParams.get('run_id') ? [] : syntheticApprovals(listTasks) })) + } await page.goto(`${WEB}/runs?tab=approvals`, { waitUntil: 'networkidle' }) await page.waitForTimeout(400) check('页签叫「待办」', (await page.locator('[role=tab][data-tab=approvals]').innerText()).includes('待办')) const taskRows = page.locator('[data-tasks-list] [data-task-id]') const shownTasks = await taskRows.evaluateAll((els) => els.map((e) => e.getAttribute('data-task-id'))) - check('沙箱里不属于应用的待办都在页签里', pendingTasks.length > 0 && pendingTasks.every((t) => shownTasks.includes(t.id)), - `API ${pendingTasks.length} 条 / 页面 ${shownTasks.length} 条`) + check(synthetic ? '不属于应用的待办都在页签里(合成的 2 条)' : '沙箱里不属于应用的待办都在页签里', + listTasks.length > 0 && listTasks.every((t) => shownTasks.includes(t.id)), + `API ${listTasks.length} 条 / 页面 ${shownTasks.length} 条`) check('存量的待审批都补建了待办', pending.every((a) => pendingTasks.some((t) => t.approval_id === a.id)), `${pending.filter((a) => !pendingTasks.some((t) => t.approval_id === a.id)).length} 条没有对应的待办`) - const deep = pendingTasks.filter((t) => allRuns.findIndex((r) => r.id === t.run_id) >= 100) + const deep = listTasks.filter((t) => allRuns.findIndex((r) => r.id === t.run_id) >= 100) check('排在最近 100 条之外的待办也看得到(原来的死路)', deep.every((t) => shownTasks.includes(t.id)), `${deep.length} 条在第 100 名之后`) check('每条都写着等了多久', await page.locator('[data-tasks-list] [data-task-age]').count() === shownTasks.length) check('每条标着待办的种类', await page.locator('[data-tasks-list] [data-task-kind-chip]').count() === shownTasks.length) - if (pendingTasks.length) { - const rowText = await page.locator(`[data-task-id="${pendingTasks[0].id}"]`).innerText() - const hasWf = rowText.includes(pendingTasks[0].workflow_name ?? '') + if (listTasks.length) { + const first = listTasks[0] + const rowText = await page.locator(`[data-task-id="${first.id}"]`).innerText() + // 应用里的写应用名;没保存就跑的写「未保存的工作流」、不露「临时图」(同审批行的 runName) + const unsaved = !first.workflow_id && (!first.workflow_name || ['临时图', '未保存的工作流'].includes(first.workflow_name)) + const wfName = first.app_name || (unsaved ? '未保存的工作流' : first.workflow_name ?? '') + const hasWf = rowText.includes(wfName) && !rowText.includes('临时图') + const hasNode = rowText.includes(`「${first.node_label ?? first.node_id}」`) // 名字来自沙箱库,只报在不在 - check('每条写着工作流名', hasWf, `工作流名 ${hasWf ? '在' : '缺'}`) + check('每条写着工作流名和节点(节点写名字,不写 id)', hasWf && hasNode, + `工作流名 ${hasWf ? '在' : '缺'} · 节点 ${hasNode ? '在' : '缺'}`) check('每条有处理入口', rowText.includes('去处理')) } check('列表下面写明应用里的待办在哪里处理', (await page.locator('[data-tasks-list]').innerText().catch(() => '')).includes('应用 → 待办')) const badge = await page.locator('[role=tab][data-tab=approvals]').innerText() - check('页签上的数和待办条数一致', badge.includes(String(pendingTasks.length)), badge.replace(/\s+/g, ' ')) + check('页签上的数和待办条数一致', badge.includes(String(listTasks.length)), badge.replace(/\s+/g, ' ')) + if (synthetic) { + fakes.delete('GET /api/tasks') + fakes.delete('GET /api/approvals') + } if (pending.length) { const target = pending[0] const task = pendingTasks.find((t) => t.approval_id === target.id) @@ -1563,8 +1605,21 @@ await section('工作流筛选:未保存的工作流', async () => { if (wfOptions.includes('__none__')) { await listAfter(() => page.locator('[data-runs-workflow]').selectOption('__none__'), (p) => p.get('workflow_id') === '__none__') check('地址记下 ?wf=__none__', new URL(page.url()).searchParams.get('wf') === '__none__') - const names = await page.locator('[data-runs-list] [data-run-id] > div:first-of-type').evaluateAll((els) => els.map((e) => e.textContent ?? '')) - check('筛出来的都是未保存的工作流', names.length > 0 && names.every((n) => n.includes('未保存的工作流')), `${names.length} 行`) + const rows = await page.locator('[data-runs-list] [data-run-id]').evaluateAll((els) => els.map((e) => ({ + id: e.getAttribute('data-run-id'), name: e.querySelector(':scope > div:first-of-type')?.textContent ?? '' }))) + // 没有工作流的运行有两种:没保存就跑的(记的名字是「临时图」、空,或已经是「未保存的工作流」),和工作流后来删掉了的 + // (记着当时的名字)。前一种一律写「未保存的工作流」、不露「临时图」;后一种照记下的名字写。名字来自沙箱库,只报条数 + const byId = new Map((await getJson('/runs?workflow_id=__none__&limit=200')).map((r) => [r.id, r])) + const unsavedName = (r) => !r.workflow_name || ['临时图', '未保存的工作流'].includes(r.workflow_name) + const wrong = rows.filter(({ id, name }) => { + const r = byId.get(id) + if (!r || r.workflow_id) return true + return unsavedName(r) ? !name.includes('未保存的工作流') : !name.includes(r.workflow_name) + }) + check('筛出来的都是没有工作流的运行:没保存就跑的写「未保存的工作流」,不露「临时图」', + rows.length > 0 && !wrong.length && rows.every((r) => !r.name.includes('临时图')) + && rows.some((r) => r.name.includes('未保存的工作流')), + `${rows.length} 行 · ${rows.filter((r) => unsavedName(byId.get(r.id) ?? {})).length} 行是没保存就跑的 · ${wrong.length} 行不对`) await page.locator('[data-runs-workflow]').selectOption('') await settle() } @@ -1886,8 +1941,10 @@ await section('证据页签:左边报告、右边常驻面板、下方审计 }) await page.goto(`${WEB}/runs/${EV}`, { waitUntil: 'networkidle' }) await page.locator('[data-run-detail]').waitFor() + // 应用平台在工件后面加了「生效记录」(§3.8) + const tabOrder = (await page.locator('[data-run-detail] [role=tab]').evaluateAll((els) => els.map((e) => e.getAttribute('data-tab')))).join(',') check('详情多了「证据」页签(在航迹和工件之间)', await viewTab('evidence').count() === 1 - && (await page.locator('[data-run-detail] [role=tab]').evaluateAll((els) => els.map((e) => e.getAttribute('data-tab')))).join(',') === 'stream,trace,evidence,artifacts') + && tabOrder === 'stream,trace,evidence,artifacts,effects', tabOrder) check('没打开证据页签之前不取证据图和审计表', !evRequests.some((u) => u.includes(EV)), evRequests.join(' ')) await viewTab('evidence').click() await page.locator('[data-view-pane=evidence] [data-evidence-pane=cited]').waitFor({ timeout: 8000 }).catch(() => {}) @@ -2294,7 +2351,9 @@ await section('待办页签:审批带着 task_id,新来的审批按 id 补 const byId = [] const onRequest = (req) => { const u = new URL(req.url()) - if (u.pathname === '/api/tasks') lists.push(u.search) + // 只数这个页签的整表重取(assigned=all)。外壳的新待办通知按「派给我的」(assigned=me)取,那是另一回事: + // 它在派给我的待办数变了、或刚打开页面记底时取一次 + if (u.pathname === '/api/tasks' && u.searchParams.get('assigned') !== 'me') lists.push(u.search) const m = u.pathname.match(/^\/api\/tasks\/([^/]+)$/) if (m && m[1] !== 'count') byId.push(m[1]) } diff --git a/scripts/check-shell.mjs b/scripts/check-shell.mjs index be45939c..efb4bb53 100644 --- a/scripts/check-shell.mjs +++ b/scripts/check-shell.mjs @@ -92,11 +92,13 @@ async function open({ theme = 'dark', state }) { // 主题以本地为准,别让沙箱库里存的偏好把这次检查的主题改掉 if (url.pathname === '/api/settings') return route.fulfill({ json: { ui: { theme } } }) if (url.pathname === '/api/approvals') return route.fulfill({ json: state.approvals }) - // 导航上的两个徽标按来源分开数:「记录」数不属于应用的(other),「应用」数应用里的(apps)。这里的待审批都不属于应用 + // 导航上的两个徽标按来源分开数:「记录」数不属于应用的(other),「应用」数应用里的(apps)。这里的待审批都不属于应用。 + // latest 同服务端:派给我的待处理待办里最新一条的开出时间(数不变、却换了一条时,新待办通知看它) if (url.pathname === '/api/tasks/count') { const other = state.approvals.filter((a) => a.status === 'pending').length const apps = state.appTasks ?? 0 - return route.fulfill({ json: { mine: state.tasks ? state.tasks.length : other + apps, apps, other } }) + const latest = state.tasks?.length ? state.tasks.map((t) => t.created_at).sort().at(-1) : null + return route.fulfill({ json: { mine: state.tasks ? state.tasks.length : other + apps, apps, other, latest } }) } // 派给我的待办(外壳只在待办数变了时取一次,拿来判断哪些是新来的) if (url.pathname === '/api/tasks' && state.tasks) return route.fulfill({ json: { items: state.tasks } }) @@ -566,6 +568,12 @@ await section('新的待办', '\n=== 派给自己的新待办:后台时发系 const notes = await page.evaluate(() => window.__notes) check('已按待审批通知过的那一条不再发第二条', notes.some((n) => n.tag === 'approval:shell-ap-task') && !notes.some((n) => n.tag === 'task:shell-task-ap'), notes.map((n) => n.tag).join(',')) + // 数不变、却换了一条(核实的那条处理掉了,又来一条新的):派给我的还是 2 条,靠最新一条的时间(latest)认出来 + state.tasks = [state.tasks[1], taskOf('shell-task-swap', { title: '退款结果未知,请核实', created_at: new Date().toISOString() })] + const swapped = await page.waitForFunction(() => window.__notes.some((n) => n.tag === 'task:shell-task-swap'), null, { timeout: 12000 }) + .then(() => true, () => false) + check('数不变、换了一条:按最新一条的时间认出新来的,照样发通知', swapped, + (await page.evaluate(() => window.__notes.map((n) => n.tag))).join(',')) check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') await ctx.close() }) diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index a39e88c0..7701226b 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -2901,7 +2901,8 @@ await section('应用平台:人工节点的流程配置、入口字段的类 nodes: [ node('in', 'input', 0, 200, '入口', { fields: [{ name: 'amount', required: true }, { name: 'kind' }] }), node('h', 'human', 300, 200, '财务审批', { mode: 'approve', title: '审批', message: '{{ input.amount }}' }), - node('pay', 'tool', 600, 200, '付款', { args: {} }), + // 选了工具:调用工具节点没选工具时「运行」按钮被挡住(错误级问题),发起表单就打不开 + node('pay', 'tool', 600, 200, '付款', { tool: 'http_request', args: {} }), node('out', 'output', 900, 200, '成果', { fields: [{ name: '结果', value: '{{ last_message }}' }] }), ], edges: [edge('in', 'h'), edge('h', 'pay', 'approved'), edge('pay', 'out')], diff --git a/scripts/check-ui.mjs b/scripts/check-ui.mjs index d4326c14..0ca5d048 100644 --- a/scripts/check-ui.mjs +++ b/scripts/check-ui.mjs @@ -118,7 +118,14 @@ async function countNotice(page, text, settle = 1200) { } await section('画布助手栏', async () => { - const { page, errors } = await visit('/studio') + // 守的是空态(还没跟助手说过话):/studio 落到最近改过的那张图,真数据里它常常已经有几轮对话,空态就不出来。 + // 这张图的助手对话按「没有」回,和一张新图一样(建对话的 POST 照旧被写闸门掐掉) + const { page, errors } = await newPage() + await page.route((url) => url.pathname === '/api/conversations' && url.searchParams.get('kind') === 'canvas', + (route) => (route.request().method() === 'GET' + ? route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) : route.fallback())) + await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' }) + await page.waitForTimeout(700) check('没有运行时报错', errors.length === 0, errors.join(' | ')) const body = await page.locator('body').innerText()