mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
fix(flows): show "awaiting approval" state in runs lists when a run is halted at an approval gate (#4932)
This commit is contained in:
@@ -23,6 +23,9 @@ import { FlowRunsDrawer } from './FlowRunsDrawer';
|
||||
const listFlowRuns = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns }));
|
||||
|
||||
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals }));
|
||||
|
||||
const FlowRunInspectorDrawer = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./FlowRunInspectorDrawer', () => ({
|
||||
FLOW_RUN_STATUS_ACCENT: {
|
||||
@@ -87,6 +90,7 @@ function renderDrawer(flowId: string | null, onClose: () => void, flowName?: str
|
||||
describe('FlowRunsDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('renders null when flowId is null', () => {
|
||||
@@ -123,6 +127,41 @@ describe('FlowRunsDrawer', () => {
|
||||
expect(row).toHaveTextContent('Completed with warnings');
|
||||
});
|
||||
|
||||
it('shows "Awaiting approval" for a running run halted at a matching flow approval gate', async () => {
|
||||
listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]);
|
||||
fetchPendingApprovals.mockResolvedValue([
|
||||
{
|
||||
request_id: 'req-1',
|
||||
tool_name: 'SLACK_SEND_MESSAGE',
|
||||
action_summary: 'Send Slack message',
|
||||
args_redacted: {},
|
||||
session_id: 'session-1',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
expires_at: null,
|
||||
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
|
||||
},
|
||||
]);
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
|
||||
const row = await screen.findByTestId('flow-run-row-run-1');
|
||||
await waitFor(() => expect(row).toHaveTextContent('Awaiting approval'));
|
||||
expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-pending')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a running run without a matching flow approval labeled "Running"', async () => {
|
||||
listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]);
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
|
||||
const row = await screen.findByTestId('flow-run-row-run-1');
|
||||
await waitFor(() => expect(row).toHaveTextContent('Running'));
|
||||
expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-running')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a generic title when no flowName is given', async () => {
|
||||
listFlowRuns.mockResolvedValue([]);
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
|
||||
@@ -29,6 +29,10 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh';
|
||||
import {
|
||||
resolveDisplayStatus,
|
||||
useRunsPendingApprovalSet,
|
||||
} from '../../hooks/useRunsPendingApprovalSet';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi';
|
||||
import {
|
||||
@@ -142,6 +146,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
|
||||
}, [flowId]);
|
||||
|
||||
useFlowRunsLiveRefresh(runs, refetch);
|
||||
const pendingRunIds = useRunsPendingApprovalSet(runs);
|
||||
|
||||
useEscapeKey(
|
||||
() => {
|
||||
@@ -213,6 +218,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
|
||||
<ul className="space-y-2" data-testid="flow-runs-list">
|
||||
{runs.map(run => {
|
||||
const startedAt = formatTimestamp(run.started_at);
|
||||
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
|
||||
return (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
@@ -222,12 +228,12 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-line bg-surface-muted px-3 py-2 text-left text-xs hover:bg-surface-hover">
|
||||
<span
|
||||
data-testid={`flow-run-row-dot-${run.id}`}
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[displayStatus]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
|
||||
{t(FLOW_RUN_STATUS_KEY[run.status])}
|
||||
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 font-medium ${FLOW_RUN_STATUS_ACCENT[displayStatus]}`}>
|
||||
{t(FLOW_RUN_STATUS_KEY[displayStatus])}
|
||||
</span>
|
||||
{startedAt && (
|
||||
<span className="truncate text-content-muted">{startedAt}</span>
|
||||
|
||||
@@ -21,6 +21,9 @@ import FlowRunsSidebar from './FlowRunsSidebar';
|
||||
const listFlowRuns = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns }));
|
||||
|
||||
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals }));
|
||||
|
||||
// Capture the props handed to the drawer so "Fix with agent" can be invoked
|
||||
// directly without standing up the drawer's own run-polling machinery
|
||||
// (mirrors `FlowApprovalCard.test.tsx`'s stub pattern).
|
||||
@@ -97,6 +100,7 @@ describe('FlowRunsSidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
inspectorDrawerProps.current = null;
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('lists runs and opens the inspector drawer for the clicked run', async () => {
|
||||
@@ -179,4 +183,33 @@ describe('FlowRunsSidebar', () => {
|
||||
|
||||
expect(await screen.findByTestId('flow-runs-sidebar-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Awaiting approval" for a running run halted at an approval gate', async () => {
|
||||
listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]);
|
||||
fetchPendingApprovals.mockResolvedValue([
|
||||
{
|
||||
request_id: 'req-1',
|
||||
tool_name: 'SLACK_SEND_MESSAGE',
|
||||
action_summary: 'Send Slack message',
|
||||
args_redacted: {},
|
||||
session_id: 'session-1',
|
||||
created_at: '2026-07-13T18:23:00Z',
|
||||
expires_at: null,
|
||||
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
|
||||
},
|
||||
]);
|
||||
renderSidebar();
|
||||
|
||||
const runRow = await screen.findByTestId('flow-runs-sidebar-run-run-1');
|
||||
await waitFor(() => expect(runRow).toHaveTextContent('Awaiting approval'));
|
||||
});
|
||||
|
||||
it('leaves a running run without a matching approval labeled "Running"', async () => {
|
||||
listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]);
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
renderSidebar();
|
||||
|
||||
const runRow = await screen.findByTestId('flow-runs-sidebar-run-run-1');
|
||||
await waitFor(() => expect(runRow).toHaveTextContent('Running'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh';
|
||||
import {
|
||||
resolveDisplayStatus,
|
||||
useRunsPendingApprovalSet,
|
||||
} from '../../hooks/useRunsPendingApprovalSet';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi';
|
||||
import { CenteredLoadingState, ErrorBanner } from '../ui/LoadingState';
|
||||
@@ -101,6 +105,7 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) {
|
||||
}, [load]);
|
||||
|
||||
useFlowRunsLiveRefresh(runs, load);
|
||||
const pendingRunIds = useRunsPendingApprovalSet(runs);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" data-testid="flow-runs-sidebar">
|
||||
@@ -150,31 +155,34 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) {
|
||||
)}
|
||||
|
||||
<ul className="space-y-1">
|
||||
{runs.map(run => (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`flow-runs-sidebar-run-${run.id}`}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${
|
||||
selectedRunId === run.id ? 'bg-surface-hover' : ''
|
||||
}`}>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
{runs.map(run => {
|
||||
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
|
||||
return (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`flow-runs-sidebar-run-${run.id}`}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${
|
||||
selectedRunId === run.id ? 'bg-surface-hover' : ''
|
||||
}`}>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
|
||||
{t(FLOW_RUN_STATUS_KEY[run.status])}
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[displayStatus]}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${FLOW_RUN_STATUS_ACCENT[displayStatus]}`}>
|
||||
{t(FLOW_RUN_STATUS_KEY[displayStatus])}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] text-content-faint">
|
||||
{relativeTime(run.started_at, t)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] text-content-faint">
|
||||
{relativeTime(run.started_at, t)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* useRunsPendingApprovalSet — unit tests.
|
||||
*
|
||||
* Verifies: no poll when every run is terminal (or the list is empty); polls
|
||||
* `approval_list_pending` every 3s while a run is `running`; collects only
|
||||
* flow-origin (`source_context.kind === 'flow'`) matches into the returned
|
||||
* Set; a chat-origin approval (no flow `source_context`) is excluded; a
|
||||
* failed poll keeps the last-known set instead of clearing it; teardown once
|
||||
* every run settles to terminal; cleanup on unmount.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PendingApproval } from '../../services/api/approvalApi';
|
||||
import type { FlowRun } from '../../services/api/flowsApi';
|
||||
import { resolveDisplayStatus, useRunsPendingApprovalSet } from '../useRunsPendingApprovalSet';
|
||||
|
||||
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals }));
|
||||
|
||||
function makeRun(overrides: Partial<FlowRun> = {}): FlowRun {
|
||||
return {
|
||||
id: 'run-1',
|
||||
flow_id: 'flow-1',
|
||||
thread_id: 'run-1',
|
||||
status: 'running',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
steps: [],
|
||||
pending_approvals: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeApproval(overrides: Partial<PendingApproval> = {}): PendingApproval {
|
||||
return {
|
||||
request_id: 'req-1',
|
||||
tool_name: 'shell',
|
||||
action_summary: 'Run `shell`',
|
||||
args_redacted: {},
|
||||
session_id: 'session-1',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
expires_at: null,
|
||||
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRunsPendingApprovalSet', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not poll when every run is terminal', async () => {
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
renderHook(() =>
|
||||
useRunsPendingApprovalSet([
|
||||
makeRun({ status: 'completed' }),
|
||||
makeRun({ id: 'run-2', status: 'failed' }),
|
||||
])
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
});
|
||||
expect(fetchPendingApprovals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not poll for an empty runs list', async () => {
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
renderHook(() => useRunsPendingApprovalSet([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(fetchPendingApprovals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls every 3s while a run is running and includes it when a matching flow approval exists', async () => {
|
||||
fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]);
|
||||
const { result } = renderHook(() =>
|
||||
useRunsPendingApprovalSet([makeRun({ status: 'running' })])
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.has('run-1')).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
expect(fetchPendingApprovals).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('excludes a running run with no matching pending approval', async () => {
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
const { result } = renderHook(() =>
|
||||
useRunsPendingApprovalSet([makeRun({ status: 'running' })])
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes a chat-origin approval (no flow source_context)', async () => {
|
||||
fetchPendingApprovals.mockResolvedValue([
|
||||
makeApproval({ request_id: 'req-chat', source_context: undefined }),
|
||||
]);
|
||||
const { result } = renderHook(() =>
|
||||
useRunsPendingApprovalSet([makeRun({ status: 'running' })])
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the last-known set when a poll fails', async () => {
|
||||
fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]);
|
||||
const { result, rerender } = renderHook(
|
||||
({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs),
|
||||
{ initialProps: { runs: [makeRun({ status: 'running' })] } }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.has('run-1')).toBe(true);
|
||||
|
||||
fetchPendingApprovals.mockRejectedValueOnce(new Error('network down'));
|
||||
rerender({ runs: [makeRun({ status: 'running' })] });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
expect(fetchPendingApprovals).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.has('run-1')).toBe(true);
|
||||
|
||||
// A subsequent successful tick keeps polling on the same cadence.
|
||||
fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
expect(fetchPendingApprovals).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('tears down polling once every run settles to terminal', async () => {
|
||||
fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]);
|
||||
const { rerender } = renderHook(
|
||||
({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs),
|
||||
{ initialProps: { runs: [makeRun({ status: 'running' })] } }
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ runs: [makeRun({ status: 'completed' })] });
|
||||
|
||||
fetchPendingApprovals.mockClear();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
});
|
||||
expect(fetchPendingApprovals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up pending timers on unmount', async () => {
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
const { unmount } = renderHook(() =>
|
||||
useRunsPendingApprovalSet([makeRun({ status: 'running' })])
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
|
||||
fetchPendingApprovals.mockClear();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(fetchPendingApprovals).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDisplayStatus', () => {
|
||||
it('overrides to pending_approval when running and the run id is in the pending set', () => {
|
||||
const run = makeRun({ status: 'running' });
|
||||
const pendingRunIds = new Set(['run-1']);
|
||||
expect(resolveDisplayStatus(run, pendingRunIds)).toBe('pending_approval');
|
||||
});
|
||||
|
||||
it('leaves the status untouched when running but not in the pending set', () => {
|
||||
const run = makeRun({ status: 'running' });
|
||||
expect(resolveDisplayStatus(run, new Set())).toBe('running');
|
||||
});
|
||||
|
||||
it('leaves non-running statuses untouched even if the id is (stale) in the pending set', () => {
|
||||
const run = makeRun({ status: 'completed' });
|
||||
const pendingRunIds = new Set(['run-1']);
|
||||
expect(resolveDisplayStatus(run, pendingRunIds)).toBe('completed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* useRunsPendingApprovalSet — cross-references the shared approval queue
|
||||
* against a runs LIST so surfaces that show many runs at once (sidebar,
|
||||
* drawer, all-runs page) can distinguish a run merely `running` from one
|
||||
* that's actually halted at an interactive `ApprovalGate` mid-run.
|
||||
*
|
||||
* The core has no separate DB status for "parked at an approval gate" — a
|
||||
* run stays `status: "running"` in `FlowRun` while it waits; only the shared
|
||||
* `openhuman.approval_list_pending` queue (see `useFlowPendingApprovals`,
|
||||
* the single-run analogue used by `FlowRunInspectorDrawer`) knows it's
|
||||
* parked, via `PendingApproval.source_context = { kind: "flow", run_id }`.
|
||||
*
|
||||
* While at least one run in the list is `running`, this hook polls that
|
||||
* shared queue every {@link POLL_INTERVAL_MS} and returns the Set of
|
||||
* `run_id`s with a matching flow-origin pending approval. Callers combine
|
||||
* this with `run.status` via {@link resolveDisplayStatus} to override the
|
||||
* DISPLAY status only — `run.status` itself, and `useFlowRunsLiveRefresh`
|
||||
* (which reads the ORIGINAL runs array), are untouched, so `running` stays
|
||||
* non-terminal there and the list keeps refetching until the run truly
|
||||
* finishes.
|
||||
*
|
||||
* Mirrors the poll-loop + cleanup shape of `useFlowPendingApprovals`
|
||||
* (cancelled flag + `setTimeout` reschedule + teardown) and the
|
||||
* gate-on-activity contract of `useFlowRunsLiveRefresh`. Unlike
|
||||
* `useFlowPendingApprovals`, a failed poll here does NOT surface an error or
|
||||
* stop polling — this is a best-effort display hint, not a source of truth,
|
||||
* so a transient failure just keeps showing the last-known set and retries
|
||||
* on the next tick.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { fetchPendingApprovals } from '../services/api/approvalApi';
|
||||
import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi';
|
||||
|
||||
const log = debug('flows:runs-pending-approval-set');
|
||||
|
||||
/** How often to re-poll the shared approval queue while any run is `running`. */
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
/**
|
||||
* Poll `openhuman.approval_list_pending` every {@link POLL_INTERVAL_MS}ms
|
||||
* while `runs` contains at least one `status === 'running'` entry, and
|
||||
* return the Set of `run_id`s with a flow-origin pending approval
|
||||
* (`source_context.kind === 'flow'`). Stops polling — but does not clear the
|
||||
* last-known set — once every run has left `running` (completed, failed,
|
||||
* cancelled, or already reflected as `pending_approval`). Best-effort: a
|
||||
* failed poll is logged and simply retried next tick, keeping the
|
||||
* last-known set rather than surfacing an error to the caller.
|
||||
*/
|
||||
export function useRunsPendingApprovalSet(runs: FlowRun[]): Set<string> {
|
||||
const [pendingRunIds, setPendingRunIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const hasRunning = runs.some(run => run.status === 'running');
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRunning) {
|
||||
log('approval polling skipped: no-running-runs');
|
||||
return;
|
||||
}
|
||||
log('approval polling started');
|
||||
|
||||
let cancelled = false;
|
||||
let pollHandle: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
log('approval poll: calling fetchPendingApprovals');
|
||||
const all = await fetchPendingApprovals();
|
||||
if (cancelled) return;
|
||||
const next = new Set<string>();
|
||||
for (const approval of all) {
|
||||
if (approval.source_context?.kind === 'flow') {
|
||||
next.add(approval.source_context.run_id);
|
||||
}
|
||||
}
|
||||
log('approval poll: succeeded total=%d flow-scoped=%d', all.length, next.size);
|
||||
setPendingRunIds(next);
|
||||
} catch (err) {
|
||||
const errorType = err instanceof Error ? err.name : typeof err;
|
||||
log('approval poll: failed error_type=%s; preserving-last-known-set', errorType);
|
||||
// Best-effort — leave `pendingRunIds` as-is and retry next tick.
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
log('approval poll: scheduling retry delay_ms=%d', POLL_INTERVAL_MS);
|
||||
pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollHandle !== undefined) window.clearTimeout(pollHandle);
|
||||
log('approval polling stopped');
|
||||
};
|
||||
}, [hasRunning]);
|
||||
|
||||
return pendingRunIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides a run's DISPLAY status to `pending_approval` when it's currently
|
||||
* `running` AND the shared approval queue (via {@link useRunsPendingApprovalSet})
|
||||
* has a flow-origin pending approval for it. Does not mutate `run` or touch
|
||||
* any other status — callers substitute the result at render sites only
|
||||
* (status dot / accent / label), leaving `run.status` itself (and anything
|
||||
* keyed on it, like `useFlowRunsLiveRefresh`'s terminal-status check) intact.
|
||||
*/
|
||||
export function resolveDisplayStatus(run: FlowRun, pendingRunIds: Set<string>): FlowRunStatus {
|
||||
if (run.status === 'running' && pendingRunIds.has(run.id)) {
|
||||
return 'pending_approval';
|
||||
}
|
||||
return run.status;
|
||||
}
|
||||
|
||||
export default useRunsPendingApprovalSet;
|
||||
@@ -17,6 +17,9 @@ vi.mock('../services/api/flowsApi', () => ({
|
||||
listFlows: (...a: unknown[]) => listFlows(...a),
|
||||
}));
|
||||
|
||||
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../services/api/approvalApi', () => ({ fetchPendingApprovals }));
|
||||
|
||||
// PanelPage + LoadingState pull i18n/redux we don't need — stub to bare markup.
|
||||
vi.mock('../components/layout/PanelPage', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
@@ -31,6 +34,8 @@ describe('WorkflowRunsPage', () => {
|
||||
navigateMock.mockReset();
|
||||
listAllFlowRuns.mockReset();
|
||||
listFlows.mockReset();
|
||||
fetchPendingApprovals.mockReset();
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('renders aggregate runs with their workflow name and status', async () => {
|
||||
@@ -76,6 +81,43 @@ describe('WorkflowRunsPage', () => {
|
||||
await waitFor(() => expect(screen.getByText('rpc down')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows "pending approval" for a running run halted at a matching flow approval gate', async () => {
|
||||
listAllFlowRuns.mockResolvedValue([
|
||||
{ id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' },
|
||||
]);
|
||||
listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]);
|
||||
fetchPendingApprovals.mockResolvedValue([
|
||||
{
|
||||
request_id: 'req-1',
|
||||
tool_name: 'SLACK_SEND_MESSAGE',
|
||||
action_summary: 'Send Slack message',
|
||||
args_redacted: {},
|
||||
session_id: 'session-1',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
expires_at: null,
|
||||
source_context: { kind: 'flow', flow_id: 'f1', run_id: 'r1' },
|
||||
},
|
||||
]);
|
||||
|
||||
render(<WorkflowRunsPage />);
|
||||
|
||||
const row = await screen.findByTestId('workflow-run-r1');
|
||||
await waitFor(() => expect(row).toHaveTextContent('pending approval'));
|
||||
});
|
||||
|
||||
it('leaves a running run without a matching flow approval labeled "running"', async () => {
|
||||
listAllFlowRuns.mockResolvedValue([
|
||||
{ id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' },
|
||||
]);
|
||||
listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]);
|
||||
fetchPendingApprovals.mockResolvedValue([]);
|
||||
|
||||
render(<WorkflowRunsPage />);
|
||||
|
||||
const row = await screen.findByTestId('workflow-run-r1');
|
||||
await waitFor(() => expect(row).toHaveTextContent('running'));
|
||||
});
|
||||
|
||||
describe('live refresh (useFlowRunsLiveRefresh integration)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -13,6 +13,10 @@ import { useNavigate } from 'react-router-dom';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
|
||||
import { useFlowRunsLiveRefresh } from '../hooks/useFlowRunsLiveRefresh';
|
||||
import {
|
||||
resolveDisplayStatus,
|
||||
useRunsPendingApprovalSet,
|
||||
} from '../hooks/useRunsPendingApprovalSet';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import {
|
||||
type Flow,
|
||||
@@ -77,6 +81,7 @@ export default function WorkflowRunsPage() {
|
||||
}, []);
|
||||
|
||||
useFlowRunsLiveRefresh(runs, refetchRuns);
|
||||
const pendingRunIds = useRunsPendingApprovalSet(runs);
|
||||
|
||||
const statusLabel = (status: FlowRunStatus) =>
|
||||
t(`flows.allRuns.status.${status}`, status.replace(/_/g, ' '));
|
||||
@@ -101,31 +106,34 @@ export default function WorkflowRunsPage() {
|
||||
<ul
|
||||
className="divide-y divide-line rounded-xl border border-line"
|
||||
data-testid="workflow-runs-list">
|
||||
{runs.map(run => (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workflow-run-${run.id}`}
|
||||
onClick={() => navigate(`/flows/${run.flow_id}`)}
|
||||
className="flex w-full items-center gap-3 p-3 text-left hover:bg-surface-hover">
|
||||
<span
|
||||
className={`flex-shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_CLASS[run.status]}`}>
|
||||
{statusLabel(run.status)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
|
||||
{flowNames[run.flow_id] ?? t('flows.allRuns.unknownWorkflow')}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[11px] text-content-faint">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
{run.error && (
|
||||
<p className="px-3 pb-2 text-[11px] text-coral-600 dark:text-coral-300">
|
||||
{run.error}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{runs.map(run => {
|
||||
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
|
||||
return (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workflow-run-${run.id}`}
|
||||
onClick={() => navigate(`/flows/${run.flow_id}`)}
|
||||
className="flex w-full items-center gap-3 p-3 text-left hover:bg-surface-hover">
|
||||
<span
|
||||
className={`flex-shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_CLASS[displayStatus]}`}>
|
||||
{statusLabel(displayStatus)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
|
||||
{flowNames[run.flow_id] ?? t('flows.allRuns.unknownWorkflow')}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[11px] text-content-faint">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
{run.error && (
|
||||
<p className="px-3 pb-2 text-[11px] text-coral-600 dark:text-coral-300">
|
||||
{run.error}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user