diff --git a/app/src/components/chat/WorkflowProposalCard.test.tsx b/app/src/components/chat/WorkflowProposalCard.test.tsx index bb2179da92..cebb4ff877 100644 --- a/app/src/components/chat/WorkflowProposalCard.test.tsx +++ b/app/src/components/chat/WorkflowProposalCard.test.tsx @@ -30,7 +30,6 @@ function proposal(partial: Partial = {}): WorkflowProposal { return { name: 'Daily standup summary', graph: { nodes: [], edges: [] }, - requireApproval: true, summary: { trigger: 'schedule: 0 9 * * *', steps: [ @@ -70,9 +69,7 @@ describe('WorkflowProposalCard', () => { const p = proposal(); render(); fireEvent.click(screen.getByText('chat.flowProposal.save')); - await waitFor(() => - expect(mockCreateFlow).toHaveBeenCalledWith(p.name, p.graph, p.requireApproval) - ); + await waitFor(() => expect(mockCreateFlow).toHaveBeenCalledWith(p.name, p.graph)); expect(mockDispatch).toHaveBeenCalledTimes(1); }); @@ -107,11 +104,7 @@ describe('WorkflowProposalCard', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); const [route, opts] = mockNavigate.mock.calls[0]; expect(route).toBe('/flows/draft'); - expect(opts.state).toEqual({ - name: p.name, - graph: p.graph, - requireApproval: p.requireApproval, - }); + expect(opts.state).toEqual({ name: p.name, graph: p.graph }); // The single persistence gate is untouched — no create/update, and the // proposal is left intact in the thread (not dismissed). @@ -136,16 +129,4 @@ describe('WorkflowProposalCard', () => { ); expect(screen.getByText('chat.flowProposal.noSteps')).toBeInTheDocument(); }); - - it('shows the require-approval hint only when requireApproval is true', () => { - const { rerender } = render( - - ); - expect(screen.getByText('chat.flowProposal.requireApprovalHint')).toBeInTheDocument(); - - rerender( - - ); - expect(screen.queryByText('chat.flowProposal.requireApprovalHint')).not.toBeInTheDocument(); - }); }); diff --git a/app/src/components/chat/WorkflowProposalCard.tsx b/app/src/components/chat/WorkflowProposalCard.tsx index 7db034f3b3..f03e163843 100644 --- a/app/src/components/chat/WorkflowProposalCard.tsx +++ b/app/src/components/chat/WorkflowProposalCard.tsx @@ -68,11 +68,7 @@ export const WorkflowProposalCard: React.FC = ({ threadId, proposal, onSa graph.nodes.length, graph.edges.length ); - const draft: FlowCanvasDraftState = { - name: proposal.name, - graph, - requireApproval: proposal.requireApproval, - }; + const draft: FlowCanvasDraftState = { name: proposal.name, graph }; navigate(FLOW_CANVAS_DRAFT_ROUTE, { state: draft }); }; @@ -81,7 +77,7 @@ export const WorkflowProposalCard: React.FC = ({ threadId, proposal, onSa setSaving(true); setErrorMsg(null); try { - await createFlow(proposal.name, proposal.graph, proposal.requireApproval); + await createFlow(proposal.name, proposal.graph); dispatch(clearWorkflowProposalForThread({ threadId })); onSaved?.(); } catch (e) { @@ -145,12 +141,6 @@ export const WorkflowProposalCard: React.FC = ({ threadId, proposal, onSa )} - {proposal.requireApproval && ( -

- {t('chat.flowProposal.requireApprovalHint')} -

- )} - {errorMsg &&

⚠ {errorMsg}

}
diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index 18cbb609e7..892a8169ad 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -24,7 +24,6 @@ function makeFlow(overrides: Partial = {}): Flow { updated_at: '2026-01-01T00:00:00Z', last_run_at: null, last_status: null, - require_approval: false, ...overrides, }; } diff --git a/app/src/components/flows/FlowRunInspectorDrawer.tsx b/app/src/components/flows/FlowRunInspectorDrawer.tsx index 44298be027..ddf2e668ca 100644 --- a/app/src/components/flows/FlowRunInspectorDrawer.tsx +++ b/app/src/components/flows/FlowRunInspectorDrawer.tsx @@ -3,15 +3,18 @@ * ---------------------------------- * * Right-side drawer showing a single durable `tinyflows` run's status + step - * timeline, opened from the "View run" action on {@link FlowApprovalCard}. - * Drawer chrome mirrors `features/conversations/components/SubagentDrawer.tsx` - * (fixed overlay + backdrop-click-to-close + Escape-to-close) so it renders - * as a fixed overlay regardless of where the parent mounts it in the DOM. + * timeline, opened from the "View run" action on a flow's run history + * (`FlowRunsDrawer` / `FlowRunsSidebar`). Drawer chrome mirrors + * `features/conversations/components/SubagentDrawer.tsx` (fixed overlay + + * backdrop-click-to-close + Escape-to-close) so it renders as a fixed + * overlay regardless of where the parent mounts it in the DOM. * * Data comes from {@link useFlowRunPoller}, which polls * `openhuman.flows_get_run` every 2s until the run reaches a terminal status - * (`completed`/`failed`) — `pending_approval` keeps polling since the run can - * still be resumed elsewhere. + * (`completed`/`failed`) — `pending_approval` keeps polling since a node-level + * checkpoint gate elsewhere in the graph could still resolve it. There is no + * approval UI here (the flows human-in-the-loop toggle was removed); this + * status just displays like any other non-terminal state. * * `FlowRunStep` is lean by design (`node_id` + `output` + optional `port` * only — no per-step status/timing), so each step renders as a plain label @@ -247,7 +250,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,18 +360,6 @@ export function FlowRunInspectorDrawer({ runId, onClose, onFixWithAgent }: Props
)} - {/* Pending approvals banner */} - {run.status === 'pending_approval' && pendingCount > 0 && ( -
- {t('flowRuns.inspector.pendingApprovalsCount').replace( - '{count}', - String(pendingCount) - )} -
- )} - {/* Steps timeline */}

