feat(ui/workflows): paused-run resume loop in workflow editor

This commit is contained in:
matevip 2026-05-08 15:06:36 +08:00
parent 2a803d2904
commit 247caaac3d
4 changed files with 263 additions and 3 deletions

View File

@ -825,6 +825,42 @@ export interface WorkflowRunStep {
completedAt?: string
}
/** Pause record carried inline on a paused run so the UI can resume
* without a second roundtrip. Mirrors mate_workflow_run_pause. */
export interface WorkflowRunPause {
id: number
runId: number
stepId?: number
pauseKind: string
pauseToken: string
externalApprovalId?: number
pausedAt: string
resumeDeadline?: string
resumePayloadRef?: string
resumedAt?: string
resumeOutcome?: string
}
export interface PausedRunSummary {
run: WorkflowRun
pause: WorkflowRunPause | null
}
export interface RunDetail {
run: WorkflowRun
steps: WorkflowRunStep[]
/** Most recent unresolved pause record; null when the run is not paused. */
activePause: WorkflowRunPause | null
}
export type ResumeOutcome = 'approved' | 'rejected' | 'timeout' | 'cancelled'
export interface ResumeResponse {
kind: string
runId?: number | null
errorMessage?: string | null
}
export const workflowApi = {
list: (workspaceId: number) =>
http.get<WorkflowSummary[]>('/workflows', { params: { workspaceId } }),
@ -844,7 +880,17 @@ export const workflowApi = {
runs: (id: number, limit = 50) =>
http.get<WorkflowRun[]>(`/workflows/${id}/runs`, { params: { limit } }),
runDetail: (runId: number) =>
http.get<{ run: WorkflowRun; steps: WorkflowRunStep[] }>(`/workflows/runs/${runId}`),
http.get<RunDetail>(`/workflows/runs/${runId}`),
/** List paused runs across the workspace so operators can resume them. */
listPausedRuns: (limit = 50) =>
http.get<PausedRunSummary[]>('/workflows/runs/paused', { params: { limit } }),
/** Resume a paused workflow run with one of the four documented outcomes. */
resumeRun: (runId: number, pauseToken: string, outcome: ResumeOutcome, payload?: string) =>
http.post<ResumeResponse>(`/workflows/runs/${runId}/resume`, {
pauseToken,
outcome,
payload,
}),
}
// ==================== Trigger ====================

View File

@ -2015,6 +2015,20 @@ export default {
deleteContent: 'Delete workflow "{name}"? This is reversible via the audit log.',
},
selectHint: 'Select a workflow on the left, or click "New Workflow" to start a fresh draft.',
paused: {
header: 'Paused runs ({count})',
empty: 'No paused runs.',
pauseTokenLabel: 'pause token',
deadlineLabel: 'deadline',
pausedAtLabel: 'paused at',
runHash: 'Run #{id}',
resumeApproved: 'Approve',
resumeRejected: 'Reject',
resumeTimeout: 'Time out',
resumeCancelled: 'Cancel',
resumeOk: 'Run resumed ({outcome}).',
resumeFailed: 'Resume failed: {msg}',
},
runs: {
header: 'Recent runs ({count})',
empty: 'No runs yet.',

View File

@ -2027,6 +2027,20 @@ export default {
deleteContent: '将删除工作流「{name}」?此操作可通过审计日志撤销。',
},
selectHint: '在左侧选择一个工作流,或点击「新建工作流」开始。',
paused: {
header: '待恢复运行 ({count})',
empty: '当前没有暂停中的运行。',
pauseTokenLabel: 'pause token',
deadlineLabel: '超时时间',
pausedAtLabel: '暂停于',
runHash: 'Run #{id}',
resumeApproved: '通过',
resumeRejected: '拒绝',
resumeTimeout: '标记超时',
resumeCancelled: '取消',
resumeOk: '运行已恢复 ({outcome})。',
resumeFailed: '恢复失败:{msg}',
},
runs: {
header: '近期运行 ({count})',
empty: '暂无运行记录。',

View File

@ -122,6 +122,49 @@
<!-- right: runs -->
<aside class="workflows-runs mc-surface-card" v-if="selected">
<section v-if="pausedRuns.length" class="paused-section">
<header class="paused-header">
<span>{{ t('workflows.paused.header', { count: pausedRuns.length }) }}</span>
<button class="btn-ghost" @click="reloadPausedRuns">{{ t('workflows.refresh') }}</button>
</header>
<ul class="paused-list">
<li v-for="entry in pausedRuns" :key="entry.run.id" class="paused-row">
<div class="paused-row-line">
<span class="run-state state-paused">paused</span>
<span class="paused-run-hash">{{ t('workflows.paused.runHash', { id: entry.run.id }) }}</span>
</div>
<div class="paused-meta" v-if="entry.pause">
<div><span>{{ t('workflows.paused.pauseTokenLabel') }}:</span>
<code class="pause-token">{{ truncateToken(entry.pause.pauseToken) }}</code></div>
<div v-if="entry.pause.pausedAt">
<span>{{ t('workflows.paused.pausedAtLabel') }}:</span> {{ formatTime(entry.pause.pausedAt) }}
</div>
<div v-if="entry.pause.resumeDeadline">
<span>{{ t('workflows.paused.deadlineLabel') }}:</span> {{ formatTime(entry.pause.resumeDeadline) }}
</div>
</div>
<div class="paused-actions" v-if="entry.pause">
<button class="resume-btn approve" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'approved')">
{{ t('workflows.paused.resumeApproved') }}
</button>
<button class="resume-btn reject" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'rejected')">
{{ t('workflows.paused.resumeRejected') }}
</button>
<button class="resume-btn neutral" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'timeout')">
{{ t('workflows.paused.resumeTimeout') }}
</button>
<button class="resume-btn neutral" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'cancelled')">
{{ t('workflows.paused.resumeCancelled') }}
</button>
</div>
</li>
</ul>
</section>
<header class="runs-header">
<span>{{ t('workflows.runs.header', { count: runs.length }) }}</span>
<button class="btn-ghost" @click="reloadRuns">{{ t('workflows.refresh') }}</button>
@ -165,6 +208,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcConfirm } from '@/components/common/useConfirm'
import { ElMessage } from 'element-plus'
import {
workflowApi,
type WorkflowSummary,
@ -172,6 +216,8 @@ import {
type WorkflowRunStep,
type WorkflowCompileError,
type WorkflowCompileFailure,
type PausedRunSummary,
type ResumeOutcome,
} from '@/api'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import WorkflowCanvas from '@/components/workflow/WorkflowCanvas.vue'
@ -201,6 +247,8 @@ const busy = ref(false)
const runs = ref<WorkflowRun[]>([])
const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(null)
const pausedRuns = ref<PausedRunSummary[]>([])
const resumingId = ref<number | null>(null)
const templateChoice = ref('')
@ -377,6 +425,40 @@ async function reloadRuns() {
} catch (e) {
console.error('listRuns failed', e)
}
// Paused-run list spans the whole workspace, not just the selected
// workflow operators usually want the global queue when they reach
// for "what's blocked right now". Refresh on every workflow switch
// so the count accurately reflects the post-publish state.
await reloadPausedRuns()
}
async function reloadPausedRuns() {
try {
const res = await workflowApi.listPausedRuns(50)
pausedRuns.value = (res.data as unknown as PausedRunSummary[]) ?? []
} catch (e) {
console.error('listPausedRuns failed', e)
}
}
function truncateToken(token: string | undefined): string {
if (!token) return '-'
return token.length <= 14 ? token : token.slice(0, 6) + '…' + token.slice(-4)
}
async function onResume(entry: PausedRunSummary, outcome: ResumeOutcome) {
if (!entry.pause?.pauseToken) return
resumingId.value = entry.run.id
try {
await workflowApi.resumeRun(entry.run.id, entry.pause.pauseToken, outcome)
ElMessage.success(t('workflows.paused.resumeOk', { outcome }))
await reloadPausedRuns()
await reloadRuns()
} catch (e) {
ElMessage.error(t('workflows.paused.resumeFailed', { msg: (e as Error).message }))
} finally {
resumingId.value = null
}
}
async function loadRun(runId: number) {
@ -554,8 +636,14 @@ function formatTime(iso?: string) {
return iso.replace('T', ' ').slice(0, 19)
}
onMounted(reload)
watch(workspaceId, reload)
onMounted(async () => {
await reload()
await reloadPausedRuns()
})
watch(workspaceId, async () => {
await reload()
await reloadPausedRuns()
})
</script>
<style scoped>
@ -956,6 +1044,104 @@ button:disabled {
gap: 6px;
min-height: 360px;
}
.paused-section {
border-bottom: 1px dashed var(--mc-border-light, rgba(0, 0, 0, 0.08));
padding-bottom: 10px;
margin-bottom: 10px;
}
.paused-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12.5px;
font-weight: 600;
margin-bottom: 6px;
color: var(--mc-text-primary, inherit);
}
.paused-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.paused-row {
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.03));
border: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.08));
border-left: 3px solid var(--node-band, #ffb84d);
border-radius: 6px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.paused-row-line {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.paused-run-hash {
font-weight: 600;
}
.paused-meta {
font-size: 11px;
opacity: 0.85;
display: flex;
flex-direction: column;
gap: 2px;
}
.paused-meta span {
opacity: 0.6;
margin-right: 4px;
}
.pause-token {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
background: var(--mc-bg-elevated, rgba(0, 0, 0, 0.04));
padding: 1px 5px;
border-radius: 3px;
}
.paused-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.resume-btn {
flex: 1 0 calc(50% - 2px);
padding: 5px 8px;
border-radius: 4px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
background: transparent;
color: inherit;
font-size: 11.5px;
cursor: pointer;
}
.resume-btn:hover:not(:disabled) {
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.04));
}
.resume-btn.approve {
background: rgba(46, 204, 113, 0.12);
border-color: rgba(46, 204, 113, 0.5);
color: #1e8449;
}
.resume-btn.approve:hover:not(:disabled) {
background: rgba(46, 204, 113, 0.22);
}
.resume-btn.reject {
background: rgba(231, 76, 60, 0.1);
border-color: rgba(231, 76, 60, 0.5);
color: var(--mc-danger, #c0392b);
}
.resume-btn.reject:hover:not(:disabled) {
background: rgba(231, 76, 60, 0.18);
}
.resume-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Responsive: collapse to a single column below 1100px so the editor
can breathe on tablets, and stack everything vertically below 720px