feat(flows): Workflows B3b — Run Inspector drawer (#4450)

This commit is contained in:
Cyrus Gray
2026-07-04 01:46:55 +05:30
committed by GitHub
parent 9a5176a3e6
commit 83d298d56a
21 changed files with 1090 additions and 14 deletions
+4 -1
View File
@@ -569,7 +569,10 @@ mod tests {
#[test]
fn marker_is_fresh_bounds_on_age() {
assert!(marker_is_fresh(Some(Duration::from_secs(0)), MARKER_MAX_AGE));
assert!(marker_is_fresh(
Some(Duration::from_secs(0)),
MARKER_MAX_AGE
));
assert!(marker_is_fresh(Some(MARKER_MAX_AGE), MARKER_MAX_AGE));
assert!(!marker_is_fresh(
Some(MARKER_MAX_AGE + Duration::from_secs(1)),
@@ -0,0 +1,276 @@
/**
* FlowRunInspectorDrawer (issue B3b)
* ----------------------------------
*
* Right-side drawer showing a single durable `tinyflows` run's status + step
* timeline, opened from the "View run" action on {@link FlowApprovalCard}.
* Drawer chrome mirrors `pages/conversations/components/SubagentDrawer.tsx`
* (fixed overlay + backdrop-click-to-close + Escape-to-close) so it renders
* as a fixed overlay regardless of where the parent mounts it in the DOM.
*
* Data comes from {@link useFlowRunPoller}, which polls
* `openhuman.flows_get_run` every 2s until the run reaches a terminal status
* (`completed`/`failed`) — `pending_approval` keeps polling since the run can
* still be resumed elsewhere.
*
* `FlowRunStep` is lean by design (`node_id` + `output` + optional `port`
* only — no per-step status/timing), so each step renders as a plain label
* + collapsible output, not a graduated status timeline. Status-dot/pill
* visual language borrows from `components/intelligence/WorkflowRunDetail.tsx`
* (`RUN_STATUS_ACCENT`/`PHASE_STATUS_DOT`) and
* `pages/conversations/components/ToolTimelineBlock.tsx` (`StatusTag`) —
* dots, not progress bars (project rule).
*/
import debug from 'debug';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useFlowRunPoller } from '../../hooks/useFlowRunPoller';
import { useT } from '../../lib/i18n/I18nContext';
import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi';
const log = debug('flows:run-inspector-drawer');
/** Accent classes per run status (semantic palette from tailwind.config.js). */
const FLOW_RUN_STATUS_ACCENT: Record<FlowRunStatus, string> = {
running:
'border-ocean-200 bg-ocean-50 text-ocean-700 dark:border-ocean-500/30 dark:bg-ocean-500/10 dark:text-ocean-300',
completed:
'border-sage-200 bg-sage-50 text-sage-700 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-300',
pending_approval:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300',
failed:
'border-coral-200 bg-coral-50 text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300',
};
/** Header status dot per run status — mirrors `PHASE_STATUS_DOT`. */
const FLOW_RUN_STATUS_DOT: Record<FlowRunStatus, string> = {
running: 'bg-ocean-500 animate-pulse',
completed: 'bg-sage-500',
pending_approval: 'bg-amber-500 animate-pulse',
failed: 'bg-coral-500',
};
const FLOW_RUN_STATUS_KEY: Record<FlowRunStatus, string> = {
running: 'flowRuns.status.running',
completed: 'flowRuns.status.completed',
pending_approval: 'flowRuns.status.pending_approval',
failed: 'flowRuns.status.failed',
};
function formatTimestamp(value: string | null | undefined): string | null {
if (!value) return null;
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return null;
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
}).format(new Date(parsed));
}
/** Render a step's `output` — pretty-printed JSON for objects/arrays, verbatim for strings. */
function formatStepOutput(output: unknown): string {
if (output == null) return '';
if (typeof output === 'string') return output;
try {
return JSON.stringify(output, null, 2);
} catch {
return String(output);
}
}
function StepRow({ step, index }: { step: FlowRunStep; index: number }) {
const { t } = useT();
const outputText = formatStepOutput(step.output);
return (
<li
data-testid={`flow-run-step-${index}`}
className="rounded-lg border border-line bg-surface-muted p-2.5 text-xs">
<div className="flex flex-wrap items-center gap-1.5">
<span className="h-1.5 w-1.5 flex-none rounded-full bg-content-faint" aria-hidden />
<span className="truncate font-mono font-medium text-content-secondary">
{step.node_id}
</span>
{step.port !== undefined && (
<span
data-testid={`flow-run-step-port-${index}`}
className="rounded-md border border-line px-1.5 py-0.5 text-[10px] font-medium text-content-muted">
{t('flowRuns.inspector.port')}: {step.port}
</span>
)}
</div>
{outputText.length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] font-medium text-content-faint hover:text-content-secondary">
{t('flowRuns.inspector.output')}
</summary>
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words rounded bg-surface px-2 py-1.5 font-mono text-[11px] leading-relaxed text-content-secondary">
{outputText}
</pre>
</details>
)}
</li>
);
}
interface Props {
/** Run id (== thread_id) to inspect. Renders `null` (nothing) when absent. */
runId: string | null;
onClose: () => void;
}
/**
* Renders `null` when `runId` is `null` so the parent can mount this
* unconditionally and just flip `runId` (same convention as
* `SubagentDrawer`).
*/
export function FlowRunInspectorDrawer({ runId, onClose }: Props) {
const { t } = useT();
const { run, loading, error } = useFlowRunPoller(runId);
useEscapeKey(() => {
log('escape: closing runId=%s', runId);
onClose();
}, runId !== null);
if (!runId) return null;
const startedAt = formatTimestamp(run?.started_at);
const finishedAt = formatTimestamp(run?.finished_at);
const pendingCount = run?.pending_approvals.length ?? 0;
return (
<div className="fixed inset-0 z-50 flex justify-end" data-testid="flow-run-inspector-drawer">
{/* Backdrop */}
<button
type="button"
aria-label={t('conversations.subagent.close')}
data-testid="flow-run-inspector-backdrop"
className="absolute inset-0 bg-stone-900/30 dark:bg-black/50"
onClick={onClose}
/>
<aside className="relative flex h-full w-full max-w-md flex-col bg-surface shadow-xl">
{/* Header */}
<header className="flex items-start gap-2.5 border-b border-line px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-semibold text-content">
{t('flowRuns.inspector.title')}
</span>
{run && (
<span
data-testid="flow-run-status-dot"
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
/>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
{run && (
<span
data-testid="flow-run-status-pill"
className={`inline-flex 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])}
</span>
)}
{run && <span className="truncate font-mono">{run.flow_id}</span>}
{run && <span className="truncate font-mono">{run.thread_id}</span>}
</div>
</div>
<button
type="button"
data-testid="flow-run-inspector-close"
onClick={onClose}
aria-label={t('conversations.subagent.close')}
className="shrink-0 rounded-full p-1.5 text-content-faint hover:bg-surface-hover hover:text-content-secondary">
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{loading && !run && (
<div
className="flex items-center gap-2 py-8 text-content-faint"
data-testid="flow-run-inspector-loading">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('flowRuns.inspector.loading')}</span>
</div>
)}
{error && (
<div
role="alert"
data-testid="flow-run-inspector-error"
className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('flowRuns.inspector.loadError')}: {error}
</div>
)}
{run && (
<>
{/* Timing */}
<div className="text-xs text-content-muted" data-testid="flow-run-timing">
{startedAt && (
<div>
{t('flowRuns.inspector.startedAt')}: {startedAt}
</div>
)}
{finishedAt ? (
<div>
{t('flowRuns.inspector.finishedAt')}: {finishedAt}
</div>
) : run.status === 'running' || run.status === 'pending_approval' ? (
<div className="animate-pulse">{t('flowRuns.inspector.running')}</div>
) : null}
</div>
{/* Error banner */}
{run.error && (
<div
role="alert"
data-testid="flow-run-error-banner"
className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('flowRuns.inspector.error')}: {run.error}
</div>
)}
{/* Pending approvals banner */}
{run.status === 'pending_approval' && pendingCount > 0 && (
<div
data-testid="flow-run-pending-approvals-banner"
className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300">
{t('flowRuns.inspector.pendingApprovalsCount').replace(
'{count}',
String(pendingCount)
)}
</div>
)}
{/* Steps timeline */}
<div>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">
{t('flowRuns.inspector.steps')}
</h3>
{run.steps.length === 0 ? (
<p className="text-xs italic text-content-faint">
{t('flowRuns.inspector.noSteps')}
</p>
) : (
<ol className="space-y-2" data-testid="flow-run-steps">
{run.steps.map((step, idx) => (
<StepRow key={`${step.node_id}-${idx}`} step={step} index={idx} />
))}
</ol>
)}
</div>
</>
)}
</div>
</aside>
</div>
);
}
export default FlowRunInspectorDrawer;
@@ -0,0 +1,143 @@
/**
* FlowRunInspectorDrawer (issue B3b) — rendering contract.
*
* Asserts: renders null when `runId` is null; loading state; renders fetched
* run data (status pill, steps, expandable output, port pill); error state;
* pending-approvals banner when `status === 'pending_approval'`; run.error
* banner; Escape and backdrop both close; close button calls `onClose`.
*
* Mocks `useFlowRunPoller` directly rather than the underlying RPC client —
* its own poll-until-terminal contract is covered by
* `hooks/__tests__/useFlowRunPoller.test.ts`.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { FlowRun } from '../../../services/api/flowsApi';
import { store } from '../../../store';
import { FlowRunInspectorDrawer } from '../FlowRunInspectorDrawer';
const useFlowRunPoller = vi.hoisted(() => vi.fn());
vi.mock('../../../hooks/useFlowRunPoller', () => ({ useFlowRunPoller }));
function makeRun(overrides: Partial<FlowRun> = {}): FlowRun {
return {
id: 'thread-1',
flow_id: 'flow-1',
thread_id: 'thread-1',
status: 'running',
started_at: '2026-01-01T00:00:00Z',
steps: [
{ node_id: 'fetch-data', output: { rows: 3 } },
{ node_id: 'branch', output: 'ok', port: 'true' },
],
pending_approvals: [],
...overrides,
};
}
function renderDrawer(runId: string | null, onClose: () => void) {
return render(
<Provider store={store}>
<FlowRunInspectorDrawer runId={runId} onClose={onClose} />
</Provider>
);
}
describe('FlowRunInspectorDrawer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders null when runId is null', () => {
useFlowRunPoller.mockReturnValue({ run: null, loading: false, error: null });
const { container } = renderDrawer(null, vi.fn());
expect(container).toBeEmptyDOMElement();
expect(useFlowRunPoller).toHaveBeenCalledWith(null);
});
it('shows a loading state before data resolves', () => {
useFlowRunPoller.mockReturnValue({ run: null, loading: true, error: null });
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-inspector-loading')).toBeInTheDocument();
});
it('renders the run status pill and step list once data resolves', () => {
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-status-pill')).toHaveTextContent('Running');
expect(screen.getByTestId('flow-run-steps')).toBeInTheDocument();
expect(screen.getByText('fetch-data')).toBeInTheDocument();
expect(screen.getByText('branch')).toBeInTheDocument();
expect(screen.getByTestId('flow-run-step-port-1')).toHaveTextContent('true');
});
it('expands a step to reveal its output', () => {
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
renderDrawer('thread-1', vi.fn());
const step = screen.getByTestId('flow-run-step-0');
expect(step.querySelector('pre')).not.toBeVisible();
fireEvent.click(screen.getAllByText('Output')[0]);
expect(step.querySelector('pre')).toBeVisible();
expect(step.querySelector('pre')?.textContent).toContain('"rows": 3');
});
it('shows an error state when the poller reports an error', () => {
useFlowRunPoller.mockReturnValue({ run: null, loading: false, error: 'network down' });
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-inspector-error')).toHaveTextContent('network down');
});
it('shows the pending-approvals banner when status is pending_approval', () => {
useFlowRunPoller.mockReturnValue({
run: makeRun({ status: 'pending_approval', pending_approvals: ['node-a', 'node-b'] }),
loading: false,
error: null,
});
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-pending-approvals-banner')).toHaveTextContent('2');
});
it('does not show the pending-approvals banner for a running run', () => {
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
renderDrawer('thread-1', vi.fn());
expect(screen.queryByTestId('flow-run-pending-approvals-banner')).not.toBeInTheDocument();
});
it('shows the run.error banner when present', () => {
useFlowRunPoller.mockReturnValue({
run: makeRun({ status: 'failed', error: 'node crashed' }),
loading: false,
error: null,
});
renderDrawer('thread-1', vi.fn());
expect(screen.getByTestId('flow-run-error-banner')).toHaveTextContent('node crashed');
});
it('calls onClose when the close button is clicked', () => {
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
const onClose = vi.fn();
renderDrawer('thread-1', onClose);
fireEvent.click(screen.getByTestId('flow-run-inspector-close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when the backdrop is clicked', () => {
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
const onClose = vi.fn();
renderDrawer('thread-1', onClose);
fireEvent.click(screen.getByTestId('flow-run-inspector-backdrop'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when Escape is pressed', () => {
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
const onClose = vi.fn();
renderDrawer('thread-1', onClose);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -1,10 +1,12 @@
/**
* Approve/Dismiss contract for the flow-pending-approval notification card
* (issue B3a). Asserts that Approve reads `{ flow_id, thread_id, node_ids }`
* from the notification's action payload, calls `flowsApi.resumeFlow` with
* those args, clears the notification on success, surfaces a localized error
* on failure, and that Dismiss clears the notification WITHOUT calling any RPC
* (there is no `flows_deny` endpoint yet).
* Approve/Dismiss/View-run contract for the flow-pending-approval
* notification card (issues B3a + B3b). Asserts that Approve reads
* `{ flow_id, thread_id, node_ids }` from the notification's action payload,
* calls `flowsApi.resumeFlow` with those args, clears the notification on
* success, surfaces a localized error on failure (including when `node_ids`
* contains non-string entries — an invalid payload), that Dismiss clears the
* notification WITHOUT calling any RPC (there is no `flows_deny` endpoint
* yet), and that "View run" opens the {@link FlowRunInspectorDrawer}.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
@@ -17,6 +19,11 @@ import FlowApprovalCard from './FlowApprovalCard';
const resumeFlow = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/flowsApi', () => ({ resumeFlow }));
vi.mock('../flows/FlowRunInspectorDrawer', () => ({
FlowRunInspectorDrawer: ({ runId }: { runId: string | null; onClose: () => void }) =>
runId ? <div data-testid="flow-run-inspector-drawer-stub">{runId}</div> : null,
}));
function makeItem(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'flow-pending-approval:flow-1:thread-1',
@@ -92,7 +99,11 @@ describe('FlowApprovalCard', () => {
it('does NOT clear the notification when the run parks again on the next gate', async () => {
// Sequential gates: resume returns with pending_approvals still non-empty and
// the core re-publishes the same-id prompt — the card must not wipe it.
resumeFlow.mockResolvedValue({ output: null, pending_approvals: ['node-c'], thread_id: 'thread-1' });
resumeFlow.mockResolvedValue({
output: null,
pending_approvals: ['node-c'],
thread_id: 'thread-1',
});
store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() });
renderCard(makeItem());
@@ -105,9 +116,7 @@ describe('FlowApprovalCard', () => {
expect(item?.actions).toHaveLength(1);
expect(item?.read).toBe(false);
// Approve re-enabled so the user can act on the next gate.
await waitFor(() =>
expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled()
);
await waitFor(() => expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled());
});
it('shows a localized error and re-enables the buttons when resumeFlow rejects', async () => {
@@ -168,4 +177,54 @@ describe('FlowApprovalCard', () => {
});
expect(resumeFlow).not.toHaveBeenCalled();
});
it('treats non-string node_ids as an invalid payload (Approve errors, no resumeFlow call)', async () => {
renderCard(
makeItem({
actions: [
{
actionId: 'approve',
label: 'Review',
payload: { flow_id: 'flow-1', thread_id: 'thread-1', node_ids: [42, null] },
},
],
})
);
fireEvent.click(screen.getByTestId('flow-approval-approve'));
await waitFor(() => {
expect(
screen.getByText(
(_content, element) =>
element?.tagName.toLowerCase() === 'p' &&
(element?.textContent ?? '').includes(
'Could not resume the workflow. Please try again.'
)
)
).toBeInTheDocument();
});
expect(resumeFlow).not.toHaveBeenCalled();
});
it('does not render "View run" when the payload is invalid', () => {
renderCard(
makeItem({
actions: [{ actionId: 'approve', label: 'Review', payload: { flow_id: 'flow-1' } }],
})
);
expect(screen.queryByTestId('flow-approval-view-run')).not.toBeInTheDocument();
});
it('"View run" opens the run inspector drawer for the payload thread_id', () => {
renderCard(makeItem());
expect(screen.queryByTestId('flow-run-inspector-drawer-stub')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('flow-approval-view-run'));
const drawer = screen.getByTestId('flow-run-inspector-drawer-stub');
expect(drawer).toBeInTheDocument();
expect(drawer).toHaveTextContent('thread-1');
});
});
@@ -15,12 +15,16 @@
* actions and marks it read. Dismiss is UI-only: there is no `flows_deny` /
* cancel-run RPC yet (documented follow-up — the run stays parked
* `pending_approval` server-side and can still be approved later from the run
* history once B3b's inspector ships), so Dismiss just clears the prompt from
* the Notification Center without touching the engine.
* history), so Dismiss just clears the prompt from the Notification Center
* without touching the engine.
*
* Styling mirrors the existing amber approval chrome
* (`WorkflowRunApprovalCard`) and the `role="alertdialog"` a11y pattern
* (`ApprovalRequestCard`) so this reads as the same affordance family.
*
* "View run" (B3b) opens {@link FlowRunInspectorDrawer} for the run's status +
* step timeline (run id === the payload's `thread_id`) without disturbing the
* Approve/Dismiss flow above.
*/
import debug from 'debug';
import { useState } from 'react';
@@ -33,6 +37,7 @@ import {
markRead,
type NotificationItem,
} from '../../store/notificationSlice';
import { FlowRunInspectorDrawer } from '../flows/FlowRunInspectorDrawer';
import Button from '../ui/Button';
const log = debug('notifications:flow-approval-card');
@@ -50,7 +55,8 @@ function isFlowApprovalPayload(value: unknown): value is FlowApprovalPayload {
return (
typeof record.flow_id === 'string' &&
typeof record.thread_id === 'string' &&
Array.isArray(record.node_ids)
Array.isArray(record.node_ids) &&
record.node_ids.every((x: unknown) => typeof x === 'string')
);
}
@@ -67,6 +73,7 @@ const FlowApprovalCard = ({ notification: n }: Props) => {
const dispatch = useAppDispatch();
const [pending, setPending] = useState<'approve' | null>(null);
const [error, setError] = useState<string | null>(null);
const [inspecting, setInspecting] = useState(false);
const payload = n.actions?.[0]?.payload;
const parsed = isFlowApprovalPayload(payload) ? payload : null;
@@ -174,9 +181,31 @@ const FlowApprovalCard = ({ notification: n }: Props) => {
onClick={handleDismiss}>
{t('notifications.flow.dismiss')}
</Button>
{parsed && (
<Button
variant="tertiary"
size="sm"
data-testid="flow-approval-view-run"
onClick={() => {
log(
'viewRun: opening drawer notification=%s threadId=%s',
n.id,
parsed.thread_id
);
setInspecting(true);
}}>
{t('notifications.flow.viewRun')}
</Button>
)}
</div>
</div>
</div>
{parsed && (
<FlowRunInspectorDrawer
runId={inspecting ? parsed.thread_id : null}
onClose={() => setInspecting(false)}
/>
)}
</div>
);
};
@@ -0,0 +1,197 @@
/**
* useFlowRunPoller (issue B3b) — poll-until-terminal contract.
*
* Asserts: initial loading→resolved, 2s poll cadence while `running` /
* `pending_approval`, stop on `completed`/`failed`, stop when `runId` goes
* `null`, error surfaced (and no further poll) on rejection, and effect
* cleanup on unmount.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FlowRun } from '../../services/api/flowsApi';
import { useFlowRunPoller } from '../useFlowRunPoller';
const getFlowRun = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/flowsApi', () => ({ getFlowRun }));
function makeRun(overrides: Partial<FlowRun> = {}): FlowRun {
return {
id: 'thread-1',
flow_id: 'flow-1',
thread_id: 'thread-1',
status: 'running',
started_at: '2026-01-01T00:00:00Z',
steps: [],
pending_approvals: [],
...overrides,
};
}
describe('useFlowRunPoller', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('starts in loading and resolves with the first fetched run', async () => {
getFlowRun.mockResolvedValue(makeRun());
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
expect(result.current.loading).toBe(true);
expect(result.current.run).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.loading).toBe(false);
expect(result.current.run?.status).toBe('running');
expect(result.current.error).toBeNull();
});
it('polls every 2s while the run is running', async () => {
getFlowRun.mockResolvedValue(makeRun({ status: 'running' }));
renderHook(() => useFlowRunPoller('thread-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(getFlowRun).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(getFlowRun).toHaveBeenCalledTimes(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(getFlowRun).toHaveBeenCalledTimes(3);
});
it('keeps polling while pending_approval (not terminal)', async () => {
getFlowRun.mockResolvedValue(makeRun({ status: 'pending_approval' }));
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.run?.status).toBe('pending_approval');
expect(getFlowRun).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(getFlowRun).toHaveBeenCalledTimes(2);
});
it('stops polling once the run completes', async () => {
getFlowRun.mockResolvedValue(
makeRun({ status: 'completed', finished_at: '2026-01-01T00:01:00Z' })
);
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.run?.status).toBe('completed');
expect(getFlowRun).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(getFlowRun).toHaveBeenCalledTimes(1);
});
it('stops polling once the run fails', async () => {
getFlowRun.mockResolvedValue(makeRun({ status: 'failed', error: 'boom' }));
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.run?.status).toBe('failed');
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(getFlowRun).toHaveBeenCalledTimes(1);
});
it('stops and clears state when runId becomes null', async () => {
getFlowRun.mockResolvedValue(makeRun({ status: 'running' }));
const { result, rerender } = renderHook(({ runId }) => useFlowRunPoller(runId), {
initialProps: { runId: 'thread-1' as string | null },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.run).not.toBeNull();
rerender({ runId: null });
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.run).toBeNull();
expect(result.current.loading).toBe(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(getFlowRun).toHaveBeenCalledTimes(1);
});
it('sets error on rejection and does not schedule another poll', async () => {
getFlowRun.mockRejectedValue(new Error('network down'));
const { result } = renderHook(() => useFlowRunPoller('thread-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.error).toBe('network down');
expect(result.current.loading).toBe(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(getFlowRun).toHaveBeenCalledTimes(1);
});
it('cleans up pending timers on unmount', async () => {
getFlowRun.mockResolvedValue(makeRun({ status: 'running' }));
const { unmount } = renderHook(() => useFlowRunPoller('thread-1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(getFlowRun).toHaveBeenCalledTimes(1);
unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
// No further calls after unmount.
expect(getFlowRun).toHaveBeenCalledTimes(1);
});
it('does nothing when runId starts null', async () => {
const { result } = renderHook(() => useFlowRunPoller(null));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.loading).toBe(false);
expect(result.current.run).toBeNull();
expect(getFlowRun).not.toHaveBeenCalled();
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* useFlowRunPoller (issue B3b)
* ----------------------------
*
* Poll-until-terminal loop for a single durable `tinyflows` run, feeding the
* {@link FlowRunInspectorDrawer}. The flows engine emits no socket events for
* run progress (same situation as the `workflow_run_*` orchestration surface),
* so this mirrors the setTimeout-chained poll loop in
* `components/intelligence/IntelligenceOrchestrationTab.tsx` (~lines 112-143):
* schedule the next poll only after the current one resolves and the run is
* still non-terminal, guard against races with `cancelled`/`inFlight`, and
* never let an unmounted component call `setState`.
*
* `pending_approval` is explicitly NOT terminal — a paused run still needs
* live status so the drawer reflects an approval elsewhere resolving it.
*/
import debug from 'debug';
import { useEffect, useRef, useState } from 'react';
import { type FlowRun, type FlowRunStatus, getFlowRun } from '../services/api/flowsApi';
const log = debug('flows:poller');
/** How often to poll a non-terminal run for progress. */
const POLL_INTERVAL_MS = 2000;
const TERMINAL = new Set<FlowRunStatus>(['completed', 'failed']);
function isTerminal(run: FlowRun | null): boolean {
return run !== null && TERMINAL.has(run.status);
}
export interface UseFlowRunPollerResult {
run: FlowRun | null;
loading: boolean;
error: string | null;
}
/**
* Poll `openhuman.flows_get_run` for `runId` every {@link POLL_INTERVAL_MS}ms
* while the run is `running` or `pending_approval`. Stops polling once the
* run reaches a terminal status, when `runId` becomes `null`, when `runId`
* changes, or on unmount. A failed fetch surfaces `error` and does NOT
* schedule another poll — a broken endpoint shouldn't be hammered.
*/
export function useFlowRunPoller(runId: string | null): UseFlowRunPollerResult {
// Lazy initial state keyed off the `runId` this hook instance first mounts
// with, so the loading spinner is already correct on the very first paint
// without a synchronous `setState` in the effect body below.
const [run, setRun] = useState<FlowRun | null>(null);
const [loading, setLoading] = useState(() => runId !== null);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
// Reset view state for the new target — avoids painting the previous
// runId's data/error under a different runId while the first fetch for
// it is in flight. (On the very first mount this just re-applies the
// lazy-initial values above, so it's a no-op paint-wise.)
setRun(null);
setError(null);
if (!runId) {
setLoading(false);
return;
}
setLoading(true);
let cancelled = false;
let inFlight = false;
let pollHandle: number | undefined;
const tick = async () => {
if (cancelled || inFlight) return;
inFlight = true;
try {
const next = await getFlowRun(runId);
if (cancelled || !mountedRef.current) return;
setRun(next);
setLoading(false);
setError(null);
if (!isTerminal(next)) {
pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS);
} else {
log('tick: runId=%s reached terminal status=%s', runId, next.status);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('tick: error runId=%s err=%s', runId, msg);
if (cancelled || !mountedRef.current) return;
setError(msg);
setLoading(false);
// Do not schedule another poll — leave retrying to the caller (e.g.
// reopening the drawer) rather than hammering a broken endpoint.
} finally {
inFlight = false;
}
};
void tick();
return () => {
cancelled = true;
if (pollHandle !== undefined) window.clearTimeout(pollHandle);
};
}, [runId]);
return { run, loading, error };
}
export default useFlowRunPoller;
+18
View File
@@ -3547,6 +3547,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'في انتظار {count} من بوابات الموافقة',
'notifications.flow.approveHint': 'استئناف سير العمل بعد نقطة التحقق هذه',
'notifications.flow.dismissHint': 'إخفاء هذا التنبيه دون استئناف سير العمل',
'notifications.flow.viewRun': 'عرض التشغيل',
'flowRuns.inspector.title': 'تفاصيل التشغيل',
'flowRuns.inspector.startedAt': 'بدأ',
'flowRuns.inspector.finishedAt': 'انتهى',
'flowRuns.inspector.running': 'قيد التشغيل…',
'flowRuns.inspector.error': 'خطأ',
'flowRuns.inspector.pendingApprovals': 'الموافقات المعلقة',
'flowRuns.inspector.pendingApprovalsCount': '{count} عقدة (عقد) في انتظار الموافقة',
'flowRuns.inspector.steps': 'الخطوات',
'flowRuns.inspector.noSteps': 'لم يتم تسجيل أي خطوات بعد.',
'flowRuns.inspector.output': 'المخرجات',
'flowRuns.inspector.port': 'المنفذ',
'flowRuns.inspector.loading': 'جارٍ تحميل التشغيل…',
'flowRuns.inspector.loadError': 'تعذّر تحميل هذا التشغيل',
'flowRuns.status.running': 'قيد التشغيل',
'flowRuns.status.completed': 'مكتمل',
'flowRuns.status.pending_approval': 'بانتظار الموافقة',
'flowRuns.status.failed': 'فشل',
'oauth.button.connecting': 'جارٍ الاتصال...',
'oauth.button.loopbackTimeout':
'انتهت مهلة تسجيل الدخول — لم يكتمل المتصفح إعادة توجيه OAuth. يرجى المحاولة مرة أخرى.',
+18
View File
@@ -3628,6 +3628,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': '{count}টি অনুমোদন গেট মুলতুবি আছে',
'notifications.flow.approveHint': 'এই চেকপয়েন্টের পরে ওয়ার্কফ্লো আবার শুরু করুন',
'notifications.flow.dismissHint': 'ওয়ার্কফ্লো আবার শুরু না করে এই প্রম্পটটি লুকান',
'notifications.flow.viewRun': 'রান দেখুন',
'flowRuns.inspector.title': 'রান বিবরণ',
'flowRuns.inspector.startedAt': 'শুরু হয়েছে',
'flowRuns.inspector.finishedAt': 'শেষ হয়েছে',
'flowRuns.inspector.running': 'চলছে…',
'flowRuns.inspector.error': 'ত্রুটি',
'flowRuns.inspector.pendingApprovals': 'মুলতুবি অনুমোদন',
'flowRuns.inspector.pendingApprovalsCount': '{count}টি নোড অনুমোদনের অপেক্ষায়',
'flowRuns.inspector.steps': 'ধাপ',
'flowRuns.inspector.noSteps': 'এখনও কোনো ধাপ রেকর্ড করা হয়নি।',
'flowRuns.inspector.output': 'আউটপুট',
'flowRuns.inspector.port': 'পোর্ট',
'flowRuns.inspector.loading': 'রান লোড হচ্ছে…',
'flowRuns.inspector.loadError': 'এই রানটি লোড করা যায়নি',
'flowRuns.status.running': 'চলছে',
'flowRuns.status.completed': 'সম্পন্ন',
'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়',
'flowRuns.status.failed': 'ব্যর্থ',
'oauth.button.connecting': 'সংযোগ হচ্ছে...',
'oauth.button.loopbackTimeout':
'সাইন-ইন টাইম আউট হয়েছে — ব্রাউজার OAuth পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।',
+18
View File
@@ -3717,6 +3717,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'Wartet auf {count} Genehmigungsschritt(e)',
'notifications.flow.approveHint': 'Workflow nach diesem Kontrollpunkt fortsetzen',
'notifications.flow.dismissHint': 'Diesen Hinweis ausblenden, ohne den Workflow fortzusetzen',
'notifications.flow.viewRun': 'Lauf anzeigen',
'flowRuns.inspector.title': 'Laufdetails',
'flowRuns.inspector.startedAt': 'Gestartet',
'flowRuns.inspector.finishedAt': 'Beendet',
'flowRuns.inspector.running': 'Läuft…',
'flowRuns.inspector.error': 'Fehler',
'flowRuns.inspector.pendingApprovals': 'Ausstehende Genehmigungen',
'flowRuns.inspector.pendingApprovalsCount': '{count} Knoten warten auf Genehmigung',
'flowRuns.inspector.steps': 'Schritte',
'flowRuns.inspector.noSteps': 'Noch keine Schritte aufgezeichnet.',
'flowRuns.inspector.output': 'Ausgabe',
'flowRuns.inspector.port': 'Port',
'flowRuns.inspector.loading': 'Lauf wird geladen…',
'flowRuns.inspector.loadError': 'Dieser Lauf konnte nicht geladen werden',
'flowRuns.status.running': 'Läuft',
'flowRuns.status.completed': 'Abgeschlossen',
'flowRuns.status.pending_approval': 'Wartet auf Genehmigung',
'flowRuns.status.failed': 'Fehlgeschlagen',
'oauth.button.connecting': 'Verbinden...',
'oauth.button.loopbackTimeout':
'Anmeldung abgelaufen — der Browser hat die OAuth-Weiterleitung nicht abgeschlossen. Bitte versuche es erneut.',
+18
View File
@@ -4267,6 +4267,24 @@ const en: TranslationMap = {
'notifications.flow.gateCount': 'Waiting on {count} approval gate(s)',
'notifications.flow.approveHint': 'Resume the workflow past this checkpoint',
'notifications.flow.dismissHint': 'Hide this prompt without resuming the workflow',
'notifications.flow.viewRun': 'View run',
'flowRuns.inspector.title': 'Run details',
'flowRuns.inspector.startedAt': 'Started',
'flowRuns.inspector.finishedAt': 'Finished',
'flowRuns.inspector.running': 'Running…',
'flowRuns.inspector.error': 'Error',
'flowRuns.inspector.pendingApprovals': 'Pending approvals',
'flowRuns.inspector.pendingApprovalsCount': '{count} node(s) awaiting approval',
'flowRuns.inspector.steps': 'Steps',
'flowRuns.inspector.noSteps': 'No steps recorded yet.',
'flowRuns.inspector.output': 'Output',
'flowRuns.inspector.port': 'Port',
'flowRuns.inspector.loading': 'Loading run…',
'flowRuns.inspector.loadError': 'Could not load this run',
'flowRuns.status.running': 'Running',
'flowRuns.status.completed': 'Completed',
'flowRuns.status.pending_approval': 'Awaiting approval',
'flowRuns.status.failed': 'Failed',
'oauth.button.connecting': 'Connecting...',
'oauth.button.loopbackTimeout':
'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.',
+18
View File
@@ -3691,6 +3691,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'Esperando {count} puerta(s) de aprobación',
'notifications.flow.approveHint': 'Reanudar el flujo de trabajo después de este punto de control',
'notifications.flow.dismissHint': 'Ocultar este aviso sin reanudar el flujo de trabajo',
'notifications.flow.viewRun': 'Ver ejecución',
'flowRuns.inspector.title': 'Detalles de la ejecución',
'flowRuns.inspector.startedAt': 'Iniciado',
'flowRuns.inspector.finishedAt': 'Finalizado',
'flowRuns.inspector.running': 'En ejecución…',
'flowRuns.inspector.error': 'Error',
'flowRuns.inspector.pendingApprovals': 'Aprobaciones pendientes',
'flowRuns.inspector.pendingApprovalsCount': '{count} nodo(s) esperando aprobación',
'flowRuns.inspector.steps': 'Pasos',
'flowRuns.inspector.noSteps': 'Aún no se han registrado pasos.',
'flowRuns.inspector.output': 'Salida',
'flowRuns.inspector.port': 'Puerto',
'flowRuns.inspector.loading': 'Cargando ejecución…',
'flowRuns.inspector.loadError': 'No se pudo cargar esta ejecución',
'flowRuns.status.running': 'En ejecución',
'flowRuns.status.completed': 'Completado',
'flowRuns.status.pending_approval': 'Esperando aprobación',
'flowRuns.status.failed': 'Fallido',
'oauth.button.connecting': 'Conectando...',
'oauth.button.loopbackTimeout':
'El inicio de sesión expiró — el navegador no completó la redirección OAuth. Por favor, inténtalo de nuevo.',
+18
View File
@@ -3706,6 +3706,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'En attente de {count} validation(s)',
'notifications.flow.approveHint': 'Reprendre le workflow après ce point de contrôle',
'notifications.flow.dismissHint': 'Masquer cette invite sans reprendre le workflow',
'notifications.flow.viewRun': "Voir l'exécution",
'flowRuns.inspector.title': "Détails de l'exécution",
'flowRuns.inspector.startedAt': 'Démarré',
'flowRuns.inspector.finishedAt': 'Terminé',
'flowRuns.inspector.running': 'En cours…',
'flowRuns.inspector.error': 'Erreur',
'flowRuns.inspector.pendingApprovals': 'Approbations en attente',
'flowRuns.inspector.pendingApprovalsCount': "{count} nœud(s) en attente d'approbation",
'flowRuns.inspector.steps': 'Étapes',
'flowRuns.inspector.noSteps': 'Aucune étape enregistrée pour le moment.',
'flowRuns.inspector.output': 'Sortie',
'flowRuns.inspector.port': 'Port',
'flowRuns.inspector.loading': "Chargement de l'exécution…",
'flowRuns.inspector.loadError': 'Impossible de charger cette exécution',
'flowRuns.status.running': 'En cours',
'flowRuns.status.completed': 'Terminé',
'flowRuns.status.pending_approval': "En attente d'approbation",
'flowRuns.status.failed': 'Échoué',
'oauth.button.connecting': 'Connexion en cours…',
'oauth.button.loopbackTimeout':
"La connexion a expiré — le navigateur n'a pas complété la redirection OAuth. Veuillez réessayer.",
+18
View File
@@ -3629,6 +3629,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': '{count} अनुमोदन गेट लंबित हैं',
'notifications.flow.approveHint': 'इस चेकपॉइंट के बाद वर्कफ़्लो फिर से शुरू करें',
'notifications.flow.dismissHint': 'वर्कफ़्लो को फिर से शुरू किए बिना यह संकेत छिपाएं',
'notifications.flow.viewRun': 'रन देखें',
'flowRuns.inspector.title': 'रन विवरण',
'flowRuns.inspector.startedAt': 'शुरू हुआ',
'flowRuns.inspector.finishedAt': 'समाप्त हुआ',
'flowRuns.inspector.running': 'चल रहा है…',
'flowRuns.inspector.error': 'त्रुटि',
'flowRuns.inspector.pendingApprovals': 'लंबित अनुमोदन',
'flowRuns.inspector.pendingApprovalsCount': '{count} नोड अनुमोदन की प्रतीक्षा में',
'flowRuns.inspector.steps': 'चरण',
'flowRuns.inspector.noSteps': 'अभी तक कोई चरण दर्ज नहीं किया गया है।',
'flowRuns.inspector.output': 'आउटपुट',
'flowRuns.inspector.port': 'पोर्ट',
'flowRuns.inspector.loading': 'रन लोड हो रहा है…',
'flowRuns.inspector.loadError': 'यह रन लोड नहीं हो सका',
'flowRuns.status.running': 'चल रहा है',
'flowRuns.status.completed': 'पूर्ण',
'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में',
'flowRuns.status.failed': 'विफल',
'oauth.button.connecting': 'कनेक्ट हो रहा है...',
'oauth.button.loopbackTimeout':
'साइन-इन का समय समाप्त हो गया — ब्राउज़र ने OAuth पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।',
+18
View File
@@ -3636,6 +3636,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'Menunggu {count} gerbang persetujuan',
'notifications.flow.approveHint': 'Lanjutkan alur kerja setelah titik pemeriksaan ini',
'notifications.flow.dismissHint': 'Sembunyikan prompt ini tanpa melanjutkan alur kerja',
'notifications.flow.viewRun': 'Lihat proses',
'flowRuns.inspector.title': 'Detail proses',
'flowRuns.inspector.startedAt': 'Dimulai',
'flowRuns.inspector.finishedAt': 'Selesai',
'flowRuns.inspector.running': 'Berjalan…',
'flowRuns.inspector.error': 'Kesalahan',
'flowRuns.inspector.pendingApprovals': 'Persetujuan tertunda',
'flowRuns.inspector.pendingApprovalsCount': '{count} node menunggu persetujuan',
'flowRuns.inspector.steps': 'Langkah',
'flowRuns.inspector.noSteps': 'Belum ada langkah yang tercatat.',
'flowRuns.inspector.output': 'Keluaran',
'flowRuns.inspector.port': 'Port',
'flowRuns.inspector.loading': 'Memuat proses…',
'flowRuns.inspector.loadError': 'Tidak dapat memuat proses ini',
'flowRuns.status.running': 'Berjalan',
'flowRuns.status.completed': 'Selesai',
'flowRuns.status.pending_approval': 'Menunggu persetujuan',
'flowRuns.status.failed': 'Gagal',
'oauth.button.connecting': 'Menghubungkan...',
'oauth.button.loopbackTimeout':
'Masuk habis waktu — browser tidak menyelesaikan pengalihan OAuth. Silakan coba lagi.',
+18
View File
@@ -3686,6 +3686,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'In attesa di {count} punto/i di approvazione',
'notifications.flow.approveHint': 'Riprendi il workflow dopo questo checkpoint',
'notifications.flow.dismissHint': 'Nascondi questo avviso senza riprendere il workflow',
'notifications.flow.viewRun': 'Visualizza esecuzione',
'flowRuns.inspector.title': 'Dettagli esecuzione',
'flowRuns.inspector.startedAt': 'Avviato',
'flowRuns.inspector.finishedAt': 'Terminato',
'flowRuns.inspector.running': 'In esecuzione…',
'flowRuns.inspector.error': 'Errore',
'flowRuns.inspector.pendingApprovals': 'Approvazioni in sospeso',
'flowRuns.inspector.pendingApprovalsCount': '{count} nodo/i in attesa di approvazione',
'flowRuns.inspector.steps': 'Passaggi',
'flowRuns.inspector.noSteps': 'Nessun passaggio registrato finora.',
'flowRuns.inspector.output': 'Output',
'flowRuns.inspector.port': 'Porta',
'flowRuns.inspector.loading': 'Caricamento esecuzione…',
'flowRuns.inspector.loadError': 'Impossibile caricare questa esecuzione',
'flowRuns.status.running': 'In esecuzione',
'flowRuns.status.completed': 'Completato',
'flowRuns.status.pending_approval': 'In attesa di approvazione',
'flowRuns.status.failed': 'Non riuscito',
'oauth.button.connecting': 'Connessione...',
'oauth.button.loopbackTimeout':
'Accesso scaduto — il browser non ha completato il reindirizzamento OAuth. Riprova.',
+18
View File
@@ -3592,6 +3592,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': '승인 게이트 {count}개 대기 중',
'notifications.flow.approveHint': '이 체크포인트 이후 워크플로 재개',
'notifications.flow.dismissHint': '워크플로를 재개하지 않고 이 알림 숨기기',
'notifications.flow.viewRun': '실행 보기',
'flowRuns.inspector.title': '실행 세부정보',
'flowRuns.inspector.startedAt': '시작됨',
'flowRuns.inspector.finishedAt': '종료됨',
'flowRuns.inspector.running': '실행 중…',
'flowRuns.inspector.error': '오류',
'flowRuns.inspector.pendingApprovals': '대기 중인 승인',
'flowRuns.inspector.pendingApprovalsCount': '노드 {count}개가 승인 대기 중',
'flowRuns.inspector.steps': '단계',
'flowRuns.inspector.noSteps': '아직 기록된 단계가 없습니다.',
'flowRuns.inspector.output': '출력',
'flowRuns.inspector.port': '포트',
'flowRuns.inspector.loading': '실행 로드 중…',
'flowRuns.inspector.loadError': '이 실행을 로드할 수 없습니다',
'flowRuns.status.running': '실행 중',
'flowRuns.status.completed': '완료됨',
'flowRuns.status.pending_approval': '승인 대기 중',
'flowRuns.status.failed': '실패',
'oauth.button.connecting': '연결 중...',
'oauth.button.loopbackTimeout':
'로그인 시간 초과 — 브라우저가 OAuth 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.',
+18
View File
@@ -3672,6 +3672,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'Oczekiwanie na {count} bramkę/bramki zatwierdzenia',
'notifications.flow.approveHint': 'Wznów przepływ pracy po tym punkcie kontrolnym',
'notifications.flow.dismissHint': 'Ukryj ten monit bez wznawiania przepływu pracy',
'notifications.flow.viewRun': 'Zobacz przebieg',
'flowRuns.inspector.title': 'Szczegóły przebiegu',
'flowRuns.inspector.startedAt': 'Rozpoczęto',
'flowRuns.inspector.finishedAt': 'Zakończono',
'flowRuns.inspector.running': 'W trakcie…',
'flowRuns.inspector.error': 'Błąd',
'flowRuns.inspector.pendingApprovals': 'Oczekujące zatwierdzenia',
'flowRuns.inspector.pendingApprovalsCount': '{count} węzeł(y) oczekuje(ą) na zatwierdzenie',
'flowRuns.inspector.steps': 'Kroki',
'flowRuns.inspector.noSteps': 'Nie zarejestrowano jeszcze żadnych kroków.',
'flowRuns.inspector.output': 'Dane wyjściowe',
'flowRuns.inspector.port': 'Port',
'flowRuns.inspector.loading': 'Ładowanie przebiegu…',
'flowRuns.inspector.loadError': 'Nie można załadować tego przebiegu',
'flowRuns.status.running': 'W trakcie',
'flowRuns.status.completed': 'Zakończono',
'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie',
'flowRuns.status.failed': 'Niepowodzenie',
'oauth.button.connecting': 'Łączenie...',
'oauth.button.loopbackTimeout':
'Logowanie przekroczyło limit czasu — przeglądarka nie ukończyła przekierowania OAuth. Spróbuj ponownie.',
+18
View File
@@ -3687,6 +3687,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'Aguardando {count} porta(s) de aprovação',
'notifications.flow.approveHint': 'Retomar o fluxo de trabalho após este ponto de verificação',
'notifications.flow.dismissHint': 'Ocultar este aviso sem retomar o fluxo de trabalho',
'notifications.flow.viewRun': 'Ver execução',
'flowRuns.inspector.title': 'Detalhes da execução',
'flowRuns.inspector.startedAt': 'Iniciado',
'flowRuns.inspector.finishedAt': 'Concluído',
'flowRuns.inspector.running': 'Em execução…',
'flowRuns.inspector.error': 'Erro',
'flowRuns.inspector.pendingApprovals': 'Aprovações pendentes',
'flowRuns.inspector.pendingApprovalsCount': '{count} nó(s) aguardando aprovação',
'flowRuns.inspector.steps': 'Etapas',
'flowRuns.inspector.noSteps': 'Nenhuma etapa registrada ainda.',
'flowRuns.inspector.output': 'Saída',
'flowRuns.inspector.port': 'Porta',
'flowRuns.inspector.loading': 'Carregando execução…',
'flowRuns.inspector.loadError': 'Não foi possível carregar esta execução',
'flowRuns.status.running': 'Em execução',
'flowRuns.status.completed': 'Concluído',
'flowRuns.status.pending_approval': 'Aguardando aprovação',
'flowRuns.status.failed': 'Falhou',
'oauth.button.connecting': 'Conectando...',
'oauth.button.loopbackTimeout':
'Login expirou — o navegador não concluiu o redirecionamento OAuth. Por favor, tente novamente.',
+18
View File
@@ -3661,6 +3661,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': 'Ожидание {count} шлюз(ов) одобрения',
'notifications.flow.approveHint': 'Возобновить рабочий процесс после этой контрольной точки',
'notifications.flow.dismissHint': 'Скрыть это уведомление без возобновления рабочего процесса',
'notifications.flow.viewRun': 'Просмотреть запуск',
'flowRuns.inspector.title': 'Детали запуска',
'flowRuns.inspector.startedAt': 'Начато',
'flowRuns.inspector.finishedAt': 'Завершено',
'flowRuns.inspector.running': 'Выполняется…',
'flowRuns.inspector.error': 'Ошибка',
'flowRuns.inspector.pendingApprovals': 'Ожидающие подтверждения',
'flowRuns.inspector.pendingApprovalsCount': '{count} узел(-ов) ожидает подтверждения',
'flowRuns.inspector.steps': 'Шаги',
'flowRuns.inspector.noSteps': 'Пока не зафиксировано ни одного шага.',
'flowRuns.inspector.output': 'Вывод',
'flowRuns.inspector.port': 'Порт',
'flowRuns.inspector.loading': 'Загрузка запуска…',
'flowRuns.inspector.loadError': 'Не удалось загрузить этот запуск',
'flowRuns.status.running': 'Выполняется',
'flowRuns.status.completed': 'Завершено',
'flowRuns.status.pending_approval': 'Ожидает подтверждения',
'flowRuns.status.failed': 'Не удалось',
'oauth.button.connecting': 'Подключение...',
'oauth.button.loopbackTimeout':
'Время входа истекло — браузер не завершил перенаправление OAuth. Пожалуйста, попробуйте снова.',
+18
View File
@@ -3438,6 +3438,24 @@ const messages: TranslationMap = {
'notifications.flow.gateCount': '正在等待 {count} 个批准节点',
'notifications.flow.approveHint': '在此检查点之后恢复工作流',
'notifications.flow.dismissHint': '隐藏此提示但不恢复工作流',
'notifications.flow.viewRun': '查看运行',
'flowRuns.inspector.title': '运行详情',
'flowRuns.inspector.startedAt': '开始时间',
'flowRuns.inspector.finishedAt': '结束时间',
'flowRuns.inspector.running': '运行中…',
'flowRuns.inspector.error': '错误',
'flowRuns.inspector.pendingApprovals': '待批准',
'flowRuns.inspector.pendingApprovalsCount': '{count} 个节点等待批准',
'flowRuns.inspector.steps': '步骤',
'flowRuns.inspector.noSteps': '尚未记录任何步骤。',
'flowRuns.inspector.output': '输出',
'flowRuns.inspector.port': '端口',
'flowRuns.inspector.loading': '正在加载运行…',
'flowRuns.inspector.loadError': '无法加载此运行',
'flowRuns.status.running': '运行中',
'flowRuns.status.completed': '已完成',
'flowRuns.status.pending_approval': '等待批准',
'flowRuns.status.failed': '失败',
'oauth.button.connecting': '连接中...',
'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。',
'oauth.login.continueWith': '继续使用',