diff --git a/app/src/components/chat/FlowApprovalRequestCard.tsx b/app/src/components/chat/FlowApprovalRequestCard.tsx new file mode 100644 index 000000000..337f0d83c --- /dev/null +++ b/app/src/components/chat/FlowApprovalRequestCard.tsx @@ -0,0 +1,119 @@ +/** + * FlowApprovalRequestCard (flow-approval surface — chat) + * --------------------------------------------------------- + * + * Chat-surfaced banner for a single `flow_approval_request` socket event + * (see {@link useFlowApprovalRequests}) — a paused `tinyflows` run's gate, + * shown to the user while they're chatting rather than inspecting the run + * directly. Styling mirrors `ApprovalRequestCard` (the thread-scoped chat + * tool-approval card) so both read as the same affordance family; unlike + * that card, this one isn't keyed to the active thread — the payload has no + * `thread_id` — so it renders independent of which thread is selected. + * + * All three decisions route through the shared `openhuman.approval_decide` + * RPC via {@link decideApproval}. On success (or once the request no longer + * needs surfacing) the parent removes it from its list via `onResolved`. + */ +import debug from 'debug'; +import React, { useState } from 'react'; + +import type { FlowApprovalRequest } from '../../hooks/useFlowApprovalRequests'; +import { useT } from '../../lib/i18n/I18nContext'; +import { type ApprovalDecision, decideApproval } from '../../services/api/approvalApi'; +import Button from '../ui/Button'; + +const log = debug('openhuman:chat:flow-approval-card'); + +interface Props { + request: FlowApprovalRequest; + onResolved: (requestId: string) => void; +} + +export const FlowApprovalRequestCard: React.FC = ({ request, onResolved }) => { + const { t } = useT(); + const [deciding, setDeciding] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const decide = async (decision: ApprovalDecision) => { + if (deciding) return; + setDeciding(decision); + setErrorMsg(null); + try { + await decideApproval(request.request_id, decision); + log('decide: ok request=%s decision=%s', request.request_id, decision); + onResolved(request.request_id); + } catch (err) { + log('decide: failed request=%s err=%o', request.request_id, err); + setErrorMsg(t('chat.flowApproval.error')); + setDeciding(null); + } + }; + + return ( +
+
+ + 🔒 + +
+

+ {t('chat.flowApproval.title')} +

+

+ {request.summary || t('chat.flowApproval.fallback')} +

+

+ {t('chat.flowApproval.tool')}{' '} + + {request.tool_name} + +

+

+ {t('chat.flowApproval.flow')}{' '} + {request.flow_id} +

+ + {errorMsg &&

⚠ {errorMsg}

} + +
+ + + +
+
+
+
+ ); +}; + +export default FlowApprovalRequestCard; diff --git a/app/src/components/chat/__tests__/FlowApprovalRequestCard.test.tsx b/app/src/components/chat/__tests__/FlowApprovalRequestCard.test.tsx new file mode 100644 index 000000000..4a91ad5a1 --- /dev/null +++ b/app/src/components/chat/__tests__/FlowApprovalRequestCard.test.tsx @@ -0,0 +1,89 @@ +/** + * FlowApprovalRequestCard (flow-approval surface — chat) — rendering + + * decision contract. Mirrors `ApprovalRequestCard.test.tsx`'s approach: mocks + * `decideApproval` directly rather than the underlying RPC client. + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowApprovalRequest } from '../../../hooks/useFlowApprovalRequests'; +import { decideApproval } from '../../../services/api/approvalApi'; +import FlowApprovalRequestCard from '../FlowApprovalRequestCard'; + +vi.mock('../../../services/api/approvalApi', () => ({ decideApproval: vi.fn() })); + +const REQUEST: FlowApprovalRequest = { + request_id: 'req-1', + flow_id: 'flow-1', + run_id: 'run-1', + tool_name: 'shell', + summary: 'Run `shell` — rm -rf /tmp/scratch', +}; + +describe('FlowApprovalRequestCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the summary, tool name, and flow id', () => { + render(); + expect(screen.getByText('Workflow needs approval')).toBeInTheDocument(); + expect(screen.getByText('Run `shell` — rm -rf /tmp/scratch')).toBeInTheDocument(); + expect(screen.getByText('shell')).toBeInTheDocument(); + expect(screen.getByText('flow-1')).toBeInTheDocument(); + }); + + it('falls back to the generic prompt when summary is empty', () => { + render(); + expect( + screen.getByText('A workflow run wants to perform an action that needs your approval.') + ).toBeInTheDocument(); + }); + + it('Approve once routes approve_once to approval_decide and resolves', async () => { + vi.mocked(decideApproval).mockResolvedValueOnce(undefined); + const onResolved = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Approve once')); + + expect(decideApproval).toHaveBeenCalledWith('req-1', 'approve_once'); + await waitFor(() => expect(onResolved).toHaveBeenCalledWith('req-1')); + }); + + it('Approve always routes approve_always_for_flow to approval_decide', async () => { + vi.mocked(decideApproval).mockResolvedValueOnce(undefined); + const onResolved = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Approve always')); + + expect(decideApproval).toHaveBeenCalledWith('req-1', 'approve_always_for_flow'); + await waitFor(() => expect(onResolved).toHaveBeenCalledWith('req-1')); + }); + + it('Deny routes deny to approval_decide', async () => { + vi.mocked(decideApproval).mockResolvedValueOnce(undefined); + const onResolved = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Deny')); + + expect(decideApproval).toHaveBeenCalledWith('req-1', 'deny'); + await waitFor(() => expect(onResolved).toHaveBeenCalledWith('req-1')); + }); + + it('keeps the prompt and shows an error when the decide RPC fails', async () => { + vi.mocked(decideApproval).mockRejectedValueOnce(new Error('gate not installed')); + const onResolved = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Approve once')); + + await waitFor(() => { + expect(screen.getByText(/Could not record your decision/)).toBeInTheDocument(); + }); + expect(onResolved).not.toHaveBeenCalled(); + expect(screen.getByText('Approve once')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/flows/FlowRunInspectorDrawer.tsx b/app/src/components/flows/FlowRunInspectorDrawer.tsx index 44298be02..9b889f246 100644 --- a/app/src/components/flows/FlowRunInspectorDrawer.tsx +++ b/app/src/components/flows/FlowRunInspectorDrawer.tsx @@ -24,12 +24,14 @@ import debug from 'debug'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowPendingApprovals } from '../../hooks/useFlowPendingApprovals'; import { useFlowRunPoller } from '../../hooks/useFlowRunPoller'; import { type FlowNodeRunStatus, useFlowRunProgress } from '../../hooks/useFlowRunProgress'; import { type FlowRunItem, normalizeItems } from '../../lib/flows/runItems'; import { useT } from '../../lib/i18n/I18nContext'; import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi'; import Button from '../ui/Button'; +import { FlowRunPendingApprovalCard } from './FlowRunPendingApprovalCard'; import { RunItemDataBrowser } from './RunItemDataBrowser'; /** @@ -217,6 +219,19 @@ export function FlowRunInspectorDrawer({ runId, onClose, onFixWithAgent }: Props // Live per-node status overlay (Phase 3e): the socket feed makes the poller's // durable step list feel live without replacing it as the source of truth. const liveStatuses = useFlowRunProgress(runId); + // Actionable approval gates for this run (flow-approval surface — run + // details). Only polls while the run is in an active state; `null`/`null` + // stops the underlying poll loop. + const isActiveRun = !!run && (run.status === 'running' || run.status === 'pending_approval'); + const { + approvals: pendingApprovals, + decidingId: decidingApprovalId, + error: pendingApprovalsError, + decide: decideApproval, + } = useFlowPendingApprovals( + isActiveRun && run ? run.flow_id : null, + isActiveRun && run ? run.thread_id : null + ); const handleFixWithAgent = () => { if (!run || !onFixWithAgent) return; @@ -247,7 +262,6 @@ export function FlowRunInspectorDrawer({ runId, onClose, onFixWithAgent }: Props const startedAt = formatTimestamp(run?.started_at); const finishedAt = formatTimestamp(run?.finished_at); - const pendingCount = run?.pending_approvals.length ?? 0; return (
@@ -358,14 +372,32 @@ export function FlowRunInspectorDrawer({ runId, onClose, onFixWithAgent }: Props
)} - {/* Pending approvals banner */} - {run.status === 'pending_approval' && pendingCount > 0 && ( -
- {t('flowRuns.inspector.pendingApprovalsCount').replace( - '{count}', - String(pendingCount) + {/* Actionable pending-approval gates for this run (flow-approval + surface). Replaces the old read-only "N node(s) awaiting + approval" banner — Approve once / Approve always / Deny + resolve the gate in place via `openhuman.approval_decide`; + the run poller above picks up the resulting steps on its + own 2s cadence once the gate clears. */} + {isActiveRun && pendingApprovals.length > 0 && ( +
+

+ {t('flowRuns.inspector.pendingApprovals')} +

+ {pendingApprovals.map(approval => ( + decideApproval(approval.request_id, decision)} + /> + ))} + {pendingApprovalsError && ( +

+ {t('flowRuns.inspector.approval.loadError')} +

)}
)} diff --git a/app/src/components/flows/FlowRunPendingApprovalCard.tsx b/app/src/components/flows/FlowRunPendingApprovalCard.tsx new file mode 100644 index 000000000..6cb5548ff --- /dev/null +++ b/app/src/components/flows/FlowRunPendingApprovalCard.tsx @@ -0,0 +1,100 @@ +/** + * FlowRunPendingApprovalCard (flow-approval surface — run details) + * ------------------------------------------------------------------ + * + * Actionable replacement for the old read-only "N node(s) awaiting approval" + * banner in `FlowRunInspectorDrawer`. Renders one gate from + * `useFlowPendingApprovals` with Approve once / Approve always / Deny, + * routing every decision through `openhuman.approval_decide` (same RPC and + * decision vocabulary as the chat `ApprovalRequestCard`). Styling mirrors + * that card's amber warning chrome, scaled down for the drawer's narrower + * column. + */ +import { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { type ApprovalDecision, type PendingApproval } from '../../services/api/approvalApi'; +import Button from '../ui/Button'; + +interface Props { + approval: PendingApproval; + /** Whether THIS approval's decision RPC is currently in flight. */ + deciding: boolean; + onDecide: (decision: ApprovalDecision) => Promise; +} + +export function FlowRunPendingApprovalCard({ approval, deciding, onDecide }: Props) { + const { t } = useT(); + const [localDecision, setLocalDecision] = useState(null); + + const handleDecide = (decision: ApprovalDecision) => { + if (deciding) return; + setLocalDecision(decision); + void onDecide(decision).catch(() => { + // Error surfaces via the hook's shared `error` field; nothing extra to + // do here besides letting the buttons re-enable (`deciding` flips back + // to false on the parent's next render). + }); + }; + + return ( +
+
+ + 🔒 + +
+

+ {approval.action_summary} +

+

+ {t('flowRuns.inspector.approval.tool')}{' '} + + {approval.tool_name} + +

+ +
+ + + +
+
+
+
+ ); +} + +export default FlowRunPendingApprovalCard; diff --git a/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx b/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx index f5a603312..8b26da503 100644 --- a/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx +++ b/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx @@ -3,17 +3,20 @@ * * 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`. + * actionable pending-approval cards when `status === 'pending_approval'` + * (flow-approval surface — run details); 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`. + * Mocks `useFlowRunPoller` and `useFlowPendingApprovals` directly rather than + * the underlying RPC client — their own poll contracts are covered by + * `hooks/__tests__/useFlowRunPoller.test.ts` and + * `hooks/__tests__/useFlowPendingApprovals.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 { PendingApproval } from '../../../services/api/approvalApi'; import type { FlowRun } from '../../../services/api/flowsApi'; import { store } from '../../../store'; import { FlowRunInspectorDrawer } from '../FlowRunInspectorDrawer'; @@ -21,6 +24,23 @@ import { FlowRunInspectorDrawer } from '../FlowRunInspectorDrawer'; const useFlowRunPoller = vi.hoisted(() => vi.fn()); vi.mock('../../../hooks/useFlowRunPoller', () => ({ useFlowRunPoller })); +const useFlowPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../../../hooks/useFlowPendingApprovals', () => ({ useFlowPendingApprovals })); + +function makeApproval(overrides: Partial = {}): PendingApproval { + return { + request_id: 'req-1', + tool_name: 'shell', + action_summary: 'Run `shell` — rm -rf /tmp/scratch', + 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: 'thread-1' }, + ...overrides, + }; +} + function makeRun(overrides: Partial = {}): FlowRun { return { id: 'thread-1', @@ -48,6 +68,14 @@ function renderDrawer(runId: string | null, onClose: () => void) { describe('FlowRunInspectorDrawer', () => { beforeEach(() => { vi.clearAllMocks(); + // Default: no pending approvals, no decide-in-flight. Tests that exercise + // the actionable approval cards override this explicitly. + useFlowPendingApprovals.mockReturnValue({ + approvals: [], + decidingId: null, + error: null, + decide: vi.fn(), + }); }); it('renders null when runId is null', () => { @@ -99,20 +127,92 @@ describe('FlowRunInspectorDrawer', () => { expect(screen.getByTestId('flow-run-inspector-error')).toHaveTextContent('network down'); }); - it('shows the pending-approvals banner when status is pending_approval', () => { + // ── Actionable pending-approval gates (flow-approval surface — run + // details). Replaces the old read-only "N node(s) awaiting approval" + // banner with Approve once / Approve always / Deny cards wired to + // `openhuman.approval_decide` via `useFlowPendingApprovals`. + it('polls scoped to this run only while pending_approval/running, passing null otherwise', () => { useFlowRunPoller.mockReturnValue({ - run: makeRun({ status: 'pending_approval', pending_approvals: ['node-a', 'node-b'] }), + run: makeRun({ status: 'pending_approval', flow_id: 'flow-9', thread_id: 'thread-9' }), + loading: false, + error: null, + }); + renderDrawer('thread-9', vi.fn()); + expect(useFlowPendingApprovals).toHaveBeenCalledWith('flow-9', 'thread-9'); + }); + + it('passes null/null to stop polling once the run is terminal', () => { + useFlowRunPoller.mockReturnValue({ + run: makeRun({ status: 'completed' }), loading: false, error: null, }); renderDrawer('thread-1', vi.fn()); - expect(screen.getByTestId('flow-run-pending-approvals-banner')).toHaveTextContent('2'); + expect(useFlowPendingApprovals).toHaveBeenCalledWith(null, null); }); - it('does not show the pending-approvals banner for a running run', () => { + it('renders an actionable card per pending approval scoped to this run', () => { + useFlowRunPoller.mockReturnValue({ + run: makeRun({ status: 'pending_approval' }), + loading: false, + error: null, + }); + useFlowPendingApprovals.mockReturnValue({ + approvals: [makeApproval({ request_id: 'req-a' }), makeApproval({ request_id: 'req-b' })], + decidingId: null, + error: null, + decide: vi.fn(), + }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-pending-approval-req-a')).toBeInTheDocument(); + expect(screen.getByTestId('flow-run-pending-approval-req-b')).toBeInTheDocument(); + }); + + it('does not show any approval card for a running run with no pending approvals', () => { useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); renderDrawer('thread-1', vi.fn()); - expect(screen.queryByTestId('flow-run-pending-approvals-banner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('flow-run-pending-approvals')).not.toBeInTheDocument(); + }); + + it("routes Approve once / Approve always / Deny to the hook's decide() with the request id", () => { + const decide = vi.fn().mockResolvedValue(undefined); + useFlowRunPoller.mockReturnValue({ + run: makeRun({ status: 'pending_approval' }), + loading: false, + error: null, + }); + useFlowPendingApprovals.mockReturnValue({ + approvals: [makeApproval({ request_id: 'req-a' })], + decidingId: null, + error: null, + decide, + }); + renderDrawer('thread-1', vi.fn()); + + fireEvent.click(screen.getByTestId('flow-run-pending-approval-approve-req-a')); + expect(decide).toHaveBeenCalledWith('req-a', 'approve_once'); + + fireEvent.click(screen.getByTestId('flow-run-pending-approval-always-req-a')); + expect(decide).toHaveBeenCalledWith('req-a', 'approve_always_for_flow'); + + fireEvent.click(screen.getByTestId('flow-run-pending-approval-deny-req-a')); + expect(decide).toHaveBeenCalledWith('req-a', 'deny'); + }); + + it('shows the polling error message when useFlowPendingApprovals reports one', () => { + useFlowRunPoller.mockReturnValue({ + run: makeRun({ status: 'pending_approval' }), + loading: false, + error: null, + }); + useFlowPendingApprovals.mockReturnValue({ + approvals: [makeApproval()], + decidingId: null, + error: 'network down', + decide: vi.fn(), + }); + renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-pending-approvals-error')).toBeInTheDocument(); }); it('shows the run.error banner when present', () => { diff --git a/app/src/components/notifications/GateApprovalCard.test.tsx b/app/src/components/notifications/GateApprovalCard.test.tsx new file mode 100644 index 000000000..fbc196d68 --- /dev/null +++ b/app/src/components/notifications/GateApprovalCard.test.tsx @@ -0,0 +1,195 @@ +/** + * GateApprovalCard (flow-approval surface — notifications) — rendering + + * decision contract for the `flow-gate-approval` CoreNotification kind. + * Asserts: renders as an alertdialog with tool + summary; Approve once / + * Approve always / Deny each route the matching decision to + * `openhuman.approval_decide` reading `{ request_id }` from the action + * payload; a successful decision marks the notification read and clears its + * actions; a failed decision surfaces a localized error and re-enables the + * buttons without touching the notification; and an invalid payload is + * handled defensively (error shown, no RPC call). Mirrors + * `FlowApprovalCard.test.tsx`'s approach (real Redux `store`, dispatch + * `notificationReceived` directly). + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { store } from '../../store'; +import { type NotificationItem } from '../../store/notificationSlice'; +import GateApprovalCard, { isGateApprovalNotification } from './GateApprovalCard'; + +const decideApproval = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/approvalApi', () => ({ decideApproval })); + +function makeItem(overrides: Partial = {}): NotificationItem { + return { + id: 'flow-gate-approval:req-1', + kind: 'flow-gate-approval', + category: 'agents', + title: 'Workflow needs approval', + body: '', + timestamp: Date.now(), + read: false, + actions: [ + { + actionId: 'decide', + label: 'Review', + payload: { + request_id: 'req-1', + flow_id: 'flow-1', + tool_name: 'shell', + summary: 'Run `shell` — rm -rf /tmp/scratch', + }, + }, + ], + ...overrides, + }; +} + +function renderCard(item: NotificationItem) { + return render( + + + + ); +} + +describe('isGateApprovalNotification', () => { + it('matches on the kind field', () => { + expect(isGateApprovalNotification({ id: 'anything', kind: 'flow-gate-approval' })).toBe(true); + }); + + it('matches on the legacy id-prefix fallback', () => { + expect(isGateApprovalNotification({ id: 'flow-gate-approval:req-1', kind: undefined })).toBe( + true + ); + }); + + it('does not match unrelated notifications', () => { + expect( + isGateApprovalNotification({ id: 'flow-pending-approval:flow-1:t1', kind: undefined }) + ).toBe(false); + }); +}); + +describe('GateApprovalCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + store.dispatch({ type: 'notifications/clearAll' }); + }); + + it('renders as an alertdialog with the summary and tool name', () => { + renderCard(makeItem()); + const card = screen.getByTestId('gate-approval-card'); + expect(card).toHaveAttribute('role', 'alertdialog'); + expect(screen.getByText('Run `shell` — rm -rf /tmp/scratch')).toBeInTheDocument(); + expect(screen.getByText('shell')).toBeInTheDocument(); + }); + + it('renders all three decision buttons', () => { + renderCard(makeItem()); + expect(screen.getByTestId('gate-approval-approve')).toBeInTheDocument(); + expect(screen.getByTestId('gate-approval-always')).toBeInTheDocument(); + expect(screen.getByTestId('gate-approval-deny')).toBeInTheDocument(); + }); + + it('Approve once calls approval_decide with the request id and marks the notification read', async () => { + decideApproval.mockResolvedValue(undefined); + store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); + renderCard(makeItem()); + + fireEvent.click(screen.getByTestId('gate-approval-approve')); + + await waitFor(() => expect(decideApproval).toHaveBeenCalledWith('req-1', 'approve_once')); + await waitFor(() => { + const item = store + .getState() + .notifications.items.find(i => i.id === 'flow-gate-approval:req-1'); + expect(item?.read).toBe(true); + expect(item?.actions ?? []).toHaveLength(0); + }); + }); + + it('Approve always calls approval_decide with approve_always_for_flow', async () => { + decideApproval.mockResolvedValue(undefined); + renderCard(makeItem()); + + fireEvent.click(screen.getByTestId('gate-approval-always')); + + await waitFor(() => + expect(decideApproval).toHaveBeenCalledWith('req-1', 'approve_always_for_flow') + ); + }); + + it('Deny calls approval_decide with deny', async () => { + decideApproval.mockResolvedValue(undefined); + renderCard(makeItem()); + + fireEvent.click(screen.getByTestId('gate-approval-deny')); + + await waitFor(() => expect(decideApproval).toHaveBeenCalledWith('req-1', 'deny')); + }); + + it('shows a localized error and re-enables the buttons when the decide RPC fails', async () => { + decideApproval.mockRejectedValue(new Error('gate not installed')); + store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); + renderCard(makeItem()); + + fireEvent.click(screen.getByTestId('gate-approval-approve')); + + await waitFor(() => { + expect(screen.getByTestId('gate-approval-approve')).not.toBeDisabled(); + }); + expect( + screen.getByText( + (_content, element) => + element?.tagName.toLowerCase() === 'p' && + (element?.textContent ?? '').includes('Could not record your decision') + ) + ).toBeInTheDocument(); + const item = store + .getState() + .notifications.items.find(i => i.id === 'flow-gate-approval:req-1'); + expect(item?.actions).toHaveLength(1); + }); + + it('treats a missing payload as invalid (shows error, no RPC call)', async () => { + renderCard( + makeItem({ actions: [{ actionId: 'decide', label: 'Review', payload: undefined }] }) + ); + + fireEvent.click(screen.getByTestId('gate-approval-approve')); + + await waitFor(() => { + expect( + screen.getByText( + (_content, element) => + element?.tagName.toLowerCase() === 'p' && + (element?.textContent ?? '').includes('Could not record your decision') + ) + ).toBeInTheDocument(); + }); + expect(decideApproval).not.toHaveBeenCalled(); + }); + + it('disables all buttons while a decision is in flight', async () => { + let resolve!: (v: unknown) => void; + decideApproval.mockImplementation( + () => + new Promise(r => { + resolve = r; + }) + ); + renderCard(makeItem()); + + fireEvent.click(screen.getByTestId('gate-approval-approve')); + + expect(screen.getByTestId('gate-approval-approve')).toBeDisabled(); + expect(screen.getByTestId('gate-approval-always')).toBeDisabled(); + expect(screen.getByTestId('gate-approval-deny')).toBeDisabled(); + + resolve(undefined); + await waitFor(() => expect(screen.getByTestId('gate-approval-approve')).not.toBeDisabled()); + }); +}); diff --git a/app/src/components/notifications/GateApprovalCard.tsx b/app/src/components/notifications/GateApprovalCard.tsx new file mode 100644 index 000000000..18e86bd0f --- /dev/null +++ b/app/src/components/notifications/GateApprovalCard.tsx @@ -0,0 +1,178 @@ +/** + * GateApprovalCard (flow-approval surface — notifications) + * ------------------------------------------------------------ + * + * Approval surface for the `flow-gate-approval` CoreNotification kind — a + * paused `tinyflows` run's approval gate, surfaced in the Notification + * Center. `NotificationCenter` routes any notification matching + * {@link isGateApprovalNotification} here instead of the generic + * `CoreNotificationCard`. + * + * Distinct from the older `FlowApprovalCard` (`flow-pending-approval:`-id + * notifications, issue B3a): that card resumes the run via + * `openhuman.flows_resume` naming specific node ids. This one decides a + * single gate via the shared `openhuman.approval_decide` RPC — the same + * decision vocabulary as the chat `ApprovalRequestCard` and the flow-run + * inspector's actionable cards (Approve once / Approve always / Deny). + * + * Payload shape (`notification.actions[0].payload`): + * `{ request_id, flow_id, tool_name, summary }`. + */ +import debug from 'debug'; +import { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { type ApprovalDecision, decideApproval } from '../../services/api/approvalApi'; +import { useAppDispatch } from '../../store/hooks'; +import { + clearNotificationActions, + markRead, + type NotificationItem, +} from '../../store/notificationSlice'; +import Button from '../ui/Button'; + +const log = debug('notifications:gate-approval-card'); + +/** Shape of `notification.actions[0].payload` for the `flow-gate-approval` kind. */ +interface GateApprovalPayload { + request_id: string; + flow_id: string; + tool_name: string; + summary: string; +} + +function isGateApprovalPayload(value: unknown): value is GateApprovalPayload { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return ( + typeof record.request_id === 'string' && + typeof record.flow_id === 'string' && + typeof record.tool_name === 'string' && + typeof record.summary === 'string' + ); +} + +/** + * Matches a `NotificationItem` that should render as {@link GateApprovalCard} + * — either the explicit `kind` field or (as a defensive fallback, mirroring + * the older `flow-pending-approval:` id-prefix convention) an id starting + * with `flow-gate-approval:`. + */ +export function isGateApprovalNotification(item: Pick): boolean { + return item.kind === 'flow-gate-approval' || item.id.startsWith('flow-gate-approval:'); +} + +interface Props { + notification: NotificationItem; +} + +const GateApprovalCard = ({ notification: n }: Props) => { + const { t } = useT(); + const dispatch = useAppDispatch(); + const [deciding, setDeciding] = useState(null); + const [error, setError] = useState(null); + + const payload = n.actions?.[0]?.payload; + const parsed = isGateApprovalPayload(payload) ? payload : null; + + const handleDecide = async (decision: ApprovalDecision) => { + if (deciding) return; + if (!parsed) { + // Defensive — the core always stamps this shape, but never crash the + // notification center on an unexpected payload. + log('decide: missing/invalid payload notification=%s', n.id); + setError(t('notifications.flowGate.error')); + return; + } + setDeciding(decision); + setError(null); + log('decide: request=%s decision=%s', parsed.request_id, decision); + try { + await decideApproval(parsed.request_id, decision); + log('decide: ok request=%s', parsed.request_id); + dispatch(markRead({ id: n.id })); + dispatch(clearNotificationActions({ id: n.id })); + } catch (err) { + log('decide: failed request=%s err=%o', parsed.request_id, err); + setError(t('notifications.flowGate.error')); + } finally { + setDeciding(null); + } + }; + + return ( +
+
+ + 🔒 + +
+

+ {t('notifications.flowGate.title')} +

+ {(parsed?.summary || n.body) && ( +

+ {parsed?.summary || n.body} +

+ )} + {parsed && ( +

+ {t('notifications.flowGate.tool')}{' '} + + {parsed.tool_name} + +

+ )} + + {error &&

⚠ {error}

} + +
+ + + +
+
+
+
+ ); +}; + +export default GateApprovalCard; diff --git a/app/src/components/notifications/NotificationCenter.tsx b/app/src/components/notifications/NotificationCenter.tsx index 989d0ae6e..e3ed5a4cf 100644 --- a/app/src/components/notifications/NotificationCenter.tsx +++ b/app/src/components/notifications/NotificationCenter.tsx @@ -21,6 +21,7 @@ import { import Button from '../ui/Button'; import CoreNotificationCard from './CoreNotificationCard'; import FlowApprovalCard from './FlowApprovalCard'; +import GateApprovalCard, { isGateApprovalNotification } from './GateApprovalCard'; import NotificationCard from './NotificationCard'; // ───────────────────────────────────────────────────────────────────────────── @@ -196,13 +197,15 @@ const NotificationCenter = () => { always shown first, independent of integration load state. */} {coreActionItems.length > 0 && (
- {coreActionItems.map(item => - isFlowApproval(item) ? ( - - ) : ( - - ) - )} + {coreActionItems.map(item => { + if (isFlowApproval(item)) { + return ; + } + if (isGateApprovalNotification(item)) { + return ; + } + return ; + })}
)} diff --git a/app/src/components/settings/panels/ApprovalHistoryPanel.tsx b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx index d49fed3fa..8169cc012 100644 --- a/app/src/components/settings/panels/ApprovalHistoryPanel.tsx +++ b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx @@ -28,11 +28,17 @@ const formatDateTime = (value: string): string => { const DECISION_BADGE_VARIANT: Record< ApprovalDecision, 'success' | 'danger' | 'warning' | 'neutral' | 'primary' -> = { approve_once: 'success', approve_always_for_tool: 'success', deny: 'danger' }; +> = { + approve_once: 'success', + approve_always_for_tool: 'success', + approve_always_for_flow: 'success', + deny: 'danger', +}; const DECISION_LABEL_KEY: Record = { approve_once: 'settings.approvalHistory.decision.approveOnce', approve_always_for_tool: 'settings.approvalHistory.decision.approveAlways', + approve_always_for_flow: 'settings.approvalHistory.decision.approveAlwaysFlow', deny: 'settings.approvalHistory.decision.deny', }; diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 727408da0..1ed85f244 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -11,6 +11,7 @@ import ChatComposer from '../../components/chat/ChatComposer'; import ChatFilesChip from '../../components/chat/ChatFilesChip'; import ChatNewWindowHero from '../../components/chat/ChatNewWindowHero'; import ComposerTokenStats from '../../components/chat/ComposerTokenStats'; +import { FlowApprovalRequestCard } from '../../components/chat/FlowApprovalRequestCard'; import IntegrationConnectCard from '../../components/chat/IntegrationConnectCard'; import QueuedFollowups from '../../components/chat/QueuedFollowups'; import SuperContextToggle from '../../components/chat/SuperContextToggle'; @@ -62,6 +63,7 @@ import { isThreadVisibleInTab, } from '../../features/conversations/utils/threadFilter'; import MicComposer from '../../features/human/MicComposer'; +import { useFlowApprovalRequests } from '../../hooks/useFlowApprovalRequests'; import { useStickToBottom } from '../../hooks/useStickToBottom'; import { useUsageState } from '../../hooks/useUsageState'; import { @@ -403,6 +405,12 @@ const Conversations = ({ const pendingApprovalByThread = useAppSelector( state => state.chatRuntime.pendingApprovalByThread ); + // Flow-approval surface (chat): a paused tinyflows run's gate, pushed via + // the `flow_approval_request` socket event. Not thread-scoped — the + // payload carries no `thread_id` — so it's tracked independently of the + // selected thread and surfaced regardless of which one is open. + const { requests: flowApprovalRequests, dismiss: dismissFlowApprovalRequest } = + useFlowApprovalRequests(); const pendingPlanReviewByThread = useAppSelector( state => state.chatRuntime.pendingPlanReviewByThread ); @@ -2789,6 +2797,23 @@ const Conversations = ({ ); })()} + {/* Flow-approval surface (chat): actionable banner(s) for paused + tinyflows runs, pushed via the `flow_approval_request` socket + event (issue: flow-approval surfacing). Not gated on the selected + thread — see the hook call above for why — so every pending + request renders regardless of which thread is open. */} + {flowApprovalRequests.length > 0 && ( +
+ {flowApprovalRequests.map(request => ( + + ))} +
+ )} + {(() => { // Surface in-flight + failed artifact cards above the composer // (#2779). Mirrors the approval-card placement so the user sees diff --git a/app/src/hooks/__tests__/useFlowPendingApprovals.test.ts b/app/src/hooks/__tests__/useFlowPendingApprovals.test.ts new file mode 100644 index 000000000..68dcad5dd --- /dev/null +++ b/app/src/hooks/__tests__/useFlowPendingApprovals.test.ts @@ -0,0 +1,173 @@ +/** + * useFlowPendingApprovals (flow-approval surface — run details) — poll + + * decide contract. + * + * Asserts: no-op while `flowId`/`runId` is null; polls + * `approval_list_pending` every 2s while both are set; filters to gates + * matching `source_context.{kind:"flow",flow_id,run_id}`; stops polling on a + * fetch error (surfacing it) without hammering the endpoint; resets state + * when `flowId`/`runId` change; `decide()` calls `decideApproval` and + * optimistically drops the request on success, surfaces `error` and keeps it + * in the list on failure; cleans up timers 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 { useFlowPendingApprovals } from '../useFlowPendingApprovals'; + +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +const decideApproval = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals, decideApproval })); + +function makeApproval(overrides: Partial = {}): 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('useFlowPendingApprovals', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does nothing when flowId or runId is null', async () => { + const { result, rerender } = renderHook( + ({ flowId, runId }: { flowId: string | null; runId: string | null }) => + useFlowPendingApprovals(flowId, runId), + { initialProps: { flowId: null as string | null, runId: null as string | null } } + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.approvals).toEqual([]); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + + rerender({ flowId: 'flow-1', runId: null }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('polls every 2s and filters to this flow/run via source_context', async () => { + fetchPendingApprovals.mockResolvedValue([ + makeApproval({ request_id: 'req-a' }), + makeApproval({ + request_id: 'req-b', + source_context: { kind: 'flow', flow_id: 'flow-2', run_id: 'run-1' }, + }), + makeApproval({ request_id: 'req-c', source_context: undefined }), + ]); + const { result } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.approvals.map(a => a.request_id)).toEqual(['req-a']); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(2); + }); + + it('surfaces an error and stops polling on a failed fetch', async () => { + fetchPendingApprovals.mockRejectedValue(new Error('network down')); + const { result } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.error).toBe('network down'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + }); + + it('resets approvals when flowId/runId change', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + const { result, rerender } = renderHook( + ({ flowId, runId }: { flowId: string | null; runId: string | null }) => + useFlowPendingApprovals(flowId, runId), + { initialProps: { flowId: 'flow-1' as string | null, runId: 'run-1' as string | null } } + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.approvals).toHaveLength(1); + + rerender({ flowId: null, runId: null }); + expect(result.current.approvals).toEqual([]); + }); + + it('decide() calls decideApproval and optimistically drops the request on success', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + decideApproval.mockResolvedValue(undefined); + const { result } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.approvals).toHaveLength(1); + + await act(async () => { + await result.current.decide('req-a', 'approve_once'); + }); + + expect(decideApproval).toHaveBeenCalledWith('req-a', 'approve_once'); + expect(result.current.approvals).toEqual([]); + expect(result.current.decidingId).toBeNull(); + }); + + it('decide() surfaces an error and keeps the request when the RPC fails', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + decideApproval.mockRejectedValue(new Error('gate not installed')); + const { result } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + await act(async () => { + await expect(result.current.decide('req-a', 'deny')).rejects.toThrow('gate not installed'); + }); + + expect(result.current.error).toBe('gate not installed'); + expect(result.current.approvals).toHaveLength(1); + expect(result.current.decidingId).toBeNull(); + }); + + it('cleans up pending timers on unmount', async () => { + fetchPendingApprovals.mockResolvedValue([]); + const { unmount } = renderHook(() => useFlowPendingApprovals('flow-1', 'run-1')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + + unmount(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/hooks/useFlowApprovalRequests.test.ts b/app/src/hooks/useFlowApprovalRequests.test.ts new file mode 100644 index 000000000..b114242d7 --- /dev/null +++ b/app/src/hooks/useFlowApprovalRequests.test.ts @@ -0,0 +1,106 @@ +/** + * useFlowApprovalRequests (flow-approval surface — chat) — unit tests. + * + * Verifies the hook builds a de-duplicated list from the socket + * `flow_approval_request` feed (both colon and underscore aliases), drops + * malformed payloads, `dismiss()` removes a request by id, and it + * unsubscribes on unmount. The socket is a tiny in-memory emitter so events + * can be simulated deterministically — same harness as + * `useFlowRunProgress.test.ts`. + */ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useFlowApprovalRequests } from './useFlowApprovalRequests'; + +const handlers = vi.hoisted(() => new Map void>>()); +const on = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + const set = handlers.get(event) ?? new Set(); + set.add(cb); + handlers.set(event, set); + }) +); +const off = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + handlers.get(event)?.delete(cb); + }) +); +vi.mock('../services/socketService', () => ({ socketService: { on, off } })); + +function emit(payload: unknown) { + act(() => { + for (const event of ['flow:approval_request', 'flow_approval_request']) { + for (const cb of handlers.get(event) ?? []) cb(payload); + } + }); +} + +const REQUEST = { + request_id: 'req-1', + flow_id: 'flow-1', + run_id: 'run-1', + tool_name: 'shell', + summary: 'Run `shell` — rm -rf /tmp/scratch', +}; + +describe('useFlowApprovalRequests', () => { + beforeEach(() => { + handlers.clear(); + on.mockClear(); + off.mockClear(); + }); + + it('subscribes to both event aliases on mount', () => { + renderHook(() => useFlowApprovalRequests()); + expect(on).toHaveBeenCalledWith('flow:approval_request', expect.any(Function)); + expect(on).toHaveBeenCalledWith('flow_approval_request', expect.any(Function)); + }); + + it('adds a request from a valid payload', () => { + const { result } = renderHook(() => useFlowApprovalRequests()); + emit(REQUEST); + expect(result.current.requests).toEqual([REQUEST]); + }); + + it('de-duplicates by request_id', () => { + const { result } = renderHook(() => useFlowApprovalRequests()); + emit(REQUEST); + emit(REQUEST); + expect(result.current.requests).toHaveLength(1); + }); + + it('ignores malformed payloads', () => { + const { result } = renderHook(() => useFlowApprovalRequests()); + emit(null); + emit({ ...REQUEST, request_id: undefined }); + emit({ ...REQUEST, flow_id: 42 }); + expect(result.current.requests).toEqual([]); + }); + + it('accumulates distinct requests', () => { + const { result } = renderHook(() => useFlowApprovalRequests()); + emit(REQUEST); + emit({ ...REQUEST, request_id: 'req-2' }); + expect(result.current.requests.map(r => r.request_id)).toEqual(['req-1', 'req-2']); + }); + + it('dismiss() removes a request by id', () => { + const { result } = renderHook(() => useFlowApprovalRequests()); + emit(REQUEST); + emit({ ...REQUEST, request_id: 'req-2' }); + expect(result.current.requests).toHaveLength(2); + + act(() => { + result.current.dismiss('req-1'); + }); + expect(result.current.requests.map(r => r.request_id)).toEqual(['req-2']); + }); + + it('unsubscribes both event aliases on unmount', () => { + const { unmount } = renderHook(() => useFlowApprovalRequests()); + unmount(); + expect(off).toHaveBeenCalledWith('flow:approval_request', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_approval_request', expect.any(Function)); + }); +}); diff --git a/app/src/hooks/useFlowApprovalRequests.ts b/app/src/hooks/useFlowApprovalRequests.ts new file mode 100644 index 000000000..92d40b6e4 --- /dev/null +++ b/app/src/hooks/useFlowApprovalRequests.ts @@ -0,0 +1,110 @@ +/** + * useFlowApprovalRequests (flow-approval surface — chat) + * -------------------------------------------------------- + * + * Subscribes to the core's `flow_approval_request` socket event — raised + * when a `tinyflows` run pauses on an approval-gated tool call — and + * surfaces it as a live list so the chat surface can show an actionable + * banner without the user having to open the run inspector. Mirrors + * `useFlowRunProgress`'s subscription shape (`socketService.on`/`off` with + * cleanup on unmount) and listens for both the colon and underscore aliases + * the core socket bridge emits for every bridged event. + * + * Payload: `{ request_id, flow_id, run_id, tool_name, summary }`. Unlike the + * thread-scoped `approval_request` chat event (`ApprovalRequestCard`), this + * event carries no `thread_id` — a flow run isn't tied to the chat thread + * that's open when the gate fires — so requests are tracked independently + * of the active thread and surfaced regardless of which thread is selected. + * + * Decisions are NOT made here — `decide`/`dismiss` just drop the request off + * the local list once `FlowApprovalRequestCard` has resolved it via + * `approvalApi.decideApproval`, so a slow-to-arrive duplicate broadcast (or + * the same request already decided from another surface, e.g. the run + * inspector) doesn't linger. + */ +import debug from 'debug'; +import { useCallback, useEffect, useState } from 'react'; + +import { socketService } from '../services/socketService'; + +const log = debug('flows:approval-requests'); + +/** Socket event aliases the core bridge emits (colon + underscore forms). */ +const EVENT_COLON = 'flow:approval_request'; +const EVENT_UNDERSCORE = 'flow_approval_request'; + +export interface FlowApprovalRequest { + request_id: string; + flow_id: string; + run_id: string; + tool_name: string; + summary: string; +} + +function parsePayload(data: unknown): FlowApprovalRequest | null { + if (!data || typeof data !== 'object') return null; + const obj = data as Record; + if (typeof obj.request_id !== 'string') return null; + if (typeof obj.flow_id !== 'string') return null; + if (typeof obj.run_id !== 'string') return null; + if (typeof obj.tool_name !== 'string') return null; + if (typeof obj.summary !== 'string') return null; + return { + request_id: obj.request_id, + flow_id: obj.flow_id, + run_id: obj.run_id, + tool_name: obj.tool_name, + summary: obj.summary, + }; +} + +export interface UseFlowApprovalRequestsResult { + /** Live flow-approval requests awaiting a decision, oldest first. */ + requests: FlowApprovalRequest[]; + /** Drop a request off the list once it's been decided (or otherwise resolved). */ + dismiss: (requestId: string) => void; +} + +/** + * Watches for `flow_approval_request` broadcasts and keeps a de-duplicated + * (by `request_id`) list of pending ones for the caller to render. + */ +export function useFlowApprovalRequests(): UseFlowApprovalRequestsResult { + const [requests, setRequests] = useState([]); + + const handleRequest = useCallback((data: unknown) => { + const parsed = parsePayload(data); + if (!parsed) { + log('dropped — invalid payload %o', data); + return; + } + log( + 'request: id=%s flow=%s run=%s tool=%s', + parsed.request_id, + parsed.flow_id, + parsed.run_id, + parsed.tool_name + ); + setRequests(prev => + prev.some(r => r.request_id === parsed.request_id) ? prev : [...prev, parsed] + ); + }, []); + + useEffect(() => { + socketService.on(EVENT_COLON, handleRequest); + socketService.on(EVENT_UNDERSCORE, handleRequest); + return () => { + socketService.off(EVENT_COLON, handleRequest); + socketService.off(EVENT_UNDERSCORE, handleRequest); + }; + }, [handleRequest]); + + const dismiss = useCallback((requestId: string) => { + log('dismiss: id=%s', requestId); + setRequests(prev => prev.filter(r => r.request_id !== requestId)); + }, []); + + return { requests, dismiss }; +} + +export default useFlowApprovalRequests; diff --git a/app/src/hooks/useFlowPendingApprovals.ts b/app/src/hooks/useFlowPendingApprovals.ts new file mode 100644 index 000000000..552fd7f3a --- /dev/null +++ b/app/src/hooks/useFlowPendingApprovals.ts @@ -0,0 +1,137 @@ +/** + * useFlowPendingApprovals (flow-approval surface — run details) + * --------------------------------------------------------------- + * + * Feeds the actionable approval cards in `FlowRunInspectorDrawer`. The core + * has no dedicated "pending approvals for this run" endpoint — approvals are + * a single shared queue (`openhuman.approval_list_pending`) covering chat, + * flow, and any future origin — so this hook polls that shared queue every + * ~2s and filters client-side to the gates raised for one specific flow run + * via `PendingApproval.source_context` (`{ kind: "flow", flow_id, run_id }`). + * + * Mirrors the poll-until-told-to-stop shape of `useFlowRunPoller`: the caller + * controls the polling window by passing `null` for either `flowId` or + * `runId` once the run leaves an active state (`running` / + * `pending_approval`) — this hook does not know about run status itself. + * + * `decide()` wraps `approvalApi.decideApproval` and optimistically drops the + * request from the local list on success; the next poll tick reconciles + * against the server (e.g. a sequential-gate flow re-parking on a fresh + * request id). On success the run itself proceeds server-side — + * `useFlowRunPoller`'s independent 2s loop is what picks up the new steps. + */ +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + type ApprovalDecision, + decideApproval, + fetchPendingApprovals, + type PendingApproval, +} from '../services/api/approvalApi'; + +const log = debug('flows:pending-approvals'); + +/** How often to re-poll the shared approval queue while a run is active. */ +const POLL_INTERVAL_MS = 2000; + +function matchesRun(approval: PendingApproval, flowId: string, runId: string): boolean { + const ctx = approval.source_context; + return !!ctx && ctx.kind === 'flow' && ctx.flow_id === flowId && ctx.run_id === runId; +} + +export interface UseFlowPendingApprovalsResult { + /** Pending approvals scoped to this flow/run, oldest first (server order). */ + approvals: PendingApproval[]; + /** `request_id` of the approval currently being decided, or `null`. */ + decidingId: string | null; + /** Set when the last poll or decide call failed; cleared on the next success. */ + error: string | null; + /** Record a decision for one of `approvals`. Throws on failure (caller may ignore). */ + decide: (requestId: string, decision: ApprovalDecision) => Promise; +} + +/** + * Poll `openhuman.approval_list_pending` every {@link POLL_INTERVAL_MS}ms + * while both `flowId` and `runId` are non-null, filtered to that run via + * `source_context`. Stops polling (and clears state) when either argument + * becomes `null` or changes. A failed poll surfaces `error` and does not + * reschedule — same "don't hammer a broken endpoint" contract as + * `useFlowRunPoller`; the caller re-enabling polling (e.g. the drawer + * reopening) is what retries. + */ +export function useFlowPendingApprovals( + flowId: string | null, + runId: string | null +): UseFlowPendingApprovalsResult { + const [approvals, setApprovals] = useState([]); + const [decidingId, setDecidingId] = useState(null); + const [error, setError] = useState(null); + + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + setApprovals([]); + setError(null); + + if (!flowId || !runId) return; + + let cancelled = false; + let pollHandle: number | undefined; + + const tick = async () => { + if (cancelled) return; + try { + const all = await fetchPendingApprovals(); + if (cancelled || !mountedRef.current) return; + const scoped = all.filter(a => matchesRun(a, flowId, runId)); + log('tick: flow=%s run=%s total=%d scoped=%d', flowId, runId, all.length, scoped.length); + setApprovals(scoped); + setError(null); + pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log('tick: error flow=%s run=%s err=%s', flowId, runId, msg); + if (cancelled || !mountedRef.current) return; + setError(msg); + // Do not schedule another poll — leave retrying to the caller. + } + }; + + void tick(); + return () => { + cancelled = true; + if (pollHandle !== undefined) window.clearTimeout(pollHandle); + }; + }, [flowId, runId]); + + const decide = useCallback(async (requestId: string, decision: ApprovalDecision) => { + setDecidingId(requestId); + setError(null); + try { + await decideApproval(requestId, decision); + log('decide: ok request=%s decision=%s', requestId, decision); + if (mountedRef.current) { + setApprovals(prev => prev.filter(a => a.request_id !== requestId)); + } + } catch (err) { + log('decide: failed request=%s err=%o', requestId, err); + if (mountedRef.current) { + setError(err instanceof Error ? err.message : String(err)); + } + throw err; + } finally { + if (mountedRef.current) setDecidingId(null); + } + }, []); + + return { approvals, decidingId, error, decide }; +} + +export default useFlowPendingApprovals; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 8d1cdb59b..4e2d3b7ae 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3077,6 +3077,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'العميل يريد القيام بعمل يحتاج إلى موافقتك', 'chat.approval.title': 'الموافقة المطلوبة', 'chat.approval.tool': 'Tool:', + 'chat.flowApproval.title': 'يحتاج سير العمل إلى موافقة', + 'chat.flowApproval.fallback': 'يريد تشغيل سير العمل تنفيذ إجراء يحتاج إلى موافقتك.', + 'chat.flowApproval.tool': 'الأداة:', + 'chat.flowApproval.flow': 'التدفق:', + 'chat.flowApproval.approve': 'الموافقة مرة واحدة', + 'chat.flowApproval.approveAlways': 'الموافقة دائماً', + 'chat.flowApproval.approveAlwaysHint': 'تخطي نقطة التحقق هذه في التشغيلات المستقبلية لهذا التدفق', + 'chat.flowApproval.deny': 'رفض', + 'chat.flowApproval.deciding': 'جارٍ العمل…', + 'chat.flowApproval.error': 'تعذّر تسجيل قرارك. حاول مرة أخرى.', 'chat.flowProposal.title': 'اقتراح مسار العمل', 'chat.flowProposal.subtitle': 'راجع هذه الأتمتة قبل حفظها.', 'chat.flowProposal.triggerLabel': 'المُشغّل', @@ -3618,6 +3628,15 @@ const messages: TranslationMap = { 'notifications.flow.approveHint': 'استئناف سير العمل بعد نقطة التحقق هذه', 'notifications.flow.dismissHint': 'إخفاء هذا التنبيه دون استئناف سير العمل', 'notifications.flow.viewRun': 'عرض التشغيل', + 'notifications.flowGate.title': 'يحتاج سير العمل إلى موافقة', + 'notifications.flowGate.tool': 'الأداة:', + 'notifications.flowGate.approve': 'الموافقة مرة واحدة', + 'notifications.flowGate.approveAlways': 'الموافقة دائماً', + 'notifications.flowGate.approveAlwaysHint': + 'تخطي نقطة التحقق هذه في التشغيلات المستقبلية لهذا التدفق', + 'notifications.flowGate.deny': 'رفض', + 'notifications.flowGate.deciding': 'جارٍ العمل…', + 'notifications.flowGate.error': 'تعذّر تسجيل قرارك. حاول مرة أخرى.', 'flowRuns.inspector.title': 'تفاصيل التشغيل', 'flowRuns.inspector.startedAt': 'بدأ', 'flowRuns.inspector.finishedAt': 'انتهى', @@ -3625,6 +3644,14 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'خطأ', 'flowRuns.inspector.pendingApprovals': 'الموافقات المعلقة', 'flowRuns.inspector.pendingApprovalsCount': '{count} عقدة (عقد) في انتظار الموافقة', + 'flowRuns.inspector.approval.tool': 'الأداة:', + 'flowRuns.inspector.approval.approve': 'الموافقة مرة واحدة', + 'flowRuns.inspector.approval.approveAlways': 'الموافقة دائماً', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'تخطي نقطة التحقق هذه في التشغيلات المستقبلية لهذا التدفق', + 'flowRuns.inspector.approval.deny': 'رفض', + 'flowRuns.inspector.approval.deciding': 'جارٍ العمل…', + 'flowRuns.inspector.approval.loadError': 'تعذّر تحميل الموافقات المعلقة لهذا التشغيل.', 'flowRuns.inspector.steps': 'الخطوات', 'flowRuns.inspector.noSteps': 'لم يتم تسجيل أي خطوات بعد.', 'flowRuns.inspector.output': 'المخرجات', @@ -5154,6 +5181,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'تم القرار في {date}', 'settings.approvalHistory.decision.approveOnce': 'تمت الموافقة مرة واحدة', 'settings.approvalHistory.decision.approveAlways': 'مسموح دائمًا', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'مسموح دائمًا (التدفق)', 'settings.approvalHistory.decision.deny': 'مرفوض', 'settings.theme.variantLight': 'فاتح', 'settings.theme.variantDark': 'داكن', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a19456a98..a31f61442 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3143,6 +3143,17 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'এজেন্ট এমন কাজ করতে চায় যা আপনার অনুমোদন প্রয়োজন.', 'chat.approval.title': 'অনুমোদন প্রয়োজন', 'chat.approval.tool': 'টুল:', + 'chat.flowApproval.title': 'ওয়ার্কফ্লোর জন্য অনুমোদন প্রয়োজন', + 'chat.flowApproval.fallback': + 'একটি ওয়ার্কফ্লো রান আপনার অনুমোদনের প্রয়োজন এমন একটি কাজ সম্পাদন করতে চায়।', + 'chat.flowApproval.tool': 'টুল:', + 'chat.flowApproval.flow': 'ফ্লো:', + 'chat.flowApproval.approve': 'একবার অনুমোদন করুন', + 'chat.flowApproval.approveAlways': 'সর্বদা অনুমোদন করুন', + 'chat.flowApproval.approveAlwaysHint': 'এই ফ্লো-এর ভবিষ্যত রানগুলিতে এই চেকপয়েন্ট এড়িয়ে যান', + 'chat.flowApproval.deny': 'প্রত্যাখ্যান করুন', + 'chat.flowApproval.deciding': 'চলমান...', + 'chat.flowApproval.error': 'আপনার সিদ্ধান্তের কথা রেকর্ড করা যায় নি — আবার চেষ্টা করুন ।', 'chat.flowProposal.title': 'ওয়ার্কফ্লো প্রস্তাব', 'chat.flowProposal.subtitle': 'সংরক্ষণ করার আগে এই অটোমেশনটি পর্যালোচনা করুন।', 'chat.flowProposal.triggerLabel': 'ট্রিগার', @@ -3701,6 +3712,15 @@ const messages: TranslationMap = { 'notifications.flow.approveHint': 'এই চেকপয়েন্টের পরে ওয়ার্কফ্লো আবার শুরু করুন', 'notifications.flow.dismissHint': 'ওয়ার্কফ্লো আবার শুরু না করে এই প্রম্পটটি লুকান', 'notifications.flow.viewRun': 'রান দেখুন', + 'notifications.flowGate.title': 'ওয়ার্কফ্লোর জন্য অনুমোদন প্রয়োজন', + 'notifications.flowGate.tool': 'টুল:', + 'notifications.flowGate.approve': 'একবার অনুমোদন করুন', + 'notifications.flowGate.approveAlways': 'সর্বদা অনুমোদন করুন', + 'notifications.flowGate.approveAlwaysHint': + 'এই ফ্লো-এর ভবিষ্যত রানগুলিতে এই চেকপয়েন্ট এড়িয়ে যান', + 'notifications.flowGate.deny': 'প্রত্যাখ্যান করুন', + 'notifications.flowGate.deciding': 'চলমান...', + 'notifications.flowGate.error': 'আপনার সিদ্ধান্তের কথা রেকর্ড করা যায় নি — আবার চেষ্টা করুন ।', 'flowRuns.inspector.title': 'রান বিবরণ', 'flowRuns.inspector.startedAt': 'শুরু হয়েছে', 'flowRuns.inspector.finishedAt': 'শেষ হয়েছে', @@ -3708,6 +3728,14 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'ত্রুটি', 'flowRuns.inspector.pendingApprovals': 'মুলতুবি অনুমোদন', 'flowRuns.inspector.pendingApprovalsCount': '{count}টি নোড অনুমোদনের অপেক্ষায়', + 'flowRuns.inspector.approval.tool': 'টুল:', + 'flowRuns.inspector.approval.approve': 'একবার অনুমোদন করুন', + 'flowRuns.inspector.approval.approveAlways': 'সর্বদা অনুমোদন করুন', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'এই ফ্লো-এর ভবিষ্যত রানগুলিতে এই চেকপয়েন্ট এড়িয়ে যান', + 'flowRuns.inspector.approval.deny': 'প্রত্যাখ্যান করুন', + 'flowRuns.inspector.approval.deciding': 'চলমান...', + 'flowRuns.inspector.approval.loadError': 'এই রানের জন্য মুলতুবি অনুমোদনগুলি লোড করা যায়নি।', 'flowRuns.inspector.steps': 'ধাপ', 'flowRuns.inspector.noSteps': 'এখনও কোনো ধাপ রেকর্ড করা হয়নি।', 'flowRuns.inspector.output': 'আউটপুট', @@ -5270,6 +5298,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': '{date} তারিখে সিদ্ধান্ত নেওয়া হয়েছে', 'settings.approvalHistory.decision.approveOnce': 'একবার অনুমোদিত', 'settings.approvalHistory.decision.approveAlways': 'সর্বদা অনুমোদিত', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'সর্বদা অনুমোদিত (ফ্লো)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'হালকা', 'settings.theme.variantDark': 'গাঢ়', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 8945148ca..13107b914 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3221,6 +3221,19 @@ const messages: TranslationMap = { 'Der Agent möchte eine Aktion ausführen, die Ihre Zustimmung erfordert.', 'chat.approval.title': 'Genehmigung erforderlich', 'chat.approval.tool': 'Werkzeug:', + 'chat.flowApproval.title': 'Workflow benötigt Genehmigung', + 'chat.flowApproval.fallback': + 'Ein Workflow-Lauf möchte eine Aktion ausführen, die deine Genehmigung erfordert.', + 'chat.flowApproval.tool': 'Werkzeug:', + 'chat.flowApproval.flow': 'Ablauf:', + 'chat.flowApproval.approve': 'Einmal genehmigen', + 'chat.flowApproval.approveAlways': 'Immer genehmigen', + 'chat.flowApproval.approveAlwaysHint': + 'Diesen Prüfpunkt für zukünftige Läufe dieses Flows überspringen', + 'chat.flowApproval.deny': 'Ablehnen', + 'chat.flowApproval.deciding': 'Arbeiten…', + 'chat.flowApproval.error': + 'Deine Entscheidung konnte nicht gespeichert werden — bitte versuche es erneut.', 'chat.flowProposal.title': 'Workflow-Vorschlag', 'chat.flowProposal.subtitle': 'Prüfen Sie diese Automatisierung, bevor Sie sie speichern.', 'chat.flowProposal.triggerLabel': 'Auslöser', @@ -3791,6 +3804,16 @@ const messages: TranslationMap = { 'notifications.flow.approveHint': 'Workflow nach diesem Kontrollpunkt fortsetzen', 'notifications.flow.dismissHint': 'Diesen Hinweis ausblenden, ohne den Workflow fortzusetzen', 'notifications.flow.viewRun': 'Lauf anzeigen', + 'notifications.flowGate.title': 'Workflow benötigt Genehmigung', + 'notifications.flowGate.tool': 'Werkzeug:', + 'notifications.flowGate.approve': 'Einmal genehmigen', + 'notifications.flowGate.approveAlways': 'Immer genehmigen', + 'notifications.flowGate.approveAlwaysHint': + 'Diesen Prüfpunkt für zukünftige Läufe dieses Flows überspringen', + 'notifications.flowGate.deny': 'Ablehnen', + 'notifications.flowGate.deciding': 'Arbeiten…', + 'notifications.flowGate.error': + 'Deine Entscheidung konnte nicht gespeichert werden — bitte versuche es erneut.', 'flowRuns.inspector.title': 'Laufdetails', 'flowRuns.inspector.startedAt': 'Gestartet', 'flowRuns.inspector.finishedAt': 'Beendet', @@ -3798,6 +3821,15 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'Fehler', 'flowRuns.inspector.pendingApprovals': 'Ausstehende Genehmigungen', 'flowRuns.inspector.pendingApprovalsCount': '{count} Knoten warten auf Genehmigung', + 'flowRuns.inspector.approval.tool': 'Werkzeug:', + 'flowRuns.inspector.approval.approve': 'Einmal genehmigen', + 'flowRuns.inspector.approval.approveAlways': 'Immer genehmigen', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Diesen Prüfpunkt für zukünftige Läufe dieses Flows überspringen', + 'flowRuns.inspector.approval.deny': 'Ablehnen', + 'flowRuns.inspector.approval.deciding': 'Arbeiten…', + 'flowRuns.inspector.approval.loadError': + 'Ausstehende Genehmigungen für diesen Lauf konnten nicht geladen werden.', 'flowRuns.inspector.steps': 'Schritte', 'flowRuns.inspector.noSteps': 'Noch keine Schritte aufgezeichnet.', 'flowRuns.inspector.output': 'Ausgabe', @@ -5408,6 +5440,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Entschieden am {date}', 'settings.approvalHistory.decision.approveOnce': 'Einmal genehmigt', 'settings.approvalHistory.decision.approveAlways': 'Immer erlaubt', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Immer erlaubt (Flow)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'Hell', 'settings.theme.variantDark': 'Dunkel', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index bdac4e103..0210139d9 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3560,6 +3560,20 @@ const en: TranslationMap = { 'chat.approval.fallback': 'The agent wants to run an action that needs your approval.', 'chat.approval.title': 'Approval needed', 'chat.approval.tool': 'Tool:', + // Flow-approval surface — chat banner for a `flow_approval_request` socket + // event (a paused tinyflows run's gate, surfaced while the user is + // chatting rather than inspecting the run directly). + 'chat.flowApproval.title': 'Workflow needs approval', + 'chat.flowApproval.fallback': + 'A workflow run wants to perform an action that needs your approval.', + 'chat.flowApproval.tool': 'Tool:', + 'chat.flowApproval.flow': 'Flow:', + 'chat.flowApproval.approve': 'Approve once', + 'chat.flowApproval.approveAlways': 'Approve always', + 'chat.flowApproval.approveAlwaysHint': 'Skip this checkpoint for future runs of this flow', + 'chat.flowApproval.deny': 'Deny', + 'chat.flowApproval.deciding': 'Working…', + 'chat.flowApproval.error': 'Could not record your decision — try again.', 'chat.flowProposal.title': 'Workflow proposal', 'chat.flowProposal.subtitle': 'Review this automation before saving it.', 'chat.flowProposal.triggerLabel': 'Trigger', @@ -4339,6 +4353,18 @@ const en: TranslationMap = { 'notifications.flow.approveHint': 'Resume the workflow past this checkpoint', 'notifications.flow.dismissHint': 'Hide this prompt without resuming the workflow', 'notifications.flow.viewRun': 'View run', + // Flow-approval surface — notification-center gate card for the + // `flow-gate-approval` CoreNotification kind. Distinct from + // `notifications.flow.*` above (that's the older `flows_resume`-based + // pending-approval prompt); this one decides via `approval_decide`. + 'notifications.flowGate.title': 'Workflow needs approval', + 'notifications.flowGate.tool': 'Tool:', + 'notifications.flowGate.approve': 'Approve once', + 'notifications.flowGate.approveAlways': 'Approve always', + 'notifications.flowGate.approveAlwaysHint': 'Skip this checkpoint for future runs of this flow', + 'notifications.flowGate.deny': 'Deny', + 'notifications.flowGate.deciding': 'Working…', + 'notifications.flowGate.error': 'Could not record your decision. Please try again.', 'flowRuns.inspector.title': 'Run details', 'flowRuns.inspector.startedAt': 'Started', 'flowRuns.inspector.finishedAt': 'Finished', @@ -4346,6 +4372,15 @@ const en: TranslationMap = { 'flowRuns.inspector.error': 'Error', 'flowRuns.inspector.pendingApprovals': 'Pending approvals', 'flowRuns.inspector.pendingApprovalsCount': '{count} node(s) awaiting approval', + // Actionable approval gate cards (flow-approval surface — run details). + 'flowRuns.inspector.approval.tool': 'Tool:', + 'flowRuns.inspector.approval.approve': 'Approve once', + 'flowRuns.inspector.approval.approveAlways': 'Approve always', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Skip this checkpoint for future runs of this flow', + 'flowRuns.inspector.approval.deny': 'Deny', + 'flowRuns.inspector.approval.deciding': 'Working…', + 'flowRuns.inspector.approval.loadError': 'Could not load pending approvals for this run.', 'flowRuns.inspector.steps': 'Steps', 'flowRuns.inspector.noSteps': 'No steps recorded yet.', 'flowRuns.inspector.output': 'Output', @@ -5954,6 +5989,7 @@ const en: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Always allowed (flow)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.title': 'Theme Studio', 'settings.theme.menuDesc': 'Fully customize colours and fonts, or pick a preset theme.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 7168645fe..6f77c60ab 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3199,6 +3199,18 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'El agente quiere ejecutar una acción que necesita su aprobación.', 'chat.approval.title': 'Aprobación necesaria', 'chat.approval.tool': 'Herramienta:', + 'chat.flowApproval.title': 'El flujo de trabajo necesita aprobación', + 'chat.flowApproval.fallback': + 'Una ejecución de flujo de trabajo quiere realizar una acción que necesita tu aprobación.', + 'chat.flowApproval.tool': 'Herramienta:', + 'chat.flowApproval.flow': 'Flujo:', + 'chat.flowApproval.approve': 'Aprobar una vez', + 'chat.flowApproval.approveAlways': 'Aprobar siempre', + 'chat.flowApproval.approveAlwaysHint': + 'Omitir esta verificación en futuras ejecuciones de este flujo', + 'chat.flowApproval.deny': 'Denegar', + 'chat.flowApproval.deciding': 'Laboral…', + 'chat.flowApproval.error': 'No se pudo registrar su decisión; inténtelo de nuevo.', 'chat.flowProposal.title': 'Propuesta de flujo de trabajo', 'chat.flowProposal.subtitle': 'Revisa esta automatización antes de guardarla.', 'chat.flowProposal.triggerLabel': 'Disparador', @@ -3764,6 +3776,15 @@ const messages: TranslationMap = { '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', + 'notifications.flowGate.title': 'El flujo de trabajo necesita aprobación', + 'notifications.flowGate.tool': 'Herramienta:', + 'notifications.flowGate.approve': 'Aprobar una vez', + 'notifications.flowGate.approveAlways': 'Aprobar siempre', + 'notifications.flowGate.approveAlwaysHint': + 'Omitir esta verificación en futuras ejecuciones de este flujo', + 'notifications.flowGate.deny': 'Denegar', + 'notifications.flowGate.deciding': 'Laboral…', + 'notifications.flowGate.error': 'No se pudo registrar su decisión; inténtelo de nuevo.', 'flowRuns.inspector.title': 'Detalles de la ejecución', 'flowRuns.inspector.startedAt': 'Iniciado', 'flowRuns.inspector.finishedAt': 'Finalizado', @@ -3771,6 +3792,15 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'Error', 'flowRuns.inspector.pendingApprovals': 'Aprobaciones pendientes', 'flowRuns.inspector.pendingApprovalsCount': '{count} nodo(s) esperando aprobación', + 'flowRuns.inspector.approval.tool': 'Herramienta:', + 'flowRuns.inspector.approval.approve': 'Aprobar una vez', + 'flowRuns.inspector.approval.approveAlways': 'Aprobar siempre', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Omitir esta verificación en futuras ejecuciones de este flujo', + 'flowRuns.inspector.approval.deny': 'Denegar', + 'flowRuns.inspector.approval.deciding': 'Laboral…', + 'flowRuns.inspector.approval.loadError': + 'No se pudieron cargar las aprobaciones pendientes de esta ejecución.', 'flowRuns.inspector.steps': 'Pasos', 'flowRuns.inspector.noSteps': 'Aún no se han registrado pasos.', 'flowRuns.inspector.output': 'Salida', @@ -5365,6 +5395,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Decidido el {date}', 'settings.approvalHistory.decision.approveOnce': 'Aprobado una vez', 'settings.approvalHistory.decision.approveAlways': 'Siempre permitido', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Siempre permitido (flujo)', 'settings.approvalHistory.decision.deny': 'Denegado', 'settings.theme.variantLight': 'Claro', 'settings.theme.variantDark': 'Oscuro', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index a0a73379d..457d9ed8c 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3213,6 +3213,18 @@ const messages: TranslationMap = { 'chat.approval.fallback': "L'agent veut exécuter une action qui nécessite votre approbation.", 'chat.approval.title': 'Approbation requise', 'chat.approval.tool': 'Outil:', + 'chat.flowApproval.title': 'Le workflow nécessite une approbation', + 'chat.flowApproval.fallback': + 'Une exécution de workflow souhaite effectuer une action qui nécessite votre approbation.', + 'chat.flowApproval.tool': 'Outil:', + 'chat.flowApproval.flow': 'Flux :', + 'chat.flowApproval.approve': 'Approuver une fois', + 'chat.flowApproval.approveAlways': 'Toujours approuver', + 'chat.flowApproval.approveAlwaysHint': + 'Ignorer ce point de contrôle pour les futures exécutions de ce flux', + 'chat.flowApproval.deny': 'Refuser', + 'chat.flowApproval.deciding': 'Travail en cours…', + 'chat.flowApproval.error': "Impossible d'enregistrer votre décision — veuillez réessayer.", 'chat.flowProposal.title': 'Proposition de workflow', 'chat.flowProposal.subtitle': "Vérifiez cette automatisation avant de l'enregistrer.", 'chat.flowProposal.triggerLabel': 'Déclencheur', @@ -3779,6 +3791,15 @@ const messages: TranslationMap = { '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", + 'notifications.flowGate.title': 'Le workflow nécessite une approbation', + 'notifications.flowGate.tool': 'Outil:', + 'notifications.flowGate.approve': 'Approuver une fois', + 'notifications.flowGate.approveAlways': 'Toujours approuver', + 'notifications.flowGate.approveAlwaysHint': + 'Ignorer ce point de contrôle pour les futures exécutions de ce flux', + 'notifications.flowGate.deny': 'Refuser', + 'notifications.flowGate.deciding': 'Travail en cours…', + 'notifications.flowGate.error': "Impossible d'enregistrer votre décision — veuillez réessayer.", 'flowRuns.inspector.title': "Détails de l'exécution", 'flowRuns.inspector.startedAt': 'Démarré', 'flowRuns.inspector.finishedAt': 'Terminé', @@ -3786,6 +3807,15 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'Erreur', 'flowRuns.inspector.pendingApprovals': 'Approbations en attente', 'flowRuns.inspector.pendingApprovalsCount': "{count} nœud(s) en attente d'approbation", + 'flowRuns.inspector.approval.tool': 'Outil:', + 'flowRuns.inspector.approval.approve': 'Approuver une fois', + 'flowRuns.inspector.approval.approveAlways': 'Toujours approuver', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Ignorer ce point de contrôle pour les futures exécutions de ce flux', + 'flowRuns.inspector.approval.deny': 'Refuser', + 'flowRuns.inspector.approval.deciding': 'Travail en cours…', + 'flowRuns.inspector.approval.loadError': + 'Impossible de charger les approbations en attente pour cette exécution.', 'flowRuns.inspector.steps': 'Étapes', 'flowRuns.inspector.noSteps': 'Aucune étape enregistrée pour le moment.', 'flowRuns.inspector.output': 'Sortie', @@ -5385,6 +5415,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Décidé le {date}', 'settings.approvalHistory.decision.approveOnce': 'Approuvé une fois', 'settings.approvalHistory.decision.approveAlways': 'Toujours autorisé', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Toujours autorisé (flux)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'Clair', 'settings.theme.variantDark': 'Sombre', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 50b427a0c..7a3973055 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3144,6 +3144,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'एजेंट अपने अनुमोदन की जरूरत है कि एक कार्रवाई चलाने के लिए चाहता है।', 'chat.approval.title': 'आवश्यक अनुमोदन', 'chat.approval.tool': 'उपकरण:', + 'chat.flowApproval.title': 'वर्कफ़्लो को अनुमोदन की आवश्यकता है', + 'chat.flowApproval.fallback': 'एक वर्कफ़्लो रन आपकी अनुमति चाहने वाली एक क्रिया करना चाहता है।', + 'chat.flowApproval.tool': 'उपकरण:', + 'chat.flowApproval.flow': 'फ़्लो:', + 'chat.flowApproval.approve': 'एक बार स्वीकृति दें', + 'chat.flowApproval.approveAlways': 'हमेशा स्वीकृति दें', + 'chat.flowApproval.approveAlwaysHint': 'इस फ़्लो के भविष्य के रन के लिए इस चेकपॉइंट को छोड़ें', + 'chat.flowApproval.deny': 'अस्वीकार करें', + 'chat.flowApproval.deciding': 'कार्य प्रगति पर…', + 'chat.flowApproval.error': 'अपने निर्णय को रिकॉर्ड नहीं कर सकता - फिर से प्रयास करें।', 'chat.flowProposal.title': 'वर्कफ़्लो प्रस्ताव', 'chat.flowProposal.subtitle': 'सहेजने से पहले इस स्वचालन की समीक्षा करें।', 'chat.flowProposal.triggerLabel': 'ट्रिगर', @@ -3702,6 +3712,15 @@ const messages: TranslationMap = { 'notifications.flow.approveHint': 'इस चेकपॉइंट के बाद वर्कफ़्लो फिर से शुरू करें', 'notifications.flow.dismissHint': 'वर्कफ़्लो को फिर से शुरू किए बिना यह संकेत छिपाएं', 'notifications.flow.viewRun': 'रन देखें', + 'notifications.flowGate.title': 'वर्कफ़्लो को अनुमोदन की आवश्यकता है', + 'notifications.flowGate.tool': 'उपकरण:', + 'notifications.flowGate.approve': 'एक बार स्वीकृति दें', + 'notifications.flowGate.approveAlways': 'हमेशा स्वीकृति दें', + 'notifications.flowGate.approveAlwaysHint': + 'इस फ़्लो के भविष्य के रन के लिए इस चेकपॉइंट को छोड़ें', + 'notifications.flowGate.deny': 'अस्वीकार करें', + 'notifications.flowGate.deciding': 'कार्य प्रगति पर…', + 'notifications.flowGate.error': 'अपने निर्णय को रिकॉर्ड नहीं कर सकता - फिर से प्रयास करें।', 'flowRuns.inspector.title': 'रन विवरण', 'flowRuns.inspector.startedAt': 'शुरू हुआ', 'flowRuns.inspector.finishedAt': 'समाप्त हुआ', @@ -3709,6 +3728,14 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'त्रुटि', 'flowRuns.inspector.pendingApprovals': 'लंबित अनुमोदन', 'flowRuns.inspector.pendingApprovalsCount': '{count} नोड अनुमोदन की प्रतीक्षा में', + 'flowRuns.inspector.approval.tool': 'उपकरण:', + 'flowRuns.inspector.approval.approve': 'एक बार स्वीकृति दें', + 'flowRuns.inspector.approval.approveAlways': 'हमेशा स्वीकृति दें', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'इस फ़्लो के भविष्य के रन के लिए इस चेकपॉइंट को छोड़ें', + 'flowRuns.inspector.approval.deny': 'अस्वीकार करें', + 'flowRuns.inspector.approval.deciding': 'कार्य प्रगति पर…', + 'flowRuns.inspector.approval.loadError': 'इस रन के लिए लंबित अनुमोदन लोड नहीं किए जा सके।', 'flowRuns.inspector.steps': 'चरण', 'flowRuns.inspector.noSteps': 'अभी तक कोई चरण दर्ज नहीं किया गया है।', 'flowRuns.inspector.output': 'आउटपुट', @@ -5270,6 +5297,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': '{date} को निर्णय लिया गया', 'settings.approvalHistory.decision.approveOnce': 'एक बार स्वीकृत', 'settings.approvalHistory.decision.approveAlways': 'हमेशा अनुमति', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'हमेशा अनुमति (फ़्लो)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'हल्का', 'settings.theme.variantDark': 'गहरा', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 3d2a02548..1a81cda28 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3154,6 +3154,18 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Agen ingin melakukan tindakan yang membutuhkan persetujuanmu.', 'chat.approval.title': 'Perlu persetujuan', 'chat.approval.tool': 'Alat:', + 'chat.flowApproval.title': 'Alur kerja memerlukan persetujuan', + 'chat.flowApproval.fallback': + 'Proses alur kerja ingin melakukan tindakan yang memerlukan persetujuan Anda.', + 'chat.flowApproval.tool': 'Alat:', + 'chat.flowApproval.flow': 'Alur:', + 'chat.flowApproval.approve': 'Setujui sekali', + 'chat.flowApproval.approveAlways': 'Selalu setujui', + 'chat.flowApproval.approveAlwaysHint': + 'Lewati pemeriksaan ini untuk proses mendatang pada alur ini', + 'chat.flowApproval.deny': 'Tolak', + 'chat.flowApproval.deciding': 'Bekerja…', + 'chat.flowApproval.error': 'Tidak bisa merekam keputusan Anda - coba lagi.', 'chat.flowProposal.title': 'Proposal alur kerja', 'chat.flowProposal.subtitle': 'Tinjau otomatisasi ini sebelum menyimpannya.', 'chat.flowProposal.triggerLabel': 'Pemicu', @@ -3710,6 +3722,15 @@ const messages: TranslationMap = { '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', + 'notifications.flowGate.title': 'Alur kerja memerlukan persetujuan', + 'notifications.flowGate.tool': 'Alat:', + 'notifications.flowGate.approve': 'Setujui sekali', + 'notifications.flowGate.approveAlways': 'Selalu setujui', + 'notifications.flowGate.approveAlwaysHint': + 'Lewati pemeriksaan ini untuk proses mendatang pada alur ini', + 'notifications.flowGate.deny': 'Tolak', + 'notifications.flowGate.deciding': 'Bekerja…', + 'notifications.flowGate.error': 'Tidak bisa merekam keputusan Anda - coba lagi.', 'flowRuns.inspector.title': 'Detail proses', 'flowRuns.inspector.startedAt': 'Dimulai', 'flowRuns.inspector.finishedAt': 'Selesai', @@ -3717,6 +3738,15 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'Kesalahan', 'flowRuns.inspector.pendingApprovals': 'Persetujuan tertunda', 'flowRuns.inspector.pendingApprovalsCount': '{count} node menunggu persetujuan', + 'flowRuns.inspector.approval.tool': 'Alat:', + 'flowRuns.inspector.approval.approve': 'Setujui sekali', + 'flowRuns.inspector.approval.approveAlways': 'Selalu setujui', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Lewati pemeriksaan ini untuk proses mendatang pada alur ini', + 'flowRuns.inspector.approval.deny': 'Tolak', + 'flowRuns.inspector.approval.deciding': 'Bekerja…', + 'flowRuns.inspector.approval.loadError': + 'Tidak dapat memuat persetujuan yang tertunda untuk proses ini.', 'flowRuns.inspector.steps': 'Langkah', 'flowRuns.inspector.noSteps': 'Belum ada langkah yang tercatat.', 'flowRuns.inspector.output': 'Keluaran', @@ -5286,6 +5316,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Diputuskan {date}', 'settings.approvalHistory.decision.approveOnce': 'Disetujui sekali', 'settings.approvalHistory.decision.approveAlways': 'Selalu diizinkan', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Selalu diizinkan (alur)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'Terang', 'settings.theme.variantDark': 'Gelap', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 5ec29a5f5..dbacdaee7 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3195,6 +3195,18 @@ const messages: TranslationMap = { "L'agente vuole eseguire un'azione che necessita della tua approvazione.", 'chat.approval.title': 'Approvazione necessaria', 'chat.approval.tool': 'Strumento:', + 'chat.flowApproval.title': "Il workflow richiede l'approvazione", + 'chat.flowApproval.fallback': + "Un'esecuzione del workflow vuole eseguire un'azione che richiede la tua approvazione.", + 'chat.flowApproval.tool': 'Strumento:', + 'chat.flowApproval.flow': 'Flusso:', + 'chat.flowApproval.approve': 'Approva una volta', + 'chat.flowApproval.approveAlways': 'Approva sempre', + 'chat.flowApproval.approveAlwaysHint': + 'Salta questo checkpoint per le future esecuzioni di questo flusso', + 'chat.flowApproval.deny': 'Rifiuta', + 'chat.flowApproval.deciding': 'Lavorando…', + 'chat.flowApproval.error': 'Impossibile registrare la tua decisione — riprova.', 'chat.flowProposal.title': 'Proposta di workflow', 'chat.flowProposal.subtitle': 'Rivedi questa automazione prima di salvarla.', 'chat.flowProposal.triggerLabel': 'Trigger', @@ -3759,6 +3771,15 @@ const messages: TranslationMap = { 'notifications.flow.approveHint': 'Riprendi il workflow dopo questo checkpoint', 'notifications.flow.dismissHint': 'Nascondi questo avviso senza riprendere il workflow', 'notifications.flow.viewRun': 'Visualizza esecuzione', + 'notifications.flowGate.title': "Il workflow richiede l'approvazione", + 'notifications.flowGate.tool': 'Strumento:', + 'notifications.flowGate.approve': 'Approva una volta', + 'notifications.flowGate.approveAlways': 'Approva sempre', + 'notifications.flowGate.approveAlwaysHint': + 'Salta questo checkpoint per le future esecuzioni di questo flusso', + 'notifications.flowGate.deny': 'Rifiuta', + 'notifications.flowGate.deciding': 'Lavorando…', + 'notifications.flowGate.error': 'Impossibile registrare la tua decisione — riprova.', 'flowRuns.inspector.title': 'Dettagli esecuzione', 'flowRuns.inspector.startedAt': 'Avviato', 'flowRuns.inspector.finishedAt': 'Terminato', @@ -3766,6 +3787,15 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'Errore', 'flowRuns.inspector.pendingApprovals': 'Approvazioni in sospeso', 'flowRuns.inspector.pendingApprovalsCount': '{count} nodo/i in attesa di approvazione', + 'flowRuns.inspector.approval.tool': 'Strumento:', + 'flowRuns.inspector.approval.approve': 'Approva una volta', + 'flowRuns.inspector.approval.approveAlways': 'Approva sempre', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Salta questo checkpoint per le future esecuzioni di questo flusso', + 'flowRuns.inspector.approval.deny': 'Rifiuta', + 'flowRuns.inspector.approval.deciding': 'Lavorando…', + 'flowRuns.inspector.approval.loadError': + 'Impossibile caricare le approvazioni in sospeso per questa esecuzione.', 'flowRuns.inspector.steps': 'Passaggi', 'flowRuns.inspector.noSteps': 'Nessun passaggio registrato finora.', 'flowRuns.inspector.output': 'Output', @@ -5355,6 +5385,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Deciso il {date}', 'settings.approvalHistory.decision.approveOnce': 'Approvato una volta', 'settings.approvalHistory.decision.approveAlways': 'Sempre consentito', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Sempre consentito (flusso)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'Chiaro', 'settings.theme.variantDark': 'Scuro', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index e6e420416..2ad9776fa 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3113,6 +3113,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': '에이전트가 승인이 필요한 작업을 실행하려고 합니다.', 'chat.approval.title': '승인 필요', 'chat.approval.tool': '도구:', + 'chat.flowApproval.title': '워크플로에 승인이 필요합니다', + 'chat.flowApproval.fallback': '워크플로 실행이 승인이 필요한 작업을 수행하려고 합니다.', + 'chat.flowApproval.tool': '도구:', + 'chat.flowApproval.flow': '플로우:', + 'chat.flowApproval.approve': '한 번 승인', + 'chat.flowApproval.approveAlways': '항상 승인', + 'chat.flowApproval.approveAlwaysHint': '이 플로우의 향후 실행에서 이 검사 지점을 건너뜁니다', + 'chat.flowApproval.deny': '거부', + 'chat.flowApproval.deciding': '작업 중…', + 'chat.flowApproval.error': '결정을 기록할 수 없습니다. 다시 시도하세요.', 'chat.flowProposal.title': '워크플로 제안', 'chat.flowProposal.subtitle': '저장하기 전에 이 자동화를 검토하세요.', 'chat.flowProposal.triggerLabel': '트리거', @@ -3665,6 +3675,14 @@ const messages: TranslationMap = { 'notifications.flow.approveHint': '이 체크포인트 이후 워크플로 재개', 'notifications.flow.dismissHint': '워크플로를 재개하지 않고 이 알림 숨기기', 'notifications.flow.viewRun': '실행 보기', + 'notifications.flowGate.title': '워크플로에 승인이 필요합니다', + 'notifications.flowGate.tool': '도구:', + 'notifications.flowGate.approve': '한 번 승인', + 'notifications.flowGate.approveAlways': '항상 승인', + 'notifications.flowGate.approveAlwaysHint': '이 플로우의 향후 실행에서 이 검사 지점을 건너뜁니다', + 'notifications.flowGate.deny': '거부', + 'notifications.flowGate.deciding': '작업 중…', + 'notifications.flowGate.error': '결정을 기록할 수 없습니다. 다시 시도하세요.', 'flowRuns.inspector.title': '실행 세부정보', 'flowRuns.inspector.startedAt': '시작됨', 'flowRuns.inspector.finishedAt': '종료됨', @@ -3672,6 +3690,14 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': '오류', 'flowRuns.inspector.pendingApprovals': '대기 중인 승인', 'flowRuns.inspector.pendingApprovalsCount': '노드 {count}개가 승인 대기 중', + 'flowRuns.inspector.approval.tool': '도구:', + 'flowRuns.inspector.approval.approve': '한 번 승인', + 'flowRuns.inspector.approval.approveAlways': '항상 승인', + 'flowRuns.inspector.approval.approveAlwaysHint': + '이 플로우의 향후 실행에서 이 검사 지점을 건너뜁니다', + 'flowRuns.inspector.approval.deny': '거부', + 'flowRuns.inspector.approval.deciding': '작업 중…', + 'flowRuns.inspector.approval.loadError': '이 실행에 대한 대기 중인 승인을 불러올 수 없습니다.', 'flowRuns.inspector.steps': '단계', 'flowRuns.inspector.noSteps': '아직 기록된 단계가 없습니다.', 'flowRuns.inspector.output': '출력', @@ -5209,6 +5235,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': '{date}에 결정됨', 'settings.approvalHistory.decision.approveOnce': '1회 승인', 'settings.approvalHistory.decision.approveAlways': '항상 허용', + 'settings.approvalHistory.decision.approveAlwaysFlow': '항상 허용(플로우)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': '밝게', 'settings.theme.variantDark': '어둡게', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 81e5ec2be..25b13543f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3179,6 +3179,18 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Agent chce wykonać akcję wymagającą Twojej zgody.', 'chat.approval.title': 'Wymagana zgoda', 'chat.approval.tool': 'Narzędzie:', + 'chat.flowApproval.title': 'Przepływ pracy wymaga zatwierdzenia', + 'chat.flowApproval.fallback': + 'Uruchomienie przepływu pracy chce wykonać czynność, która wymaga twojego zatwierdzenia.', + 'chat.flowApproval.tool': 'Narzędzie:', + 'chat.flowApproval.flow': 'Przepływ:', + 'chat.flowApproval.approve': 'Zatwierdź raz', + 'chat.flowApproval.approveAlways': 'Zawsze zatwierdzaj', + 'chat.flowApproval.approveAlwaysHint': + 'Pomijaj ten punkt kontrolny w przyszłych uruchomieniach tego przepływu', + 'chat.flowApproval.deny': 'Odrzuć', + 'chat.flowApproval.deciding': 'Przetwarzanie…', + 'chat.flowApproval.error': 'Nie udało się zapisać decyzji — spróbuj ponownie.', 'chat.flowProposal.title': 'Propozycja przepływu pracy', 'chat.flowProposal.subtitle': 'Sprawdź tę automatyzację przed zapisaniem.', 'chat.flowProposal.triggerLabel': 'Wyzwalacz', @@ -3745,6 +3757,15 @@ const messages: TranslationMap = { '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', + 'notifications.flowGate.title': 'Przepływ pracy wymaga zatwierdzenia', + 'notifications.flowGate.tool': 'Narzędzie:', + 'notifications.flowGate.approve': 'Zatwierdź raz', + 'notifications.flowGate.approveAlways': 'Zawsze zatwierdzaj', + 'notifications.flowGate.approveAlwaysHint': + 'Pomijaj ten punkt kontrolny w przyszłych uruchomieniach tego przepływu', + 'notifications.flowGate.deny': 'Odrzuć', + 'notifications.flowGate.deciding': 'Przetwarzanie…', + 'notifications.flowGate.error': 'Nie udało się zapisać decyzji — spróbuj ponownie.', 'flowRuns.inspector.title': 'Szczegóły przebiegu', 'flowRuns.inspector.startedAt': 'Rozpoczęto', 'flowRuns.inspector.finishedAt': 'Zakończono', @@ -3752,6 +3773,15 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'Błąd', 'flowRuns.inspector.pendingApprovals': 'Oczekujące zatwierdzenia', 'flowRuns.inspector.pendingApprovalsCount': '{count} węzeł(y) oczekuje(ą) na zatwierdzenie', + 'flowRuns.inspector.approval.tool': 'Narzędzie:', + 'flowRuns.inspector.approval.approve': 'Zatwierdź raz', + 'flowRuns.inspector.approval.approveAlways': 'Zawsze zatwierdzaj', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Pomijaj ten punkt kontrolny w przyszłych uruchomieniach tego przepływu', + 'flowRuns.inspector.approval.deny': 'Odrzuć', + 'flowRuns.inspector.approval.deciding': 'Przetwarzanie…', + 'flowRuns.inspector.approval.loadError': + 'Nie udało się załadować oczekujących zatwierdzeń dla tego przebiegu.', 'flowRuns.inspector.steps': 'Kroki', 'flowRuns.inspector.noSteps': 'Nie zarejestrowano jeszcze żadnych kroków.', 'flowRuns.inspector.output': 'Dane wyjściowe', @@ -5348,6 +5378,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Zdecydowano {date}', 'settings.approvalHistory.decision.approveOnce': 'Zatwierdzone raz', 'settings.approvalHistory.decision.approveAlways': 'Zawsze dozwolone', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Zawsze dozwolone (przepływ)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'Jasny', 'settings.theme.variantDark': 'Ciemny', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 0583b7195..bd64f6a1c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3197,6 +3197,18 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'O agente quer executar uma ação que precisa da sua aprovação.', 'chat.approval.title': 'Aprovação necessária', 'chat.approval.tool': 'Ferramenta:', + 'chat.flowApproval.title': 'O fluxo de trabalho precisa de aprovação', + 'chat.flowApproval.fallback': + 'Uma execução de fluxo de trabalho deseja realizar uma ação que precisa da sua aprovação.', + 'chat.flowApproval.tool': 'Ferramenta:', + 'chat.flowApproval.flow': 'Fluxo:', + 'chat.flowApproval.approve': 'Aprovar uma vez', + 'chat.flowApproval.approveAlways': 'Aprovar sempre', + 'chat.flowApproval.approveAlwaysHint': + 'Ignorar esta verificação em futuras execuções deste fluxo', + 'chat.flowApproval.deny': 'Negar', + 'chat.flowApproval.deciding': 'Trabalhando…', + 'chat.flowApproval.error': 'Não foi possível registrar sua decisão — tente novamente.', 'chat.flowProposal.title': 'Proposta de fluxo de trabalho', 'chat.flowProposal.subtitle': 'Revise esta automação antes de salvá-la.', 'chat.flowProposal.triggerLabel': 'Gatilho', @@ -3760,6 +3772,15 @@ const messages: TranslationMap = { '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', + 'notifications.flowGate.title': 'O fluxo de trabalho precisa de aprovação', + 'notifications.flowGate.tool': 'Ferramenta:', + 'notifications.flowGate.approve': 'Aprovar uma vez', + 'notifications.flowGate.approveAlways': 'Aprovar sempre', + 'notifications.flowGate.approveAlwaysHint': + 'Ignorar esta verificação em futuras execuções deste fluxo', + 'notifications.flowGate.deny': 'Negar', + 'notifications.flowGate.deciding': 'Trabalhando…', + 'notifications.flowGate.error': 'Não foi possível registrar sua decisão — tente novamente.', 'flowRuns.inspector.title': 'Detalhes da execução', 'flowRuns.inspector.startedAt': 'Iniciado', 'flowRuns.inspector.finishedAt': 'Concluído', @@ -3767,6 +3788,15 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'Erro', 'flowRuns.inspector.pendingApprovals': 'Aprovações pendentes', 'flowRuns.inspector.pendingApprovalsCount': '{count} nó(s) aguardando aprovação', + 'flowRuns.inspector.approval.tool': 'Ferramenta:', + 'flowRuns.inspector.approval.approve': 'Aprovar uma vez', + 'flowRuns.inspector.approval.approveAlways': 'Aprovar sempre', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Ignorar esta verificação em futuras execuções deste fluxo', + 'flowRuns.inspector.approval.deny': 'Negar', + 'flowRuns.inspector.approval.deciding': 'Trabalhando…', + 'flowRuns.inspector.approval.loadError': + 'Não foi possível carregar as aprovações pendentes desta execução.', 'flowRuns.inspector.steps': 'Etapas', 'flowRuns.inspector.noSteps': 'Nenhuma etapa registrada ainda.', 'flowRuns.inspector.output': 'Saída', @@ -5354,6 +5384,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Decidido em {date}', 'settings.approvalHistory.decision.approveOnce': 'Aprovado uma vez', 'settings.approvalHistory.decision.approveAlways': 'Sempre permitido', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Sempre permitido (fluxo)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'Claro', 'settings.theme.variantDark': 'Escuro', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index d52f00a3d..a6959c9ad 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3170,6 +3170,18 @@ const messages: TranslationMap = { 'chat.approval.fallback': 'Агент хочет выполнить действие, требующее вашего одобрения.', 'chat.approval.title': 'Требуется одобрение', 'chat.approval.tool': 'Инструмент:', + 'chat.flowApproval.title': 'Рабочий процесс требует одобрения', + 'chat.flowApproval.fallback': + 'Запуск рабочего процесса хочет выполнить действие, которое требует вашего одобрения.', + 'chat.flowApproval.tool': 'Инструмент:', + 'chat.flowApproval.flow': 'Поток:', + 'chat.flowApproval.approve': 'Утвердить один раз', + 'chat.flowApproval.approveAlways': 'Утверждать всегда', + 'chat.flowApproval.approveAlwaysHint': + 'Пропускать эту проверку при будущих запусках этого потока', + 'chat.flowApproval.deny': 'Отклонить', + 'chat.flowApproval.deciding': 'Выполняется…', + 'chat.flowApproval.error': 'Не удалось записать свое решение — попробуйте еще раз.', 'chat.flowProposal.title': 'Предложение рабочего процесса', 'chat.flowProposal.subtitle': 'Проверьте эту автоматизацию перед сохранением.', 'chat.flowProposal.triggerLabel': 'Триггер', @@ -3734,6 +3746,15 @@ const messages: TranslationMap = { 'notifications.flow.approveHint': 'Возобновить рабочий процесс после этой контрольной точки', 'notifications.flow.dismissHint': 'Скрыть это уведомление без возобновления рабочего процесса', 'notifications.flow.viewRun': 'Просмотреть запуск', + 'notifications.flowGate.title': 'Рабочий процесс требует одобрения', + 'notifications.flowGate.tool': 'Инструмент:', + 'notifications.flowGate.approve': 'Утвердить один раз', + 'notifications.flowGate.approveAlways': 'Утверждать всегда', + 'notifications.flowGate.approveAlwaysHint': + 'Пропускать эту проверку при будущих запусках этого потока', + 'notifications.flowGate.deny': 'Отклонить', + 'notifications.flowGate.deciding': 'Выполняется…', + 'notifications.flowGate.error': 'Не удалось записать свое решение — попробуйте еще раз.', 'flowRuns.inspector.title': 'Детали запуска', 'flowRuns.inspector.startedAt': 'Начато', 'flowRuns.inspector.finishedAt': 'Завершено', @@ -3741,6 +3762,15 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': 'Ошибка', 'flowRuns.inspector.pendingApprovals': 'Ожидающие подтверждения', 'flowRuns.inspector.pendingApprovalsCount': '{count} узел(-ов) ожидает подтверждения', + 'flowRuns.inspector.approval.tool': 'Инструмент:', + 'flowRuns.inspector.approval.approve': 'Утвердить один раз', + 'flowRuns.inspector.approval.approveAlways': 'Утверждать всегда', + 'flowRuns.inspector.approval.approveAlwaysHint': + 'Пропускать эту проверку при будущих запусках этого потока', + 'flowRuns.inspector.approval.deny': 'Отклонить', + 'flowRuns.inspector.approval.deciding': 'Выполняется…', + 'flowRuns.inspector.approval.loadError': + 'Не удалось загрузить ожидающие подтверждения для этого запуска.', 'flowRuns.inspector.steps': 'Шаги', 'flowRuns.inspector.noSteps': 'Пока не зафиксировано ни одного шага.', 'flowRuns.inspector.output': 'Вывод', @@ -5322,6 +5352,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': 'Решено {date}', 'settings.approvalHistory.decision.approveOnce': 'Одобрено однократно', 'settings.approvalHistory.decision.approveAlways': 'Всегда разрешено', + 'settings.approvalHistory.decision.approveAlwaysFlow': 'Всегда разрешено (поток)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': 'Светлая', 'settings.theme.variantDark': 'Тёмная', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index f115fe884..4eced2c31 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2981,6 +2981,16 @@ const messages: TranslationMap = { 'chat.approval.fallback': '智能体想要运行一项需要你批准的操作。', 'chat.approval.title': '需要批准', 'chat.approval.tool': '工具:', + 'chat.flowApproval.title': '工作流需要批准', + 'chat.flowApproval.fallback': '工作流运行想要执行一个需要你批准的操作。', + 'chat.flowApproval.tool': '工具:', + 'chat.flowApproval.flow': '流程:', + 'chat.flowApproval.approve': '批准一次', + 'chat.flowApproval.approveAlways': '始终批准', + 'chat.flowApproval.approveAlwaysHint': '跳过此流程未来运行中的此检查点', + 'chat.flowApproval.deny': '拒绝', + 'chat.flowApproval.deciding': '处理中…', + 'chat.flowApproval.error': '无法记录你的决定 — 请重试。', 'chat.flowProposal.title': '工作流建议', 'chat.flowProposal.subtitle': '保存前请先查看此自动化流程。', 'chat.flowProposal.triggerLabel': '触发器', @@ -3508,6 +3518,14 @@ const messages: TranslationMap = { 'notifications.flow.approveHint': '在此检查点之后恢复工作流', 'notifications.flow.dismissHint': '隐藏此提示但不恢复工作流', 'notifications.flow.viewRun': '查看运行', + 'notifications.flowGate.title': '工作流需要批准', + 'notifications.flowGate.tool': '工具:', + 'notifications.flowGate.approve': '批准一次', + 'notifications.flowGate.approveAlways': '始终批准', + 'notifications.flowGate.approveAlwaysHint': '跳过此流程未来运行中的此检查点', + 'notifications.flowGate.deny': '拒绝', + 'notifications.flowGate.deciding': '处理中…', + 'notifications.flowGate.error': '无法记录你的决定 — 请重试。', 'flowRuns.inspector.title': '运行详情', 'flowRuns.inspector.startedAt': '开始时间', 'flowRuns.inspector.finishedAt': '结束时间', @@ -3515,6 +3533,13 @@ const messages: TranslationMap = { 'flowRuns.inspector.error': '错误', 'flowRuns.inspector.pendingApprovals': '待批准', 'flowRuns.inspector.pendingApprovalsCount': '{count} 个节点等待批准', + 'flowRuns.inspector.approval.tool': '工具:', + 'flowRuns.inspector.approval.approve': '批准一次', + 'flowRuns.inspector.approval.approveAlways': '始终批准', + 'flowRuns.inspector.approval.approveAlwaysHint': '跳过此流程未来运行中的此检查点', + 'flowRuns.inspector.approval.deny': '拒绝', + 'flowRuns.inspector.approval.deciding': '处理中…', + 'flowRuns.inspector.approval.loadError': '无法加载此运行的待批准项。', 'flowRuns.inspector.steps': '步骤', 'flowRuns.inspector.noSteps': '尚未记录任何步骤。', 'flowRuns.inspector.output': '输出', @@ -4994,6 +5019,7 @@ const messages: TranslationMap = { 'settings.approvalHistory.decidedAt': '决定于 {date}', 'settings.approvalHistory.decision.approveOnce': '批准一次', 'settings.approvalHistory.decision.approveAlways': '始终允许', + 'settings.approvalHistory.decision.approveAlwaysFlow': '始终允许(流程)', 'settings.approvalHistory.decision.deny': 'Denied', 'settings.theme.variantLight': '浅色', 'settings.theme.variantDark': '深色', diff --git a/app/src/services/api/approvalApi.test.ts b/app/src/services/api/approvalApi.test.ts index fa6722ba9..3276325f0 100644 --- a/app/src/services/api/approvalApi.test.ts +++ b/app/src/services/api/approvalApi.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type ApprovalAuditEntry, + decideApproval, fetchPendingApprovals, fetchRecentApprovalDecisions, unwrapRows, @@ -90,4 +91,33 @@ describe('fetchPendingApprovals', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.approval_list_pending' }); expect(rows[0].request_id).toBe('p-1'); }); + + it('preserves a flow-origin source_context when present', async () => { + mockCallCoreRpc.mockResolvedValueOnce([ + { + request_id: 'p-2', + tool_name: 'shell', + source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' }, + }, + ]); + + const rows = await fetchPendingApprovals(); + + expect(rows[0].source_context).toEqual({ kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' }); + }); +}); + +describe('decideApproval', () => { + beforeEach(() => mockCallCoreRpc.mockReset()); + + it('calls openhuman.approval_decide with the request id and decision', async () => { + mockCallCoreRpc.mockResolvedValueOnce({}); + + await decideApproval('req-1', 'approve_always_for_flow'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.approval_decide', + params: { request_id: 'req-1', decision: 'approve_always_for_flow' }, + }); + }); }); diff --git a/app/src/services/api/approvalApi.ts b/app/src/services/api/approvalApi.ts index 32c84519e..868a46145 100644 --- a/app/src/services/api/approvalApi.ts +++ b/app/src/services/api/approvalApi.ts @@ -14,8 +14,31 @@ import { callCoreRpc } from '../coreRpcClient'; // is NOT installed the core returns a bare `[]`. `unwrapRows` normalizes both. // --------------------------------------------------------------------------- -/** User's decision on a pending approval (mirrors Rust `ApprovalDecision`). */ -export type ApprovalDecision = 'approve_once' | 'approve_always_for_tool' | 'deny'; +/** + * User's decision on a pending approval (mirrors Rust `ApprovalDecision`). + * `approve_always_for_flow` (flow-approval surface, see {@link ApprovalSourceContext}) + * additionally allowlists the gate for every future run of the same flow, the + * flow-scoped analogue of `approve_always_for_tool`'s session-scoped allowlist. + */ +export type ApprovalDecision = + | 'approve_once' + | 'approve_always_for_tool' + | 'approve_always_for_flow' + | 'deny'; + +/** + * Origin hint attached to a pending approval that was raised from inside a + * `tinyflows` run rather than an interactive chat turn. Lets the flow-run + * inspector (and any other flow-aware surface) filter the shared + * `approval_list_pending` queue down to just the gates for one run without a + * dedicated endpoint. Absent for chat-originated approvals. + */ +export interface ApprovalSourceContext { + kind: 'flow'; + flow_id: string; + run_id: string; + node_id?: string; +} /** A pending approval awaiting a decision (mirrors Rust `PendingApproval`). */ export interface PendingApproval { @@ -30,6 +53,8 @@ export interface PendingApproval { created_at: string; /** RFC3339 timestamp, or null when the request does not expire. */ expires_at: string | null; + /** Present when this gate was raised from a flow run — see {@link ApprovalSourceContext}. */ + source_context?: ApprovalSourceContext; } /** A decided approval audit row (mirrors Rust `ApprovalAuditEntry`). */ @@ -84,6 +109,22 @@ export const fetchPendingApprovals = async (): Promise => { return unwrapRows(raw); }; +/** + * Record a decision on a pending approval. Shared by every approval surface + * (chat `ApprovalRequestCard`, the flow-run inspector, the flow chat banner, + * and the notification-center gate card) — they all park on the same + * `ApprovalGate` and resolve through this one RPC. + */ +export const decideApproval = async ( + requestId: string, + decision: ApprovalDecision +): Promise => { + await callCoreRpc({ + method: 'openhuman.approval_decide', + params: { request_id: requestId, decision }, + }); +}; + /** * Snapshot of the host-aware approval-gate boot decision. Mirrors the Rust * `ApprovalGateBootState` struct in `src/openhuman/approval/gate.rs`. diff --git a/app/src/store/notificationSlice.ts b/app/src/store/notificationSlice.ts index 206d5f131..8f51918be 100644 --- a/app/src/store/notificationSlice.ts +++ b/app/src/store/notificationSlice.ts @@ -30,6 +30,16 @@ export interface NotificationItem { provider?: string; deepLink?: string; actions?: NotificationAction[]; + /** + * Discriminator for a core-originated notification that needs a dedicated + * rendering (routed in `NotificationCenter`) instead of the generic + * `CoreNotificationCard`. Currently only `'flow-gate-approval'` — a paused + * `tinyflows` run's approval gate, resolved via `GateApprovalCard` and the + * `openhuman.approval_decide` RPC. Absent for plain action notifications + * (e.g. the meeting auto-join prompt) and for the older + * `flow-pending-approval:`-prefixed id convention (`FlowApprovalCard`). + */ + kind?: string; } export interface NotificationPreferences {