mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
前端:审批卡按审批当场拼出待办(不再为一张卡取一次待办,收到中断就出卡;有 task_id 走待办接口,没有走老的审批接口);矮窗口导航两档高度不再叠加,480px 放得下九项;待办数随目录启动时就取、外壳订阅时当场记底,派给自己的新待办通知不再漏;头一回取目录的卡住从启动算起;外壳横向裁掉悬停提示的占位;证据抽屉同一指标在取时不另发请求;编辑器去掉第二个 main、检查一次只跑一遍;记录页对不上待办的审批照旧画审批卡,待办行写 runName 和节点名;检查脚本按新页面更新断言(沙箱没有待办时用合成的两条、未保存与已删除工作流分开认、生效记录页签、能力子页签)
This commit is contained in:
+25
-7
@@ -126,7 +126,9 @@ export default function App() {
|
||||
if (booting) return <BootScreen onEnterOffline={() => setOfflineOk(true)} />
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
// 横向裁掉:导航项的悬停提示(不可见时也占位)在窄屏上伸出视口,整页就能左右滚出一块空白。clip 不建滚动容器,
|
||||
// 粘性定位和纵向都照旧
|
||||
<div className="flex h-full overflow-x-clip">
|
||||
<Nav stalled={stalled} />
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
@@ -319,9 +321,9 @@ function Nav({ stalled }: { stalled: CatalogCheck[] }) {
|
||||
|
||||
const pages = usePages()
|
||||
// 整条导航约 640px 高(九个导航项),窗口再矮,最底下的遥测点和署名就被切掉、够不着。
|
||||
// 矮窗口(≤660px)按高度收紧到 500px 左右(导航项 36px),≤520px 再收一档到 460px 左右(导航项 32px、
|
||||
// 矮窗口(521–660px)按高度收紧到 500px 左右(导航项 36px),≤520px 再收一档到 460px 左右(导航项 32px、
|
||||
// 图标和字之间只留 2px),而不是让导航滚动:导航一旦 overflow,挂在右侧的悬停提示和遥测浮层也会被
|
||||
// 一起裁掉。加导航项时在 600px、480px 高的窗口里量一遍(check-shell 的「窗口高 …px」)
|
||||
// 一起裁掉。加导航项时在 600px、520px、480px 高的窗口里量一遍(check-shell 的「窗口高 …px」)
|
||||
return (
|
||||
<nav aria-label="主导航" className="relative z-30 flex w-16 shrink-0 flex-col border-r bg-panel">
|
||||
<Link
|
||||
@@ -345,7 +347,9 @@ function Nav({ stalled }: { stalled: CatalogCheck[] }) {
|
||||
aria-keyshortcuts={ariaShortcut(p.shortcut)}
|
||||
className={({ isActive }) => clsx(
|
||||
'group relative flex h-12 w-full flex-col items-center justify-center gap-1 rounded-md text-2xs leading-none outline-none',
|
||||
'[@media(max-height:660px)]:h-9 [@media(max-height:520px)]:h-8 [@media(max-height:520px)]:gap-0.5',
|
||||
// 两档的高度不能叠在同一个窗口高度上:同时命中时后出的那条赢,而 Tailwind 把 660 那条排在后面,
|
||||
// ≤520px 的 32px 就一直没生效(九项在 480px 高的窗口里放不下)。所以 36px 那档只管 521–660px
|
||||
'[@media(min-height:521px)_and_(max-height:660px)]:h-9 [@media(max-height:520px)]:h-8 [@media(max-height:520px)]:gap-0.5',
|
||||
'transition-colors focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
|
||||
isActive ? 'bg-hover font-medium text-fg' : 'text-dim hover:bg-hover hover:text-fg',
|
||||
)}
|
||||
@@ -621,6 +625,10 @@ const NO_CHECKS: CatalogCheck[] = []
|
||||
|
||||
/** 请求发出去这么久还没回来,就不算「还在加载」,算卡住了 */
|
||||
const STALL_MS = 3000
|
||||
/** 页面开始加载外壳的时刻(头一回取目录的卡住从这里算,见 useStalledChecks) */
|
||||
const BOOT_AT = performance.now()
|
||||
/** 头一回取目录还没有着落 */
|
||||
let bootLoad = true
|
||||
|
||||
/**
|
||||
* 卡着没回来的 catalog 请求(后端没断时)。
|
||||
@@ -636,10 +644,17 @@ function useStalledChecks(): CatalogCheck[] {
|
||||
const checks = useCatalog((s) => s.checks)
|
||||
const [stalled, setStalled] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!waiting) return
|
||||
const t = setTimeout(() => setStalled(true), STALL_MS)
|
||||
if (!waiting) {
|
||||
// 头一回取目录有了着落(取回来了或出错了):之后的刷新都从发出请求算
|
||||
if (checks.length) bootLoad = false
|
||||
return
|
||||
}
|
||||
// 启动时先认人(最多等 AUTH_WAIT_MS)再取目录:头一回加载从启动算起,认人等掉的那段也算——人已经在启动页上
|
||||
// 等了那么久,进来以后不能还说「在线」
|
||||
const wait = bootLoad ? Math.max(0, STALL_MS - (performance.now() - BOOT_AT)) : STALL_MS
|
||||
const t = setTimeout(() => setStalled(true), wait)
|
||||
return () => { clearTimeout(t); setStalled(false) }
|
||||
}, [waiting])
|
||||
}, [waiting, checks.length])
|
||||
// 断开时归离线横幅说,这里不再叠一层
|
||||
return stalled && waiting && !down ? checks.filter((c) => c.state === 'pending') : NO_CHECKS
|
||||
}
|
||||
@@ -1190,6 +1205,9 @@ function watchTasks(go: Go): () => void {
|
||||
if (again) { again = false; void pull() }
|
||||
}
|
||||
}
|
||||
// 待办数随目录在启动时就取了,常常早于外壳挂上这个订阅:已经有数时当场记底,别等到它下一次变——那时新来的
|
||||
// 已经在列表里,会被当成「已有的」记进底里,一条通知都不发
|
||||
if (useCatalog.getState().taskCounts && !needsLogin(useAuth.getState().me)) void pull()
|
||||
return useCatalog.subscribe((s, p) => {
|
||||
if (s.taskCounts === p.taskCounts || !s.taskCounts) return
|
||||
if (seen && s.taskCounts.mine === p.taskCounts?.mine && s.taskCounts.latest === p.taskCounts?.latest) return
|
||||
|
||||
@@ -81,6 +81,22 @@ export function EvidenceDrawer({ target, onClose }: { target: EvidenceTarget | n
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 正在取的指标链,按缓存键记一份:同一个指标块在取的途中又挂了一次(开发时 StrictMode 的二次挂载、上层重画换了 target
|
||||
* 对象)不另发一次请求,等同一个结果。取完(成也好败也好)就摘掉,重试时重新发
|
||||
*/
|
||||
const inflight = new Map<string, ReturnType<typeof api.apps.metricEvidence>>()
|
||||
function fetchMetric(key: string, ev: ViewEvidence) {
|
||||
let p = inflight.get(key)
|
||||
if (!p) {
|
||||
p = api.apps.metricEvidence(ev.run_id, ev.node!, ev.metric!)
|
||||
inflight.set(key, p)
|
||||
const drop = () => { if (inflight.get(key) === p) inflight.delete(key) }
|
||||
p.then(drop, drop)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
function MetricChain({ target, onClose }: { target: EvidenceTarget; onClose: () => void }) {
|
||||
const ev = target.evidence
|
||||
const segId = segIdOf(ev)
|
||||
@@ -94,7 +110,7 @@ function MetricChain({ target, onClose }: { target: EvidenceTarget; onClose: ()
|
||||
if (seeded) return
|
||||
let alive = true
|
||||
setError(null)
|
||||
api.apps.metricEvidence(ev.run_id, ev.node!, ev.metric!).then(
|
||||
fetchMetric(key, ev).then(
|
||||
(detail) => {
|
||||
if (!alive) return
|
||||
// 接口答的是这个指标,不是报告里的片段:片段的说法换成这一份文档里的,面板才不会当成「另一份报告」
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
/**
|
||||
* 预览里合成的审批带上它对应的待办(应用平台 X4:一次中断对应一条 Approval 和一条 Task,审批卡内容在 run/TaskCard,
|
||||
* 回复走 /api/tasks/{id}/respond)。审批卡(RunPanel 的 ApprovalCard)看到审批上带着 task 就直接用,不再按运行去取待办
|
||||
* ——预览页没有服务端,按运行取只会落到「未能读取这条审批对应的待办」。
|
||||
*
|
||||
* 种类同服务端 process/tasks.kind_of:人工节点按 mode,工具审批、代码审批、双盲复算、动作确认按中断内容的 kind。
|
||||
* 检查脚本按 task-<审批 id> 伪造 /api/tasks/{id}/respond
|
||||
* 预览里合成的审批带上待办 id(同服务端审批列表上的 task_id)和待办本身。审批卡(RunPanel 的 ApprovalCard)按审批当场拼出
|
||||
* 待办来渲染(run/approvalTask),带着 task_id 时回复走 /api/tasks/{id}/respond;检查脚本按 task-<审批 id> 伪造这个接口。
|
||||
* 带上 task 是给想换掉某几项(表单、会签)的样例用的:审批卡看到它就照用
|
||||
*/
|
||||
import type { Approval } from '../types'
|
||||
import type { Task, TaskKind } from '../typesApp'
|
||||
|
||||
const KIND_OF: Record<string, TaskKind> = {
|
||||
tool_approval: 'tool', code_approval: 'code', dual_check: 'dual_check', action_confirm: 'action_confirm',
|
||||
}
|
||||
import type { Task } from '../typesApp'
|
||||
import { taskFromApproval } from '../run/approvalTask'
|
||||
|
||||
export const taskIdOf = (approvalId: string) => `task-${approvalId}`
|
||||
|
||||
export function taskOf(a: Approval, patch: Partial<Task> = {}): Task {
|
||||
const p: Record<string, any> = a.payload ?? {}
|
||||
const mode = String(p.mode ?? a.mode ?? 'approve')
|
||||
const kind: TaskKind = p.kind === 'human_node'
|
||||
? (mode === 'input' || mode === 'edit' ? mode : 'approve')
|
||||
: KIND_OF[String(p.kind)] ?? 'approve'
|
||||
const pending = a.status === 'pending'
|
||||
return {
|
||||
id: taskIdOf(a.id), approval_id: a.id, run_id: a.run_id, node_id: a.node_id, 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, title: a.title, 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,
|
||||
}
|
||||
return taskFromApproval(a, taskIdOf(a.id), patch)
|
||||
}
|
||||
|
||||
/** 审批带上待办和待办 id(同服务端审批列表上的 task_id) */
|
||||
|
||||
@@ -4937,8 +4937,6 @@ export const TASK_LOG_TEXT = {
|
||||
assignTo: (who: string[]) => `派给 ${who.join('、')}`,
|
||||
anyone: '任何人',
|
||||
initiator: '发起人',
|
||||
loadFailed: '未能读取这条审批对应的待办',
|
||||
retry: '重试',
|
||||
// ---- 时间线(task.* 事件)
|
||||
opened: (title: string) => `待办「${title}」已打开`,
|
||||
responded: (who: string, decision: string, i?: number, n?: number) =>
|
||||
|
||||
@@ -245,7 +245,8 @@ function EditApp({ appId }: { appId: string }) {
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<main className="min-w-0 flex-1 overflow-y-auto" data-app-pane={section}>
|
||||
{/* 外壳已经有 <main>,这里再套一个就是两个主区域地标(同记录页) */}
|
||||
<section className="min-w-0 flex-1 overflow-y-auto" data-app-pane={section} aria-label={T.sections[section]}>
|
||||
{section === 'pins' && <PinsSection />}
|
||||
{section === 'pages' && (ctx.catalog ? <PagesSection /> : <div className="p-4"><Skeleton rows={4} /></div>)}
|
||||
{section === 'forms' && <FormsSection />}
|
||||
@@ -255,7 +256,7 @@ function EditApp({ appId }: { appId: string }) {
|
||||
{section === 'access' && <AccessSection />}
|
||||
{section === 'hooks' && <HooksSection />}
|
||||
{section === 'notify' && <NotifySection />}
|
||||
</main>
|
||||
</section>
|
||||
{assistant && (
|
||||
<aside className="flex w-[360px] shrink-0 flex-col border-l bg-panel" aria-label={T.assistant}>
|
||||
<AppAssistant />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AlertTriangle, CheckCircle2, Crosshair, RotateCw, ShieldCheck, XCircle } from 'lucide-react'
|
||||
import { api } from '../../../api/client'
|
||||
import { EmptyState, ErrorState, Field, Modal, Notice, SectionBar, Skeleton, Spinner, toast } from '../../../components/ui'
|
||||
@@ -80,7 +80,11 @@ export function CheckSection() {
|
||||
const [result, setResult] = useState<{ issues: LintIssue[]; level: string | null } | null>(null)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 一次只跑一遍:上一遍没回来又来一次(开发时 StrictMode 的二次挂载)不另发,免得两份结果先后覆盖
|
||||
const running = useRef(false)
|
||||
const run = useCallback(async () => {
|
||||
if (running.current) return
|
||||
running.current = true
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -89,6 +93,7 @@ export function CheckSection() {
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
running.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}, [appId, dirty, save])
|
||||
|
||||
@@ -77,7 +77,8 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
|
||||
const run = d.run
|
||||
// 待办卡:这次运行待处理的待办(含没有审批的核实动作结果、等外部系统)。待审批、运行状态、待办和动作的事件变了就重新取
|
||||
const taskEvents = d.events.filter((e) => e.type.startsWith('task.') || e.type.startsWith('action.')).length
|
||||
const runTasks = useRunTasks(runId, `${(d.pending ?? []).map((a) => a.id).join(',')}|${run?.status ?? ''}|${taskEvents}`)
|
||||
const pendingIds = (d.pending ?? []).map((a) => a.id)
|
||||
const runTasks = useRunTasks(runId, `${pendingIds.join(',')}|${run?.status ?? ''}|${taskEvents}`, pendingIds)
|
||||
// 停着等人的不只有审批:动作结果未知时运行挂起、开一条核实待办,那也是在等人,不是服务重启打断的「已挂起」
|
||||
const pendingFlag = d.pending == null ? undefined : d.pending.length > 0 || !!runTasks.tasks?.length
|
||||
// 接着流时事件就是事实;不接流时把 GET 到的状态和有没有待审批交给解码器收尾:
|
||||
@@ -527,9 +528,15 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
|
||||
: phase !== 'waiting' && clocks.wall != null ? { elapsedMs: paused ? clocks.active ?? clocks.wall : clocks.wall } : {}),
|
||||
}
|
||||
|
||||
// 待办卡(TaskCard):一次中断一条待办;凑齐了运行恢复,接上实时流。没凑齐(会签里记下一票)只刷新这几张卡
|
||||
const cards = runTasks.tasks?.length ? (
|
||||
<RunTaskCards tasks={runTasks.tasks} onResolved={async (task, resolved) => {
|
||||
// 待办卡(TaskCard):一次中断一条待办;凑齐了运行恢复,接上实时流。没凑齐(会签里记下一票)只刷新这几张卡。
|
||||
// 待审批里对不上待办的(开待办出过错还没补上、老服务端没有待办接口)按审批画审批卡。只认取待办那一刻就在的审批:
|
||||
// 刚来的那条,待办还在路上,不先按审批画一张、取回来又换成待办卡
|
||||
const covered = new Set((runTasks.tasks ?? []).map((t) => t.approval_id).filter(Boolean))
|
||||
const orphans = (d.pending ?? []).filter((a) => runTasks.asked.has(a.id) && !covered.has(a.id))
|
||||
const cards = runTasks.tasks?.length || orphans.length ? (
|
||||
<RunTaskCards tasks={runTasks.tasks ?? []} approvals={orphans}
|
||||
onApprovalResolved={async (a) => { await onResolved(a); runTasks.reload() }}
|
||||
onResolved={async (task, resolved) => {
|
||||
if (!resolved) { runTasks.reload(); return }
|
||||
const approval = task.approval_id ? d.pending?.find((a) => a.id === task.approval_id) : undefined
|
||||
if (approval) await onResolved(approval)
|
||||
|
||||
@@ -533,7 +533,9 @@ export function TaskRows({ queue, filter, onOpen, selectedId, dupNames }: {
|
||||
const overdue = due != null && due.getTime() < now
|
||||
const responded = t.assignments.filter((a) => a.status === 'responded').length
|
||||
const counted = t.assignments.filter((a) => a.status !== 'withdrawn').length
|
||||
const name = t.workflow_name ?? ''
|
||||
// 应用里的待办写应用名;别处的同审批行:没保存就跑的写「未保存的工作流」,不露服务端记的「临时图」
|
||||
const name = t.app_name || runName(t)
|
||||
const unsaved = !t.app_name && isUnsaved(t)
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -566,9 +568,9 @@ export function TaskRows({ queue, filter, onOpen, selectedId, dupNames }: {
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-2xs text-dim">
|
||||
<span>{name || '—'}</span>
|
||||
{t.node_id && <><span className="text-faint"> · 节点</span>「{t.node_id}」</>}
|
||||
{name && dupNames.has(name) && <span className="mono text-faint"> {idTail(t.run_id)}</span>}
|
||||
<span className={unsaved ? 'text-faint' : undefined}>{name || '—'}</span>
|
||||
{t.node_id && <><span className="text-faint"> · 节点</span>「{t.node_label ?? t.node_id}」</>}
|
||||
{name && !unsaved && dupNames.has(name) && <span className="mono text-faint"> {idTail(t.run_id)}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-2xs text-faint">
|
||||
<span className="chip shrink-0" data-task-kind-chip="">{TASK_KIND_LABEL[t.kind]}</span>
|
||||
|
||||
@@ -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<Task[] | null>(null)
|
||||
/**
|
||||
* 这次运行待处理的待办。sig 变了(待审批列表变了、运行状态变了)就重新取。
|
||||
*
|
||||
* asked:取这一次时手上有哪几条待审批。待审批里对不上待办的只在这几条里认——刚来的审批,它的待办还在路上,
|
||||
* 等这次取回来再说,不先按审批画一张、取回来又换成待办卡
|
||||
*/
|
||||
export function useRunTasks(runId: string, sig: string, pendingIds: string[] = []): {
|
||||
tasks: Task[] | null; asked: ReadonlySet<string>; reload: () => void
|
||||
} {
|
||||
const [got, setGot] = useState<{ tasks: Task[]; asked: ReadonlySet<string> } | 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<string> = 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<void>
|
||||
/** 按审批画的那几张:运行恢复了(审批卡只在凑齐时回调) */
|
||||
onApprovalResolved?: (approval: Approval) => void | Promise<void>
|
||||
}) {
|
||||
if (!tasks.length) return null
|
||||
if (!tasks.length && !approvals.length) return null
|
||||
return (
|
||||
<div className="mt-3 space-y-2" data-run-tasks={tasks.length}>
|
||||
<div className="mt-3 space-y-2" data-run-tasks={tasks.length + approvals.length}>
|
||||
{tasks.map((t) => (
|
||||
<div key={t.id} data-approval-card={t.approval_id ?? t.id} data-task-card={t.id}
|
||||
className="relative overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))' }}>
|
||||
className="relative overflow-hidden rounded-lg border" style={CARD_STYLE}>
|
||||
<TaskCard task={t} onResolved={(r) => onResolved(r.task, r.resolved)} />
|
||||
</div>
|
||||
))}
|
||||
{approvals.map((a) => (
|
||||
<div key={a.id} data-approval-card={a.id} className="relative overflow-hidden rounded-lg border" style={CARD_STYLE}>
|
||||
<ApprovalCard approval={a} onResolved={() => onApprovalResolved?.(a)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
/** 老调用方传的:卡头写不写工作流名现在由 TaskCard 自己定(待办上带着工作流名),这里不再用它 */
|
||||
/** 画布上工作流就是眼前这张,卡头不用再说一遍 */
|
||||
showWorkflow?: boolean
|
||||
}) {
|
||||
const refreshApprovals = useCatalog((s) => s.refreshApprovals)
|
||||
const attachRun = useStudio((s) => s.attachRun)
|
||||
const found = useApprovalTask(approval)
|
||||
if (found.state === 'loading') return <div className="border-b p-3"><Skeleton rows={2} height={20} /></div>
|
||||
if (found.state === 'error') {
|
||||
return (
|
||||
<div className="border-b p-3 text-2xs" data-approval={approval.id} data-approval-task="missing">
|
||||
<span style={{ color: 'var(--st-failed)' }}>{TASK_LOG_TEXT.loadFailed}{found.message ? `:${found.message}` : ''}</span>
|
||||
<button type="button" className="btn btn-sm ml-2" onClick={found.reload}>{TASK_LOG_TEXT.retry}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<div data-approval={approval.id} data-task={found.task.id}>
|
||||
<TaskCard task={found.task} onResolved={async (result) => {
|
||||
await refreshApprovals()
|
||||
if (!result.resolved) return
|
||||
if (onResolved) await onResolved(approval.run_id)
|
||||
else await attachRun(approval.run_id)
|
||||
}} />
|
||||
</div>
|
||||
<TaskCard task={task} send={send} showOwner={showWorkflow} onResolved={async (result) => {
|
||||
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<ApprovalTask>(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
|
||||
|
||||
@@ -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<TaskKind, typeof Hand> = {
|
||||
@@ -59,7 +66,7 @@ const KIND_ICON: Record<TaskKind, typeof Hand> = {
|
||||
/** 一天没人处理就用琥珀色写等了多久,同审批卡 */
|
||||
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<string, any> = task.payload ?? {}
|
||||
const kind = task.kind
|
||||
@@ -71,6 +78,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
|
||||
const [errors, setErrors] = useState<FormError[]>([])
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() => (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)' }}>
|
||||
<Icon size={13} aria-hidden /> <span className="min-w-0 truncate">{task.title || TASK_TEXT.untitled}</span>
|
||||
</div>
|
||||
<TaskMeta task={task} />
|
||||
<TaskMeta task={task} showOwner={showOwner} />
|
||||
|
||||
{!pending && <Resolved task={task} />}
|
||||
{(task.assignments.length > 1 || task.policy.kind !== 'any') && <Signoffs task={task} />}
|
||||
@@ -153,7 +161,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
|
||||
{kind === 'dual_check' && (
|
||||
<DualCheckApproval approval={asApproval(task, runClass)}
|
||||
decide={async (ok, reply) => {
|
||||
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) {
|
||||
</button>
|
||||
{trustKey && (
|
||||
<button type="button" className="btn flex-1 justify-center" disabled={busy || inquiring} data-approval-always=""
|
||||
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} onClick={() => void always()}>
|
||||
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`task-always-${task.id}`}
|
||||
onClick={() => void always()}>
|
||||
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
|
||||
</button>
|
||||
)}
|
||||
@@ -238,6 +247,12 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
|
||||
{binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint}
|
||||
</div>
|
||||
)}
|
||||
{/* 「始终允许」会改工具的信任档、影响之后的运行:常显同一句旁注,不只藏在悬停里 */}
|
||||
{trustKey && (
|
||||
<div id={`task-always-${task.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
|
||||
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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 (
|
||||
<div className="tnum mb-2 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-2xs text-dim">
|
||||
<span>{TASK_KIND_LABEL[task.kind] ?? task.kind}</span>
|
||||
|
||||
@@ -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<string, TaskKind> = {
|
||||
tool_approval: 'tool', code_approval: 'code', dual_check: 'dual_check', action_confirm: 'action_confirm',
|
||||
}
|
||||
|
||||
/** 中断内容 → 待办种类,同服务端 process/tasks.kind_of */
|
||||
export function taskKindOf(payload: Record<string, any> | 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> = {}): Task {
|
||||
const p: Record<string, any> = 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,
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,14 @@ const FETCHERS: Record<CatalogKey, () => Promise<unknown[]>> = {
|
||||
|
||||
export const useCatalog = create<CatalogState>((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<CatalogState>((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<CatalogState>((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() })
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(' | '))
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+71
-12
@@ -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])
|
||||
}
|
||||
|
||||
+10
-2
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user