mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): Workflows B5a — list page + nav tab (/flows) (#4471)
This commit is contained in:
@@ -12,6 +12,7 @@ import Accounts from './pages/Accounts';
|
||||
import Brain from './pages/Brain';
|
||||
import AgentInsightsPreview from './pages/dev/AgentInsightsPreview';
|
||||
import Feedback from './pages/Feedback';
|
||||
import FlowsPage from './pages/FlowsPage';
|
||||
import Invites from './pages/Invites';
|
||||
import Notifications from './pages/Notifications';
|
||||
import Onboarding from './pages/onboarding/Onboarding';
|
||||
@@ -94,6 +95,20 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Workflows — the `flows::` domain's discoverable list hub (issue
|
||||
B5a). Distinct from the legacy SKILL.md `/workflows/*` Skill routes
|
||||
below (create/run) and their `/workflows` → `/settings/automations`
|
||||
back-compat redirect, which stay untouched. The canvas (B5b) and
|
||||
agent-proposal surface (B4) are separate, later work. */}
|
||||
<Route
|
||||
path="/flows/*"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<FlowsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Back-compat: /activity and /intelligence → settings notifications page. */}
|
||||
<Route path="/activity" element={<Navigate to="/settings/notifications" replace />} />
|
||||
<Route path="/intelligence" element={<Navigate to="/settings/notifications" replace />} />
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* FlowListRow (issue B5a) — one saved-flow row on the Workflows list page.
|
||||
* Asserts the name/status rendering, the last-run/never-run text (including
|
||||
* the localized relative-time strings), and that the toggle/Run controls
|
||||
* call back with the row's `Flow`. No "View runs" control yet — it was
|
||||
* pulled until B3b's run inspector lands (see `FlowListRow.tsx`'s module
|
||||
* doc and the commented integration point in `FlowsPage.tsx`).
|
||||
*/
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Flow } from '../../services/api/flowsApi';
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import FlowListRow from './FlowListRow';
|
||||
|
||||
function makeFlow(overrides: Partial<Flow> = {}): Flow {
|
||||
return {
|
||||
id: 'flow-1',
|
||||
name: 'Daily digest',
|
||||
enabled: true,
|
||||
graph: { nodes: [], edges: [] },
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
last_run_at: null,
|
||||
last_status: null,
|
||||
require_approval: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FlowListRow', () => {
|
||||
it('renders the flow name and an Enabled badge when enabled', () => {
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Daily digest')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled');
|
||||
});
|
||||
|
||||
it('renders a Paused badge when disabled', () => {
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow({ enabled: false })} onToggle={vi.fn()} onRun={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused');
|
||||
});
|
||||
|
||||
it('shows "Never run" when the flow has no last_run_at', () => {
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Never run')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the capitalized status and "Just now" for a run seconds ago', () => {
|
||||
renderWithProviders(
|
||||
<FlowListRow
|
||||
flow={makeFlow({ last_run_at: new Date().toISOString(), last_status: 'completed' })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Completed · Just now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a minutes-ago relative time', () => {
|
||||
const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
renderWithProviders(
|
||||
<FlowListRow
|
||||
flow={makeFlow({ last_run_at: fiveMinAgo, last_status: 'completed' })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Completed · 5m ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an hours-ago relative time', () => {
|
||||
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60_000).toISOString();
|
||||
renderWithProviders(
|
||||
<FlowListRow
|
||||
flow={makeFlow({ last_run_at: threeHoursAgo, last_status: 'failed' })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Failed · 3h ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a days-ago relative time', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString();
|
||||
renderWithProviders(
|
||||
<FlowListRow
|
||||
flow={makeFlow({ last_run_at: twoDaysAgo, last_status: 'pending_approval' })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Pending_approval · 2d ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onToggle with the flow when the switch is clicked', () => {
|
||||
const onToggle = vi.fn();
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={onToggle} onRun={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-toggle-flow-1'));
|
||||
|
||||
expect(onToggle).toHaveBeenCalledWith(makeFlow());
|
||||
});
|
||||
|
||||
it('calls onRun with the flow when the Run button is clicked', () => {
|
||||
const onRun = vi.fn();
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={onRun} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-run-flow-1'));
|
||||
|
||||
expect(onRun).toHaveBeenCalledWith(makeFlow());
|
||||
});
|
||||
|
||||
it('shows the running label and disables Run while busy', () => {
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} busy="run" />
|
||||
);
|
||||
|
||||
const runButton = screen.getByTestId('flow-run-flow-1');
|
||||
expect(runButton).toHaveTextContent('Running…');
|
||||
expect(runButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the toggle while busy=toggle', () => {
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} busy="toggle" />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not render a "View runs" control', () => {
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId('flow-view-runs-flow-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('View runs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* FlowListRow — one saved-flow row on the Workflows list page (issue B5a).
|
||||
*
|
||||
* Mirrors the row layout of `CoreJobList`
|
||||
* (`app/src/components/settings/panels/cron/CoreJobList.tsx`): name + status
|
||||
* badge header, a line of run metadata, then a row of `Button` actions. Swaps
|
||||
* the cron "pause/resume" text button for a `SettingsSwitch` toggle (the
|
||||
* canonical boolean control — see `components/settings/controls`) since
|
||||
* enable/disable here is a persistent setting, not a one-off action.
|
||||
*
|
||||
* No "View runs" action yet: it would only stub-log a `selectedFlowId` with
|
||||
* nothing to show for it until B3b's run inspector lands (tracked as a
|
||||
* commented-out integration point in `FlowsPage.tsx`), so it's a dead button
|
||||
* until then and was pulled rather than shipped as a no-op.
|
||||
*/
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { Flow } from '../../services/api/flowsApi';
|
||||
import SettingsSwitch from '../settings/controls/SettingsSwitch';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
/** Which of this row's actions currently has a request in flight, if any. */
|
||||
export type FlowListRowBusy = 'toggle' | 'run' | null;
|
||||
|
||||
/** Matches `useT()`'s `t` signature (`I18nContextValue['t']` isn't exported). */
|
||||
type TFn = (key: string, fallback?: string) => string;
|
||||
|
||||
export interface FlowListRowProps {
|
||||
flow: Flow;
|
||||
onToggle: (flow: Flow) => void;
|
||||
onRun: (flow: Flow) => void;
|
||||
busy?: FlowListRowBusy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the "last run" line. `t()` doesn't interpolate, so counts are
|
||||
* spliced into the translated template in code (`{count}` placeholder) rather
|
||||
* than templated through raw string concatenation.
|
||||
*/
|
||||
function relativeTime(iso: string, t: TFn): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return t('flows.list.justNow');
|
||||
if (mins < 60) return t('flows.list.minutesAgo').replace('{count}', String(mins));
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return t('flows.list.hoursAgo').replace('{count}', String(hrs));
|
||||
const days = Math.floor(hrs / 24);
|
||||
return t('flows.list.daysAgo').replace('{count}', String(days));
|
||||
}
|
||||
|
||||
/**
|
||||
* `last_status` is rendered as-is (capitalized) rather than mapped through
|
||||
* i18n — the same precedent `CoreJobList` follows for `job.last_status` —
|
||||
* since it's a raw engine-status word, not prose.
|
||||
*/
|
||||
function capitalize(value: string): string {
|
||||
return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
|
||||
}
|
||||
|
||||
const FlowListRow = ({ flow, onToggle, onRun, busy = null }: FlowListRowProps) => {
|
||||
const { t } = useT();
|
||||
const toggleBusy = busy === 'toggle';
|
||||
const runBusy = busy === 'run';
|
||||
|
||||
const lastRunLabel =
|
||||
flow.last_run_at && flow.last_status
|
||||
? `${capitalize(flow.last_status)} · ${relativeTime(flow.last_run_at, t)}`
|
||||
: t('flows.list.neverRun');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`flow-row-${flow.id}`}
|
||||
className="space-y-3 border-t border-line p-4 first:border-t-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-content">{flow.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-content-faint">{lastRunLabel}</div>
|
||||
</div>
|
||||
<span
|
||||
data-testid={`flow-status-${flow.id}`}
|
||||
className={`flex-shrink-0 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase ${
|
||||
flow.enabled
|
||||
? 'border-sage-200 bg-sage-50 text-sage-700 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-300'
|
||||
: 'border-line bg-surface-subtle text-content-secondary'
|
||||
}`}>
|
||||
{flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SettingsSwitch
|
||||
id={`flow-toggle-${flow.id}`}
|
||||
data-testid={`flow-toggle-${flow.id}`}
|
||||
checked={flow.enabled}
|
||||
disabled={toggleBusy}
|
||||
aria-label={t('flows.list.toggleEnabled')}
|
||||
onCheckedChange={() => onToggle(flow)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-testid={`flow-run-${flow.id}`}
|
||||
disabled={runBusy}
|
||||
onClick={() => onRun(flow)}>
|
||||
{runBusy ? t('flows.list.running') : t('flows.list.runNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowListRow;
|
||||
@@ -28,6 +28,7 @@ describe('CollapsedNavRail', () => {
|
||||
'nav.chat',
|
||||
'nav.human',
|
||||
'nav.brain',
|
||||
'nav.flows',
|
||||
'nav.agentWorld',
|
||||
'nav.connections',
|
||||
]) {
|
||||
@@ -83,6 +84,22 @@ describe('CollapsedNavRail', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('marks Workflows active on the /flows list route', () => {
|
||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/flows'] });
|
||||
expect(screen.getByRole('button', { name: 'nav.flows' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks Workflows active on a nested /flows/* sub-route', () => {
|
||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/flows/some-flow-id'] });
|
||||
expect(screen.getByRole('button', { name: 'nav.flows' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a Settings icon that navigates to /settings', () => {
|
||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||
const settings = screen.getByRole('button', { name: 'nav.settings' });
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useHomeNav } from './useHomeNav';
|
||||
function matchActive(path: string, pathname: string): boolean {
|
||||
if (path === '/chat') return pathname.startsWith('/chat');
|
||||
if (path === '/settings') return pathname === '/settings' || pathname.startsWith('/settings/');
|
||||
if (path === '/flows') return pathname === '/flows' || pathname.startsWith('/flows/');
|
||||
if (path === '/home') return pathname === '/home';
|
||||
return pathname === path;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,24 @@ describe('SidebarNav active matching', () => {
|
||||
expect(tabButton('Chat')).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('keeps Workflows active on the /flows list route', () => {
|
||||
renderWithProviders(<SidebarNav />, { initialEntries: ['/flows'] });
|
||||
|
||||
expect(tabButton('Workflows')).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('keeps Workflows active on a nested /flows/* sub-route', () => {
|
||||
renderWithProviders(<SidebarNav />, { initialEntries: ['/flows/some-flow-id'] });
|
||||
|
||||
expect(tabButton('Workflows')).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('does not mark Workflows active on an unrelated route', () => {
|
||||
renderWithProviders(<SidebarNav />, { initialEntries: ['/chat'] });
|
||||
|
||||
expect(tabButton('Workflows')).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('gives the active tab a visible brand-accent fill (not the white sidebar background)', () => {
|
||||
renderWithProviders(<SidebarNav />, { initialEntries: ['/chat'] });
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import { NavIcon } from './navIcons';
|
||||
* - `/agent-world` → the index and every `/agent-world/*` section (it
|
||||
* redirects to `/agent-world/explore`, so an exact match
|
||||
* would never light up)
|
||||
* - `/flows` → the list page and any future `/flows/*` sub-route
|
||||
* (canvas, run detail, …)
|
||||
* - `/home` → exact match (so `/` redirects don't light it up)
|
||||
*/
|
||||
function matchActive(path: string, pathname: string): boolean {
|
||||
@@ -26,6 +28,7 @@ function matchActive(path: string, pathname: string): boolean {
|
||||
if (path === '/settings') return pathname === '/settings' || pathname.startsWith('/settings/');
|
||||
if (path === '/agent-world')
|
||||
return pathname === '/agent-world' || pathname.startsWith('/agent-world/');
|
||||
if (path === '/flows') return pathname === '/flows' || pathname.startsWith('/flows/');
|
||||
if (path === '/home') return pathname === '/home';
|
||||
return pathname === path;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,22 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'flows':
|
||||
// Three connected nodes — a saved automation graph, matching the
|
||||
// Workflows list page's empty-state glyph (FlowsPage.tsx).
|
||||
return (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="5" cy="6" r="2" strokeWidth={1.8} />
|
||||
<circle cx="5" cy="18" r="2" strokeWidth={1.8} />
|
||||
<circle cx="19" cy="12" r="2" strokeWidth={1.8} />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M7 6h4a4 4 0 014 4M7 18h4a4 4 0 004-4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'agent-world':
|
||||
// Globe/network glyph — represents the A2A agent social network.
|
||||
return (
|
||||
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig';
|
||||
|
||||
describe('NAV_TABS', () => {
|
||||
it('has exactly 5 entries', () => {
|
||||
expect(NAV_TABS).toHaveLength(5);
|
||||
it('has exactly 6 entries', () => {
|
||||
expect(NAV_TABS).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('has the correct ids in order', () => {
|
||||
@@ -12,6 +12,7 @@ describe('NAV_TABS', () => {
|
||||
'chat',
|
||||
'human',
|
||||
'brain',
|
||||
'flows',
|
||||
'agent-world',
|
||||
'connections',
|
||||
]);
|
||||
@@ -22,6 +23,7 @@ describe('NAV_TABS', () => {
|
||||
'/chat',
|
||||
'/human',
|
||||
'/brain',
|
||||
'/flows',
|
||||
'/agent-world',
|
||||
'/connections',
|
||||
]);
|
||||
@@ -32,6 +34,7 @@ describe('NAV_TABS', () => {
|
||||
'nav.chat',
|
||||
'nav.human',
|
||||
'nav.brain',
|
||||
'nav.flows',
|
||||
'nav.agentWorld',
|
||||
'nav.connections',
|
||||
]);
|
||||
@@ -42,6 +45,7 @@ describe('NAV_TABS', () => {
|
||||
'tab-chat',
|
||||
'tab-human',
|
||||
'tab-brain',
|
||||
'tab-flows',
|
||||
'tab-agent-world',
|
||||
'tab-connections',
|
||||
]);
|
||||
|
||||
@@ -36,6 +36,7 @@ export const NAV_TABS: NavTab[] = [
|
||||
{ id: 'chat', labelKey: 'nav.chat', path: '/chat', walkthroughAttr: 'tab-chat' },
|
||||
{ id: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' },
|
||||
{ id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' },
|
||||
{ id: 'flows', labelKey: 'nav.flows', path: '/flows', walkthroughAttr: 'tab-flows' },
|
||||
{
|
||||
id: 'agent-world',
|
||||
labelKey: 'nav.agentWorld',
|
||||
|
||||
@@ -164,6 +164,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'لم يتم العثور على ملفات وكلاء',
|
||||
'nav.activity': 'النشاط',
|
||||
'nav.brain': 'الدماغ',
|
||||
'nav.flows': 'سير العمل',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'المحفظة',
|
||||
'agentWorld.description':
|
||||
@@ -3565,6 +3566,28 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'مكتمل',
|
||||
'flowRuns.status.pending_approval': 'بانتظار الموافقة',
|
||||
'flowRuns.status.failed': 'فشل',
|
||||
|
||||
'flows.page.title': 'سير العمل',
|
||||
'flows.page.description': 'أتمتة محفوظة يمكنك تفعيلها وتشغيلها ومتابعتها.',
|
||||
'flows.page.emptyTitle': 'لا توجد عمليات سير عمل بعد',
|
||||
'flows.page.emptyDescription':
|
||||
'ستظهر عمليات سير العمل المحفوظة هنا بمجرد إنشاء واحدة من لوحة الرسم.',
|
||||
'flows.page.loading': 'جارٍ تحميل عمليات سير العمل…',
|
||||
'flows.page.loadError': 'تعذر تحميل عمليات سير العمل. يرجى المحاولة مرة أخرى.',
|
||||
'flows.list.lastRun': 'آخر تشغيل',
|
||||
'flows.list.neverRun': 'لم يتم التشغيل بعد',
|
||||
'flows.list.justNow': 'الآن',
|
||||
'flows.list.minutesAgo': 'منذ {count} دقيقة',
|
||||
'flows.list.hoursAgo': 'منذ {count} ساعة',
|
||||
'flows.list.daysAgo': 'منذ {count} يوم',
|
||||
'flows.list.runNow': 'تشغيل',
|
||||
'flows.list.running': 'جارٍ التشغيل…',
|
||||
'flows.list.viewRuns': 'عرض التشغيلات',
|
||||
'flows.list.toggleEnabled': 'تفعيل سير العمل',
|
||||
'flows.list.enabled': 'مفعّل',
|
||||
'flows.list.paused': 'متوقف مؤقتًا',
|
||||
'flows.list.runStarted': 'بدأ تشغيل سير العمل',
|
||||
|
||||
'oauth.button.connecting': 'جارٍ الاتصال...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'انتهت مهلة تسجيل الدخول — لم يكتمل المتصفح إعادة توجيه OAuth. يرجى المحاولة مرة أخرى.',
|
||||
|
||||
@@ -169,6 +169,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'কোনো এজেন্ট প্রোফাইল পাওয়া যায়নি',
|
||||
'nav.activity': 'কার্যকলাপ',
|
||||
'nav.brain': 'ব্রেইন',
|
||||
'nav.flows': 'ওয়ার্কফ্লো',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'ওয়ালেট',
|
||||
'agentWorld.description':
|
||||
@@ -3646,6 +3647,28 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'সম্পন্ন',
|
||||
'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়',
|
||||
'flowRuns.status.failed': 'ব্যর্থ',
|
||||
|
||||
'flows.page.title': 'ওয়ার্কফ্লো',
|
||||
'flows.page.description': 'সংরক্ষিত অটোমেশন যা আপনি সক্ষম, চালাতে এবং পর্যবেক্ষণ করতে পারেন।',
|
||||
'flows.page.emptyTitle': 'এখনো কোনো ওয়ার্কফ্লো নেই',
|
||||
'flows.page.emptyDescription':
|
||||
'ক্যানভাস থেকে একটি তৈরি করলে সংরক্ষিত ওয়ার্কফ্লোগুলো এখানে দেখা যাবে।',
|
||||
'flows.page.loading': 'ওয়ার্কফ্লো লোড হচ্ছে…',
|
||||
'flows.page.loadError': 'ওয়ার্কফ্লো লোড করা যায়নি। আবার চেষ্টা করুন।',
|
||||
'flows.list.lastRun': 'সর্বশেষ চালানো',
|
||||
'flows.list.neverRun': 'কখনো চালানো হয়নি',
|
||||
'flows.list.justNow': 'এইমাত্র',
|
||||
'flows.list.minutesAgo': '{count} মিনিট আগে',
|
||||
'flows.list.hoursAgo': '{count} ঘণ্টা আগে',
|
||||
'flows.list.daysAgo': '{count} দিন আগে',
|
||||
'flows.list.runNow': 'চালান',
|
||||
'flows.list.running': 'চলছে…',
|
||||
'flows.list.viewRuns': 'রান দেখুন',
|
||||
'flows.list.toggleEnabled': 'ওয়ার্কফ্লো সক্ষম করুন',
|
||||
'flows.list.enabled': 'সক্ষম',
|
||||
'flows.list.paused': 'বিরতি দেওয়া',
|
||||
'flows.list.runStarted': 'ওয়ার্কফ্লো শুরু হয়েছে',
|
||||
|
||||
'oauth.button.connecting': 'সংযোগ হচ্ছে...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'সাইন-ইন টাইম আউট হয়েছে — ব্রাউজার OAuth পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।',
|
||||
|
||||
@@ -174,6 +174,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'Keine Agentenprofile gefunden',
|
||||
'nav.activity': 'Aktivität',
|
||||
'nav.brain': 'Gehirn',
|
||||
'nav.flows': 'Workflows',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Wallet',
|
||||
'agentWorld.description':
|
||||
@@ -3735,6 +3736,29 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Abgeschlossen',
|
||||
'flowRuns.status.pending_approval': 'Wartet auf Genehmigung',
|
||||
'flowRuns.status.failed': 'Fehlgeschlagen',
|
||||
|
||||
'flows.page.title': 'Workflows',
|
||||
'flows.page.description':
|
||||
'Gespeicherte Automatisierungen, die du aktivieren, ausführen und überwachen kannst.',
|
||||
'flows.page.emptyTitle': 'Noch keine Workflows',
|
||||
'flows.page.emptyDescription':
|
||||
'Gespeicherte Workflows erscheinen hier, sobald du einen im Canvas erstellst.',
|
||||
'flows.page.loading': 'Workflows werden geladen…',
|
||||
'flows.page.loadError': 'Workflows konnten nicht geladen werden. Bitte versuche es erneut.',
|
||||
'flows.list.lastRun': 'Letzter Lauf',
|
||||
'flows.list.neverRun': 'Noch nie ausgeführt',
|
||||
'flows.list.justNow': 'Gerade eben',
|
||||
'flows.list.minutesAgo': 'vor {count} Min.',
|
||||
'flows.list.hoursAgo': 'vor {count} Std.',
|
||||
'flows.list.daysAgo': 'vor {count} Tagen',
|
||||
'flows.list.runNow': 'Ausführen',
|
||||
'flows.list.running': 'Läuft…',
|
||||
'flows.list.viewRuns': 'Läufe anzeigen',
|
||||
'flows.list.toggleEnabled': 'Workflow aktivieren',
|
||||
'flows.list.enabled': 'Aktiviert',
|
||||
'flows.list.paused': 'Pausiert',
|
||||
'flows.list.runStarted': 'Workflow gestartet',
|
||||
|
||||
'oauth.button.connecting': 'Verbinden...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Anmeldung abgelaufen — der Browser hat die OAuth-Weiterleitung nicht abgeschlossen. Bitte versuche es erneut.',
|
||||
|
||||
@@ -25,6 +25,7 @@ const en: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'No agent profiles found',
|
||||
'nav.activity': 'Activity',
|
||||
'nav.brain': 'Brain',
|
||||
'nav.flows': 'Workflows',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Wallet',
|
||||
// Agent World section sub-navigation labels
|
||||
@@ -4285,6 +4286,33 @@ const en: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Completed',
|
||||
'flowRuns.status.pending_approval': 'Awaiting approval',
|
||||
'flowRuns.status.failed': 'Failed',
|
||||
|
||||
// ── Workflows list page + nav tab (B5a) — the `flows::` domain's
|
||||
// discoverable hub at /flows. Distinct from the legacy SKILL.md
|
||||
// "workflows.*" namespace above and from B3b's "flowRuns.*" / B4's
|
||||
// "chat.flowProposal.*" namespaces (kept apart here to avoid merge
|
||||
// conflicts with those in-flight branches).
|
||||
'flows.page.title': 'Workflows',
|
||||
'flows.page.description': 'Saved automations you can enable, run, and monitor.',
|
||||
'flows.page.emptyTitle': 'No workflows yet',
|
||||
'flows.page.emptyDescription':
|
||||
'Saved workflows will show up here once you create one from the canvas.',
|
||||
'flows.page.loading': 'Loading workflows…',
|
||||
'flows.page.loadError': 'Could not load workflows. Please try again.',
|
||||
'flows.list.lastRun': 'Last run',
|
||||
'flows.list.neverRun': 'Never run',
|
||||
'flows.list.justNow': 'Just now',
|
||||
'flows.list.minutesAgo': '{count}m ago',
|
||||
'flows.list.hoursAgo': '{count}h ago',
|
||||
'flows.list.daysAgo': '{count}d ago',
|
||||
'flows.list.runNow': 'Run',
|
||||
'flows.list.running': 'Running…',
|
||||
'flows.list.viewRuns': 'View runs',
|
||||
'flows.list.toggleEnabled': 'Enable workflow',
|
||||
'flows.list.enabled': 'Enabled',
|
||||
'flows.list.paused': 'Paused',
|
||||
'flows.list.runStarted': 'Workflow started',
|
||||
|
||||
'oauth.button.connecting': 'Connecting...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.',
|
||||
|
||||
@@ -171,6 +171,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'No se encontraron perfiles de agente',
|
||||
'nav.activity': 'Actividad',
|
||||
'nav.brain': 'Cerebro',
|
||||
'nav.flows': 'Flujos de trabajo',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Cartera',
|
||||
'agentWorld.description':
|
||||
@@ -3709,6 +3710,29 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Completado',
|
||||
'flowRuns.status.pending_approval': 'Esperando aprobación',
|
||||
'flowRuns.status.failed': 'Fallido',
|
||||
|
||||
'flows.page.title': 'Flujos de trabajo',
|
||||
'flows.page.description':
|
||||
'Automatizaciones guardadas que puedes habilitar, ejecutar y supervisar.',
|
||||
'flows.page.emptyTitle': 'Aún no hay flujos de trabajo',
|
||||
'flows.page.emptyDescription':
|
||||
'Los flujos de trabajo guardados aparecerán aquí en cuanto crees uno desde el lienzo.',
|
||||
'flows.page.loading': 'Cargando flujos de trabajo…',
|
||||
'flows.page.loadError': 'No se pudieron cargar los flujos de trabajo. Inténtalo de nuevo.',
|
||||
'flows.list.lastRun': 'Última ejecución',
|
||||
'flows.list.neverRun': 'Nunca ejecutado',
|
||||
'flows.list.justNow': 'Justo ahora',
|
||||
'flows.list.minutesAgo': 'hace {count} min',
|
||||
'flows.list.hoursAgo': 'hace {count} h',
|
||||
'flows.list.daysAgo': 'hace {count} d',
|
||||
'flows.list.runNow': 'Ejecutar',
|
||||
'flows.list.running': 'Ejecutando…',
|
||||
'flows.list.viewRuns': 'Ver ejecuciones',
|
||||
'flows.list.toggleEnabled': 'Habilitar flujo de trabajo',
|
||||
'flows.list.enabled': 'Habilitado',
|
||||
'flows.list.paused': 'Pausado',
|
||||
'flows.list.runStarted': 'Flujo de trabajo iniciado',
|
||||
|
||||
'oauth.button.connecting': 'Conectando...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'El inicio de sesión expiró — el navegador no completó la redirección OAuth. Por favor, inténtalo de nuevo.',
|
||||
|
||||
@@ -171,6 +171,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': "Aucun profil d'agent trouvé",
|
||||
'nav.activity': 'Activité',
|
||||
'nav.brain': 'Cerveau',
|
||||
'nav.flows': 'Workflows',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Portefeuille',
|
||||
'agentWorld.description':
|
||||
@@ -3724,6 +3725,29 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Terminé',
|
||||
'flowRuns.status.pending_approval': "En attente d'approbation",
|
||||
'flowRuns.status.failed': 'Échoué',
|
||||
|
||||
'flows.page.title': 'Workflows',
|
||||
'flows.page.description':
|
||||
'Automatisations enregistrées que vous pouvez activer, exécuter et surveiller.',
|
||||
'flows.page.emptyTitle': "Aucun workflow pour l'instant",
|
||||
'flows.page.emptyDescription':
|
||||
'Les workflows enregistrés apparaîtront ici dès que vous en créerez un depuis le canevas.',
|
||||
'flows.page.loading': 'Chargement des workflows…',
|
||||
'flows.page.loadError': 'Impossible de charger les workflows. Veuillez réessayer.',
|
||||
'flows.list.lastRun': 'Dernière exécution',
|
||||
'flows.list.neverRun': 'Jamais exécuté',
|
||||
'flows.list.justNow': "À l'instant",
|
||||
'flows.list.minutesAgo': 'il y a {count} min',
|
||||
'flows.list.hoursAgo': 'il y a {count} h',
|
||||
'flows.list.daysAgo': 'il y a {count} j',
|
||||
'flows.list.runNow': 'Exécuter',
|
||||
'flows.list.running': 'Exécution…',
|
||||
'flows.list.viewRuns': 'Voir les exécutions',
|
||||
'flows.list.toggleEnabled': 'Activer le workflow',
|
||||
'flows.list.enabled': 'Activé',
|
||||
'flows.list.paused': 'En pause',
|
||||
'flows.list.runStarted': 'Workflow démarré',
|
||||
|
||||
'oauth.button.connecting': 'Connexion en cours…',
|
||||
'oauth.button.loopbackTimeout':
|
||||
"La connexion a expiré — le navigateur n'a pas complété la redirection OAuth. Veuillez réessayer.",
|
||||
|
||||
@@ -168,6 +168,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'कोई एजेंट प्रोफाइल नहीं मिला',
|
||||
'nav.activity': 'गतिविधि',
|
||||
'nav.brain': 'ब्रेन',
|
||||
'nav.flows': 'वर्कफ़्लो',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'वॉलेट',
|
||||
'agentWorld.description':
|
||||
@@ -3647,6 +3648,27 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'पूर्ण',
|
||||
'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में',
|
||||
'flowRuns.status.failed': 'विफल',
|
||||
|
||||
'flows.page.title': 'वर्कफ़्लो',
|
||||
'flows.page.description': 'सहेजे गए ऑटोमेशन जिन्हें आप सक्षम, चला और मॉनिटर कर सकते हैं।',
|
||||
'flows.page.emptyTitle': 'अभी तक कोई वर्कफ़्लो नहीं',
|
||||
'flows.page.emptyDescription': 'कैनवास से एक बनाने के बाद सहेजे गए वर्कफ़्लो यहां दिखाई देंगे।',
|
||||
'flows.page.loading': 'वर्कफ़्लो लोड हो रहे हैं…',
|
||||
'flows.page.loadError': 'वर्कफ़्लो लोड नहीं हो सके। कृपया फिर से प्रयास करें।',
|
||||
'flows.list.lastRun': 'अंतिम रन',
|
||||
'flows.list.neverRun': 'कभी नहीं चला',
|
||||
'flows.list.justNow': 'अभी अभी',
|
||||
'flows.list.minutesAgo': '{count} मिनट पहले',
|
||||
'flows.list.hoursAgo': '{count} घंटे पहले',
|
||||
'flows.list.daysAgo': '{count} दिन पहले',
|
||||
'flows.list.runNow': 'चलाएं',
|
||||
'flows.list.running': 'चल रहा है…',
|
||||
'flows.list.viewRuns': 'रन देखें',
|
||||
'flows.list.toggleEnabled': 'वर्कफ़्लो सक्षम करें',
|
||||
'flows.list.enabled': 'सक्षम',
|
||||
'flows.list.paused': 'रोका गया',
|
||||
'flows.list.runStarted': 'वर्कफ़्लो शुरू हुआ',
|
||||
|
||||
'oauth.button.connecting': 'कनेक्ट हो रहा है...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'साइन-इन का समय समाप्त हो गया — ब्राउज़र ने OAuth पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।',
|
||||
|
||||
@@ -169,6 +169,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'Profil agen tidak ditemukan',
|
||||
'nav.activity': 'Aktivitas',
|
||||
'nav.brain': 'Otak',
|
||||
'nav.flows': 'Alur Kerja',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Dompet',
|
||||
'agentWorld.description':
|
||||
@@ -3654,6 +3655,28 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Selesai',
|
||||
'flowRuns.status.pending_approval': 'Menunggu persetujuan',
|
||||
'flowRuns.status.failed': 'Gagal',
|
||||
|
||||
'flows.page.title': 'Alur Kerja',
|
||||
'flows.page.description': 'Otomatisasi tersimpan yang dapat Anda aktifkan, jalankan, dan pantau.',
|
||||
'flows.page.emptyTitle': 'Belum ada alur kerja',
|
||||
'flows.page.emptyDescription':
|
||||
'Alur kerja tersimpan akan muncul di sini setelah Anda membuat satu dari kanvas.',
|
||||
'flows.page.loading': 'Memuat alur kerja…',
|
||||
'flows.page.loadError': 'Alur kerja gagal dimuat. Silakan coba lagi.',
|
||||
'flows.list.lastRun': 'Terakhir dijalankan',
|
||||
'flows.list.neverRun': 'Belum pernah dijalankan',
|
||||
'flows.list.justNow': 'Baru saja',
|
||||
'flows.list.minutesAgo': '{count} menit lalu',
|
||||
'flows.list.hoursAgo': '{count} jam lalu',
|
||||
'flows.list.daysAgo': '{count} hari lalu',
|
||||
'flows.list.runNow': 'Jalankan',
|
||||
'flows.list.running': 'Sedang berjalan…',
|
||||
'flows.list.viewRuns': 'Lihat riwayat',
|
||||
'flows.list.toggleEnabled': 'Aktifkan alur kerja',
|
||||
'flows.list.enabled': 'Aktif',
|
||||
'flows.list.paused': 'Dijeda',
|
||||
'flows.list.runStarted': 'Alur kerja dimulai',
|
||||
|
||||
'oauth.button.connecting': 'Menghubungkan...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Masuk habis waktu — browser tidak menyelesaikan pengalihan OAuth. Silakan coba lagi.',
|
||||
|
||||
@@ -171,6 +171,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'Nessun profilo agente trovato',
|
||||
'nav.activity': 'Attività',
|
||||
'nav.brain': 'Cervello',
|
||||
'nav.flows': 'Flussi di lavoro',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Portafoglio',
|
||||
'agentWorld.description':
|
||||
@@ -3704,6 +3705,28 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Completato',
|
||||
'flowRuns.status.pending_approval': 'In attesa di approvazione',
|
||||
'flowRuns.status.failed': 'Non riuscito',
|
||||
|
||||
'flows.page.title': 'Flussi di lavoro',
|
||||
'flows.page.description': 'Automazioni salvate che puoi abilitare, eseguire e monitorare.',
|
||||
'flows.page.emptyTitle': 'Ancora nessun flusso di lavoro',
|
||||
'flows.page.emptyDescription':
|
||||
'I flussi di lavoro salvati appariranno qui non appena ne crei uno dalla lavagna.',
|
||||
'flows.page.loading': 'Caricamento dei flussi di lavoro…',
|
||||
'flows.page.loadError': 'Impossibile caricare i flussi di lavoro. Riprova.',
|
||||
'flows.list.lastRun': 'Ultima esecuzione',
|
||||
'flows.list.neverRun': 'Mai eseguito',
|
||||
'flows.list.justNow': 'Proprio ora',
|
||||
'flows.list.minutesAgo': '{count} min fa',
|
||||
'flows.list.hoursAgo': '{count} h fa',
|
||||
'flows.list.daysAgo': '{count} g fa',
|
||||
'flows.list.runNow': 'Esegui',
|
||||
'flows.list.running': 'In esecuzione…',
|
||||
'flows.list.viewRuns': 'Visualizza esecuzioni',
|
||||
'flows.list.toggleEnabled': 'Abilita flusso di lavoro',
|
||||
'flows.list.enabled': 'Abilitato',
|
||||
'flows.list.paused': 'In pausa',
|
||||
'flows.list.runStarted': 'Flusso di lavoro avviato',
|
||||
|
||||
'oauth.button.connecting': 'Connessione...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Accesso scaduto — il browser non ha completato il reindirizzamento OAuth. Riprova.',
|
||||
|
||||
@@ -165,6 +165,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': '에이전트 프로필을 찾을 수 없습니다',
|
||||
'nav.activity': '활동',
|
||||
'nav.brain': '브레인',
|
||||
'nav.flows': '워크플로',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': '지갑',
|
||||
'agentWorld.description':
|
||||
@@ -3610,6 +3611,27 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': '완료됨',
|
||||
'flowRuns.status.pending_approval': '승인 대기 중',
|
||||
'flowRuns.status.failed': '실패',
|
||||
|
||||
'flows.page.title': '워크플로',
|
||||
'flows.page.description': '활성화, 실행, 모니터링할 수 있는 저장된 자동화입니다.',
|
||||
'flows.page.emptyTitle': '아직 워크플로가 없습니다',
|
||||
'flows.page.emptyDescription': '캔버스에서 워크플로를 만들면 여기에 표시됩니다.',
|
||||
'flows.page.loading': '워크플로 로드 중…',
|
||||
'flows.page.loadError': '워크플로를 불러올 수 없습니다. 다시 시도해 주세요.',
|
||||
'flows.list.lastRun': '마지막 실행',
|
||||
'flows.list.neverRun': '실행된 적 없음',
|
||||
'flows.list.justNow': '방금',
|
||||
'flows.list.minutesAgo': '{count}분 전',
|
||||
'flows.list.hoursAgo': '{count}시간 전',
|
||||
'flows.list.daysAgo': '{count}일 전',
|
||||
'flows.list.runNow': '실행',
|
||||
'flows.list.running': '실행 중…',
|
||||
'flows.list.viewRuns': '실행 내역 보기',
|
||||
'flows.list.toggleEnabled': '워크플로 활성화',
|
||||
'flows.list.enabled': '활성화됨',
|
||||
'flows.list.paused': '일시 중지됨',
|
||||
'flows.list.runStarted': '워크플로가 시작되었습니다',
|
||||
|
||||
'oauth.button.connecting': '연결 중...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'로그인 시간 초과 — 브라우저가 OAuth 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
|
||||
@@ -173,6 +173,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'Nie znaleziono profili agentów',
|
||||
'nav.activity': 'Aktywność',
|
||||
'nav.brain': 'Mózg',
|
||||
'nav.flows': 'Przepływy pracy',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Portfel',
|
||||
'agentWorld.description':
|
||||
@@ -3690,6 +3691,29 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Zakończono',
|
||||
'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie',
|
||||
'flowRuns.status.failed': 'Niepowodzenie',
|
||||
|
||||
'flows.page.title': 'Przepływy pracy',
|
||||
'flows.page.description':
|
||||
'Zapisane automatyzacje, które możesz włączyć, uruchomić i monitorować.',
|
||||
'flows.page.emptyTitle': 'Brak przepływów pracy',
|
||||
'flows.page.emptyDescription':
|
||||
'Zapisane przepływy pracy pojawią się tutaj, gdy utworzysz jeden na płótnie.',
|
||||
'flows.page.loading': 'Ładowanie przepływów pracy…',
|
||||
'flows.page.loadError': 'Nie udało się załadować przepływów pracy. Spróbuj ponownie.',
|
||||
'flows.list.lastRun': 'Ostatnie uruchomienie',
|
||||
'flows.list.neverRun': 'Nigdy nie uruchomiono',
|
||||
'flows.list.justNow': 'Przed chwilą',
|
||||
'flows.list.minutesAgo': '{count} min temu',
|
||||
'flows.list.hoursAgo': '{count} godz. temu',
|
||||
'flows.list.daysAgo': '{count} dni temu',
|
||||
'flows.list.runNow': 'Uruchom',
|
||||
'flows.list.running': 'Uruchamianie…',
|
||||
'flows.list.viewRuns': 'Zobacz uruchomienia',
|
||||
'flows.list.toggleEnabled': 'Włącz przepływ pracy',
|
||||
'flows.list.enabled': 'Włączony',
|
||||
'flows.list.paused': 'Wstrzymany',
|
||||
'flows.list.runStarted': 'Przepływ pracy uruchomiony',
|
||||
|
||||
'oauth.button.connecting': 'Łączenie...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Logowanie przekroczyło limit czasu — przeglądarka nie ukończyła przekierowania OAuth. Spróbuj ponownie.',
|
||||
|
||||
@@ -170,6 +170,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'Nenhum perfil de agente encontrado',
|
||||
'nav.activity': 'Atividade',
|
||||
'nav.brain': 'Cérebro',
|
||||
'nav.flows': 'Fluxos de trabalho',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Carteira',
|
||||
'agentWorld.description':
|
||||
@@ -3705,6 +3706,28 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Concluído',
|
||||
'flowRuns.status.pending_approval': 'Aguardando aprovação',
|
||||
'flowRuns.status.failed': 'Falhou',
|
||||
|
||||
'flows.page.title': 'Fluxos de trabalho',
|
||||
'flows.page.description': 'Automações salvas que você pode habilitar, executar e monitorar.',
|
||||
'flows.page.emptyTitle': 'Ainda não há fluxos de trabalho',
|
||||
'flows.page.emptyDescription':
|
||||
'Os fluxos de trabalho salvos aparecerão aqui assim que você criar um a partir do canvas.',
|
||||
'flows.page.loading': 'Carregando fluxos de trabalho…',
|
||||
'flows.page.loadError': 'Não foi possível carregar os fluxos de trabalho. Tente novamente.',
|
||||
'flows.list.lastRun': 'Última execução',
|
||||
'flows.list.neverRun': 'Nunca executado',
|
||||
'flows.list.justNow': 'Agora mesmo',
|
||||
'flows.list.minutesAgo': 'há {count} min',
|
||||
'flows.list.hoursAgo': 'há {count} h',
|
||||
'flows.list.daysAgo': 'há {count} d',
|
||||
'flows.list.runNow': 'Executar',
|
||||
'flows.list.running': 'Executando…',
|
||||
'flows.list.viewRuns': 'Ver execuções',
|
||||
'flows.list.toggleEnabled': 'Habilitar fluxo de trabalho',
|
||||
'flows.list.enabled': 'Habilitado',
|
||||
'flows.list.paused': 'Pausado',
|
||||
'flows.list.runStarted': 'Fluxo de trabalho iniciado',
|
||||
|
||||
'oauth.button.connecting': 'Conectando...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Login expirou — o navegador não concluiu o redirecionamento OAuth. Por favor, tente novamente.',
|
||||
|
||||
@@ -173,6 +173,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': 'Профили агентов не найдены',
|
||||
'nav.activity': 'Активность',
|
||||
'nav.brain': 'Мозг',
|
||||
'nav.flows': 'Рабочие процессы',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': 'Кошелёк',
|
||||
'agentWorld.description':
|
||||
@@ -3679,6 +3680,29 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': 'Завершено',
|
||||
'flowRuns.status.pending_approval': 'Ожидает подтверждения',
|
||||
'flowRuns.status.failed': 'Не удалось',
|
||||
|
||||
'flows.page.title': 'Рабочие процессы',
|
||||
'flows.page.description':
|
||||
'Сохранённые автоматизации, которые можно включать, запускать и отслеживать.',
|
||||
'flows.page.emptyTitle': 'Пока нет рабочих процессов',
|
||||
'flows.page.emptyDescription':
|
||||
'Сохранённые рабочие процессы появятся здесь, как только вы создадите один на холсте.',
|
||||
'flows.page.loading': 'Загрузка рабочих процессов…',
|
||||
'flows.page.loadError': 'Не удалось загрузить рабочие процессы. Попробуйте снова.',
|
||||
'flows.list.lastRun': 'Последний запуск',
|
||||
'flows.list.neverRun': 'Ещё не запускался',
|
||||
'flows.list.justNow': 'Только что',
|
||||
'flows.list.minutesAgo': '{count} мин назад',
|
||||
'flows.list.hoursAgo': '{count} ч назад',
|
||||
'flows.list.daysAgo': '{count} дн назад',
|
||||
'flows.list.runNow': 'Запустить',
|
||||
'flows.list.running': 'Выполняется…',
|
||||
'flows.list.viewRuns': 'Просмотреть запуски',
|
||||
'flows.list.toggleEnabled': 'Включить рабочий процесс',
|
||||
'flows.list.enabled': 'Включён',
|
||||
'flows.list.paused': 'Приостановлен',
|
||||
'flows.list.runStarted': 'Рабочий процесс запущен',
|
||||
|
||||
'oauth.button.connecting': 'Подключение...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Время входа истекло — браузер не завершил перенаправление OAuth. Пожалуйста, попробуйте снова.',
|
||||
|
||||
@@ -153,6 +153,7 @@ const messages: TranslationMap = {
|
||||
'nav.noAgentProfiles': '未找到代理档案',
|
||||
'nav.activity': '动态',
|
||||
'nav.brain': '大脑',
|
||||
'nav.flows': '工作流',
|
||||
'nav.agentWorld': 'Tiny Place',
|
||||
'nav.wallet': '钱包',
|
||||
'agentWorld.description':
|
||||
@@ -3456,6 +3457,27 @@ const messages: TranslationMap = {
|
||||
'flowRuns.status.completed': '已完成',
|
||||
'flowRuns.status.pending_approval': '等待批准',
|
||||
'flowRuns.status.failed': '失败',
|
||||
|
||||
'flows.page.title': '工作流',
|
||||
'flows.page.description': '已保存的自动化流程,可启用、运行并监控。',
|
||||
'flows.page.emptyTitle': '还没有工作流',
|
||||
'flows.page.emptyDescription': '在画布中创建工作流后,将显示在此处。',
|
||||
'flows.page.loading': '正在加载工作流…',
|
||||
'flows.page.loadError': '无法加载工作流,请重试。',
|
||||
'flows.list.lastRun': '上次运行',
|
||||
'flows.list.neverRun': '从未运行',
|
||||
'flows.list.justNow': '刚刚',
|
||||
'flows.list.minutesAgo': '{count}分钟前',
|
||||
'flows.list.hoursAgo': '{count}小时前',
|
||||
'flows.list.daysAgo': '{count}天前',
|
||||
'flows.list.runNow': '运行',
|
||||
'flows.list.running': '运行中…',
|
||||
'flows.list.viewRuns': '查看运行记录',
|
||||
'flows.list.toggleEnabled': '启用工作流',
|
||||
'flows.list.enabled': '已启用',
|
||||
'flows.list.paused': '已暂停',
|
||||
'flows.list.runStarted': '工作流已启动',
|
||||
|
||||
'oauth.button.connecting': '连接中...',
|
||||
'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。',
|
||||
'oauth.login.continueWith': '继续使用',
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* FlowsPage (issue B5a) — the Workflows list page. Asserts the
|
||||
* loading/empty/error/list states, that toggling a flow calls
|
||||
* `setFlowEnabled` and refreshes the row, and that Run fires `runFlow`,
|
||||
* shows a "Workflow started" toast, and refetches the list.
|
||||
*/
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Flow } from '../services/api/flowsApi';
|
||||
import { renderWithProviders } from '../test/test-utils';
|
||||
import FlowsPage from './FlowsPage';
|
||||
|
||||
const listFlows = vi.hoisted(() => vi.fn());
|
||||
const setFlowEnabled = vi.hoisted(() => vi.fn());
|
||||
const runFlow = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../services/api/flowsApi', () => ({ listFlows, setFlowEnabled, runFlow }));
|
||||
|
||||
function makeFlow(overrides: Partial<Flow> = {}): Flow {
|
||||
return {
|
||||
id: 'flow-1',
|
||||
name: 'Daily digest',
|
||||
enabled: true,
|
||||
graph: { nodes: [], edges: [] },
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
last_run_at: null,
|
||||
last_status: null,
|
||||
require_approval: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FlowsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows a loading state while flows are being fetched', () => {
|
||||
listFlows.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
expect(screen.getByText('Loading workflows…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no saved flows', async () => {
|
||||
listFlows.mockResolvedValue([]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No workflows yet')).toBeInTheDocument());
|
||||
// The empty state omits a "Create" action (canvas ships in B5b).
|
||||
expect(screen.queryByRole('button', { name: /create/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error banner when the fetch fails', async () => {
|
||||
listFlows.mockRejectedValue(new Error('core unreachable'));
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Could not load workflows. Please try again.')).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('renders one row per saved flow', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow(), makeFlow({ id: 'flow-2', name: 'Weekly report' })]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Daily digest')).toBeInTheDocument());
|
||||
expect(screen.getByText('Weekly report')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles a flow via setFlowEnabled and reflects the updated state', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow({ enabled: true })]);
|
||||
setFlowEnabled.mockResolvedValue(makeFlow({ enabled: false }));
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('flow-toggle-flow-1')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('flow-toggle-flow-1'));
|
||||
|
||||
expect(setFlowEnabled).toHaveBeenCalledWith('flow-1', false);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused')
|
||||
);
|
||||
});
|
||||
|
||||
it('runs a flow, shows a "Workflow started" toast, and refetches the list', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow()]);
|
||||
runFlow.mockResolvedValue({ output: null, pending_approvals: [], thread_id: 't1' });
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('flow-run-flow-1')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('flow-run-flow-1'));
|
||||
|
||||
expect(runFlow).toHaveBeenCalledWith('flow-1');
|
||||
await waitFor(() => expect(screen.getByText('Workflow started')).toBeInTheDocument());
|
||||
// Loaded once on mount, once more on refetch after the run kicks off.
|
||||
await waitFor(() => expect(listFlows).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('shows an error banner (without a toast) when runFlow rejects', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow()]);
|
||||
runFlow.mockRejectedValue(new Error('flow disabled'));
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('flow-run-flow-1')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('flow-run-flow-1'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('flow disabled')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Workflow started')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* FlowsPage — the Workflows list page (issue B5a).
|
||||
*
|
||||
* The discoverable hub for the `flows::` domain: lists every saved
|
||||
* `Flow` (name, enabled toggle, last-run status, Run button). This is NOT the
|
||||
* canvas (B5b ships flow authoring/editing) and NOT the chat agent-proposal
|
||||
* surface (B4) — just the top-level `/flows` list, reached via the
|
||||
* "Workflows" nav tab (see `config/navConfig.ts`).
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import EmptyStateCard from '../components/EmptyStateCard';
|
||||
import FlowListRow, { type FlowListRowBusy } from '../components/flows/FlowListRow';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { type Flow, listFlows, runFlow, setFlowEnabled } from '../services/api/flowsApi';
|
||||
import type { ToastNotification } from '../types/intelligence';
|
||||
|
||||
const log = createDebug('app:flows');
|
||||
|
||||
/** Which single row + action currently has a request in flight, if any. */
|
||||
type BusyKey = `toggle:${string}` | `run:${string}`;
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export default function FlowsPage() {
|
||||
const { t } = useT();
|
||||
const [flows, setFlows] = useState<Flow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<BusyKey | null>(null);
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
|
||||
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
|
||||
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
|
||||
}, []);
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(item => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const loadFlows = useCallback(async () => {
|
||||
log('loading flows');
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await listFlows();
|
||||
setFlows(result);
|
||||
log('loaded %d flows', result.length);
|
||||
} catch (err) {
|
||||
log('load failed: %o', err);
|
||||
setError(t('flows.page.loadError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFlows();
|
||||
}, [loadFlows]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (flow: Flow) => {
|
||||
if (busyKey) return;
|
||||
const key: BusyKey = `toggle:${flow.id}`;
|
||||
setBusyKey(key);
|
||||
setError(null);
|
||||
log('toggle: id=%s next=%s', flow.id, !flow.enabled);
|
||||
try {
|
||||
const updated = await setFlowEnabled(flow.id, !flow.enabled);
|
||||
setFlows(prev => prev.map(f => (f.id === updated.id ? updated : f)));
|
||||
} catch (err) {
|
||||
log('toggle failed: id=%s err=%o', flow.id, err);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
},
|
||||
[busyKey]
|
||||
);
|
||||
|
||||
const handleRun = useCallback(
|
||||
async (flow: Flow) => {
|
||||
if (busyKey) return;
|
||||
const key: BusyKey = `run:${flow.id}`;
|
||||
setBusyKey(key);
|
||||
setError(null);
|
||||
log('run: id=%s', flow.id);
|
||||
try {
|
||||
// Fire-and-forget: the caller doesn't wait for the run to finish,
|
||||
// just that it kicked off. The refetch below picks up the refreshed
|
||||
// `last_run_at` / `last_status` once the engine settles (or, for a
|
||||
// still-running flow, on the next manual refresh). Only refetch on
|
||||
// success — `loadFlows()` clears `error`, which would otherwise wipe
|
||||
// the failure banner set in the `catch` below.
|
||||
await runFlow(flow.id);
|
||||
addToast({ type: 'success', title: t('flows.list.runStarted') });
|
||||
await loadFlows();
|
||||
} catch (err) {
|
||||
log('run failed: id=%s err=%o', flow.id, err);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
},
|
||||
[busyKey, addToast, loadFlows, t]
|
||||
);
|
||||
|
||||
const busyFor = (flow: Flow): FlowListRowBusy => {
|
||||
if (busyKey === `toggle:${flow.id}`) return 'toggle';
|
||||
if (busyKey === `run:${flow.id}`) return 'run';
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelPage
|
||||
testId="flows-page"
|
||||
title={t('flows.page.title')}
|
||||
description={t('flows.page.description')}>
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||
{error && (
|
||||
<div data-testid="flows-error">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <CenteredLoadingState label={t('flows.page.loading')} />}
|
||||
|
||||
{!loading && flows.length === 0 && !error && (
|
||||
<EmptyStateCard
|
||||
icon={
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}>
|
||||
<circle cx="5" cy="6" r="2" />
|
||||
<circle cx="5" cy="18" r="2" />
|
||||
<circle cx="19" cy="12" r="2" />
|
||||
<path strokeLinecap="round" d="M7 6h4a4 4 0 014 4M7 18h4a4 4 0 004-4" />
|
||||
</svg>
|
||||
}
|
||||
title={t('flows.page.emptyTitle')}
|
||||
description={t('flows.page.emptyDescription')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && flows.length > 0 && (
|
||||
<div
|
||||
data-testid="flows-list"
|
||||
className="overflow-hidden rounded-2xl border border-line bg-surface">
|
||||
{flows.map(flow => (
|
||||
<FlowListRow
|
||||
key={flow.id}
|
||||
flow={flow}
|
||||
busy={busyFor(flow)}
|
||||
onToggle={f => void handleToggle(f)}
|
||||
onRun={f => void handleRun(f)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === B3b integration (wire after PR #4450 merges) ===
|
||||
"View runs" was pulled from `FlowListRow` for now — it would only
|
||||
store a `selectedFlowId` with nothing to show for it until the run
|
||||
inspector lands, which reads as a dead button. Once #4450 merges,
|
||||
re-add here as: track `selectedFlowId` state, list the flow's runs
|
||||
via listFlowRuns(flowId), and open the inspector
|
||||
(FlowRunInspectorDrawer, keyed by RUN id / thread_id, NOT flowId)
|
||||
for a chosen run:
|
||||
{selectedFlowId && (
|
||||
<FlowRunInspectorRunsForFlow
|
||||
flowId={selectedFlowId}
|
||||
onClose={() => setSelectedFlowId(null)}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||
</PanelPage>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getFlowRun, listFlowRuns, resumeFlow } from './flowsApi';
|
||||
import {
|
||||
getFlowRun,
|
||||
listFlowRuns,
|
||||
listFlows,
|
||||
resumeFlow,
|
||||
runFlow,
|
||||
setFlowEnabled,
|
||||
} from './flowsApi';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) }));
|
||||
@@ -143,4 +150,117 @@ describe('flowsApi', () => {
|
||||
await expect(getFlowRun('missing')).rejects.toThrow('flow run not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFlows', () => {
|
||||
const flow = {
|
||||
id: 'flow-1',
|
||||
name: 'Demo flow',
|
||||
enabled: true,
|
||||
graph: { nodes: [], edges: [] },
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
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 () => {
|
||||
mockCallCoreRpc.mockResolvedValue(cliEnvelope([flow]));
|
||||
|
||||
await listFlows();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.flows_list', params: {} });
|
||||
});
|
||||
|
||||
it('unwraps the { result, logs } envelope into the flow array', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue(cliEnvelope([flow]));
|
||||
|
||||
const result = await listFlows();
|
||||
|
||||
expect(result).toEqual([flow]);
|
||||
});
|
||||
|
||||
it('propagates rejection from callCoreRpc', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(listFlows()).rejects.toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFlowEnabled', () => {
|
||||
it('calls openhuman.flows_set_enabled with id and enabled', async () => {
|
||||
const flow = {
|
||||
id: 'flow-1',
|
||||
name: 'Demo flow',
|
||||
enabled: false,
|
||||
graph: {},
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
last_run_at: null,
|
||||
last_status: null,
|
||||
require_approval: false,
|
||||
};
|
||||
mockCallCoreRpc.mockResolvedValue(cliEnvelope(flow));
|
||||
|
||||
const result = await setFlowEnabled('flow-1', false);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.flows_set_enabled',
|
||||
params: { id: 'flow-1', enabled: false },
|
||||
});
|
||||
expect(result).toEqual(flow);
|
||||
});
|
||||
|
||||
it('propagates rejection from callCoreRpc', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue(new Error('flow not found'));
|
||||
|
||||
await expect(setFlowEnabled('missing', true)).rejects.toThrow('flow not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runFlow', () => {
|
||||
it('calls openhuman.flows_run with id, input, and the extended timeout', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue(
|
||||
cliEnvelope({ output: { nodes: {} }, pending_approvals: [], thread_id: 't1' })
|
||||
);
|
||||
|
||||
const result = await runFlow('flow-1');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.flows_run',
|
||||
params: { id: 'flow-1', input: null },
|
||||
timeoutMs: 610_000,
|
||||
});
|
||||
expect(result).toEqual({ output: { nodes: {} }, pending_approvals: [], thread_id: 't1' });
|
||||
});
|
||||
|
||||
it('passes a supplied input payload through', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue(
|
||||
cliEnvelope({ output: null, pending_approvals: [], thread_id: 't2' })
|
||||
);
|
||||
|
||||
await runFlow('flow-1', { trigger: 'manual' });
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.flows_run',
|
||||
params: { id: 'flow-1', input: { trigger: 'manual' } },
|
||||
timeoutMs: 610_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('unwraps the { result, logs } envelope', async () => {
|
||||
const payload = { output: null, pending_approvals: ['node-a'], thread_id: 't3' };
|
||||
mockCallCoreRpc.mockResolvedValue(cliEnvelope(payload));
|
||||
|
||||
const result = await runFlow('flow-1');
|
||||
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('propagates rejection from callCoreRpc', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue(new Error('flow disabled'));
|
||||
|
||||
await expect(runFlow('flow-1')).rejects.toThrow('flow disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,10 +28,11 @@ import { callCoreRpc } from '../coreRpcClient';
|
||||
const log = debug('flowsApi');
|
||||
|
||||
/**
|
||||
* `openhuman.flows_resume` drives the engine and can run up to ~600s server-side
|
||||
* (`FLOW_RUN_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`). Give the client a
|
||||
* slightly larger budget than the default 30s so a slow resume doesn't fail
|
||||
* client-side while the engine is still running.
|
||||
* `openhuman.flows_resume` and `openhuman.flows_run` both drive the tinyflows
|
||||
* engine and can run up to ~600s server-side (`FLOW_RUN_TIMEOUT_SECS` in
|
||||
* `src/openhuman/flows/ops.rs`). Give the client a slightly larger budget than
|
||||
* the default 30s so a slow run/resume doesn't fail client-side while the
|
||||
* engine is still running.
|
||||
*/
|
||||
const FLOW_RESUME_TIMEOUT_MS = 610_000;
|
||||
|
||||
@@ -78,6 +79,33 @@ export interface FlowResumeResult {
|
||||
thread_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved automation workflow (`src/openhuman/flows/types.rs::Flow`) — the
|
||||
* Workflows list page (B5a) row shape. `graph` is the raw tinyflows
|
||||
* `WorkflowGraph`; the list page doesn't need to interpret it, only the
|
||||
* canvas (B5b) does, so it's kept as `unknown` here.
|
||||
*/
|
||||
export interface Flow {
|
||||
/** Stable identifier (UUID) for this flow. */
|
||||
id: string;
|
||||
/** Human-readable name shown in the Workflows UI. */
|
||||
name: string;
|
||||
/** Whether this flow may currently be triggered/run. */
|
||||
enabled: boolean;
|
||||
/** The validated, migrated workflow graph — opaque to this client. */
|
||||
graph: unknown;
|
||||
/** RFC3339 creation timestamp. */
|
||||
created_at: string;
|
||||
/** RFC3339 last-update timestamp. */
|
||||
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"`. */
|
||||
last_status: string | null;
|
||||
/** "Require approval for outbound actions" toggle (issue B2). */
|
||||
require_approval: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI-compatible envelope unwrapping.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -166,6 +194,69 @@ export async function getFlowRun(runId: string): Promise<FlowRun> {
|
||||
return run;
|
||||
}
|
||||
|
||||
export const flowsApi = { resumeFlow, listFlowRuns, getFlowRun };
|
||||
/**
|
||||
* List all saved flows via `openhuman.flows_list` (the Workflows list page,
|
||||
* B5a). No params. Unlike the run-surface calls above, the payload IS the
|
||||
* `Flow[]` array directly — there is no outer `{ flows: [...] }` wrapper (see
|
||||
* `src/openhuman/flows/ops.rs::flows_list`, which returns `Vec<Flow>`
|
||||
* straight through `RpcOutcome::single_log`).
|
||||
*/
|
||||
export async function listFlows(): Promise<Flow[]> {
|
||||
log('listFlows: request');
|
||||
const response = await callCoreRpc<unknown>({ method: 'openhuman.flows_list', params: {} });
|
||||
const flows = unwrapCliEnvelope<Flow[]>(response);
|
||||
log('listFlows: response count=%d', flows.length);
|
||||
return flows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a saved flow via `openhuman.flows_set_enabled`. Returns
|
||||
* the updated `Flow` row directly (same no-wrapper shape as `flows_list`'s
|
||||
* elements).
|
||||
*/
|
||||
export async function setFlowEnabled(id: string, enabled: boolean): Promise<Flow> {
|
||||
log('setFlowEnabled: request id=%s enabled=%s', id, enabled);
|
||||
const response = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.flows_set_enabled',
|
||||
params: { id, enabled },
|
||||
});
|
||||
const flow = unwrapCliEnvelope<Flow>(response);
|
||||
log('setFlowEnabled: response id=%s enabled=%s', flow.id, flow.enabled);
|
||||
return flow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a saved flow to completion (or until it pauses on a human-approval
|
||||
* gate) via `openhuman.flows_run`. This is the call that actually drives the
|
||||
* tinyflows engine, so it shares `flows_resume`'s ~600s server-side budget
|
||||
* (see {@link FLOW_RESUME_TIMEOUT_MS}). The Workflows list page's Run button
|
||||
* uses this fire-and-forget: it awaits the call just long enough to know the
|
||||
* run kicked off, shows a toast, and refetches `listFlows()` to pick up the
|
||||
* refreshed `last_run_at`/`last_status`.
|
||||
*/
|
||||
export async function runFlow(id: string, input?: unknown): Promise<FlowResumeResult> {
|
||||
log('runFlow: request id=%s', id);
|
||||
const response = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.flows_run',
|
||||
params: { id, input: input ?? null },
|
||||
timeoutMs: FLOW_RESUME_TIMEOUT_MS,
|
||||
});
|
||||
const result = unwrapCliEnvelope<FlowResumeResult>(response);
|
||||
log(
|
||||
'runFlow: response threadId=%s pendingApprovals=%d',
|
||||
result.thread_id,
|
||||
result.pending_approvals?.length ?? 0
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const flowsApi = {
|
||||
resumeFlow,
|
||||
listFlowRuns,
|
||||
getFlowRun,
|
||||
listFlows,
|
||||
setFlowEnabled,
|
||||
runFlow,
|
||||
};
|
||||
|
||||
export default flowsApi;
|
||||
|
||||
@@ -44,6 +44,7 @@ const ROUTES: Route[] = [
|
||||
{ hash: '/rewards' },
|
||||
{ hash: '/settings' },
|
||||
{ hash: '/agent-world' },
|
||||
{ hash: '/flows' },
|
||||
];
|
||||
|
||||
async function rootTextLength(): Promise<number> {
|
||||
|
||||
Reference in New Issue
Block a user