diff --git a/app/src/components/flows/SuggestedWorkflows.test.tsx b/app/src/components/flows/SuggestedWorkflows.test.tsx index 948935c4ea..697edd9e0e 100644 --- a/app/src/components/flows/SuggestedWorkflows.test.tsx +++ b/app/src/components/flows/SuggestedWorkflows.test.tsx @@ -134,7 +134,6 @@ describe('SuggestedWorkflows', () => { hookState.proposal = { name: 'Auto-file receipts', graph: { nodes: [], edges: [] }, - requireApproval: true, summary: { trigger: 'app_event', steps: [] }, } as unknown as WorkflowProposal; diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 6c9322c83f..26b507d82f 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -37,12 +37,7 @@ function graph(ids: string[]): WorkflowGraph { } function proposalWith(ids: string[]): WorkflowProposal { - return { - name: 'Revised flow', - graph: graph(ids), - requireApproval: true, - summary: { trigger: 'manual', steps: [] }, - }; + return { name: 'Revised flow', graph: graph(ids), summary: { trigger: 'manual', steps: [] } }; } const baseGraph = graph(['a', 'b']); diff --git a/app/src/components/flows/WorkflowPromptBar.test.tsx b/app/src/components/flows/WorkflowPromptBar.test.tsx index 50b5a29fb3..76aacd314c 100644 --- a/app/src/components/flows/WorkflowPromptBar.test.tsx +++ b/app/src/components/flows/WorkflowPromptBar.test.tsx @@ -30,20 +30,17 @@ describe('WorkflowPromptBar', () => { await waitFor(() => expect(navigateMock).toHaveBeenCalledTimes(1)); expect(createFlowMock).toHaveBeenCalledTimes(1); - const [name, graph, requireApproval] = createFlowMock.mock.calls[0]; + const [name, graph] = createFlowMock.mock.calls[0]; expect(name).toBe('digest my Slack every morning'); // The created flow is the standard blank graph (single manual trigger). expect(graph.nodes).toHaveLength(1); expect(graph.nodes[0].kind).toBe('trigger'); - // Parity with the proposal-card path: prompt-authored flows must default - // to requiring approval, matching WorkflowProposalCard's default. - expect(requireApproval).toBe(true); expect(navigateMock).toHaveBeenCalledWith('/flows/flow-1', { state: { copilotBuild: { description: 'digest my Slack every morning' } }, }); }); - it('defaults requireApproval to true so outbound side-effects need explicit approval', async () => { + it('calls createFlow with just name and graph (no approval-gate arg)', async () => { render(); fireEvent.change(screen.getByTestId('workflow-prompt-input'), { target: { value: 'auto-reply to every gmail thread' }, @@ -51,9 +48,7 @@ describe('WorkflowPromptBar', () => { fireEvent.click(screen.getByTestId('workflow-prompt-submit')); await waitFor(() => expect(createFlowMock).toHaveBeenCalledTimes(1)); - // Third positional arg must be `true` — flows_create's server default is - // `false`, so omitting it would silently disarm the approval gate. - expect(createFlowMock.mock.calls[0][2]).toBe(true); + expect(createFlowMock.mock.calls[0]).toHaveLength(2); }); it('submits on Enter (Shift+Enter reserved for newlines)', async () => { diff --git a/app/src/components/flows/WorkflowPromptBar.tsx b/app/src/components/flows/WorkflowPromptBar.tsx index 69d5ae849e..82b2b74bf9 100644 --- a/app/src/components/flows/WorkflowPromptBar.tsx +++ b/app/src/components/flows/WorkflowPromptBar.tsx @@ -44,13 +44,9 @@ export default function WorkflowPromptBar({ variant = 'compact', autoFocus = fal const name = deriveWorkflowName(trimmed, t('flows.page.newWorkflow')); log('submit: creating flow name=%s', name); try { - // Safe default: prompt-authored flows require approval so outbound - // Slack/Gmail/HTTP/code nodes cannot fire unattended. Omitting this - // arg would fall back to the server default of `false`. const flow = await createFlow( name, - createBlankWorkflowGraph(name, t('flows.nodeKind.trigger')), - true + createBlankWorkflowGraph(name, t('flows.nodeKind.trigger')) ); log('submit: created id=%s — opening canvas with build seed', flow.id); navigate(`/flows/${flow.id}`, { state: { copilotBuild: { description: trimmed } } }); diff --git a/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx b/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx index f5a603312c..a67c3f43e5 100644 --- a/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx +++ b/app/src/components/flows/__tests__/FlowRunInspectorDrawer.test.tsx @@ -3,8 +3,9 @@ * * 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`. + * `pending_approval` renders like any other non-terminal status (no special + * approval banner — that concept was removed); 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 @@ -99,19 +100,14 @@ describe('FlowRunInspectorDrawer', () => { expect(screen.getByTestId('flow-run-inspector-error')).toHaveTextContent('network down'); }); - it('shows the pending-approvals banner when status is pending_approval', () => { + it('renders a pending_approval run like any other non-terminal status, with no approval banner', () => { useFlowRunPoller.mockReturnValue({ run: makeRun({ status: 'pending_approval', pending_approvals: ['node-a', 'node-b'] }), loading: false, error: null, }); renderDrawer('thread-1', vi.fn()); - expect(screen.getByTestId('flow-run-pending-approvals-banner')).toHaveTextContent('2'); - }); - - it('does not show the pending-approvals banner for a running run', () => { - useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null }); - renderDrawer('thread-1', vi.fn()); + expect(screen.getByTestId('flow-run-status-pill')).toBeInTheDocument(); expect(screen.queryByTestId('flow-run-pending-approvals-banner')).not.toBeInTheDocument(); }); diff --git a/app/src/components/notifications/FlowApprovalCard.test.tsx b/app/src/components/notifications/FlowApprovalCard.test.tsx deleted file mode 100644 index eacc8c0b03..0000000000 --- a/app/src/components/notifications/FlowApprovalCard.test.tsx +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Approve/Dismiss/View-run contract for the flow-pending-approval - * notification card (issues B3a + B3b). Asserts that Approve reads - * `{ flow_id, thread_id, node_ids }` from the notification's action payload, - * calls `flowsApi.resumeFlow` with those args, clears the notification on - * success, surfaces a localized error on failure (including when `node_ids` - * contains non-string entries — an invalid payload), that Dismiss clears the - * notification WITHOUT calling any RPC (there is no `flows_deny` endpoint - * yet), and that "View run" opens the {@link FlowRunInspectorDrawer}. - */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { store } from '../../store'; -import { type NotificationItem } from '../../store/notificationSlice'; -import FlowApprovalCard from './FlowApprovalCard'; - -const resumeFlow = vi.hoisted(() => vi.fn()); -vi.mock('../../services/api/flowsApi', () => ({ resumeFlow })); - -vi.mock('../flows/FlowRunInspectorDrawer', () => ({ - FlowRunInspectorDrawer: ({ runId }: { runId: string | null; onClose: () => void }) => - runId ?
{runId}
: null, -})); - -function makeItem(overrides: Partial = {}): NotificationItem { - return { - id: 'flow-pending-approval:flow-1:thread-1', - category: 'agents', - title: 'Workflow needs approval', - body: '"Deploy pipeline" is waiting on 2 approvals before it can continue.', - timestamp: Date.now(), - read: false, - actions: [ - { - actionId: 'approve', - label: 'Review', - payload: { flow_id: 'flow-1', thread_id: 'thread-1', node_ids: ['node-a', 'node-b'] }, - }, - ], - ...overrides, - }; -} - -function renderCard(item: NotificationItem) { - return render( - - - - ); -} - -describe('FlowApprovalCard', () => { - beforeEach(() => { - vi.clearAllMocks(); - store.dispatch({ type: 'notifications/clearAll' }); - }); - - it('renders both Approve and Dismiss buttons', () => { - renderCard(makeItem()); - expect(screen.getByTestId('flow-approval-approve')).toBeInTheDocument(); - expect(screen.getByTestId('flow-approval-dismiss')).toBeInTheDocument(); - }); - - it('renders as an alertdialog with the notification body', () => { - renderCard(makeItem()); - const card = screen.getByTestId('flow-approval-card'); - expect(card).toHaveAttribute('role', 'alertdialog'); - expect(screen.getByText(makeItem().body)).toBeInTheDocument(); - }); - - it('calls resumeFlow with flow_id/thread_id/node_ids extracted from the action payload', async () => { - resumeFlow.mockResolvedValue({ output: null, pending_approvals: [], thread_id: 'thread-1' }); - renderCard(makeItem()); - - fireEvent.click(screen.getByTestId('flow-approval-approve')); - - await waitFor(() => expect(resumeFlow).toHaveBeenCalledTimes(1)); - expect(resumeFlow).toHaveBeenCalledWith('flow-1', 'thread-1', ['node-a', 'node-b']); - }); - - it('marks the notification read and clears its actions on a successful approve', async () => { - resumeFlow.mockResolvedValue({ output: null, pending_approvals: [], thread_id: 'thread-1' }); - store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); - renderCard(makeItem()); - - fireEvent.click(screen.getByTestId('flow-approval-approve')); - - await waitFor(() => { - const item = store - .getState() - .notifications.items.find(i => i.id === 'flow-pending-approval:flow-1:thread-1'); - expect(item?.read).toBe(true); - expect(item?.actions ?? []).toHaveLength(0); - }); - }); - - it('does NOT clear the notification when the run parks again on the next gate', async () => { - // Sequential gates: resume returns with pending_approvals still non-empty and - // the core re-publishes the same-id prompt — the card must not wipe it. - resumeFlow.mockResolvedValue({ - output: null, - pending_approvals: ['node-c'], - thread_id: 'thread-1', - }); - store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); - renderCard(makeItem()); - - fireEvent.click(screen.getByTestId('flow-approval-approve')); - - await waitFor(() => expect(resumeFlow).toHaveBeenCalledTimes(1)); - const item = store - .getState() - .notifications.items.find(i => i.id === 'flow-pending-approval:flow-1:thread-1'); - expect(item?.actions).toHaveLength(1); - expect(item?.read).toBe(false); - // Approve re-enabled so the user can act on the next gate. - await waitFor(() => expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled()); - }); - - it('shows a localized error and re-enables the buttons when resumeFlow rejects', async () => { - resumeFlow.mockRejectedValue(new Error('no pending approval matches')); - store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); - renderCard(makeItem()); - - fireEvent.click(screen.getByTestId('flow-approval-approve')); - - await waitFor(() => { - expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled(); - }); - expect( - screen.getByText( - (_content, element) => - element?.tagName.toLowerCase() === 'p' && - (element?.textContent ?? '').includes('Could not resume the workflow. Please try again.') - ) - ).toBeInTheDocument(); - // The notification must NOT have been cleared on failure. - const item = store - .getState() - .notifications.items.find(i => i.id === 'flow-pending-approval:flow-1:thread-1'); - expect(item?.actions).toHaveLength(1); - }); - - it('disables both buttons while the approve RPC is in flight', async () => { - let resolve!: (v: unknown) => void; - resumeFlow.mockImplementation( - () => - new Promise(r => { - resolve = r; - }) - ); - renderCard(makeItem()); - - fireEvent.click(screen.getByTestId('flow-approval-approve')); - - expect(screen.getByTestId('flow-approval-approve')).toBeDisabled(); - expect(screen.getByTestId('flow-approval-dismiss')).toBeDisabled(); - - resolve({ output: null, pending_approvals: [], thread_id: 'thread-1' }); - await waitFor(() => expect(screen.getByTestId('flow-approval-approve')).not.toBeDisabled()); - }); - - it('dismiss clears the notification without calling resumeFlow', async () => { - store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); - renderCard(makeItem()); - - fireEvent.click(screen.getByTestId('flow-approval-dismiss')); - - await waitFor(() => { - const item = store - .getState() - .notifications.items.find(i => i.id === 'flow-pending-approval:flow-1:thread-1'); - expect(item?.read).toBe(true); - expect(item?.actions ?? []).toHaveLength(0); - }); - expect(resumeFlow).not.toHaveBeenCalled(); - }); - - it('treats non-string node_ids as an invalid payload (Approve errors, no resumeFlow call)', async () => { - renderCard( - makeItem({ - actions: [ - { - actionId: 'approve', - label: 'Review', - payload: { flow_id: 'flow-1', thread_id: 'thread-1', node_ids: [42, null] }, - }, - ], - }) - ); - - fireEvent.click(screen.getByTestId('flow-approval-approve')); - - await waitFor(() => { - expect( - screen.getByText( - (_content, element) => - element?.tagName.toLowerCase() === 'p' && - (element?.textContent ?? '').includes( - 'Could not resume the workflow. Please try again.' - ) - ) - ).toBeInTheDocument(); - }); - expect(resumeFlow).not.toHaveBeenCalled(); - }); - - it('does not render "View run" when the payload is invalid', () => { - renderCard( - makeItem({ - actions: [{ actionId: 'approve', label: 'Review', payload: { flow_id: 'flow-1' } }], - }) - ); - expect(screen.queryByTestId('flow-approval-view-run')).not.toBeInTheDocument(); - }); - - it('"View run" opens the run inspector drawer for the payload thread_id', () => { - renderCard(makeItem()); - - expect(screen.queryByTestId('flow-run-inspector-drawer-stub')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByTestId('flow-approval-view-run')); - - const drawer = screen.getByTestId('flow-run-inspector-drawer-stub'); - expect(drawer).toBeInTheDocument(); - expect(drawer).toHaveTextContent('thread-1'); - }); -}); diff --git a/app/src/components/notifications/FlowApprovalCard.tsx b/app/src/components/notifications/FlowApprovalCard.tsx deleted file mode 100644 index cb4fb7f849..0000000000 --- a/app/src/components/notifications/FlowApprovalCard.tsx +++ /dev/null @@ -1,213 +0,0 @@ -/** - * FlowApprovalCard (issue B3a) - * ---------------------------- - * - * Approval surface for a paused `tinyflows` run. When a flow run pauses on an - * approval-gated node, the Rust side (`notify_pending_approval` in - * `src/openhuman/flows/ops.rs`) publishes a `CoreNotification` whose id starts - * with `"flow-pending-approval:"` and carries a single `"approve"` action with - * `{ flow_id, thread_id, node_ids }` in its payload. `NotificationCenter` - * routes any such notification here instead of the generic - * `CoreNotificationCard`. - * - * Approve calls `openhuman.flows_resume` (via {@link resumeFlow}) naming the - * pending node ids as the approvals; success clears the notification's - * actions and marks it read. Dismiss is UI-only: there is no `flows_deny` / - * cancel-run RPC yet (documented follow-up — the run stays parked - * `pending_approval` server-side and can still be approved later from the run - * history), so Dismiss just clears the prompt from the Notification Center - * without touching the engine. - * - * Styling mirrors the existing amber approval chrome - * (`WorkflowRunApprovalCard`) and the `role="alertdialog"` a11y pattern - * (`ApprovalRequestCard`) so this reads as the same affordance family. - * - * "View run" (B3b) opens {@link FlowRunInspectorDrawer} for the run's status + - * step timeline (run id === the payload's `thread_id`) without disturbing the - * Approve/Dismiss flow above. - */ -import debug from 'debug'; -import { useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import { resumeFlow } from '../../services/api/flowsApi'; -import { useAppDispatch } from '../../store/hooks'; -import { - clearNotificationActions, - markRead, - type NotificationItem, -} from '../../store/notificationSlice'; -import { FlowRunInspectorDrawer } from '../flows/FlowRunInspectorDrawer'; -import Button from '../ui/Button'; - -const log = debug('notifications:flow-approval-card'); - -/** Shape of `notification.actions[0].payload` set by `notify_pending_approval`. */ -interface FlowApprovalPayload { - flow_id: string; - thread_id: string; - node_ids: string[]; -} - -function isFlowApprovalPayload(value: unknown): value is FlowApprovalPayload { - if (!value || typeof value !== 'object') return false; - const record = value as Record; - return ( - typeof record.flow_id === 'string' && - typeof record.thread_id === 'string' && - Array.isArray(record.node_ids) && - record.node_ids.every((x: unknown) => typeof x === 'string') - ); -} - -interface Props { - notification: NotificationItem; -} - -/** - * Renders the `flow-pending-approval:*` core notification with Approve / - * Dismiss actions. See module doc above for the RPC + payload contract. - */ -const FlowApprovalCard = ({ notification: n }: Props) => { - const { t } = useT(); - const dispatch = useAppDispatch(); - const [pending, setPending] = useState<'approve' | null>(null); - const [error, setError] = useState(null); - const [inspecting, setInspecting] = useState(false); - - const payload = n.actions?.[0]?.payload; - const parsed = isFlowApprovalPayload(payload) ? payload : null; - - const clearNotification = () => { - dispatch(markRead({ id: n.id })); - dispatch(clearNotificationActions({ id: n.id })); - }; - - const handleApprove = async () => { - if (pending) return; - if (!parsed) { - // Defensive — the Rust side always stamps this shape, but never crash - // the notification center on an unexpected payload. - log('approve: missing/invalid payload notification=%s', n.id); - setError(t('notifications.flow.error')); - return; - } - setPending('approve'); - setError(null); - log( - 'approve: request flowId=%s threadId=%s nodeIds=%o', - parsed.flow_id, - parsed.thread_id, - parsed.node_ids - ); - try { - const result = await resumeFlow(parsed.flow_id, parsed.thread_id, parsed.node_ids); - if (result.pending_approvals && result.pending_approvals.length > 0) { - // Sequential gates: the run parked again on the next approval. The core - // re-publishes a fresh notification under the SAME `flow-pending-approval:` - // id with the new pending node ids, so clearing here would wipe that next - // prompt. Leave it — the store already holds the updated notification, and - // resetting `pending` (below) re-enables Approve for the next gate. - log('approve: parked again pending=%o notification=%s', result.pending_approvals, n.id); - } else { - log('approve: ok notification=%s', n.id); - clearNotification(); - } - } catch (err) { - log('approve: failed notification=%s err=%o', n.id, err); - setError(t('notifications.flow.error')); - } finally { - setPending(null); - } - }; - - const handleDismiss = () => { - if (pending) return; - // UI-only: no `flows_deny` / cancel-run RPC exists yet (documented - // follow-up). The run stays parked `pending_approval` server-side; this - // only hides the prompt from the Notification Center. - log('dismiss: notification=%s (UI-only, no RPC)', n.id); - clearNotification(); - }; - - const gateCount = parsed?.node_ids.length ?? 0; - const gateCountLabel = t('notifications.flow.gateCount').replace('{count}', String(gateCount)); - - return ( -
-
- - ⚠️ - -
-

- {t('notifications.flow.approveTitle')} -

- {n.body && ( -

{n.body}

- )} - {gateCount > 0 && ( -

- {gateCountLabel} -

- )} - - {error &&

{`⚠ ${error}`}

} - -
- - - {parsed && ( - - )} -
-
-
- {parsed && ( - setInspecting(false)} - /> - )} -
- ); -}; - -export default FlowApprovalCard; diff --git a/app/src/components/notifications/NotificationCenter.tsx b/app/src/components/notifications/NotificationCenter.tsx index 989d0ae6ea..7cf269ba80 100644 --- a/app/src/components/notifications/NotificationCenter.tsx +++ b/app/src/components/notifications/NotificationCenter.tsx @@ -20,7 +20,6 @@ import { } from '../../store/notificationSlice'; import Button from '../ui/Button'; import CoreNotificationCard from './CoreNotificationCard'; -import FlowApprovalCard from './FlowApprovalCard'; import NotificationCard from './NotificationCard'; // ───────────────────────────────────────────────────────────────────────────── @@ -28,10 +27,13 @@ import NotificationCard from './NotificationCard'; // ───────────────────────────────────────────────────────────────────────────── /** - * A paused `tinyflows` run's approval prompt (issue B3a) — id set by - * `notify_pending_approval` in `src/openhuman/flows/ops.rs`. Routed to - * `FlowApprovalCard` instead of the generic `CoreNotificationCard`, which is - * hardcoded to the meeting auto-join RPC. + * A paused `tinyflows` run's node-level checkpoint gate (a per-node + * `requires_approval` config, unrelated to the removed flow-level + * human-in-the-loop toggle) still stamps a `flow-pending-approval:*` id via + * `notify_pending_approval` in `src/openhuman/flows/ops.rs`. There is no + * flow-run approval UI anymore — `CoreNotificationCard` is hardcoded to the + * meeting auto-join RPC and would render a broken "Approve" action for this + * id, so these are filtered out rather than misrendered. */ function isFlowApproval(item: { id: string }): boolean { return item.id.startsWith('flow-pending-approval:'); @@ -196,13 +198,11 @@ const NotificationCenter = () => { always shown first, independent of integration load state. */} {coreActionItems.length > 0 && (
- {coreActionItems.map(item => - isFlowApproval(item) ? ( - - ) : ( + {coreActionItems + .filter(item => !isFlowApproval(item)) + .map(item => ( - ) - )} + ))}
)} diff --git a/app/src/hooks/useWorkflowBuilderChat.test.ts b/app/src/hooks/useWorkflowBuilderChat.test.ts index e0be523329..074dda5fe1 100644 --- a/app/src/hooks/useWorkflowBuilderChat.test.ts +++ b/app/src/hooks/useWorkflowBuilderChat.test.ts @@ -117,7 +117,6 @@ describe('useWorkflowBuilderChat', () => { const proposal: WorkflowProposal = { name: 'Digest', graph: { nodes: [], edges: [] }, - requireApproval: true, summary: { trigger: 'schedule', steps: [] }, }; buildWorkflow.mockResolvedValue(okResult({ proposal })); @@ -196,7 +195,6 @@ describe('useWorkflowBuilderChat', () => { const proposal: WorkflowProposal = { name: 'Digest', graph: { nodes: [], edges: [] }, - requireApproval: true, summary: { trigger: 'schedule', steps: [] }, }; buildWorkflow.mockResolvedValue( diff --git a/app/src/lib/flows/canvasDraft.ts b/app/src/lib/flows/canvasDraft.ts index 7f1df1451a..4c56e803a5 100644 --- a/app/src/lib/flows/canvasDraft.ts +++ b/app/src/lib/flows/canvasDraft.ts @@ -29,8 +29,6 @@ export interface FlowCanvasDraftState { name: string; /** The candidate graph to open as an editable, unsaved draft. */ graph: WorkflowGraph; - /** "Require approval for outbound actions" toggle to carry into `flows_create`. */ - requireApproval: boolean; /** * Non-fatal import warnings (Phase 4d) — surfaced as toasts over the draft * canvas when a graph was imported via `flows_import` (unmapped n8n node @@ -45,12 +43,7 @@ export function asFlowCanvasDraftState(state: unknown): FlowCanvasDraftState | n if (!state || typeof state !== 'object') return null; const record = state as Record; const graph = record.graph; - if ( - typeof record.name !== 'string' || - !graph || - typeof graph !== 'object' || - typeof record.requireApproval !== 'boolean' - ) { + if (typeof record.name !== 'string' || !graph || typeof graph !== 'object') { return null; } const importWarnings = Array.isArray(record.importWarnings) @@ -59,7 +52,6 @@ export function asFlowCanvasDraftState(state: unknown): FlowCanvasDraftState | n return { name: record.name, graph: graph as WorkflowGraph, - requireApproval: record.requireApproval, ...(importWarnings ? { importWarnings } : {}), }; } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index fb3fe36169..851836491a 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3082,7 +3082,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'المُشغّل', 'chat.flowProposal.stepsLabel': 'الخطوات', 'chat.flowProposal.noSteps': 'لا توجد خطوات إضافية.', - 'chat.flowProposal.requireApprovalHint': 'سيتطلب كل إجراء صادر موافقتك.', 'chat.flowProposal.save': 'حفظ وتفعيل', 'chat.flowProposal.saving': 'جارٍ الحفظ…', 'chat.flowProposal.openInCanvas': 'فتح في اللوحة', @@ -3609,22 +3608,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'ليس هذا', 'notifications.meeting.alwaysJoin': 'الانضمام دائماً', 'notifications.meeting.actionError': 'تعذّر إكمال هذا الإجراء. يرجى المحاولة مرة أخرى.', - 'notifications.flow.approveTitle': 'يحتاج سير العمل إلى موافقة', - 'notifications.flow.approve': 'موافقة', - 'notifications.flow.approving': 'جارٍ الموافقة…', - 'notifications.flow.dismiss': 'إغلاق', - 'notifications.flow.error': 'تعذّر استئناف سير العمل. يرجى المحاولة مرة أخرى.', - 'notifications.flow.gateCount': 'في انتظار {count} من بوابات الموافقة', - 'notifications.flow.approveHint': 'استئناف سير العمل بعد نقطة التحقق هذه', - 'notifications.flow.dismissHint': 'إخفاء هذا التنبيه دون استئناف سير العمل', - 'notifications.flow.viewRun': 'عرض التشغيل', 'flowRuns.inspector.title': 'تفاصيل التشغيل', 'flowRuns.inspector.startedAt': 'بدأ', 'flowRuns.inspector.finishedAt': 'انتهى', 'flowRuns.inspector.running': 'قيد التشغيل…', 'flowRuns.inspector.error': 'خطأ', 'flowRuns.inspector.pendingApprovals': 'الموافقات المعلقة', - 'flowRuns.inspector.pendingApprovalsCount': '{count} عقدة (عقد) في انتظار الموافقة', 'flowRuns.inspector.steps': 'الخطوات', 'flowRuns.inspector.noSteps': 'لم يتم تسجيل أي خطوات بعد.', 'flowRuns.inspector.output': 'المخرجات', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index ac6a63e281..0f9af015d0 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3148,8 +3148,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'ট্রিগার', 'chat.flowProposal.stepsLabel': 'ধাপসমূহ', 'chat.flowProposal.noSteps': 'কোনো অতিরিক্ত ধাপ নেই।', - 'chat.flowProposal.requireApprovalHint': - 'প্রতিটি বহির্গামী কাজের জন্য আপনার অনুমোদন প্রয়োজন হবে।', 'chat.flowProposal.save': 'সংরক্ষণ ও সক্রিয় করুন', 'chat.flowProposal.saving': 'সংরক্ষণ করা হচ্ছে…', 'chat.flowProposal.openInCanvas': 'ক্যানভাসে খুলুন', @@ -3692,22 +3690,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'এটি নয়', 'notifications.meeting.alwaysJoin': 'সবসময় যোগ দিন', 'notifications.meeting.actionError': 'এই কাজটি সম্পন্ন করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।', - 'notifications.flow.approveTitle': 'ওয়ার্কফ্লোর জন্য অনুমোদন প্রয়োজন', - 'notifications.flow.approve': 'অনুমোদন করুন', - 'notifications.flow.approving': 'অনুমোদন করা হচ্ছে…', - 'notifications.flow.dismiss': 'খারিজ করুন', - 'notifications.flow.error': 'ওয়ার্কফ্লো আবার শুরু করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।', - 'notifications.flow.gateCount': '{count}টি অনুমোদন গেট মুলতুবি আছে', - 'notifications.flow.approveHint': 'এই চেকপয়েন্টের পরে ওয়ার্কফ্লো আবার শুরু করুন', - 'notifications.flow.dismissHint': 'ওয়ার্কফ্লো আবার শুরু না করে এই প্রম্পটটি লুকান', - 'notifications.flow.viewRun': 'রান দেখুন', 'flowRuns.inspector.title': 'রান বিবরণ', 'flowRuns.inspector.startedAt': 'শুরু হয়েছে', 'flowRuns.inspector.finishedAt': 'শেষ হয়েছে', 'flowRuns.inspector.running': 'চলছে…', 'flowRuns.inspector.error': 'ত্রুটি', 'flowRuns.inspector.pendingApprovals': 'মুলতুবি অনুমোদন', - 'flowRuns.inspector.pendingApprovalsCount': '{count}টি নোড অনুমোদনের অপেক্ষায়', 'flowRuns.inspector.steps': 'ধাপ', 'flowRuns.inspector.noSteps': 'এখনও কোনো ধাপ রেকর্ড করা হয়নি।', 'flowRuns.inspector.output': 'আউটপুট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 0210c6c340..b089123190 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3226,7 +3226,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Auslöser', 'chat.flowProposal.stepsLabel': 'Schritte', 'chat.flowProposal.noSteps': 'Keine weiteren Schritte.', - 'chat.flowProposal.requireApprovalHint': 'Jede ausgehende Aktion benötigt Ihre Genehmigung.', 'chat.flowProposal.save': 'Speichern & aktivieren', 'chat.flowProposal.saving': 'Wird gespeichert…', 'chat.flowProposal.openInCanvas': 'In der Leinwand öffnen', @@ -3781,23 +3780,12 @@ const messages: TranslationMap = { 'notifications.meeting.alwaysJoin': 'Immer beitreten', 'notifications.meeting.actionError': 'Diese Aktion konnte nicht abgeschlossen werden. Bitte versuche es erneut.', - 'notifications.flow.approveTitle': 'Workflow benötigt Genehmigung', - 'notifications.flow.approve': 'Genehmigen', - 'notifications.flow.approving': 'Wird genehmigt…', - 'notifications.flow.dismiss': 'Verwerfen', - 'notifications.flow.error': - 'Der Workflow konnte nicht fortgesetzt werden. Bitte versuche es erneut.', - 'notifications.flow.gateCount': 'Wartet auf {count} Genehmigungsschritt(e)', - 'notifications.flow.approveHint': 'Workflow nach diesem Kontrollpunkt fortsetzen', - 'notifications.flow.dismissHint': 'Diesen Hinweis ausblenden, ohne den Workflow fortzusetzen', - 'notifications.flow.viewRun': 'Lauf anzeigen', 'flowRuns.inspector.title': 'Laufdetails', 'flowRuns.inspector.startedAt': 'Gestartet', 'flowRuns.inspector.finishedAt': 'Beendet', 'flowRuns.inspector.running': 'Läuft…', 'flowRuns.inspector.error': 'Fehler', 'flowRuns.inspector.pendingApprovals': 'Ausstehende Genehmigungen', - 'flowRuns.inspector.pendingApprovalsCount': '{count} Knoten warten auf Genehmigung', 'flowRuns.inspector.steps': 'Schritte', 'flowRuns.inspector.noSteps': 'Noch keine Schritte aufgezeichnet.', 'flowRuns.inspector.output': 'Ausgabe', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 67f45bf0b5..e0ad9c3eb1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3565,7 +3565,6 @@ const en: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Trigger', 'chat.flowProposal.stepsLabel': 'Steps', 'chat.flowProposal.noSteps': 'No additional steps.', - 'chat.flowProposal.requireApprovalHint': 'Every outbound action will need your approval.', 'chat.flowProposal.save': 'Save & enable', 'chat.flowProposal.saving': 'Saving…', 'chat.flowProposal.openInCanvas': 'Open in canvas', @@ -4330,22 +4329,12 @@ const en: TranslationMap = { 'notifications.meeting.skip': 'Not this one', 'notifications.meeting.alwaysJoin': 'Always join', 'notifications.meeting.actionError': 'Could not complete that action. Please try again.', - 'notifications.flow.approveTitle': 'Workflow needs approval', - 'notifications.flow.approve': 'Approve', - 'notifications.flow.approving': 'Approving…', - 'notifications.flow.dismiss': 'Dismiss', - 'notifications.flow.error': 'Could not resume the workflow. Please try again.', - 'notifications.flow.gateCount': 'Waiting on {count} approval gate(s)', - 'notifications.flow.approveHint': 'Resume the workflow past this checkpoint', - 'notifications.flow.dismissHint': 'Hide this prompt without resuming the workflow', - 'notifications.flow.viewRun': 'View run', 'flowRuns.inspector.title': 'Run details', 'flowRuns.inspector.startedAt': 'Started', 'flowRuns.inspector.finishedAt': 'Finished', 'flowRuns.inspector.running': 'Running…', 'flowRuns.inspector.error': 'Error', 'flowRuns.inspector.pendingApprovals': 'Pending approvals', - 'flowRuns.inspector.pendingApprovalsCount': '{count} node(s) awaiting approval', 'flowRuns.inspector.steps': 'Steps', 'flowRuns.inspector.noSteps': 'No steps recorded yet.', 'flowRuns.inspector.output': 'Output', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0211d3f874..9aabd851ee 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3204,7 +3204,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Disparador', 'chat.flowProposal.stepsLabel': 'Pasos', 'chat.flowProposal.noSteps': 'No hay pasos adicionales.', - 'chat.flowProposal.requireApprovalHint': 'Cada acción saliente necesitará tu aprobación.', 'chat.flowProposal.save': 'Guardar y activar', 'chat.flowProposal.saving': 'Guardando…', 'chat.flowProposal.openInCanvas': 'Abrir en el lienzo', @@ -3755,22 +3754,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'Esta no', 'notifications.meeting.alwaysJoin': 'Unirse siempre', 'notifications.meeting.actionError': 'No se pudo completar esa acción. Inténtalo de nuevo.', - 'notifications.flow.approveTitle': 'El flujo de trabajo necesita aprobación', - 'notifications.flow.approve': 'Aprobar', - 'notifications.flow.approving': 'Aprobando…', - 'notifications.flow.dismiss': 'Descartar', - 'notifications.flow.error': 'No se pudo reanudar el flujo de trabajo. Inténtalo de nuevo.', - 'notifications.flow.gateCount': 'Esperando {count} puerta(s) de aprobación', - 'notifications.flow.approveHint': 'Reanudar el flujo de trabajo después de este punto de control', - 'notifications.flow.dismissHint': 'Ocultar este aviso sin reanudar el flujo de trabajo', - 'notifications.flow.viewRun': 'Ver ejecución', 'flowRuns.inspector.title': 'Detalles de la ejecución', 'flowRuns.inspector.startedAt': 'Iniciado', 'flowRuns.inspector.finishedAt': 'Finalizado', 'flowRuns.inspector.running': 'En ejecución…', 'flowRuns.inspector.error': 'Error', 'flowRuns.inspector.pendingApprovals': 'Aprobaciones pendientes', - 'flowRuns.inspector.pendingApprovalsCount': '{count} nodo(s) esperando aprobación', 'flowRuns.inspector.steps': 'Pasos', 'flowRuns.inspector.noSteps': 'Aún no se han registrado pasos.', 'flowRuns.inspector.output': 'Salida', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 0a68fa89d3..666a2f6658 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3218,7 +3218,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Déclencheur', 'chat.flowProposal.stepsLabel': 'Étapes', 'chat.flowProposal.noSteps': 'Aucune étape supplémentaire.', - 'chat.flowProposal.requireApprovalHint': 'Chaque action sortante nécessitera votre approbation.', 'chat.flowProposal.save': 'Enregistrer et activer', 'chat.flowProposal.saving': 'Enregistrement…', 'chat.flowProposal.openInCanvas': 'Ouvrir dans le canevas', @@ -3770,22 +3769,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'Pas celle-ci', 'notifications.meeting.alwaysJoin': 'Toujours rejoindre', 'notifications.meeting.actionError': 'Impossible de terminer cette action. Veuillez réessayer.', - 'notifications.flow.approveTitle': 'Le workflow nécessite une approbation', - 'notifications.flow.approve': 'Approuver', - 'notifications.flow.approving': 'Approbation en cours…', - 'notifications.flow.dismiss': 'Ignorer', - 'notifications.flow.error': 'Impossible de reprendre le workflow. Veuillez réessayer.', - 'notifications.flow.gateCount': 'En attente de {count} validation(s)', - 'notifications.flow.approveHint': 'Reprendre le workflow après ce point de contrôle', - 'notifications.flow.dismissHint': 'Masquer cette invite sans reprendre le workflow', - 'notifications.flow.viewRun': "Voir l'exécution", 'flowRuns.inspector.title': "Détails de l'exécution", 'flowRuns.inspector.startedAt': 'Démarré', 'flowRuns.inspector.finishedAt': 'Terminé', 'flowRuns.inspector.running': 'En cours…', 'flowRuns.inspector.error': 'Erreur', 'flowRuns.inspector.pendingApprovals': 'Approbations en attente', - 'flowRuns.inspector.pendingApprovalsCount': "{count} nœud(s) en attente d'approbation", 'flowRuns.inspector.steps': 'Étapes', 'flowRuns.inspector.noSteps': 'Aucune étape enregistrée pour le moment.', 'flowRuns.inspector.output': 'Sortie', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7f3a2f97da..fb6dee601d 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3149,7 +3149,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'ट्रिगर', 'chat.flowProposal.stepsLabel': 'चरण', 'chat.flowProposal.noSteps': 'कोई अतिरिक्त चरण नहीं।', - 'chat.flowProposal.requireApprovalHint': 'हर बाहरी कार्रवाई के लिए आपकी स्वीकृति आवश्यक होगी।', 'chat.flowProposal.save': 'सहेजें और सक्षम करें', 'chat.flowProposal.saving': 'सहेजा जा रहा है…', 'chat.flowProposal.openInCanvas': 'कैनवास में खोलें', @@ -3693,22 +3692,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'यह नहीं', 'notifications.meeting.alwaysJoin': 'हमेशा शामिल हों', 'notifications.meeting.actionError': 'यह क्रिया पूरी नहीं हो सकी। कृपया पुनः प्रयास करें।', - 'notifications.flow.approveTitle': 'वर्कफ़्लो को अनुमोदन की आवश्यकता है', - 'notifications.flow.approve': 'अनुमोदित करें', - 'notifications.flow.approving': 'अनुमोदित किया जा रहा है…', - 'notifications.flow.dismiss': 'खारिज करें', - 'notifications.flow.error': 'वर्कफ़्लो फिर से शुरू नहीं हो सका। कृपया पुनः प्रयास करें।', - 'notifications.flow.gateCount': '{count} अनुमोदन गेट लंबित हैं', - 'notifications.flow.approveHint': 'इस चेकपॉइंट के बाद वर्कफ़्लो फिर से शुरू करें', - 'notifications.flow.dismissHint': 'वर्कफ़्लो को फिर से शुरू किए बिना यह संकेत छिपाएं', - 'notifications.flow.viewRun': 'रन देखें', 'flowRuns.inspector.title': 'रन विवरण', 'flowRuns.inspector.startedAt': 'शुरू हुआ', 'flowRuns.inspector.finishedAt': 'समाप्त हुआ', 'flowRuns.inspector.running': 'चल रहा है…', 'flowRuns.inspector.error': 'त्रुटि', 'flowRuns.inspector.pendingApprovals': 'लंबित अनुमोदन', - 'flowRuns.inspector.pendingApprovalsCount': '{count} नोड अनुमोदन की प्रतीक्षा में', 'flowRuns.inspector.steps': 'चरण', 'flowRuns.inspector.noSteps': 'अभी तक कोई चरण दर्ज नहीं किया गया है।', 'flowRuns.inspector.output': 'आउटपुट', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 28e7e06d76..401ec53814 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3159,8 +3159,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Pemicu', 'chat.flowProposal.stepsLabel': 'Langkah', 'chat.flowProposal.noSteps': 'Tidak ada langkah tambahan.', - 'chat.flowProposal.requireApprovalHint': - 'Setiap tindakan keluar akan memerlukan persetujuan Anda.', 'chat.flowProposal.save': 'Simpan & aktifkan', 'chat.flowProposal.saving': 'Menyimpan…', 'chat.flowProposal.openInCanvas': 'Buka di kanvas', @@ -3701,22 +3699,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'Bukan yang ini', 'notifications.meeting.alwaysJoin': 'Selalu gabung', 'notifications.meeting.actionError': 'Tindakan tidak dapat diselesaikan. Silakan coba lagi.', - 'notifications.flow.approveTitle': 'Alur kerja memerlukan persetujuan', - 'notifications.flow.approve': 'Setujui', - 'notifications.flow.approving': 'Menyetujui…', - 'notifications.flow.dismiss': 'Abaikan', - 'notifications.flow.error': 'Tidak dapat melanjutkan alur kerja. Silakan coba lagi.', - 'notifications.flow.gateCount': 'Menunggu {count} gerbang persetujuan', - 'notifications.flow.approveHint': 'Lanjutkan alur kerja setelah titik pemeriksaan ini', - 'notifications.flow.dismissHint': 'Sembunyikan prompt ini tanpa melanjutkan alur kerja', - 'notifications.flow.viewRun': 'Lihat proses', 'flowRuns.inspector.title': 'Detail proses', 'flowRuns.inspector.startedAt': 'Dimulai', 'flowRuns.inspector.finishedAt': 'Selesai', 'flowRuns.inspector.running': 'Berjalan…', 'flowRuns.inspector.error': 'Kesalahan', 'flowRuns.inspector.pendingApprovals': 'Persetujuan tertunda', - 'flowRuns.inspector.pendingApprovalsCount': '{count} node menunggu persetujuan', 'flowRuns.inspector.steps': 'Langkah', 'flowRuns.inspector.noSteps': 'Belum ada langkah yang tercatat.', 'flowRuns.inspector.output': 'Keluaran', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index e48e92b6b1..df80ba58e5 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3200,7 +3200,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Trigger', 'chat.flowProposal.stepsLabel': 'Passaggi', 'chat.flowProposal.noSteps': 'Nessun passaggio aggiuntivo.', - 'chat.flowProposal.requireApprovalHint': 'Ogni azione in uscita richiederà la tua approvazione.', 'chat.flowProposal.save': 'Salva e attiva', 'chat.flowProposal.saving': 'Salvataggio…', 'chat.flowProposal.openInCanvas': 'Apri nel canvas', @@ -3750,22 +3749,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'Non questa', 'notifications.meeting.alwaysJoin': 'Partecipa sempre', 'notifications.meeting.actionError': 'Impossibile completare questa azione. Riprova.', - 'notifications.flow.approveTitle': "Il workflow richiede l'approvazione", - 'notifications.flow.approve': 'Approva', - 'notifications.flow.approving': 'Approvazione in corso…', - 'notifications.flow.dismiss': 'Ignora', - 'notifications.flow.error': 'Impossibile riprendere il workflow. Riprova.', - 'notifications.flow.gateCount': 'In attesa di {count} punto/i di approvazione', - 'notifications.flow.approveHint': 'Riprendi il workflow dopo questo checkpoint', - 'notifications.flow.dismissHint': 'Nascondi questo avviso senza riprendere il workflow', - 'notifications.flow.viewRun': 'Visualizza esecuzione', 'flowRuns.inspector.title': 'Dettagli esecuzione', 'flowRuns.inspector.startedAt': 'Avviato', 'flowRuns.inspector.finishedAt': 'Terminato', 'flowRuns.inspector.running': 'In esecuzione…', 'flowRuns.inspector.error': 'Errore', 'flowRuns.inspector.pendingApprovals': 'Approvazioni in sospeso', - 'flowRuns.inspector.pendingApprovalsCount': '{count} nodo/i in attesa di approvazione', 'flowRuns.inspector.steps': 'Passaggi', 'flowRuns.inspector.noSteps': 'Nessun passaggio registrato finora.', 'flowRuns.inspector.output': 'Output', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 72aa35c74a..ead6322cf6 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3118,7 +3118,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': '트리거', 'chat.flowProposal.stepsLabel': '단계', 'chat.flowProposal.noSteps': '추가 단계가 없습니다.', - 'chat.flowProposal.requireApprovalHint': '모든 외부 작업에는 승인이 필요합니다.', 'chat.flowProposal.save': '저장 및 활성화', 'chat.flowProposal.saving': '저장 중…', 'chat.flowProposal.openInCanvas': '캔버스에서 열기', @@ -3656,22 +3655,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': '이건 아니에요', 'notifications.meeting.alwaysJoin': '항상 참여', 'notifications.meeting.actionError': '작업을 완료할 수 없습니다. 다시 시도해 주세요.', - 'notifications.flow.approveTitle': '워크플로에 승인이 필요합니다', - 'notifications.flow.approve': '승인', - 'notifications.flow.approving': '승인 중…', - 'notifications.flow.dismiss': '닫기', - 'notifications.flow.error': '워크플로를 다시 시작할 수 없습니다. 다시 시도해 주세요.', - 'notifications.flow.gateCount': '승인 게이트 {count}개 대기 중', - 'notifications.flow.approveHint': '이 체크포인트 이후 워크플로 재개', - 'notifications.flow.dismissHint': '워크플로를 재개하지 않고 이 알림 숨기기', - 'notifications.flow.viewRun': '실행 보기', 'flowRuns.inspector.title': '실행 세부정보', 'flowRuns.inspector.startedAt': '시작됨', 'flowRuns.inspector.finishedAt': '종료됨', 'flowRuns.inspector.running': '실행 중…', 'flowRuns.inspector.error': '오류', 'flowRuns.inspector.pendingApprovals': '대기 중인 승인', - 'flowRuns.inspector.pendingApprovalsCount': '노드 {count}개가 승인 대기 중', 'flowRuns.inspector.steps': '단계', 'flowRuns.inspector.noSteps': '아직 기록된 단계가 없습니다.', 'flowRuns.inspector.output': '출력', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f2a7b8deda..7f654f044f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3184,7 +3184,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Wyzwalacz', 'chat.flowProposal.stepsLabel': 'Kroki', 'chat.flowProposal.noSteps': 'Brak dodatkowych kroków.', - 'chat.flowProposal.requireApprovalHint': 'Każda wychodząca akcja będzie wymagać Twojej zgody.', 'chat.flowProposal.save': 'Zapisz i włącz', 'chat.flowProposal.saving': 'Zapisywanie…', 'chat.flowProposal.openInCanvas': 'Otwórz na kanwie', @@ -3736,22 +3735,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'Nie to', 'notifications.meeting.alwaysJoin': 'Zawsze dołączaj', 'notifications.meeting.actionError': 'Nie udało się wykonać tej akcji. Spróbuj ponownie.', - 'notifications.flow.approveTitle': 'Przepływ pracy wymaga zatwierdzenia', - 'notifications.flow.approve': 'Zatwierdź', - 'notifications.flow.approving': 'Zatwierdzanie…', - 'notifications.flow.dismiss': 'Odrzuć', - 'notifications.flow.error': 'Nie udało się wznowić przepływu pracy. Spróbuj ponownie.', - 'notifications.flow.gateCount': 'Oczekiwanie na {count} bramkę/bramki zatwierdzenia', - 'notifications.flow.approveHint': 'Wznów przepływ pracy po tym punkcie kontrolnym', - 'notifications.flow.dismissHint': 'Ukryj ten monit bez wznawiania przepływu pracy', - 'notifications.flow.viewRun': 'Zobacz przebieg', 'flowRuns.inspector.title': 'Szczegóły przebiegu', 'flowRuns.inspector.startedAt': 'Rozpoczęto', 'flowRuns.inspector.finishedAt': 'Zakończono', 'flowRuns.inspector.running': 'W trakcie…', 'flowRuns.inspector.error': 'Błąd', 'flowRuns.inspector.pendingApprovals': 'Oczekujące zatwierdzenia', - 'flowRuns.inspector.pendingApprovalsCount': '{count} węzeł(y) oczekuje(ą) na zatwierdzenie', 'flowRuns.inspector.steps': 'Kroki', 'flowRuns.inspector.noSteps': 'Nie zarejestrowano jeszcze żadnych kroków.', 'flowRuns.inspector.output': 'Dane wyjściowe', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index dab7f0cb56..b04316e6b9 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3202,7 +3202,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Gatilho', 'chat.flowProposal.stepsLabel': 'Etapas', 'chat.flowProposal.noSteps': 'Nenhuma etapa adicional.', - 'chat.flowProposal.requireApprovalHint': 'Cada ação de saída exigirá sua aprovação.', 'chat.flowProposal.save': 'Salvar e ativar', 'chat.flowProposal.saving': 'Salvando…', 'chat.flowProposal.openInCanvas': 'Abrir no canvas', @@ -3751,22 +3750,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'Ignorar', 'notifications.meeting.alwaysJoin': 'Entrar sempre', 'notifications.meeting.actionError': 'Não foi possível concluir essa ação. Tente novamente.', - 'notifications.flow.approveTitle': 'O fluxo de trabalho precisa de aprovação', - 'notifications.flow.approve': 'Aprovar', - 'notifications.flow.approving': 'Aprovando…', - 'notifications.flow.dismiss': 'Dispensar', - 'notifications.flow.error': 'Não foi possível retomar o fluxo de trabalho. Tente novamente.', - 'notifications.flow.gateCount': 'Aguardando {count} porta(s) de aprovação', - 'notifications.flow.approveHint': 'Retomar o fluxo de trabalho após este ponto de verificação', - 'notifications.flow.dismissHint': 'Ocultar este aviso sem retomar o fluxo de trabalho', - 'notifications.flow.viewRun': 'Ver execução', 'flowRuns.inspector.title': 'Detalhes da execução', 'flowRuns.inspector.startedAt': 'Iniciado', 'flowRuns.inspector.finishedAt': 'Concluído', 'flowRuns.inspector.running': 'Em execução…', 'flowRuns.inspector.error': 'Erro', 'flowRuns.inspector.pendingApprovals': 'Aprovações pendentes', - 'flowRuns.inspector.pendingApprovalsCount': '{count} nó(s) aguardando aprovação', 'flowRuns.inspector.steps': 'Etapas', 'flowRuns.inspector.noSteps': 'Nenhuma etapa registrada ainda.', 'flowRuns.inspector.output': 'Saída', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index f39d6d69d4..e7902fb3c5 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3175,7 +3175,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': 'Триггер', 'chat.flowProposal.stepsLabel': 'Шаги', 'chat.flowProposal.noSteps': 'Дополнительных шагов нет.', - 'chat.flowProposal.requireApprovalHint': 'Каждое исходящее действие потребует вашего одобрения.', 'chat.flowProposal.save': 'Сохранить и включить', 'chat.flowProposal.saving': 'Сохранение…', 'chat.flowProposal.openInCanvas': 'Открыть на холсте', @@ -3725,22 +3724,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': 'Пропустить', 'notifications.meeting.alwaysJoin': 'Всегда присоединяться', 'notifications.meeting.actionError': 'Не удалось выполнить действие. Попробуйте снова.', - 'notifications.flow.approveTitle': 'Рабочий процесс требует одобрения', - 'notifications.flow.approve': 'Одобрить', - 'notifications.flow.approving': 'Одобрение…', - 'notifications.flow.dismiss': 'Скрыть', - 'notifications.flow.error': 'Не удалось возобновить рабочий процесс. Попробуйте снова.', - 'notifications.flow.gateCount': 'Ожидание {count} шлюз(ов) одобрения', - 'notifications.flow.approveHint': 'Возобновить рабочий процесс после этой контрольной точки', - 'notifications.flow.dismissHint': 'Скрыть это уведомление без возобновления рабочего процесса', - 'notifications.flow.viewRun': 'Просмотреть запуск', 'flowRuns.inspector.title': 'Детали запуска', 'flowRuns.inspector.startedAt': 'Начато', 'flowRuns.inspector.finishedAt': 'Завершено', 'flowRuns.inspector.running': 'Выполняется…', 'flowRuns.inspector.error': 'Ошибка', 'flowRuns.inspector.pendingApprovals': 'Ожидающие подтверждения', - 'flowRuns.inspector.pendingApprovalsCount': '{count} узел(-ов) ожидает подтверждения', 'flowRuns.inspector.steps': 'Шаги', 'flowRuns.inspector.noSteps': 'Пока не зафиксировано ни одного шага.', 'flowRuns.inspector.output': 'Вывод', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 2c79439483..6a4c8ecc7d 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2986,7 +2986,6 @@ const messages: TranslationMap = { 'chat.flowProposal.triggerLabel': '触发器', 'chat.flowProposal.stepsLabel': '步骤', 'chat.flowProposal.noSteps': '没有其他步骤。', - 'chat.flowProposal.requireApprovalHint': '每个外发操作都需要你的批准。', 'chat.flowProposal.save': '保存并启用', 'chat.flowProposal.saving': '保存中…', 'chat.flowProposal.openInCanvas': '在画布中打开', @@ -3499,22 +3498,12 @@ const messages: TranslationMap = { 'notifications.meeting.skip': '不是这个', 'notifications.meeting.alwaysJoin': '始终加入', 'notifications.meeting.actionError': '无法完成该操作,请重试。', - 'notifications.flow.approveTitle': '工作流需要批准', - 'notifications.flow.approve': '批准', - 'notifications.flow.approving': '批准中…', - 'notifications.flow.dismiss': '忽略', - 'notifications.flow.error': '无法恢复工作流,请重试。', - 'notifications.flow.gateCount': '正在等待 {count} 个批准节点', - 'notifications.flow.approveHint': '在此检查点之后恢复工作流', - 'notifications.flow.dismissHint': '隐藏此提示但不恢复工作流', - 'notifications.flow.viewRun': '查看运行', 'flowRuns.inspector.title': '运行详情', 'flowRuns.inspector.startedAt': '开始时间', 'flowRuns.inspector.finishedAt': '结束时间', 'flowRuns.inspector.running': '运行中…', 'flowRuns.inspector.error': '错误', 'flowRuns.inspector.pendingApprovals': '待批准', - 'flowRuns.inspector.pendingApprovalsCount': '{count} 个节点等待批准', 'flowRuns.inspector.steps': '步骤', 'flowRuns.inspector.noSteps': '尚未记录任何步骤。', 'flowRuns.inspector.output': '输出', diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx index a95d25651f..183922b066 100644 --- a/app/src/pages/FlowCanvasPage.tsx +++ b/app/src/pages/FlowCanvasPage.tsx @@ -128,8 +128,6 @@ interface EditorFlow { flowId: string | null; name: string; graph: WorkflowGraph; - /** "Require approval" toggle carried into `flows_create` when saving a draft. */ - requireApproval: boolean; } /** The editable canvas body — split out so its hooks only mount once a flow loads. */ @@ -154,7 +152,7 @@ function FlowEditor({ const [running, setRunning] = useState(false); const [runError, setRunError] = useState(null); - const { flowId, graph, requireApproval } = editorFlow; + const { flowId, graph } = editorFlow; // Draft (unsaved) canvases have no persisted id yet; Save creates the flow // rather than updating one, and there is nothing runnable to run. const isDraft = flowId === null; @@ -329,7 +327,7 @@ function FlowEditor({ next.nodes.length, next.edges.length ); - const created = await createFlow(name, next, requireApproval); + const created = await createFlow(name, next); log('save: draft persisted as flow id=%s', created.id); navigate(`/flows/${created.id}`, { replace: true }); return; @@ -339,7 +337,7 @@ function FlowEditor({ persistedGraphRef.current = next; log('save: flow id=%s persisted', flowId); }, - [isDraft, flowId, name, requireApproval, navigate] + [isDraft, flowId, name, navigate] ); // Warn on hard tab close / reload while there are unsaved edits. @@ -612,12 +610,7 @@ export default function FlowCanvasPage() { return ( @@ -701,14 +694,7 @@ export function FlowCanvasDraftPage() { if (draft) { return ( <> - + ); diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx index 7bad805ce4..d4480d7440 100644 --- a/app/src/pages/FlowsPage.test.tsx +++ b/app/src/pages/FlowsPage.test.tsx @@ -64,7 +64,6 @@ function makeFlow(overrides: Partial = {}): Flow { updated_at: '2026-01-01T00:00:00Z', last_run_at: null, last_status: null, - require_approval: false, ...overrides, }; } @@ -287,7 +286,7 @@ describe('FlowsPage', () => { await waitFor(() => expect(importFlow).toHaveBeenCalledWith({ nodes: [] }, 'auto')); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/flows/draft', { - state: { name: 'Imported', graph, requireApproval: true, importWarnings: ['heads up'] }, + state: { name: 'Imported', graph, importWarnings: ['heads up'] }, }) ); }); diff --git a/app/src/pages/FlowsPage.tsx b/app/src/pages/FlowsPage.tsx index c4c688a073..04463dd418 100644 --- a/app/src/pages/FlowsPage.tsx +++ b/app/src/pages/FlowsPage.tsx @@ -281,7 +281,6 @@ export default function FlowsPage() { const draft: FlowCanvasDraftState = { name: graph.name || file.name.replace(/\.[^.]+$/, ''), graph, - requireApproval: true, importWarnings: result.warnings, }; navigate(FLOW_CANVAS_DRAFT_ROUTE, { state: draft }); diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index 3b46338dc2..da318992d2 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -61,7 +61,6 @@ function makeFlow(overrides: Partial = {}): Flow { updated_at: '2026-01-01T00:00:00Z', last_run_at: null, last_status: null, - require_approval: false, ...overrides, }; } @@ -253,7 +252,7 @@ describe('FlowCanvasPage', () => { } it('renders the draft canvas from router state without fetching or persisting', async () => { - renderDraft({ name: 'Proposed flow', graph: draftGraph, requireApproval: true }); + renderDraft({ name: 'Proposed flow', graph: draftGraph }); await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Proposed flow'); @@ -265,18 +264,17 @@ describe('FlowCanvasPage', () => { }); it('creates (never updates) the flow when a draft is saved', async () => { - renderDraft({ name: 'Proposed flow', graph: draftGraph, requireApproval: true }); + renderDraft({ name: 'Proposed flow', graph: draftGraph }); await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); // Edit to make it dirty, then Save → the single persistence gate fires - // `flows_create` (with the require-approval flag), not `flows_update`. + // `flows_create`, not `flows_update`. fireEvent.click(screen.getByTestId('flow-palette-item-agent')); fireEvent.click(screen.getByTestId('flow-editor-save')); await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1)); - const [name, graph, requireApproval] = createFlow.mock.calls[0]; + const [name, graph] = createFlow.mock.calls[0]; expect(name).toBe('Proposed flow'); - expect(requireApproval).toBe(true); expect(graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual(['agent', 'trigger']); expect(updateFlow).not.toHaveBeenCalled(); }); diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index e93b985077..a679058ebc 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -264,8 +264,8 @@ function chatTurnUsagePayload(event: ChatDoneEvent): { * `WorkflowProposal` for `WorkflowProposalCard` (issue B4 — agent-first * Workflow authoring). The tool's `execute()` * (`src/openhuman/flows/tools.rs`) returns - * `{ type: "workflow_proposal", name, graph, require_approval, summary }` as - * its `ToolResult` body; this maps that wire shape onto the store's camelCase + * `{ type: "workflow_proposal", name, graph, summary }` as its `ToolResult` + * body; this maps that wire shape onto the store's camelCase * `WorkflowProposal`. Returns `null` for anything that fails to parse or * doesn't match the expected shape — defensive, since a malformed proposal * must never crash the chat runtime, it should just silently not render a @@ -324,10 +324,6 @@ function parseWorkflowProposal(output: string): WorkflowProposal | null { return { name: obj.name, graph: obj.graph, - // The Rust tool defaults `require_approval` to `true` when the caller - // omits it, so treat anything other than an explicit `false` as `true` - // here too — keeps the client's fallback in lockstep with the server's. - requireApproval: obj.require_approval !== false, summary: { trigger: typeof summary.trigger === 'string' ? summary.trigger : '', steps }, }; } diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx index 353a349a20..c250eb9d09 100644 --- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx +++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx @@ -1469,7 +1469,6 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria type: 'workflow_proposal', name: 'Notify on new signup', graph: { nodes: [], edges: [] }, - require_approval: true, summary: { trigger: 'signup.created', steps: [] }, }), // No `subagent` block and no prior `onSubagentSpawned`/`onSubagentToolCall` @@ -1486,7 +1485,6 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria const proposal = store.getState().chatRuntime.pendingWorkflowProposalsByThread[threadId]; expect(proposal).toMatchObject({ name: 'Notify on new signup', - requireApproval: true, summary: { trigger: 'signup.created' }, }); }); diff --git a/app/src/services/api/flowsApi.test.ts b/app/src/services/api/flowsApi.test.ts index 72c674fb86..cf5f406ea1 100644 --- a/app/src/services/api/flowsApi.test.ts +++ b/app/src/services/api/flowsApi.test.ts @@ -167,7 +167,6 @@ describe('flowsApi', () => { updated_at: '2026-01-01T00:00:00Z', last_run_at: null, last_status: null, - require_approval: false, }; it('calls openhuman.flows_list with no params', async () => { @@ -204,7 +203,6 @@ describe('flowsApi', () => { updated_at: '2026-01-01T00:00:00Z', last_run_at: null, last_status: null, - require_approval: false, }; mockCallCoreRpc.mockResolvedValue(cliEnvelope(flow)); @@ -371,7 +369,6 @@ describe('flowsApi', () => { type: 'workflow_proposal', name: 'Digest', graph: { schema_version: 1, name: 'g', nodes: [], edges: [] }, - require_approval: true, summary: { trigger: 'manual', steps: [] }, }; diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index 8b5ecc81fb..72adb86671 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -118,10 +118,8 @@ export interface Flow { updated_at: string; /** RFC3339 timestamp of the most recent run, if any. */ last_run_at: string | null; - /** Outcome of the most recent run: `"completed"` | `"pending_approval"` | `"failed"`. */ + /** Outcome of the most recent run: `"completed"` | `"failed"`. */ last_status: string | null; - /** "Require approval for outbound actions" toggle (issue B2). */ - require_approval: boolean; } /** @@ -176,7 +174,6 @@ export interface FlowConnection { export interface FlowUpdate { name?: string; graph?: unknown; - requireApproval?: boolean; } /** Lifecycle status of a {@link FlowSuggestion} (`src/openhuman/flows/types.rs::SuggestionStatus`). */ @@ -241,23 +238,13 @@ function unwrapCliEnvelope(payload: unknown): T { * `propose_workflow` tool (`src/openhuman/flows/tools.rs`) only validates a * candidate graph and returns a summary; `WorkflowProposalCard`'s "Save & * enable" button is what calls this function, directly from the client, on - * the user's explicit action. `requireApproval` defaults server-side to - * `false` when omitted, but the B4 proposal flow always passes it explicitly - * (defaulting to `true` on the Rust tool side) so a saved agent-proposed flow - * starts with its outbound-action approval gate on. + * the user's explicit action. */ -export async function createFlow( - name: string, - graph: unknown, - requireApproval?: boolean -): Promise { - log('createFlow: request name=%s requireApproval=%s', name, requireApproval ?? 'default'); +export async function createFlow(name: string, graph: unknown): Promise { + log('createFlow: request name=%s', name); const response = await callCoreRpc({ method: 'openhuman.flows_create', - params: - requireApproval === undefined - ? { name, graph } - : { name, graph, require_approval: requireApproval }, + params: { name, graph }, }); const flow = unwrapCliEnvelope(response); log('createFlow: response id=%s name=%s enabled=%s', flow.id, flow.name, flow.enabled); @@ -265,10 +252,14 @@ export async function createFlow( } /** - * Resume a `pending_approval` flow run past its checkpoint via - * `openhuman.flows_resume`. `approvals` should name the node ids from the - * triggering notification's `node_ids` payload — the Rust side rejects the - * call outright unless at least one named id matches a currently-pending gate. + * Resume a run paused on a node-level `requires_approval` checkpoint gate + * (a per-node tinyflows config, unrelated to the removed flow-level + * human-in-the-loop toggle) via `openhuman.flows_resume`. `approvals` should + * name the node ids from the triggering notification's `node_ids` payload — + * the Rust side rejects the call outright unless at least one named id + * matches a currently-pending gate. There is no dedicated UI surface for this + * anymore (the flow-run approval card was removed); kept as a thin RPC + * wrapper for callers that still need to drive a resume programmatically. */ export async function resumeFlow( id: string, @@ -434,16 +425,14 @@ export async function duplicateFlow(id: string): Promise { */ export async function updateFlow(id: string, update: FlowUpdate): Promise { log( - 'updateFlow: request id=%s name=%s graph=%s requireApproval=%s', + 'updateFlow: request id=%s name=%s graph=%s', id, update.name ?? '(unchanged)', - update.graph === undefined ? '(unchanged)' : 'present', - update.requireApproval ?? 'unchanged' + update.graph === undefined ? '(unchanged)' : 'present' ); const params: Record = { id }; if (update.name !== undefined) params.name = update.name; if (update.graph !== undefined) params.graph = update.graph; - if (update.requireApproval !== undefined) params.require_approval = update.requireApproval; const response = await callCoreRpc({ method: 'openhuman.flows_update', params }); const flow = unwrapCliEnvelope(response); log('updateFlow: response id=%s name=%s', flow.id, flow.name); @@ -633,9 +622,6 @@ export function mapWorkflowProposal(payload: unknown): WorkflowProposal | null { return { name: obj.name, graph: obj.graph, - // The Rust tool defaults `require_approval` to true when omitted, so treat - // anything other than an explicit false as true — in lockstep with the server. - requireApproval: obj.require_approval !== false, summary: { trigger: typeof summary.trigger === 'string' ? summary.trigger : '', steps }, }; } diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 549daf4517..da443fd3af 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -518,8 +518,6 @@ export interface WorkflowProposal { name: string; /** The validated tinyflows WorkflowGraph, ready to hand to `flows_create` as-is. */ graph: unknown; - /** Whether the flow should require approval on every outbound action once saved. */ - requireApproval: boolean; summary: { /** One-line description of the trigger (e.g. `"schedule: 0 9 * * *"`). */ trigger: string; diff --git a/gitbooks/developing/architecture/flows-on-tinyagents.md b/gitbooks/developing/architecture/flows-on-tinyagents.md index e5fca9d043..6a2c62c395 100644 --- a/gitbooks/developing/architecture/flows-on-tinyagents.md +++ b/gitbooks/developing/architecture/flows-on-tinyagents.md @@ -218,21 +218,24 @@ A flow run is guarded on **two independent layers**, an outer one owned by the flows runtime and an inner one owned by the agent harness. **Outer gate - the flow's origin + autonomy tier.** `flows_run`/`flows_resume` -scope a `TrustedAutomation { source: Workflow { require_approval } }` origin -around the whole engine future -([`flows/ops.rs`](../../../src/openhuman/flows/ops.rs), via -`with_origin`). Before an *acting* node dispatches, the seam consults the user's -`[autonomy]` tier through `SecurityPolicy::gate_decision` for that node's -`CommandClass` (`http_request` → Network, `code` → Write, native `oh:` tools → -their classified class) in `enforce_node_tier_gate` +scope a `TrustedAutomation { source: Workflow }` origin around the whole engine +future ([`flows/ops.rs`](../../../src/openhuman/flows/ops.rs), via +`with_origin`). Flows have no per-flow human-in-the-loop toggle: this origin +always resolves to `Allow` at the `ApprovalGate` +([`approval/gate.rs`](../../../src/openhuman/approval/gate.rs)) — a run never +parks for approval, regardless of the autonomy tier's decision. Before an +*acting* node dispatches, the seam still consults the user's `[autonomy]` tier +through `SecurityPolicy::gate_decision` for that node's `CommandClass` +(`http_request` → Network, `code` → Write, native `oh:` tools → their +classified class) in `enforce_node_tier_gate` ([`caps.rs`](../../../src/openhuman/tinyflows/caps.rs)): - a `readonly` run **`Block`s** at the network/code boundary and never dispatches; -- a `supervised` run's `Prompt` decision is escalated by `gate_call_for_tier` - into a **forced `ApprovalGate` round-trip** - even when the flow's own - `require_approval` is `false`, so the tier's "ask me" can't be silently - defeated by a saved flow's default trust; -- a `full` run passes through. +- a `supervised`/`full` run's `Allow`/`Prompt` decision both pass through to + the (always-allowing) `ApprovalGate` unattended — there is no forced + human-in-the-loop round trip anymore (the former `gate_call_for_tier` + escalation this section used to describe was removed alongside the + flow-level `require_approval` toggle it protected against). Composio `tool_call` nodes get an extra deny-by-default **curation gate** (`is_curated_flow_tool`): a slug is allowed only if it resolves to a known, diff --git a/src/openhuman/agent/turn_origin.rs b/src/openhuman/agent/turn_origin.rs index 49955a63ed..5dba06bfda 100644 --- a/src/openhuman/agent/turn_origin.rs +++ b/src/openhuman/agent/turn_origin.rs @@ -93,14 +93,14 @@ pub enum TrustedAutomationSource { /// trigger payload (webhook body, Composio event, …) stays untrusted — /// nothing in it can introduce a *new* action, only feed the pre-declared /// one's arguments. - Workflow { - /// Mirrors `Flow::require_approval`: when `true` the gate does NOT - /// auto-allow this trust root — every external_effect call still - /// parks for a real decision (same shape as `GoalContinuation`), - /// letting a user force human review on a specific flow's outbound - /// actions regardless of the trust root above. - require_approval: bool, - }, + /// + /// Flows have no per-flow human-in-the-loop toggle: a `Workflow` origin + /// always auto-allows an external_effect call (same shortcut a + /// user-authorized cron job gets) — a run never parks for approval. The + /// autonomy tier itself is still enforced ahead of this (see + /// `crate::openhuman::tinyflows::caps::enforce_node_tier_gate`); this + /// origin only governs the approval-gate layer. + Workflow, } tokio::task_local! { diff --git a/src/openhuman/approval/gate.rs b/src/openhuman/approval/gate.rs index 14f438b3a5..44901ddf44 100644 --- a/src/openhuman/approval/gate.rs +++ b/src/openhuman/approval/gate.rs @@ -320,21 +320,15 @@ impl ApprovalGate { // An autonomous goal continuation runs with no user present, so an // irreversible external action must never be auto-allowed — not even via // the `autonomy.auto_approve` allowlist. Skip the shortcut for that - // origin and fall through to the parking flow below. A workflow run - // whose flow has `require_approval` set gets the same treatment — the - // user explicitly asked for every outbound action on that flow to be - // gated, and a global tool allowlist must not silently override that - // per-flow choice. + // origin and fall through to the parking flow below. Flows have no + // per-flow human-in-the-loop toggle (removed) — a `Workflow` origin + // always takes the trust-root shortcut below, so it does not need to + // participate in this bypass. let bypass_auto_approve_shortcut = matches!( &origin, AgentTurnOrigin::TrustedAutomation { source: TrustedAutomationSource::GoalContinuation, .. - } | AgentTurnOrigin::TrustedAutomation { - source: TrustedAutomationSource::Workflow { - require_approval: true - }, - .. } ); @@ -456,43 +450,17 @@ impl ApprovalGate { // here) still make progress on the goal. } AgentTurnOrigin::TrustedAutomation { - source: - TrustedAutomationSource::Workflow { - require_approval: false, - }, + source: TrustedAutomationSource::Workflow, job_id, } => { tracing::debug!( tool = tool_name, flow_id = %job_id, "[approval::gate] trusted workflow automation — pre-declared action, \ - allowing without prompt" + allowing without prompt (flows never park for approval)" ); return (GateOutcome::Allow, None); } - AgentTurnOrigin::TrustedAutomation { - source: - TrustedAutomationSource::Workflow { - require_approval: true, - }, - job_id, - } => { - tracing::info!( - tool = tool_name, - flow_id = %job_id, - "[approval::gate] workflow run has require_approval enabled — parking for \ - HITL review instead of auto-allowing the trust root" - ); - // Fall through to the parking flow (same shape as - // GoalContinuation): persists a `pending_approvals` audit row - // and publishes `ApprovalRequested`. There is no chat thread to - // route the prompt to for a background/triggered flow run yet - // (B3 will add a dedicated review surface) — a caller can still - // decide it via `approval_decide` (e.g. a generic pending- - // approvals list) before the TTL elapses; absent a decision this - // TTL-denies, the conservative fail-closed default for a - // user-forced HITL gate. - } AgentTurnOrigin::Cli => { tracing::debug!( tool = tool_name, @@ -1402,14 +1370,14 @@ mod tests { #[tokio::test] async fn intercept_with_workflow_origin_trust_root_allows_without_prompt() { - // A saved+enabled flow's pre-declared tool/HTTP action (trust root, - // `require_approval: false`) is allowed without a prompt. + // A saved+enabled flow's pre-declared tool/HTTP action is a trust + // root — allowed without a prompt. Flows have no per-flow + // human-in-the-loop toggle (removed): a `Workflow` origin always + // takes this shortcut, it never parks. let (gate, _dir) = test_gate(); let origin = AgentTurnOrigin::TrustedAutomation { job_id: "flow-1".into(), - source: TrustedAutomationSource::Workflow { - require_approval: false, - }, + source: TrustedAutomationSource::Workflow, }; let outcome = turn_origin::with_origin( origin, @@ -1424,48 +1392,27 @@ mod tests { } #[tokio::test] - async fn intercept_with_workflow_require_approval_persists_and_ttl_denies() { - // A per-flow `require_approval: true` toggle forces every external - // action through the HITL gate even though the origin carries a - // trust root — same conservative park-and-audit shape as - // `GoalContinuation` / `ExternalChannel`, since there is no flow - // review surface to route the prompt to yet (B3). - let (gate, _dir) = test_gate(); // 2s TTL - let gate = Arc::new(gate); + async fn intercept_with_workflow_origin_never_parks_across_repeated_calls() { + // A `Workflow` origin always allows, regardless of how many + // external_effect calls a run makes — there is no per-flow toggle + // (or any other condition) that can flip it into the parking flow. + let (gate, _dir) = test_gate(); let origin = AgentTurnOrigin::TrustedAutomation { job_id: "flow-2".into(), - source: TrustedAutomationSource::Workflow { - require_approval: true, - }, + source: TrustedAutomationSource::Workflow, }; - - let g = gate.clone(); - let handle = tokio::spawn(async move { - turn_origin::with_origin( - origin, - g.intercept("composio", "post to slack", serde_json::json!({})), + for _ in 0..3 { + let outcome = turn_origin::with_origin( + origin.clone(), + gate.intercept("composio", "post to slack", serde_json::json!({})), ) - .await - }); - - let mut tries = 0; - loop { - if !gate.list_pending().unwrap().is_empty() { - break; - } - tries += 1; - assert!( - tries < 50, - "audit row never appeared for require_approval workflow origin" - ); - tokio::time::sleep(Duration::from_millis(10)).await; - } - - let outcome = handle.await.unwrap(); - match outcome { - GateOutcome::Deny { reason } => assert!(reason.contains("timed out")), - other => panic!("expected deny, got {other:?}"), + .await; + assert!(matches!(outcome, GateOutcome::Allow)); } + assert!( + gate.list_pending().unwrap().is_empty(), + "a workflow run must never park an approval request" + ); } #[tokio::test] diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 169835dbe5..f2eefd1cd4 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -48,14 +48,14 @@ flow is enabled with a schedule/app_event trigger — that it is now live and will fire on its own). Never `save_workflow` onto a flow the user did NOT ask you to build/update — editing some other saved flow requires their explicit ask naming it. It cannot create flows, and it never changes -`enabled` or the approval gate. +`enabled`. ## Testing a saved flow: `run_flow` (ask first!) Once the user has **saved** a flow, you can `run_flow { flow_id }` to test it -end-to-end. Unlike `dry_run_workflow`, this is a **real run** — real effects can -fire (the flow's own approval gate still pauses outbound-action nodes, but treat -it as real). Rules: +end-to-end. Unlike `dry_run_workflow`, this is a **real run** — real effects +fire immediately, with no approval pause (a flow never parks for human +review; treat it as real). Rules: 1. **Only a saved flow.** `run_flow` needs a `flow_id`; if the graph isn't saved yet, save it first (`save_workflow` when you have the flow id, @@ -65,9 +65,8 @@ it as real). Rules: `run_flow`. Say what it will do ("This will run the flow for real and may send/act on live data — run it now?") and only proceed once they agree. Never run a workflow unprompted or as a surprise side effect of another request. -3. After a run, read the result (status + any nodes paused for approval) and - report what happened; if it failed, `get_flow_run` for the steps and propose a - fix. +3. After a run, read the result (status) and report what happened; if it + failed, `get_flow_run` for the steps and propose a fix. ## Your authoring loop @@ -393,11 +392,12 @@ Any acting node may carry: - **`config.retry`**: `{ max_attempts, backoff_ms?, backoff? }` where `backoff` is `"fixed"` (default) or `"exponential"`. Attempts are capped and delays are bounded. -- **`config.requires_approval: true`** — pauses the run at this node for a human - to approve before it acts (human-in-the-loop). Good for irreversible steps. -Prefer `retry` + `on_error: "route"` for flaky network/tool steps, and -`requires_approval` for anything the user would not want to happen unattended. +Prefer `retry` + `on_error: "route"` for flaky network/tool steps. Flows have +no human-in-the-loop pause — never set a node's `requires_approval`; a saved, +enabled flow always runs unattended once triggered, so build in real +validation/branching (`on_error`, `Condition`) instead of relying on a human +to catch a bad step mid-run. ## Style diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 51e864e30a..0b96d8892f 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -25,7 +25,7 @@ //! effect can fire. The carve-out is [`SaveWorkflowTool`]: it persists a graph //! onto a flow that ALREADY exists (the Flows prompt bar's instant-create path //! makes the flow first and hands the agent its id) — but the agent still -//! cannot *create* a flow, and never touches `enabled`/`require_approval`. +//! cannot *create* a flow, and never touches `enabled`. //! //! The agent's full tool scope (see `agent_registry/agents/workflow_builder/ //! agent.toml`) also grants the Composio **discovery/connect** tools — @@ -111,10 +111,6 @@ impl Tool for ReviseWorkflowTool { "instruction": { "type": "string", "description": "The revision instruction that motivated this change (e.g. 'add a Slack step after the summary'). Echoed back for the review card; does not affect validation." - }, - "require_approval": { - "type": "boolean", - "description": "Force a human-approval gate on every outbound action once saved. Defaults to false; set true only when the user explicitly asks for an approval step." } }, "required": ["name", "graph"] @@ -143,15 +139,10 @@ impl Tool for ReviseWorkflowTool { .get("instruction") .and_then(Value::as_str) .map(str::to_string); - let require_approval = args - .get("require_approval") - .and_then(Value::as_bool) - .unwrap_or(false); tracing::debug!( target: "flows", %name, - require_approval, has_instruction = instruction.is_some(), workspace = %self.config.workspace_dir.display(), "[flows] revise_workflow: validating revised candidate graph" @@ -215,7 +206,6 @@ impl Tool for ReviseWorkflowTool { target: "flows", %name, node_count = graph.nodes.len(), - require_approval, warning_count = warnings.len(), "[flows] revise_workflow: revised proposal ready for user review" ); @@ -225,7 +215,6 @@ impl Tool for ReviseWorkflowTool { "revision": true, "name": name, "graph": graph_value, - "require_approval": require_approval, "summary": summary, "warnings": warnings, }); @@ -367,7 +356,6 @@ impl Tool for GetFlowTool { "id": f.id, "name": f.name, "enabled": f.enabled, - "require_approval": f.require_approval, "last_status": f.last_status, "graph": graph, }))?)) @@ -1384,8 +1372,9 @@ impl CapturingObserver { /// - **Update-only.** It requires an existing `flow_id`; there is still no tool /// to *create* a flow, so the agent can only write where the host (or user) /// already made a flow. -/// - **Never touches enablement or the approval gate.** `enabled` and -/// `require_approval` are not parameters; whatever the user set stays. +/// - **Never touches enablement.** `enabled` is not a parameter; whatever the +/// user set stays. (There is no approval-gate toggle to preserve either — +/// the flows human-in-the-loop concept was removed entirely.) /// - **Real persistence, real consequences.** Saving a `schedule`/`app_event` /// trigger onto an ENABLED flow arms it (the trigger binds and will fire on /// its own) — hence `PermissionLevel::Write`. The description tells the agent @@ -1536,7 +1525,7 @@ impl Tool for SaveWorkflowTool { "[flows] save_workflow: agent-initiated save to existing flow" ); - match ops::flows_update(&self.config, &flow_id, name, Some(graph_json), None).await { + match ops::flows_update(&self.config, &flow_id, name, Some(graph_json)).await { Ok(outcome) => { let flow = outcome.value; tracing::info!( @@ -1551,7 +1540,6 @@ impl Tool for SaveWorkflowTool { "flow_id": flow.id, "name": flow.name, "enabled": flow.enabled, - "require_approval": flow.require_approval, "node_count": flow.graph.nodes.len(), "warnings": warnings, }))?)) diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index d3c1a5ac29..885104665a 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -58,22 +58,10 @@ async fn revise_workflow_validates_and_returns_revision_proposal() { } #[tokio::test] -async fn revise_workflow_omitted_require_approval_defaults_false() { - let tmp = TempDir::new().unwrap(); - let tool = ReviseWorkflowTool::new(test_config(&tmp)); - - let result = tool - .execute(json!({ "name": "Revised flow", "graph": valid_graph() })) - .await - .unwrap(); - - assert!(!result.is_error, "{}", result.output()); - let parsed: Value = serde_json::from_str(&result.output()).unwrap(); - assert_eq!(parsed["require_approval"], false); -} - -#[tokio::test] -async fn revise_workflow_explicit_require_approval_true_is_respected() { +async fn revise_workflow_result_has_no_require_approval_field() { + // The flows human-in-the-loop toggle was removed entirely — the + // revised proposal shape must not carry it, even if a caller still + // sends it. let tmp = TempDir::new().unwrap(); let tool = ReviseWorkflowTool::new(test_config(&tmp)); @@ -88,7 +76,7 @@ async fn revise_workflow_explicit_require_approval_true_is_respected() { assert!(!result.is_error, "{}", result.output()); let parsed: Value = serde_json::from_str(&result.output()).unwrap(); - assert_eq!(parsed["require_approval"], true); + assert!(parsed.get("require_approval").is_none()); } #[tokio::test] @@ -1004,7 +992,6 @@ async fn seed_flow(config: &Arc, name: &str) -> String { "nodes": [ { "id": "t", "kind": "trigger", "name": "Manual" } ], "edges": [] }), - true, ) .await .unwrap(); @@ -1063,8 +1050,9 @@ async fn save_workflow_persists_graph_and_name_onto_existing_flow() { assert_eq!(parsed["flow_id"], flow_id.as_str()); assert_eq!(parsed["name"], "AI News Digest"); assert_eq!(parsed["node_count"], 2); - // Enablement / approval gate are NOT touched by the tool. - assert_eq!(parsed["require_approval"], true); + // Enablement is NOT touched by the tool, and there is no approval-gate + // field at all anymore (the flows human-in-the-loop concept was removed). + assert!(parsed.get("require_approval").is_none()); // The graph + name really persisted. let saved = ops::flows_get(&config, &flow_id).await.unwrap().value; diff --git a/src/openhuman/flows/bus.rs b/src/openhuman/flows/bus.rs index ec03b60a42..98028ba70f 100644 --- a/src/openhuman/flows/bus.rs +++ b/src/openhuman/flows/bus.rs @@ -300,7 +300,6 @@ mod tests { updated_at: "2026-01-01T00:00:00Z".to_string(), last_run_at: None, last_status: None, - require_approval: false, } } diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 6263454904..2c39480f1e 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -1114,12 +1114,10 @@ pub async fn flows_create( config: &Config, name: String, graph_json: Value, - require_approval: bool, ) -> Result, String> { let graph = validate_and_migrate_graph(graph_json)?; - tracing::debug!(target: "flows", %name, node_count = graph.nodes.len(), require_approval, "[flows] flows_create: persisting new flow"); - let flow = - store::create_flow(config, name, graph, require_approval).map_err(|e| e.to_string())?; + tracing::debug!(target: "flows", %name, node_count = graph.nodes.len(), "[flows] flows_create: persisting new flow"); + let flow = store::create_flow(config, name, graph).map_err(|e| e.to_string())?; if flow.enabled { tracing::debug!(target: "flows", flow_id = %flow.id, "[flows] flows_create: flow is enabled — binding automatic-dispatch trigger"); @@ -1365,30 +1363,27 @@ fn title_case_toolkit(toolkit: &str) -> String { .join(" ") } -/// Updates a flow's name, graph, and/or `require_approval` toggle. -/// Re-validates the graph (whether newly supplied or the existing one) -/// before persisting, same as `flows_create`. +/// Updates a flow's name and/or graph. Re-validates the graph (whether newly +/// supplied or the existing one) before persisting, same as `flows_create`. /// /// When the caller supplies a new `graph_json` and the flow is (still) /// enabled, re-binds the automatic-dispatch trigger if the trigger /// kind/config actually changed (e.g. a new schedule cron expression) — /// otherwise the stale binding from the old graph would keep firing on the /// old cadence, or a newly-added schedule would never get bound at all. -/// Skipped entirely for a name/`require_approval`-only update (no -/// `graph_json` supplied), since the trigger definitely didn't change. +/// Skipped entirely for a name-only update (no `graph_json` supplied), since +/// the trigger definitely didn't change. pub async fn flows_update( config: &Config, id: &str, name: Option, graph_json: Option, - require_approval: Option, ) -> Result, String> { let existing = store::get_flow(config, id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("flow '{id}' not found"))?; let new_name = name.unwrap_or_else(|| existing.name.clone()); - let new_require_approval = require_approval.unwrap_or(existing.require_approval); let graph_changed = graph_json.is_some(); let graph = match graph_json { Some(raw) => validate_and_migrate_graph(raw)?, @@ -1399,8 +1394,8 @@ pub async fn flows_update( }; tracing::debug!(target: "flows", flow_id = %id, "[flows] flows_update: persisting changes"); - let updated = store::update_flow_graph(config, id, new_name, graph, new_require_approval) - .map_err(|e| e.to_string())?; + let updated = + store::update_flow_graph(config, id, new_name, graph).map_err(|e| e.to_string())?; if graph_changed && updated.enabled { let trigger_unchanged = bus::extract_trigger_kind(&existing) @@ -1735,7 +1730,6 @@ pub async fn flows_run( target: "flows", flow_id = %flow_id, thread_id = %thread_id, - require_approval = flow.require_approval, "[flows] flows_run: starting checkpointed run" ); @@ -1763,7 +1757,7 @@ pub async fn flows_run( finish_flow_run_row(config, &thread_id, "failed", &observed, &[], Some(error)); }; - let origin = workflow_origin(flow_id, flow.require_approval); + let origin = workflow_origin(flow_id); // Per-run in-memory journal: tinyflows records every graph event as a // durable GraphObservation under the run's tinyagents run id, which the // post-run Langfuse export reads back. Process-local and dropped with the @@ -1969,7 +1963,7 @@ pub async fn flows_resume( "[flows] flows_resume: resuming checkpointed run" ); - let origin = workflow_origin(flow_id, flow.require_approval); + let origin = workflow_origin(flow_id); // Same per-run journal as `flows_run`: the resumed execution mints a new // tinyagents run id, so its observation slice is read under that id. let journal = Arc::new(tinyflows::engine::InMemoryGraphEventJournal::new()); @@ -2257,11 +2251,13 @@ async fn drop_checkpoint(config: &Config, thread_id: &str) { /// Builds the `TrustedAutomation { Workflow }` origin scoped around every /// `flows_run` / `flows_resume` invocation. See `flows_run`'s doc for why -/// this applies uniformly regardless of caller. -fn workflow_origin(flow_id: &str, require_approval: bool) -> AgentTurnOrigin { +/// this applies uniformly regardless of caller. Flows have no per-flow +/// human-in-the-loop toggle — every run scopes the same trust root, which the +/// approval gate always allows without parking. +fn workflow_origin(flow_id: &str) -> AgentTurnOrigin { AgentTurnOrigin::TrustedAutomation { job_id: flow_id.to_string(), - source: TrustedAutomationSource::Workflow { require_approval }, + source: TrustedAutomationSource::Workflow, } } diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index f69fcabba7..9d3b107224 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -35,7 +35,7 @@ async fn flows_create_rejects_graph_without_trigger() { "edges": [] }); - let err = flows_create(&config, "bad".to_string(), graph_without_trigger, false) + let err = flows_create(&config, "bad".to_string(), graph_without_trigger) .await .expect_err("graph without a trigger must be rejected"); assert!( @@ -49,7 +49,7 @@ async fn flows_create_get_list_delete_roundtrip() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); let flow_id = created.value.id.clone(); @@ -71,8 +71,7 @@ async fn flows_duplicate_produces_disabled_unbound_copy_with_new_id() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - // Enabled source with require_approval set. - let created = flows_create(&config, "My Flow".to_string(), trigger_only_graph(), true) + let created = flows_create(&config, "My Flow".to_string(), trigger_only_graph()) .await .unwrap(); assert!(created.value.enabled); @@ -87,9 +86,8 @@ async fn flows_duplicate_produces_disabled_unbound_copy_with_new_id() { !dup.value.enabled, "a duplicate must be disabled and thus not schedule/trigger-bound" ); - // Identical graph + require_approval carried over; run history reset. + // Identical graph carried over; run history reset. assert_eq!(dup.value.graph, created.value.graph); - assert!(dup.value.require_approval); assert!(dup.value.last_run_at.is_none()); assert!(dup.value.last_status.is_none()); @@ -110,7 +108,7 @@ async fn flows_duplicate_missing_flow_errors() { async fn flows_set_enabled_toggles() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); assert!(created.value.enabled); @@ -130,7 +128,7 @@ async fn flows_set_enabled_toggles() { async fn flows_update_replaces_name_and_graph() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); @@ -142,7 +140,6 @@ async fn flows_update_replaces_name_and_graph() { &created.value.id, Some("renamed".to_string()), Some(new_graph), - None, ) .await .unwrap(); @@ -152,31 +149,31 @@ async fn flows_update_replaces_name_and_graph() { } #[tokio::test] -async fn flows_update_can_set_require_approval() { +async fn flows_update_name_only_has_no_require_approval_param() { + // The flows human-in-the-loop toggle was removed entirely — `flows_update` + // no longer takes a `require_approval` param at all. let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); - assert!(!created.value.require_approval); - let updated = flows_update(&config, &created.value.id, None, None, Some(true)) - .await - .unwrap(); - assert!(updated.value.require_approval); - - // Omitting `require_approval` on a later update preserves the current value. - let unchanged = flows_update(&config, &created.value.id, None, None, None) - .await - .unwrap(); - assert!(unchanged.value.require_approval); + let updated = flows_update( + &config, + &created.value.id, + Some("renamed".to_string()), + None, + ) + .await + .unwrap(); + assert_eq!(updated.value.name, "renamed"); } #[tokio::test] async fn flows_update_rejects_invalid_replacement_graph() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); @@ -186,7 +183,7 @@ async fn flows_update_rejects_invalid_replacement_graph() { "edges": [] }); - let err = flows_update(&config, &created.value.id, None, Some(invalid_graph), None) + let err = flows_update(&config, &created.value.id, None, Some(invalid_graph)) .await .expect_err("invalid replacement graph must be rejected"); assert!(err.contains("trigger")); @@ -196,7 +193,7 @@ async fn flows_update_rejects_invalid_replacement_graph() { async fn flows_run_completes_trigger_only_graph() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); @@ -238,7 +235,7 @@ async fn flows_run_reports_pending_approval_and_blocks_downstream() { ] }); - let created = flows_create(&config, "gated".to_string(), graph, false) + let created = flows_create(&config, "gated".to_string(), graph) .await .unwrap(); @@ -297,7 +294,7 @@ async fn flows_run_records_failed_status_when_a_node_errors() { "edges": [ { "from_node": "t", "to_node": "x" } ] }); - let created = flows_create(&config, "boom".to_string(), graph, false) + let created = flows_create(&config, "boom".to_string(), graph) .await .unwrap(); @@ -340,7 +337,7 @@ async fn flows_run_populates_error_when_a_continue_policy_node_errors() { "edges": [ { "from_node": "t", "to_node": "x" } ] }); - let created = flows_create(&config, "boom-continue".to_string(), graph, false) + let created = flows_create(&config, "boom-continue".to_string(), graph) .await .unwrap(); @@ -396,7 +393,6 @@ async fn flows_create_binds_schedule_cron_job_for_an_enabled_flow() { &config, "scheduled".to_string(), schedule_trigger_graph("0 9 * * *"), - false, ) .await .unwrap(); @@ -419,7 +415,6 @@ async fn flows_delete_unbinds_schedule_cron_job() { &config, "scheduled".to_string(), schedule_trigger_graph("0 9 * * *"), - false, ) .await .unwrap(); @@ -449,7 +444,6 @@ async fn flows_update_rebinds_schedule_cron_job_when_trigger_schedule_changes() &config, "scheduled".to_string(), schedule_trigger_graph("0 9 * * *"), - false, ) .await .unwrap(); @@ -463,7 +457,6 @@ async fn flows_update_rebinds_schedule_cron_job_when_trigger_schedule_changes() &created.value.id, None, Some(schedule_trigger_graph("30 8 * * *")), - None, ) .await .unwrap(); @@ -493,7 +486,6 @@ async fn flows_update_does_not_rebind_when_graph_is_not_supplied() { &config, "scheduled".to_string(), schedule_trigger_graph("0 9 * * *"), - false, ) .await .unwrap(); @@ -508,7 +500,6 @@ async fn flows_update_does_not_rebind_when_graph_is_not_supplied() { &created.value.id, Some("renamed".to_string()), None, - None, ) .await .unwrap(); @@ -541,7 +532,7 @@ fn approval_gated_graph() -> Value { async fn flows_resume_continues_a_paused_run_to_completion() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + let created = flows_create(&config, "gated".to_string(), approval_gated_graph()) .await .unwrap(); @@ -610,7 +601,7 @@ async fn flows_resume_missing_flow_errors() { async fn flows_resume_with_empty_approvals_is_rejected_and_does_not_complete_the_run() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + let created = flows_create(&config, "gated".to_string(), approval_gated_graph()) .await .unwrap(); @@ -649,7 +640,7 @@ async fn flows_resume_with_empty_approvals_is_rejected_and_does_not_complete_the async fn flows_resume_with_mismatched_approvals_is_rejected() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + let created = flows_create(&config, "gated".to_string(), approval_gated_graph()) .await .unwrap(); @@ -680,7 +671,7 @@ async fn flows_resume_with_mismatched_approvals_is_rejected() { async fn flows_resume_with_the_correct_gate_completes_and_runs_downstream() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + let created = flows_create(&config, "gated".to_string(), approval_gated_graph()) .await .unwrap(); @@ -742,7 +733,6 @@ async fn flows_resume_denying_a_gate_routes_to_its_error_port() { &config, "gated-deny".to_string(), approval_gated_graph_with_error_port(), - false, ) .await .unwrap(); @@ -793,7 +783,7 @@ async fn flows_resume_denying_a_gate_with_no_error_port_fails_the_run() { let config = test_config(&tmp); // `approval_gated_graph()` has only a `main` edge out of the gate — no // `error` port to route a denial to, so the whole run must fail. - let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + let created = flows_create(&config, "gated".to_string(), approval_gated_graph()) .await .unwrap(); @@ -831,7 +821,7 @@ async fn flows_resume_denying_a_gate_with_no_error_port_fails_the_run() { async fn flows_resume_rejects_a_gate_named_in_both_approvals_and_rejections() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + let created = flows_create(&config, "gated".to_string(), approval_gated_graph()) .await .unwrap(); @@ -860,7 +850,7 @@ async fn flows_resume_rejects_a_gate_named_in_both_approvals_and_rejections() { async fn flows_resume_of_a_non_paused_run_errors_clearly() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); @@ -884,7 +874,7 @@ async fn flows_resume_of_a_non_paused_run_errors_clearly() { async fn flows_resume_with_no_recorded_run_for_thread_id_errors_clearly() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); @@ -906,7 +896,7 @@ async fn flows_resume_with_no_recorded_run_for_thread_id_errors_clearly() { async fn flows_run_persists_a_flow_run_row_queryable_via_list_and_get() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); @@ -954,14 +944,9 @@ async fn flows_run_emits_pending_approval_notification() { let config = test_config(&tmp); let mut rx = crate::openhuman::notifications::bus::subscribe_core_notifications(); - let created = flows_create( - &config, - "gated-notify".to_string(), - approval_gated_graph(), - false, - ) - .await - .unwrap(); + let created = flows_create(&config, "gated-notify".to_string(), approval_gated_graph()) + .await + .unwrap(); let run = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) .await @@ -1011,7 +996,7 @@ async fn flows_run_does_not_notify_when_run_completes_without_pending_approvals( let config = test_config(&tmp); let mut rx = crate::openhuman::notifications::bus::subscribe_core_notifications(); - let created = flows_create(&config, "no-gate".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "no-gate".to_string(), trigger_only_graph()) .await .unwrap(); let created_id = created.value.id.clone(); @@ -1064,7 +1049,7 @@ async fn observer_persists_each_step_incrementally() { // `start_flow_run_row`), so seed a flow + a running run row first. let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "obs".to_string(), passthrough_graph(), false) + let created = flows_create(&config, "obs".to_string(), passthrough_graph()) .await .unwrap(); let run_id = format!("flow:{}:run-under-test", created.value.id); @@ -1130,14 +1115,9 @@ async fn observer_persists_each_step_incrementally() { async fn flows_run_persists_live_steps_with_status_and_timing() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create( - &config, - "passthrough".to_string(), - passthrough_graph(), - false, - ) - .await - .unwrap(); + let created = flows_create(&config, "passthrough".to_string(), passthrough_graph()) + .await + .unwrap(); let run = flows_run( &config, @@ -1186,7 +1166,7 @@ async fn flows_run_persists_live_steps_with_status_and_timing() { async fn flows_cancel_run_cancels_a_parked_pending_approval_run() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + let created = flows_create(&config, "gated".to_string(), approval_gated_graph()) .await .unwrap(); @@ -1239,7 +1219,7 @@ async fn flows_cancel_run_cancels_a_parked_pending_approval_run() { async fn flows_cancel_run_of_an_already_completed_run_errors() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); @@ -1263,7 +1243,7 @@ async fn flows_cancel_run_of_a_completed_with_warnings_run_errors() { // the run already recorded. let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "demo".to_string(), trigger_only_graph(), false) + let created = flows_create(&config, "demo".to_string(), trigger_only_graph()) .await .unwrap(); @@ -1311,7 +1291,7 @@ async fn flows_cancel_run_missing_run_errors() { async fn parked_run_ttl_sweep_expires_stale_runs_but_spares_fresh_ones() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create(&config, "gated".to_string(), approval_gated_graph(), false) + let created = flows_create(&config, "gated".to_string(), approval_gated_graph()) .await .unwrap(); @@ -1437,14 +1417,9 @@ async fn flows_set_enabled_surfaces_unfired_trigger_warning_at_enable() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let created = flows_create( - &config, - "hooked".to_string(), - webhook_trigger_graph(), - false, - ) - .await - .unwrap(); + let created = flows_create(&config, "hooked".to_string(), webhook_trigger_graph()) + .await + .unwrap(); // Re-enable (create already enables) to exercise the enable path's warning. let enabled = flows_set_enabled(&config, &created.value.id, true) @@ -1470,7 +1445,6 @@ async fn flows_set_enabled_schedule_flow_has_no_warning() { &config, "scheduled".to_string(), schedule_trigger_graph("0 9 * * *"), - false, ) .await .unwrap(); diff --git a/src/openhuman/flows/schemas.rs b/src/openhuman/flows/schemas.rs index 0736d3a275..ab74f48a80 100644 --- a/src/openhuman/flows/schemas.rs +++ b/src/openhuman/flows/schemas.rs @@ -157,16 +157,6 @@ fn stream_request_id_input() -> FieldSchema { } } -fn require_approval_input() -> FieldSchema { - FieldSchema { - name: "require_approval", - ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), - comment: "Force a human-approval gate on every outbound tool/HTTP action this flow \ - takes, regardless of its saved-flow trust root. Defaults to `false`.", - required: false, - } -} - fn run_output_fields() -> Vec { vec![ FieldSchema { @@ -367,7 +357,6 @@ pub fn schemas(function: &str) -> ControllerSchema { "A tinyflows WorkflowGraph (nodes + edges); validated and migrated on save.", required: true, }, - require_approval_input(), ], outputs: vec![flow_output()], }, @@ -515,7 +504,6 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "Replacement WorkflowGraph, if changing it.", required: false, }, - require_approval_input(), ], outputs: vec![flow_output()], }, @@ -752,9 +740,9 @@ pub fn schemas(function: &str) -> ControllerSchema { propose-only, see #4596). The server renders the agent's brief — the \ frontend no longer crafts prompts. Returns `{ proposal, assistant_text, \ error }`, where `proposal` is the `{ type: 'workflow_proposal', name, \ - graph, require_approval, summary, warnings }` the agent produced (or \ - null). No mode auto-persists a graph; save/enable/run stay behind the \ - user's explicit action.", + graph, summary, warnings }` the agent produced (or null). No mode \ + auto-persists a graph; save/enable/run stay behind the user's explicit \ + action.", inputs: vec![ FieldSchema { name: "mode", @@ -892,11 +880,7 @@ fn handle_create(params: Map) -> ControllerFuture { let config = config_rpc::load_config_with_timeout().await?; let name = read_required::(¶ms, "name")?; let graph = read_required::(¶ms, "graph")?; - let require_approval = params - .get("require_approval") - .and_then(Value::as_bool) - .unwrap_or(false); - to_json(ops::flows_create(&config, name, graph, require_approval).await?) + to_json(ops::flows_create(&config, name, graph).await?) }) } @@ -963,8 +947,7 @@ fn handle_update(params: Map) -> ControllerFuture { .transpose() .map_err(|e| format!("invalid 'name': {e}"))?; let graph = params.get("graph").filter(|v| !v.is_null()).cloned(); - let require_approval = params.get("require_approval").and_then(Value::as_bool); - to_json(ops::flows_update(&config, id.trim(), name, graph, require_approval).await?) + to_json(ops::flows_update(&config, id.trim(), name, graph).await?) }) } @@ -1291,14 +1274,11 @@ mod tests { } #[test] - fn schemas_create_require_approval_is_optional() { + fn schemas_create_has_no_require_approval_field() { + // The flows human-in-the-loop toggle was removed entirely — the RPC + // param must not exist (not merely optional). let s = schemas("create"); - let field = s - .inputs - .iter() - .find(|f| f.name == "require_approval") - .unwrap(); - assert!(!field.required); + assert!(s.inputs.iter().all(|f| f.name != "require_approval")); } #[test] diff --git a/src/openhuman/flows/store.rs b/src/openhuman/flows/store.rs index 8649751581..4004578b1d 100644 --- a/src/openhuman/flows/store.rs +++ b/src/openhuman/flows/store.rs @@ -100,6 +100,14 @@ fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) // `require_approval` (issue B2) — added post-hoc so a workspace created // before this column existed still opens cleanly. Mirrors // `cron::store`'s `add_column_if_missing` idiom. + // + // The flows human-in-the-loop concept this column backed has since been + // removed (a workflow run never parks for approval regardless of any + // per-flow toggle). Dropping the column outright would need a real + // migration this store has no established idiom for (unlike adding one); + // instead the column stays in the schema for compatibility with rows + // written by older builds, but every write hardcodes it to `0` and no + // read ever surfaces it — see `upsert_flow`/`map_flow_row` below. add_column_if_missing( &conn, "flow_definitions", @@ -148,17 +156,21 @@ fn add_column_if_missing(conn: &Connection, table: &str, name: &str, sql_type: & /// Shared column list for every `flow_definitions` SELECT — keeps /// [`map_flow_row`]'s positional `row.get(N)` calls in sync with the query. -const FLOW_DEFINITION_COLUMNS: &str = "id, name, graph_json, enabled, created_at, updated_at, \ - last_run_at, last_status, require_approval"; - -/// Inserts or fully replaces a flow definition row. +/// Deliberately excludes `require_approval`: the column still exists (see +/// the store-open migration note above) but nothing reads it anymore. +const FLOW_DEFINITION_COLUMNS: &str = + "id, name, graph_json, enabled, created_at, updated_at, last_run_at, last_status"; + +/// Inserts or fully replaces a flow definition row. `require_approval` is +/// always persisted as `0` — the flows human-in-the-loop toggle it backed +/// was removed; the column stays for legacy-row compatibility only. pub fn upsert_flow(config: &Config, flow: &Flow) -> Result<()> { let graph_json = serde_json::to_string(&flow.graph).context("Failed to serialize graph")?; with_connection(config, |conn| { conn.execute( "INSERT INTO flow_definitions (id, name, graph_json, enabled, created_at, updated_at, last_run_at, last_status, require_approval) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0) ON CONFLICT(id) DO UPDATE SET name = excluded.name, graph_json = excluded.graph_json, @@ -166,7 +178,7 @@ pub fn upsert_flow(config: &Config, flow: &Flow) -> Result<()> { updated_at = excluded.updated_at, last_run_at = excluded.last_run_at, last_status = excluded.last_status, - require_approval = excluded.require_approval", + require_approval = 0", params![ flow.id, flow.name, @@ -176,7 +188,6 @@ pub fn upsert_flow(config: &Config, flow: &Flow) -> Result<()> { flow.updated_at, flow.last_run_at, flow.last_status, - if flow.require_approval { 1 } else { 0 }, ], ) .context("Failed to upsert flow definition")?; @@ -185,13 +196,12 @@ pub fn upsert_flow(config: &Config, flow: &Flow) -> Result<()> { }) } -/// Duplicates an existing [`Flow`] into a fresh row: same graph + -/// `require_approval`, a new id/timestamps, the given `new_name`, and -/// **`enabled = false`** so the copy never auto-fires (no schedule/app_event -/// trigger is bound while disabled — the caller relies on this to keep a -/// duplicate inert until explicitly enabled). `last_run_at`/`last_status` are -/// reset to `None` — run history does not carry over. Returns the persisted -/// copy. +/// Duplicates an existing [`Flow`] into a fresh row: same graph, a new +/// id/timestamps, the given `new_name`, and **`enabled = false`** so the copy +/// never auto-fires (no schedule/app_event trigger is bound while disabled — +/// the caller relies on this to keep a duplicate inert until explicitly +/// enabled). `last_run_at`/`last_status` are reset to `None` — run history +/// does not carry over. Returns the persisted copy. pub fn insert_duplicate_flow(config: &Config, source: &Flow, new_name: String) -> Result { let now = Utc::now().to_rfc3339(); let flow = Flow { @@ -203,7 +213,6 @@ pub fn insert_duplicate_flow(config: &Config, source: &Flow, new_name: String) - updated_at: now, last_run_at: None, last_status: None, - require_approval: source.require_approval, }; upsert_flow(config, &flow)?; tracing::debug!(target: "flows", source_id = %source.id, new_id = %flow.id, "[flows] inserted duplicate flow (disabled)"); @@ -216,7 +225,6 @@ pub fn create_flow( config: &Config, name: String, graph: tinyflows::model::WorkflowGraph, - require_approval: bool, ) -> Result { let now = Utc::now().to_rfc3339(); let flow = Flow { @@ -228,7 +236,6 @@ pub fn create_flow( updated_at: now, last_run_at: None, last_status: None, - require_approval, }; upsert_flow(config, &flow)?; Ok(flow) @@ -317,14 +324,13 @@ pub fn set_enabled(config: &Config, id: &str, enabled: bool) -> Result { get_flow(config, id)?.ok_or_else(|| anyhow::anyhow!("flow '{id}' not found after update")) } -/// Replaces a flow's name/graph/`require_approval` (re-validated by the -/// caller before this is invoked) in place, bumping `updated_at`. +/// Replaces a flow's name/graph (re-validated by the caller before this is +/// invoked) in place, bumping `updated_at`. pub fn update_flow_graph( config: &Config, id: &str, name: String, graph: tinyflows::model::WorkflowGraph, - require_approval: bool, ) -> Result { let graph_json = serde_json::to_string(&graph).context("Failed to serialize graph")?; let now = Utc::now().to_rfc3339(); @@ -333,15 +339,8 @@ pub fn update_flow_graph( // `enabled` / `last_run_at` / `last_status` from a read-modify-write. let changed = with_connection(config, |conn| { conn.execute( - "UPDATE flow_definitions SET name = ?1, graph_json = ?2, updated_at = ?3, \ - require_approval = ?4 WHERE id = ?5", - params![ - name, - graph_json, - now, - if require_approval { 1 } else { 0 }, - id - ], + "UPDATE flow_definitions SET name = ?1, graph_json = ?2, updated_at = ?3 WHERE id = ?4", + params![name, graph_json, now, id], ) .context("Failed to update flow") })?; @@ -386,7 +385,6 @@ fn map_flow_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { updated_at: row.get(5)?, last_run_at: row.get(6)?, last_status: row.get(7)?, - require_approval: row.get::<_, i64>(8)? != 0, }) } diff --git a/src/openhuman/flows/store_tests.rs b/src/openhuman/flows/store_tests.rs index e0edebd972..7ff880eff0 100644 --- a/src/openhuman/flows/store_tests.rs +++ b/src/openhuman/flows/store_tests.rs @@ -34,7 +34,7 @@ fn create_get_list_delete_roundtrip() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); assert_eq!(flow.name, "demo"); assert!(flow.enabled); @@ -70,7 +70,7 @@ fn remove_flow_errors_when_not_found() { fn set_enabled_toggles_and_persists() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); assert!(flow.enabled); let disabled = set_enabled(&config, &flow.id, false).unwrap(); @@ -87,12 +87,11 @@ fn set_enabled_toggles_and_persists() { fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); let mut new_graph = trigger_graph(); new_graph.name = "renamed-graph".to_string(); - let updated = - update_flow_graph(&config, &flow.id, "renamed".to_string(), new_graph, false).unwrap(); + let updated = update_flow_graph(&config, &flow.id, "renamed".to_string(), new_graph).unwrap(); assert_eq!(updated.name, "renamed"); assert_eq!(updated.created_at, flow.created_at); @@ -103,7 +102,7 @@ fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { fn record_run_sets_last_run_fields() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); assert!(flow.last_run_at.is_none()); record_run(&config, &flow.id, "completed").unwrap(); @@ -169,59 +168,6 @@ fn kv_get_set_round_trips_and_is_namespace_scoped() { ); } -// ── require_approval ───────────────────────────────────────────────────── - -#[test] -fn create_flow_persists_require_approval() { - let tmp = TempDir::new().unwrap(); - let config = test_config(&tmp); - - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), true).unwrap(); - assert!(flow.require_approval); - - let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); - assert!(reloaded.require_approval); -} - -#[test] -fn update_flow_graph_can_change_require_approval() { - let tmp = TempDir::new().unwrap(); - let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); - assert!(!flow.require_approval); - - let updated = - update_flow_graph(&config, &flow.id, flow.name.clone(), trigger_graph(), true).unwrap(); - assert!(updated.require_approval); - - let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); - assert!(reloaded.require_approval); -} - -#[test] -fn legacy_flow_definitions_row_without_require_approval_column_defaults_false() { - // A row inserted before the `require_approval` column existed (the - // `add_column_if_missing` ALTER runs on every `with_connection` call, so - // this simulates a workspace opened once on an older build). - let tmp = TempDir::new().unwrap(); - let config = test_config(&tmp); - - let legacy_graph_json = serde_json::to_string(&trigger_graph()).unwrap(); - with_connection(&config, |conn| { - conn.execute( - "INSERT INTO flow_definitions - (id, name, graph_json, enabled, created_at, updated_at, last_run_at, last_status) - VALUES ('legacy-2', 'legacy', ?1, 1, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z', NULL, NULL)", - rusqlite::params![legacy_graph_json], - )?; - Ok(()) - }) - .unwrap(); - - let loaded = get_flow(&config, "legacy-2").unwrap().expect("row present"); - assert!(!loaded.require_approval); -} - // ── list_enabled_flows ──────────────────────────────────────────────────── #[test] @@ -229,9 +175,8 @@ fn list_enabled_flows_excludes_disabled() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let enabled_flow = create_flow(&config, "enabled".to_string(), trigger_graph(), false).unwrap(); - let disabled_flow = - create_flow(&config, "disabled".to_string(), trigger_graph(), false).unwrap(); + let enabled_flow = create_flow(&config, "enabled".to_string(), trigger_graph()).unwrap(); + let disabled_flow = create_flow(&config, "disabled".to_string(), trigger_graph()).unwrap(); set_enabled(&config, &disabled_flow.id, false).unwrap(); let enabled = list_enabled_flows(&config).unwrap(); @@ -245,7 +190,7 @@ fn list_enabled_flows_excludes_disabled() { fn flow_run_insert_finish_get_round_trip() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); let thread_id = format!("flow:{}:run-1", flow.id); insert_flow_run( @@ -299,7 +244,7 @@ fn flow_run_insert_finish_get_round_trip() { fn finish_flow_run_records_error_on_failure() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); let thread_id = format!("flow:{}:run-2", flow.id); insert_flow_run( &config, @@ -337,8 +282,8 @@ fn get_flow_run_returns_none_for_unknown_id() { fn list_flow_runs_orders_newest_first_and_is_scoped_to_flow() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow_a = create_flow(&config, "a".to_string(), trigger_graph(), false).unwrap(); - let flow_b = create_flow(&config, "b".to_string(), trigger_graph(), false).unwrap(); + let flow_a = create_flow(&config, "a".to_string(), trigger_graph()).unwrap(); + let flow_b = create_flow(&config, "b".to_string(), trigger_graph()).unwrap(); insert_flow_run( &config, @@ -382,10 +327,10 @@ fn insert_duplicate_flow_makes_a_disabled_copy_with_new_id_and_same_graph() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - // Enabled source with require_approval + a distinctive graph name. + // Enabled source with a distinctive graph name. let mut graph = trigger_graph(); graph.name = "original-graph".to_string(); - let source = create_flow(&config, "My Flow".to_string(), graph, true).unwrap(); + let source = create_flow(&config, "My Flow".to_string(), graph).unwrap(); assert!(source.enabled); record_run(&config, &source.id, "completed").unwrap(); let source = get_flow(&config, &source.id).unwrap().unwrap(); @@ -402,10 +347,9 @@ fn insert_duplicate_flow_makes_a_disabled_copy_with_new_id_and_same_graph() { ); assert!(copy.last_run_at.is_none()); assert!(copy.last_status.is_none()); - // Same graph + require_approval carried over. + // Same graph carried over. assert_eq!(copy.graph, source.graph); assert_eq!(copy.graph.name, "original-graph"); - assert!(copy.require_approval); // Persisted and independent — both rows exist. let reloaded = get_flow(&config, ©.id).unwrap().unwrap(); @@ -437,7 +381,7 @@ fn seed_run(config: &Config, flow_id: &str, id: &str, day: u32, status: &str) { fn prune_flow_runs_keeps_newest_n_terminal_runs() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); // 5 completed runs on ascending days. for i in 1..=5 { @@ -456,7 +400,7 @@ fn prune_flow_runs_keeps_newest_n_terminal_runs() { fn prune_flow_runs_never_removes_pending_approval_run() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); // An OLD parked pending_approval run (day 1) plus newer completed runs. seed_run(&config, &flow.id, "parked", 1, "pending_approval"); @@ -482,7 +426,7 @@ fn prune_flow_runs_never_removes_pending_approval_run() { fn prune_flow_runs_leaves_running_rows_alone() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); seed_run(&config, &flow.id, "live", 1, "running"); for i in 2..=4 { @@ -499,7 +443,7 @@ fn prune_flow_runs_leaves_running_rows_alone() { fn insert_flow_run_auto_prunes_beyond_retention_cap() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); // Seed exactly MAX_FLOW_RUNS_PER_FLOW completed runs. let cap = MAX_FLOW_RUNS_PER_FLOW; @@ -543,7 +487,7 @@ fn insert_flow_run_auto_prunes_beyond_retention_cap() { fn list_flow_runs_respects_limit() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false).unwrap(); + let flow = create_flow(&config, "demo".to_string(), trigger_graph()).unwrap(); for i in 0..3 { let id = format!("run-{i}"); diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs index 147a310ef2..1d5447ed55 100644 --- a/src/openhuman/flows/tools.rs +++ b/src/openhuman/flows/tools.rs @@ -117,10 +117,6 @@ impl Tool for ProposeWorkflowTool { } }, "required": ["nodes", "edges"] - }, - "require_approval": { - "type": "boolean", - "description": "Force a human-approval gate on every outbound tool/HTTP action this flow takes once saved. Defaults to false; set true only when the user explicitly asks for an approval step." } }, "required": ["name", "graph"] @@ -149,15 +145,9 @@ impl Tool for ProposeWorkflowTool { _ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())), }; - let require_approval = args - .get("require_approval") - .and_then(Value::as_bool) - .unwrap_or(false); - tracing::debug!( target: "flows", %name, - require_approval, workspace = %self.config.workspace_dir.display(), "[flows] propose_workflow: validating candidate graph" ); @@ -229,7 +219,6 @@ impl Tool for ProposeWorkflowTool { target: "flows", %name, node_count = graph.nodes.len(), - require_approval, warning_count = warnings.len(), "[flows] propose_workflow: proposal ready for user review" ); @@ -238,7 +227,6 @@ impl Tool for ProposeWorkflowTool { "type": "workflow_proposal", "name": name, "graph": graph_value, - "require_approval": require_approval, "summary": summary, "warnings": warnings, }))?)) @@ -248,10 +236,11 @@ impl Tool for ProposeWorkflowTool { /// Runs a **saved** workflow by id so the `workflow-builder` agent can *test* /// it end-to-end. Unlike [`crate::openhuman::flows::builder_tools::DryRunWorkflowTool`] /// (a MOCK sandbox), this is a **real** run — so it is `PermissionLevel::Write` -/// with `external_effect() == true`. Two safety layers remain: the flow's own -/// `require_approval` gate still pauses outbound-action nodes mid-run, and the -/// agent prompt requires it to ASK THE USER for confirmation before ever -/// calling this. It only runs an already-persisted flow (no `flow_id`, no run). +/// with `external_effect() == true`. There is no human-in-the-loop pause on a +/// flow run (that toggle was removed — a run always executes to completion +/// unattended); the only remaining safety layer is the agent prompt requiring +/// it to ASK THE USER for confirmation before ever calling this. It only runs +/// an already-persisted flow (no `flow_id`, no run). pub struct RunFlowTool { config: Arc, } @@ -278,12 +267,11 @@ impl Tool for RunFlowTool { fn description(&self) -> &str { "Run a SAVED workflow by id to TEST it end-to-end. This is a REAL run, not a \ - simulation — real effects can fire (use dry_run_workflow for a safe MOCK run \ - instead). It only works on a flow the user has already saved; pass its `flow_id`. \ - You MUST ask the user to confirm and wait for an explicit 'yes' before calling this \ - — never run a workflow unprompted. The flow's own approval gate still pauses \ - outbound-action nodes. Params: { flow_id (required), input? }. Returns the run's \ - status + any nodes paused for approval." + simulation — real effects fire immediately, with no approval pause (use \ + dry_run_workflow for a safe MOCK run instead). It only works on a flow the user has \ + already saved; pass its `flow_id`. You MUST ask the user to confirm and wait for an \ + explicit 'yes' before calling this — never run a workflow unprompted. Params: \ + { flow_id (required), input? }. Returns the run's status." } fn parameters_schema(&self) -> Value { diff --git a/src/openhuman/flows/tools_tests.rs b/src/openhuman/flows/tools_tests.rs index f928708d0f..fb384fa9da 100644 --- a/src/openhuman/flows/tools_tests.rs +++ b/src/openhuman/flows/tools_tests.rs @@ -112,35 +112,9 @@ async fn missing_graph_is_an_error() { } #[tokio::test] -async fn omitted_require_approval_defaults_false_in_result() { - let tmp = TempDir::new().unwrap(); - let tool = ProposeWorkflowTool::new(test_config(&tmp)); - - let result = tool - .execute(json!({ "name": "demo", "graph": valid_graph() })) - .await - .unwrap(); - - let parsed: Value = serde_json::from_str(&result.output()).unwrap(); - assert_eq!(parsed["require_approval"], false); -} - -#[tokio::test] -async fn explicit_require_approval_false_is_respected() { - let tmp = TempDir::new().unwrap(); - let tool = ProposeWorkflowTool::new(test_config(&tmp)); - - let result = tool - .execute(json!({ "name": "demo", "graph": valid_graph(), "require_approval": false })) - .await - .unwrap(); - - let parsed: Value = serde_json::from_str(&result.output()).unwrap(); - assert_eq!(parsed["require_approval"], false); -} - -#[tokio::test] -async fn explicit_require_approval_true_is_respected() { +async fn proposal_result_has_no_require_approval_field() { + // The flows human-in-the-loop toggle was removed entirely — the + // proposal shape must not carry it, even if a caller still sends it. let tmp = TempDir::new().unwrap(); let tool = ProposeWorkflowTool::new(test_config(&tmp)); @@ -150,7 +124,7 @@ async fn explicit_require_approval_true_is_respected() { .unwrap(); let parsed: Value = serde_json::from_str(&result.output()).unwrap(); - assert_eq!(parsed["require_approval"], true); + assert!(parsed.get("require_approval").is_none()); } #[tokio::test] diff --git a/src/openhuman/flows/types.rs b/src/openhuman/flows/types.rs index 1afc4e836e..5f6bb67ca9 100644 --- a/src/openhuman/flows/types.rs +++ b/src/openhuman/flows/types.rs @@ -98,16 +98,8 @@ pub struct Flow { pub updated_at: String, /// RFC3339 timestamp of the most recent run, if any. pub last_run_at: Option, - /// Outcome of the most recent run: `"completed"` | `"pending_approval"` | `"failed"`. + /// Outcome of the most recent run: `"completed"` | `"failed"`. pub last_status: Option, - /// "Require approval for outbound actions" (issue B2). When `true`, the - /// approval gate does NOT auto-allow this flow's `TrustedAutomation - /// { Workflow }` trust root — every external_effect tool/HTTP call the - /// flow makes still parks for a real decision, regardless of how the run - /// was triggered. See `src/openhuman/approval/gate.rs` and - /// `src/openhuman/agent/turn_origin.rs::TrustedAutomationSource::Workflow`. - #[serde(default)] - pub require_approval: bool, } /// One step of a persisted [`FlowRun`] (run-history inspector). @@ -362,21 +354,19 @@ mod tests { updated_at: "2026-01-01T00:00:00Z".to_string(), last_run_at: None, last_status: None, - require_approval: false, }; let json = serde_json::to_string(&flow).expect("serialize"); let back: Flow = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back.id, flow.id); assert_eq!(back.graph, flow.graph); assert!(back.last_run_at.is_none()); - assert!(!back.require_approval); } #[test] - fn flow_require_approval_defaults_false_when_omitted_from_json() { - // Legacy/serialized JSON authored before the field existed must still - // deserialize (SQLite rows are migrated via `add_column_if_missing`, - // but any bare JSON fixture should also default safely). + fn flow_has_no_require_approval_field() { + // The flows human-in-the-loop toggle was removed entirely — the + // field must not exist, and legacy JSON that still carries it must + // still deserialize cleanly (serde just ignores the unknown key). let json = serde_json::json!({ "id": "flow_1", "name": "demo", @@ -384,9 +374,10 @@ mod tests { "graph": sample_graph(), "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + "require_approval": true, }); let flow: Flow = serde_json::from_value(json).expect("deserialize"); - assert!(!flow.require_approval); + assert_eq!(flow.id, "flow_1"); } #[test] diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index 5c09b2ae12..32799c3fee 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -70,14 +70,13 @@ fn usage_to_json(usage: &Option) -> Value { /// tools gate (`policy.gate_decision(CommandClass::Network)`), so a read-only /// run can never reach the network or run arbitrary code. /// -/// `Allow`/`Prompt` return `Ok(decision)`: this function only enforces the -/// non-negotiable `Block` floor itself. The caller uses the returned -/// [`GateDecision`] to drive [`gate_call_for_tier`] immediately after, which is -/// what actually performs the `Prompt` round-trip (see that function's doc for -/// why this is not automatic — a saved workflow's own `require_approval` flag -/// would otherwise silently override the tier's `Prompt` decision). The error -/// is prefixed with [`POLICY_BLOCKED_MARKER`] so the harness's repeated-failure -/// middleware recognizes it as a permanent, don't-retry refusal. +/// `Allow`/`Prompt` both return `Ok(decision)` and proceed — flows have no +/// human-in-the-loop concept (removed): a `Workflow` origin always resolves to +/// [`GateOutcome::Allow`] at [`gate_call_for_tier`] regardless of this tier +/// decision, so `Prompt` no longer forces a round trip here. This function +/// only enforces the non-negotiable `Block` floor. The error is prefixed with +/// [`POLICY_BLOCKED_MARKER`] so the harness's repeated-failure middleware +/// recognizes it as a permanent, don't-retry refusal. fn enforce_node_tier_gate( security: &SecurityPolicy, class: CommandClass, @@ -110,92 +109,28 @@ fn enforce_node_tier_gate( Ok(decision) } -/// Dispatches to the process-global [`ApprovalGate`](crate::openhuman::approval::ApprovalGate), -/// escalating a `Prompt`-tier decision into a forced human-in-the-loop round -/// trip regardless of the running flow's own `require_approval` toggle. +/// Dispatches to the process-global [`ApprovalGate`](crate::openhuman::approval::ApprovalGate) +/// for its allowlist/audit bookkeeping. /// -/// **Why this is needed (Codex P1 finding):** `ApprovalGate::intercept_audited` -/// branches on the scoped [`AgentTurnOrigin`](crate::openhuman::agent::turn_origin::AgentTurnOrigin) — -/// for a `TrustedAutomation { source: Workflow { require_approval: false }, .. }` -/// origin (the default for every saved flow unless the author opts in) it -/// returns `Allow` unconditionally, the same pre-declared-trust-root shortcut a -/// user-authorized cron job gets. That shortcut is correct when the node's -/// autonomy-tier decision was itself `Allow`, but it silently defeats a -/// Supervised-tier `Prompt` decision: without this escalation, a Supervised -/// user's `http_request`/`code` node would run unattended purely because the -/// flow's `require_approval` defaults to `false` — the tier's "ask me" was -/// never actually enforced. -/// -/// When `tier_decision` is [`GateDecision::Prompt`] and the current origin is a -/// `Workflow { require_approval: false }` trust root, this scopes a *for this -/// call only* `Workflow { require_approval: true }` origin around -/// `intercept_audited`, forcing the real parking/HITL flow. `GateDecision::Allow` -/// (and any other origin shape) passes through unchanged — existing behavior. +/// **Flows never park for approval** (the flows `require_approval` / HITL +/// concept was removed): a `Workflow` origin always resolves to +/// [`crate::openhuman::approval::GateOutcome::Allow`] inside +/// `intercept_audited` regardless of the tier decision that gated dispatch +/// (see [`enforce_node_tier_gate`]) — a `Prompt` tier decision no longer +/// forces a human-in-the-loop round trip here (that escalation, the former +/// "Codex P1" fix, was removed alongside the flow-level toggle it protected +/// against). The hard `Block` floor from [`enforce_node_tier_gate`] is +/// unaffected and still refuses outright before this is ever called. async fn gate_call_for_tier( - tier_decision: GateDecision, tool_name: &str, action_summary: &str, args_redacted: Value, ) -> (crate::openhuman::approval::GateOutcome, Option) { - use crate::openhuman::agent::turn_origin; - let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() else { return (crate::openhuman::approval::GateOutcome::Allow, None); }; - - match escalated_origin_for_prompt(tier_decision, turn_origin::current()) { - Some(escalated) => { - tracing::debug!( - target: "flows", - tool_name, - "[flows] node tier gate: tier decision is Prompt — escalating this dispatch to a \ - forced approval round-trip regardless of the flow's require_approval toggle" - ); - turn_origin::with_origin( - escalated, - gate.intercept_audited(tool_name, action_summary, args_redacted), - ) - .await - } - None => { - gate.intercept_audited(tool_name, action_summary, args_redacted) - .await - } - } -} - -/// Pure decision core of [`gate_call_for_tier`]: when `tier_decision` is -/// [`GateDecision::Prompt`] and `origin` is a `Workflow { require_approval: -/// false }` trust root, returns a clone of that origin with `require_approval` -/// flipped to `true` (the forced escalation). Otherwise returns `None` — the -/// caller then dispatches through the unmodified origin, matching prior -/// behavior. Split out as a free function over plain values (no gate, no -/// task-local read) so the escalation policy is unit-testable without a live -/// `ApprovalGate`. -fn escalated_origin_for_prompt( - tier_decision: GateDecision, - origin: Option, -) -> Option { - use crate::openhuman::agent::turn_origin::{AgentTurnOrigin, TrustedAutomationSource}; - - if tier_decision != GateDecision::Prompt { - return None; - } - match origin { - Some(AgentTurnOrigin::TrustedAutomation { - job_id, - source: - TrustedAutomationSource::Workflow { - require_approval: false, - }, - }) => Some(AgentTurnOrigin::TrustedAutomation { - job_id, - source: TrustedAutomationSource::Workflow { - require_approval: true, - }, - }), - _ => None, - } + gate.intercept_audited(tool_name, action_summary, args_redacted) + .await } /// Cap on the serialized `input_context` block size (bytes of the pretty- @@ -1331,21 +1266,21 @@ async fn resolve_composio_account( /// `composio_connection_id`). /// - **Trust gate**: invocation is also routed through the OpenHuman /// `ApprovalGate` (mirrors `tinyagents/middleware.rs::ApprovalSecurityMiddleware`) -/// before dispatch, closing the Codex P1 finding that flow tool nodes -/// bypassed the Network/tool approval gate entirely. `ops::flows_run` / -/// `flows_resume` scope a `TrustedAutomation { Workflow }` origin around -/// the whole run, so the gate either auto-allows (pre-declared trust root) -/// or — when the flow's `require_approval` is set — parks for a real -/// decision. No gate installed (unit tests, some hosts) means no gating, -/// same as the existing agent tool-loop middleware. +/// before dispatch — kept for its allowlist/audit bookkeeping only. +/// `ops::flows_run` / `flows_resume` scope a `TrustedAutomation { Workflow }` +/// origin around the whole run, and that origin always auto-allows (flows +/// have no human-in-the-loop toggle anymore — a run never parks). No gate +/// installed (unit tests, some hosts) means no gating, same as the existing +/// agent tool-loop middleware. /// /// // SECURITY NOTE (tinyflows 0.3, now the pinned version): integration nodes /// // `=`-resolve config from upstream/trigger data, so a trigger-driven flow /// // whose `slug`/`url` is `=`-derived lets untrusted trigger data pick *which* /// // curated + in-scope + connected tool/endpoint runs (blast radius bounded by -/// // the curation + scope + connection checks above and the approval gate). -/// // For such flows authors should set `require_approval`. FOLLOW-UP: auto-force -/// // approval when a trigger-driven run's tool/http config contains `=`-exprs. +/// // the curation + scope + connection checks above). There is no longer a +/// // human-in-the-loop mitigation available for this (the flows approval +/// // toggle was removed) — the curation/scope/connection checks are the only +/// // remaining backstop for an `=`-derived tool/http config. pub struct OpenHumanTools { pub config: Arc, } @@ -1927,8 +1862,7 @@ impl ToolInvoker for OpenHumanTools { let tier_decision = enforce_node_tier_gate(&security, class, "tool_call")?; let summary = crate::openhuman::approval::summarize_action(tool_name, &args); let redacted = crate::openhuman::approval::redact_args(&args); - let (outcome, _request_id) = - gate_call_for_tier(tier_decision, tool_name, &summary, redacted).await; + let (outcome, _request_id) = gate_call_for_tier(tool_name, &summary, redacted).await; if let crate::openhuman::approval::GateOutcome::Deny { reason } = outcome { return Err(EngineError::Capability(reason)); } @@ -2249,20 +2183,16 @@ impl HttpClient for OpenHumanHttp { // Autonomy-tier gate (Phase 2): an http_request node reaches the network, // so it is Network-class. A read-only run `Block`s here and never - // dispatches; Supervised/Full fall through to the ApprovalGate below. - // `gate_call_for_tier` is what actually performs the `Prompt` round-trip - // — it escalates a Supervised `Prompt` decision into a forced approval - // regardless of the flow's own `require_approval` toggle (Codex P1). - let tier_decision = - enforce_node_tier_gate(&self.security, CommandClass::Network, "http_request")?; + // dispatches; Supervised/Full fall through to the ApprovalGate below, + // which always allows (flows never park for approval). + enforce_node_tier_gate(&self.security, CommandClass::Network, "http_request")?; // The approval gate summarizes/redacts the request BEFORE any credential // is injected, so a stored secret never lands in the approval UI or // audit trail. Injection happens strictly after this point. let summary = crate::openhuman::approval::summarize_action(TOOL_NAME, &request); let redacted = crate::openhuman::approval::redact_args(&request); - let (outcome, audit_id) = - gate_call_for_tier(tier_decision, TOOL_NAME, &summary, redacted).await; + let (outcome, audit_id) = gate_call_for_tier(TOOL_NAME, &summary, redacted).await; if let crate::openhuman::approval::GateOutcome::Deny { reason } = outcome { return Err(EngineError::Capability(reason)); } @@ -2344,10 +2274,11 @@ impl HttpClient for OpenHumanHttp { /// **Phase 2 — autonomy-tier gating:** a `code` node runs arbitrary user code /// in a sandbox, so it is treated as [`CommandClass::Write`] (state-changing but /// sandbox-bounded — not inherently catastrophic). Before dispatch it consults -/// [`enforce_node_tier_gate`]: a read-only run `Block`s and never executes; a -/// Supervised run then routes through the `ApprovalGate` (Write ⇒ `Prompt`); a -/// Full run executes silently. This closes the prior gap where the code node had -/// no policy check and no approval gate at all. +/// [`enforce_node_tier_gate`]: a read-only run `Block`s and never executes; +/// Supervised/Full both fall through to the `ApprovalGate`, which always +/// allows for a flow run (flows never park for approval). This closes the +/// prior gap where the code node had no policy check at all — the hard +/// `Block` floor still applies under a read-only tier. pub struct OpenHumanCode { pub config: Arc, pub security: Arc, @@ -2360,21 +2291,20 @@ impl CodeRunner for OpenHumanCode { async fn run(&self, language: CodeLanguage, source: &str, input: Value) -> Result { // Autonomy-tier gate (Phase 2): sandboxed arbitrary-code execution is // Write-class. A read-only run `Block`s here and never spawns anything; - // Supervised/Full fall through to the ApprovalGate below. - let tier_decision = enforce_node_tier_gate(&self.security, CommandClass::Write, "code")?; - - // Approval gate (mirrors OpenHumanTools/OpenHumanHttp): `gate_call_for_tier` - // is what turns a Supervised-tier `Prompt` decision into a real human - // round-trip before any code runs — escalating past the flow's own - // `require_approval` toggle when the tier itself says "ask me" (Codex P1). - // A Deny short-circuits. The audit summary is computed on a redacted view - // of the request, never the raw source secrets, matching the other - // acting adapters. + // Supervised/Full fall through to the ApprovalGate below, which always + // allows (flows never park for approval). + enforce_node_tier_gate(&self.security, CommandClass::Write, "code")?; + + // Approval gate (mirrors OpenHumanTools/OpenHumanHttp) — kept for its + // allowlist/audit bookkeeping; it always resolves to `Allow` for a + // flow's `Workflow` origin, so this can only ever short-circuit on a + // `Deny` from some other layer, not from human review (there is none). + // The audit summary is computed on a redacted view of the request, + // never the raw source secrets, matching the other acting adapters. let action = json!({ "language": format!("{language:?}"), "source": source }); let summary = crate::openhuman::approval::summarize_action("flows_code", &action); let redacted = crate::openhuman::approval::redact_args(&action); - let (gate_outcome, audit_id) = - gate_call_for_tier(tier_decision, "flows_code", &summary, redacted).await; + let (gate_outcome, audit_id) = gate_call_for_tier("flows_code", &summary, redacted).await; if let crate::openhuman::approval::GateOutcome::Deny { reason } = gate_outcome { return Err(EngineError::Capability(reason)); } @@ -3466,68 +3396,6 @@ mod tests { } } - // ── Codex P1: Prompt-tier decisions must escalate past a workflow's own - // require_approval=false default, never silently auto-allow ──────────── - - use crate::openhuman::agent::turn_origin::{AgentTurnOrigin, TrustedAutomationSource}; - - fn workflow_origin(job_id: &str, require_approval: bool) -> AgentTurnOrigin { - AgentTurnOrigin::TrustedAutomation { - job_id: job_id.to_string(), - source: TrustedAutomationSource::Workflow { require_approval }, - } - } - - /// A `Prompt` tier decision on a default (`require_approval: false`) - /// workflow trust root escalates to `require_approval: true` — the forced - /// human-in-the-loop round trip that closes the Codex P1 finding. - #[test] - fn prompt_decision_escalates_default_workflow_origin() { - let escalated = escalated_origin_for_prompt( - GateDecision::Prompt, - Some(workflow_origin("flow-1", false)), - ) - .expect("a Prompt decision on require_approval=false must escalate"); - assert!(matches!( - escalated, - AgentTurnOrigin::TrustedAutomation { - source: TrustedAutomationSource::Workflow { - require_approval: true - }, - .. - } - )); - } - - /// A flow that already opted into `require_approval: true` needs no - /// escalation — it's already forced through the parking flow. - #[test] - fn prompt_decision_does_not_re_escalate_already_gated_workflow() { - assert!(escalated_origin_for_prompt( - GateDecision::Prompt, - Some(workflow_origin("flow-1", true)) - ) - .is_none()); - } - - /// An `Allow` tier decision never escalates, regardless of the workflow's - /// `require_approval` toggle — Full-tier runs keep running unattended. - #[test] - fn allow_decision_never_escalates() { - assert!(escalated_origin_for_prompt( - GateDecision::Allow, - Some(workflow_origin("flow-1", false)) - ) - .is_none()); - } - - /// No scoped origin (or a non-Workflow origin) never escalates — there is - /// nothing to force through the workflow-specific parking flow. - #[test] - fn prompt_decision_does_not_escalate_without_a_workflow_origin() { - assert!(escalated_origin_for_prompt(GateDecision::Prompt, None).is_none()); - } - // ── Phase 7: sub_workflow-by-id resolver ─────────────────────────────── fn resolver_test_config(tmp: &tempfile::TempDir) -> Config { @@ -3565,7 +3433,7 @@ mod tests { let config = Arc::new(resolver_test_config(&tmp)); let graph_json = serde_json::to_value(trigger_only_graph()).unwrap(); - let flow = flows::ops::flows_create(&config, "child".to_string(), graph_json, false) + let flow = flows::ops::flows_create(&config, "child".to_string(), graph_json) .await .expect("create flow"); let flow_id = flow.value.id.clone(); diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 5f335626a6..3a3e3b2f7a 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -308,13 +308,13 @@ pub fn all_tools_with_runtime( Box::new(DryRunWorkflowTool::new(security.clone(), config.clone())), // Real end-to-end test run of a SAVED flow (Write / external-effect). The // workflow-builder prompt requires it to ask the user for confirmation - // first, and the flow's own approval gate still pauses outbound nodes. + // first; a flow run never pauses for approval (no HITL toggle exists). Box::new(RunFlowTool::new(config.clone())), // Persist a built graph onto an EXISTING saved flow (Write). Used only // when the USER explicitly asks the agent to save; the seeded build // turn from the Flows prompt bar is propose-only (see #4596) — Accept // + the canvas's own Save persist the graph. The tool itself can - // never create a flow or change enabled/require_approval. + // never create a flow or change `enabled`. Box::new(SaveWorkflowTool::new(config.clone())), // Flow Scout discovery: the `flow_discovery` agent's terminal emit // sink. Read-only reasoning over the user's data ends by calling