mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type WorkflowDefinition,
|
||||
type WorkflowRun,
|
||||
workflowRunsApi,
|
||||
} from '../../services/api/workflowRunsApi';
|
||||
import IntelligenceOrchestrationTab from './IntelligenceOrchestrationTab';
|
||||
|
||||
vi.mock('../../services/api/workflowRunsApi', async importOriginal => {
|
||||
// Keep the real assessWorkflowCost / thresholds; mock only the RPC client.
|
||||
const actual = await importOriginal<typeof import('../../services/api/workflowRunsApi')>();
|
||||
return {
|
||||
...actual,
|
||||
workflowRunsApi: {
|
||||
listDefinitions: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
resumeRun: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// i18n → echo the key so assertions can target stable strings.
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
const api = vi.mocked(workflowRunsApi);
|
||||
|
||||
function builtin(overrides: Partial<WorkflowDefinition> = {}): WorkflowDefinition {
|
||||
return {
|
||||
id: 'parallel_research_cross_check',
|
||||
name: 'Parallel research',
|
||||
description: 'desc',
|
||||
phases: [{ name: 'decompose', description: '', agentIds: ['planner'], dependsOn: [] }],
|
||||
defaultConcurrency: 2,
|
||||
maxChildren: 8, // >= threshold → approval required
|
||||
safetyTier: 'read_only',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function startedRun(): WorkflowRun {
|
||||
return {
|
||||
id: 'wfrun-1',
|
||||
definitionId: 'parallel_research_cross_check',
|
||||
parentThreadId: null,
|
||||
input: {},
|
||||
phaseStates: { decompose: { status: 'running', outputs: [] } },
|
||||
childRunIds: [],
|
||||
status: 'running',
|
||||
summary: null,
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
completedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('IntelligenceOrchestrationTab — approval gating', () => {
|
||||
beforeEach(() => {
|
||||
api.listDefinitions.mockReset();
|
||||
api.listRuns.mockReset();
|
||||
api.startRun.mockReset();
|
||||
api.getRun.mockReset();
|
||||
api.listRuns.mockResolvedValue([]);
|
||||
api.getRun.mockResolvedValue(startedRun());
|
||||
});
|
||||
|
||||
it('shows the approval card (not a direct start) for a high-cost definition', async () => {
|
||||
api.listDefinitions.mockResolvedValue([builtin()]);
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-start-parallel_research_cross_check'));
|
||||
|
||||
expect(screen.getByTestId('workflow-approval-card')).toBeInTheDocument();
|
||||
// No direct "start run" button when approval is required.
|
||||
expect(screen.queryByTestId('orchestration-confirm-start')).not.toBeInTheDocument();
|
||||
// startRun must NOT have fired yet — approval is still pending.
|
||||
expect(api.startRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the run only after the approval is granted', async () => {
|
||||
api.listDefinitions.mockResolvedValue([builtin()]);
|
||||
api.startRun.mockResolvedValue(startedRun());
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-start-parallel_research_cross_check'));
|
||||
fireEvent.click(screen.getByTestId('workflow-approval-approve'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.startRun).toHaveBeenCalledWith({
|
||||
definitionId: 'parallel_research_cross_check',
|
||||
input: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('starts directly (no approval card) for a cheap read-only definition', async () => {
|
||||
api.listDefinitions.mockResolvedValue([
|
||||
builtin({ id: 'cheap', name: 'Cheap', maxChildren: 3, defaultConcurrency: 2 }),
|
||||
]);
|
||||
api.startRun.mockResolvedValue(startedRun());
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-start-cheap'));
|
||||
expect(screen.queryByTestId('workflow-approval-card')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('orchestration-confirm-start')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const cheap = () => builtin({ id: 'cheap', name: 'Cheap', maxChildren: 3, defaultConcurrency: 2 });
|
||||
|
||||
describe('IntelligenceOrchestrationTab — load + empty states', () => {
|
||||
beforeEach(() => {
|
||||
api.listDefinitions.mockReset();
|
||||
api.listRuns.mockReset();
|
||||
api.startRun.mockReset();
|
||||
api.getRun.mockReset();
|
||||
api.stopRun.mockReset();
|
||||
api.resumeRun.mockReset();
|
||||
});
|
||||
|
||||
it('shows the error state when loading fails and retries on click', async () => {
|
||||
api.listDefinitions.mockRejectedValueOnce(new Error('rpc boom'));
|
||||
api.listRuns.mockResolvedValue([]);
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
// load() catch path (lines 79-81) + error render (224) + retry button (231-233).
|
||||
expect(await screen.findByText(/orchestration.failedToLoad/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/rpc boom/)).toBeInTheDocument();
|
||||
|
||||
// Second attempt succeeds and clears the error.
|
||||
api.listDefinitions.mockResolvedValueOnce([cheap()]);
|
||||
fireEvent.click(screen.getByText('common.retry'));
|
||||
expect(await screen.findByTestId('orchestration-start-cheap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the empty definitions + empty runs placeholders', async () => {
|
||||
api.listDefinitions.mockResolvedValue([]);
|
||||
api.listRuns.mockResolvedValue([]);
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
// noDefinitions (251) + noRuns placeholders.
|
||||
expect(await screen.findByText('orchestration.noDefinitions')).toBeInTheDocument();
|
||||
expect(screen.getByText('orchestration.noRuns')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IntelligenceOrchestrationTab — start flow + run list', () => {
|
||||
beforeEach(() => {
|
||||
api.listDefinitions.mockReset();
|
||||
api.listRuns.mockReset();
|
||||
api.startRun.mockReset();
|
||||
api.getRun.mockReset();
|
||||
api.stopRun.mockReset();
|
||||
api.resumeRun.mockReset();
|
||||
api.listRuns.mockResolvedValue([]);
|
||||
api.getRun.mockResolvedValue(startedRun());
|
||||
});
|
||||
|
||||
it('starts a cheap run with the typed question and opens its detail view', async () => {
|
||||
api.listDefinitions.mockResolvedValue([cheap()]);
|
||||
api.startRun.mockResolvedValue(startedRun());
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-start-cheap'));
|
||||
// Type a question (covers the controlled textarea onChange, line 305).
|
||||
fireEvent.change(screen.getByTestId('orchestration-question'), {
|
||||
target: { value: ' why is the sky blue? ' },
|
||||
});
|
||||
// Direct confirm-start (lines 330, 332).
|
||||
fireEvent.click(screen.getByTestId('orchestration-confirm-start'));
|
||||
|
||||
// doStart trims + forwards the question (line 161) and opens the run detail.
|
||||
await waitFor(() =>
|
||||
expect(api.startRun).toHaveBeenCalledWith({
|
||||
definitionId: 'cheap',
|
||||
input: { question: 'why is the sky blue?' },
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId('orchestration-selected-run')).toBeInTheDocument();
|
||||
// The started run is upserted into the recent-runs list (lines 402-408).
|
||||
expect(screen.getByTestId('orchestration-run-wfrun-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a start error inline (doStart catch path)', async () => {
|
||||
api.listDefinitions.mockResolvedValue([cheap()]);
|
||||
api.startRun.mockRejectedValueOnce(new Error('start failed'));
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-start-cheap'));
|
||||
fireEvent.click(screen.getByTestId('orchestration-confirm-start'));
|
||||
|
||||
// doStart error branch (lines 169-171) renders the inline error.
|
||||
expect(await screen.findByText(/start failed/)).toBeInTheDocument();
|
||||
// The panel stays open (no selected run).
|
||||
expect(screen.queryByTestId('orchestration-selected-run')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels the start panel without starting a run', async () => {
|
||||
api.listDefinitions.mockResolvedValue([cheap()]);
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-start-cheap'));
|
||||
expect(screen.getByTestId('orchestration-confirm-start')).toBeInTheDocument();
|
||||
// cancelStart (lines 148-150) closes the panel.
|
||||
fireEvent.click(screen.getByText('orchestration.approval.cancel'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('orchestration-confirm-start')).not.toBeInTheDocument()
|
||||
);
|
||||
expect(api.startRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a recent run, updates it via upsert, then closes it', async () => {
|
||||
api.listDefinitions.mockResolvedValue([cheap()]);
|
||||
// A pre-existing run in the recent list (drives the runs.map render, 402-408).
|
||||
api.listRuns.mockResolvedValue([startedRun()]);
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-run-wfrun-1'));
|
||||
expect(await screen.findByTestId('orchestration-selected-run')).toBeInTheDocument();
|
||||
|
||||
// Close the drill-in (lines 364-366).
|
||||
fireEvent.click(screen.getByTestId('orchestration-close-run'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('orchestration-selected-run')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IntelligenceOrchestrationTab — stop / resume controls', () => {
|
||||
beforeEach(() => {
|
||||
api.listDefinitions.mockReset();
|
||||
api.listRuns.mockReset();
|
||||
api.getRun.mockReset();
|
||||
api.stopRun.mockReset();
|
||||
api.resumeRun.mockReset();
|
||||
api.listDefinitions.mockResolvedValue([cheap()]);
|
||||
});
|
||||
|
||||
it('stops a running run and reflects the interrupted snapshot', async () => {
|
||||
api.listRuns.mockResolvedValue([startedRun()]);
|
||||
// First poll snapshot is still running (Stop renders); once stopped the
|
||||
// backend reports interrupted, so subsequent polls agree and don't race the
|
||||
// stop result back to running (matches the serialized poll loop).
|
||||
api.getRun
|
||||
.mockResolvedValueOnce(startedRun())
|
||||
.mockResolvedValue({ ...startedRun(), status: 'interrupted' });
|
||||
api.stopRun.mockResolvedValue({ ...startedRun(), status: 'interrupted' });
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-run-wfrun-1'));
|
||||
fireEvent.click(await screen.findByTestId('workflow-run-stop'));
|
||||
|
||||
// handleStop (lines 179-185) calls stopRun and upserts the result.
|
||||
await waitFor(() => expect(api.stopRun).toHaveBeenCalledWith('wfrun-1'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('workflow-run-status')).toHaveTextContent(
|
||||
'orchestration.runStatus.interrupted'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes an interrupted run', async () => {
|
||||
const interrupted = { ...startedRun(), status: 'interrupted' as const };
|
||||
api.listRuns.mockResolvedValue([interrupted]);
|
||||
api.getRun.mockResolvedValue(interrupted);
|
||||
api.resumeRun.mockResolvedValue({ ...startedRun(), status: 'running' });
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-run-wfrun-1'));
|
||||
fireEvent.click(await screen.findByTestId('workflow-run-resume'));
|
||||
|
||||
// handleResume (lines 197-203) calls resumeRun and upserts the result.
|
||||
await waitFor(() => expect(api.resumeRun).toHaveBeenCalledWith('wfrun-1'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('workflow-run-status')).toHaveTextContent(
|
||||
'orchestration.runStatus.running'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows a stop RPC error without crashing', async () => {
|
||||
api.listRuns.mockResolvedValue([startedRun()]);
|
||||
api.getRun.mockResolvedValue(startedRun());
|
||||
api.stopRun.mockRejectedValueOnce(new Error('stop boom'));
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-run-wfrun-1'));
|
||||
fireEvent.click(await screen.findByTestId('workflow-run-stop'));
|
||||
|
||||
// handleStop catch path (lines 188, 190): still mounted, button re-enabled.
|
||||
await waitFor(() => expect(api.stopRun).toHaveBeenCalled());
|
||||
expect(screen.getByTestId('orchestration-selected-run')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IntelligenceOrchestrationTab — polling loop', () => {
|
||||
beforeEach(() => {
|
||||
api.listDefinitions.mockReset();
|
||||
api.listRuns.mockReset();
|
||||
api.getRun.mockReset();
|
||||
api.listDefinitions.mockResolvedValue([cheap()]);
|
||||
api.listRuns.mockResolvedValue([startedRun()]);
|
||||
});
|
||||
|
||||
it('polls getRun for a selected non-terminal run and upserts the fresh snapshot', async () => {
|
||||
// First poll returns a completed snapshot.
|
||||
api.getRun.mockResolvedValue({
|
||||
...startedRun(),
|
||||
status: 'completed',
|
||||
summary: 'done',
|
||||
completedAt: '2026-01-01T00:05:00Z',
|
||||
});
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
// Select the run; the parent then starts the real 2s poll interval. We wait
|
||||
// (real timers) for the first tick rather than fight fake timers vs. the
|
||||
// interval registered inside the post-select effect.
|
||||
fireEvent.click(await screen.findByTestId('orchestration-run-wfrun-1'));
|
||||
expect(await screen.findByTestId('orchestration-selected-run')).toBeInTheDocument();
|
||||
|
||||
// tick() runs after POLL_INTERVAL_MS (lines 117-121); upsert flips status.
|
||||
await waitFor(() => expect(api.getRun).toHaveBeenCalledWith('wfrun-1'), { timeout: 4000 });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('workflow-run-status')).toHaveTextContent(
|
||||
'orchestration.runStatus.completed'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows a poll error (tick catch path)', async () => {
|
||||
api.getRun.mockRejectedValue(new Error('poll boom'));
|
||||
render(<IntelligenceOrchestrationTab />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('orchestration-run-wfrun-1'));
|
||||
expect(await screen.findByTestId('orchestration-selected-run')).toBeInTheDocument();
|
||||
|
||||
// tick catch (line 123): poll rejects, the run stays open with no crash.
|
||||
await waitFor(() => expect(api.getRun).toHaveBeenCalled(), { timeout: 4000 });
|
||||
expect(screen.getByTestId('orchestration-selected-run')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* IntelligenceOrchestrationTab (#3375)
|
||||
* ------------------------------------
|
||||
*
|
||||
* The Workflows / multi-agent orchestration surface in the Intelligence command
|
||||
* center. Lets the user:
|
||||
* - browse runnable workflow definitions (`workflow_run_list_definitions`),
|
||||
* - start the parallel-research workflow (`workflow_run_start`) — gated behind
|
||||
* an explicit approval card for high-cost / high-concurrency definitions,
|
||||
* - drill into a run's phase progress, child agent refs, and final synthesis,
|
||||
* - stop / resume a run.
|
||||
*
|
||||
* Progress is poll-based (the engine emits no socket events yet): once a run is
|
||||
* selected and non-terminal, this tab polls `workflow_run_get` on an interval
|
||||
* and feeds fresh snapshots to {@link WorkflowRunDetail}.
|
||||
*
|
||||
* This is a sibling of {@link WorkflowsTab} (which manages SKILL.md authoring) —
|
||||
* the two are deliberately distinct primitives: that tab is reusable procedures,
|
||||
* this tab is declarative multi-agent runs.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
assessWorkflowCost,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowRun,
|
||||
workflowRunsApi,
|
||||
} from '../../services/api/workflowRunsApi';
|
||||
import { SAFETY_TIER_KEY, WorkflowRunApprovalCard } from './WorkflowRunApprovalCard';
|
||||
import WorkflowRunDetail from './WorkflowRunDetail';
|
||||
|
||||
const log = debug('intelligence:orchestration');
|
||||
|
||||
/** How often to poll a selected, non-terminal run for progress. */
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'interrupted']);
|
||||
|
||||
function isTerminal(run: WorkflowRun | null): boolean {
|
||||
return run !== null && TERMINAL.has(run.status);
|
||||
}
|
||||
|
||||
export default function IntelligenceOrchestrationTab() {
|
||||
const { t } = useT();
|
||||
|
||||
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
|
||||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Start flow state.
|
||||
const [startTarget, setStartTarget] = useState<WorkflowDefinition | null>(null);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
|
||||
// Drill-in state.
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [selectedRun, setSelectedRun] = useState<WorkflowRun | null>(null);
|
||||
const [controlBusy, setControlBusy] = useState(false);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
log('load: entry');
|
||||
setError(null);
|
||||
try {
|
||||
const [defs, runList] = await Promise.all([
|
||||
workflowRunsApi.listDefinitions(),
|
||||
workflowRunsApi.listRuns({ limit: 50 }),
|
||||
]);
|
||||
if (!mountedRef.current) return;
|
||||
setDefinitions(defs);
|
||||
setRuns(runList);
|
||||
log('load: defs=%d runs=%d', defs.length, runList.length);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('load: error %s', msg);
|
||||
if (mountedRef.current) setError(msg);
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
// Defer the first fetch a tick so the loading state paints before `load`
|
||||
// touches state (mirrors IntelligenceAgentWorkTab; avoids a synchronous
|
||||
// setState inside the effect body).
|
||||
const handle = window.setTimeout(() => void load(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(handle);
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
// Merge a fresh run snapshot into the runs list (newest data wins).
|
||||
const upsertRun = useCallback((run: WorkflowRun) => {
|
||||
setRuns(prev => {
|
||||
const idx = prev.findIndex(r => r.id === run.id);
|
||||
if (idx === -1) return [run, ...prev];
|
||||
const next = prev.slice();
|
||||
next[idx] = run;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ---- Polling loop for the selected run -------------------------------
|
||||
useEffect(() => {
|
||||
if (!selectedRunId) return;
|
||||
if (isTerminal(selectedRun)) return; // nothing left to poll
|
||||
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let handle: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled || inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const run = await workflowRunsApi.getRun(selectedRunId);
|
||||
if (cancelled || !mountedRef.current || !run) return;
|
||||
setSelectedRun(run);
|
||||
upsertRun(run);
|
||||
if (!isTerminal(run)) {
|
||||
handle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
log('poll error %s', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (handle !== undefined) window.clearTimeout(handle);
|
||||
};
|
||||
}, [selectedRunId, selectedRun, upsertRun]);
|
||||
|
||||
const openRun = useCallback((run: WorkflowRun) => {
|
||||
log('openRun id=%s', run.id);
|
||||
setSelectedRunId(run.id);
|
||||
setSelectedRun(run);
|
||||
}, []);
|
||||
|
||||
// ---- Start flow ------------------------------------------------------
|
||||
const beginStart = useCallback((def: WorkflowDefinition) => {
|
||||
log('beginStart definitionId=%s', def.id);
|
||||
setStartTarget(def);
|
||||
setQuestion('');
|
||||
setStartError(null);
|
||||
}, []);
|
||||
|
||||
const cancelStart = useCallback(() => {
|
||||
setStartTarget(null);
|
||||
setStarting(false);
|
||||
setStartError(null);
|
||||
}, []);
|
||||
|
||||
const doStart = useCallback(async () => {
|
||||
if (!startTarget) return;
|
||||
setStarting(true);
|
||||
setStartError(null);
|
||||
const trimmed = question.trim();
|
||||
try {
|
||||
const run = await workflowRunsApi.startRun({
|
||||
definitionId: startTarget.id,
|
||||
input: trimmed.length > 0 ? { question: trimmed } : undefined,
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
log('started runId=%s', run.id);
|
||||
upsertRun(run);
|
||||
setStartTarget(null);
|
||||
openRun(run);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('start error %s', msg);
|
||||
if (mountedRef.current) setStartError(msg);
|
||||
} finally {
|
||||
if (mountedRef.current) setStarting(false);
|
||||
}
|
||||
}, [startTarget, question, upsertRun, openRun]);
|
||||
|
||||
// ---- Stop / Resume ---------------------------------------------------
|
||||
const handleStop = useCallback(
|
||||
async (id: string) => {
|
||||
setControlBusy(true);
|
||||
try {
|
||||
const run = await workflowRunsApi.stopRun(id);
|
||||
if (run && mountedRef.current) {
|
||||
setSelectedRun(run);
|
||||
upsertRun(run);
|
||||
}
|
||||
} catch (err) {
|
||||
log('stop error %s', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (mountedRef.current) setControlBusy(false);
|
||||
}
|
||||
},
|
||||
[upsertRun]
|
||||
);
|
||||
|
||||
const handleResume = useCallback(
|
||||
async (id: string) => {
|
||||
setControlBusy(true);
|
||||
try {
|
||||
const run = await workflowRunsApi.resumeRun(id);
|
||||
if (mountedRef.current) {
|
||||
setSelectedRun(run);
|
||||
upsertRun(run);
|
||||
}
|
||||
} catch (err) {
|
||||
log('resume error %s', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (mountedRef.current) setControlBusy(false);
|
||||
}
|
||||
},
|
||||
[upsertRun]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10 text-stone-400 dark:text-neutral-500">
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
|
||||
<span className="text-sm">{t('orchestration.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('orchestration.failedToLoad')}: {error}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}}
|
||||
className="rounded-lg border border-stone-300 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300">
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="orchestration-tab">
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500">{t('orchestration.subtitle')}</p>
|
||||
|
||||
{/* Definitions catalog */}
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('orchestration.definitions')}
|
||||
</h3>
|
||||
{definitions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-stone-200 py-8 text-center text-sm text-stone-400 dark:border-neutral-800 dark:text-neutral-500">
|
||||
{t('orchestration.noDefinitions')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="orchestration-definitions">
|
||||
{definitions.map(def => {
|
||||
const assessment = assessWorkflowCost(def);
|
||||
const isStarting = startTarget?.id === def.id;
|
||||
return (
|
||||
<li
|
||||
key={def.id}
|
||||
data-testid={`orchestration-definition-${def.id}`}
|
||||
className="rounded-xl border border-stone-200 bg-white p-3 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-stone-800 dark:text-neutral-100">
|
||||
{def.name}
|
||||
</span>
|
||||
<span className="rounded-md border border-stone-200 px-1.5 py-0.5 text-[10px] font-medium text-stone-500 dark:border-neutral-700 dark:text-neutral-400">
|
||||
{t(SAFETY_TIER_KEY[def.safetyTier])}
|
||||
</span>
|
||||
{assessment.requiresApproval && (
|
||||
<span
|
||||
data-testid={`orchestration-approval-badge-${def.id}`}
|
||||
className="rounded-md border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300">
|
||||
{t('orchestration.approvalRequired')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{def.description}
|
||||
</p>
|
||||
</div>
|
||||
{!isStarting && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`orchestration-start-${def.id}`}
|
||||
onClick={() => beginStart(def)}
|
||||
className="flex-none rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white shadow-soft hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500">
|
||||
{t('orchestration.start')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline start panel */}
|
||||
{isStarting && (
|
||||
<div className="mt-3 space-y-3 border-t border-stone-100 pt-3 dark:border-neutral-800">
|
||||
<label className="block text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('orchestration.questionLabel')}
|
||||
<textarea
|
||||
data-testid="orchestration-question"
|
||||
value={question}
|
||||
onChange={e => setQuestion(e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('orchestration.questionPlaceholder')}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-800 focus:border-primary-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{startError && (
|
||||
<p className="text-xs text-coral-600 dark:text-coral-300">⚠ {startError}</p>
|
||||
)}
|
||||
|
||||
{assessment.requiresApproval ? (
|
||||
<WorkflowRunApprovalCard
|
||||
definition={def}
|
||||
reasons={assessment.reasons}
|
||||
starting={starting}
|
||||
onApprove={() => void doStart()}
|
||||
onCancel={cancelStart}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="orchestration-confirm-start"
|
||||
disabled={starting}
|
||||
onClick={() => void doStart()}
|
||||
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white shadow-soft hover:bg-primary-600 disabled:opacity-50">
|
||||
{starting
|
||||
? t('orchestration.starting')
|
||||
: t('orchestration.confirmStart')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelStart}
|
||||
disabled={starting}
|
||||
className="rounded-lg border border-stone-300 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300">
|
||||
{t('orchestration.approval.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Selected run drill-in */}
|
||||
{selectedRun && (
|
||||
<section className="space-y-2" data-testid="orchestration-selected-run">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('orchestration.runProgress')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="orchestration-close-run"
|
||||
onClick={() => {
|
||||
setSelectedRunId(null);
|
||||
setSelectedRun(null);
|
||||
}}
|
||||
className="text-[11px] text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300">
|
||||
{t('orchestration.close')}
|
||||
</button>
|
||||
</div>
|
||||
<WorkflowRunDetail
|
||||
definition={definitions.find(d => d.id === selectedRun.definitionId)}
|
||||
run={selectedRun}
|
||||
busy={controlBusy}
|
||||
onStop={id => void handleStop(id)}
|
||||
onResume={id => void handleResume(id)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent runs */}
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('orchestration.recentRuns')}
|
||||
</h3>
|
||||
{runs.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-stone-200 py-8 text-center text-sm text-stone-400 dark:border-neutral-800 dark:text-neutral-500">
|
||||
{t('orchestration.noRuns')}
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
className="divide-y divide-stone-100 overflow-hidden rounded-xl border border-stone-200 bg-white dark:divide-neutral-800 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
data-testid="orchestration-runs">
|
||||
{runs.map(run => {
|
||||
const def = definitions.find(d => d.id === run.definitionId);
|
||||
return (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`orchestration-run-${run.id}`}
|
||||
onClick={() => openRun(run)}
|
||||
className={`flex w-full items-center justify-between gap-2 p-3 text-left hover:bg-stone-50 dark:hover:bg-neutral-800/60 ${
|
||||
run.id === selectedRunId ? 'bg-stone-50 dark:bg-neutral-800/60' : ''
|
||||
}`}>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-stone-800 dark:text-neutral-100">
|
||||
{def?.name ?? run.definitionId}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{run.id}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex-none text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t(`orchestration.runStatus.${run.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type WorkflowDefinition } from '../../services/api/workflowRunsApi';
|
||||
import WorkflowRunApprovalCard from './WorkflowRunApprovalCard';
|
||||
|
||||
// i18n → echo the key so assertions can target stable strings.
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
function def(overrides: Partial<WorkflowDefinition> = {}): WorkflowDefinition {
|
||||
return {
|
||||
id: 'parallel_research_cross_check',
|
||||
name: 'Parallel research',
|
||||
description: 'desc',
|
||||
phases: [],
|
||||
defaultConcurrency: 2,
|
||||
maxChildren: 8,
|
||||
safetyTier: 'read_only',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WorkflowRunApprovalCard', () => {
|
||||
it('renders one localized line per reason', () => {
|
||||
render(
|
||||
<WorkflowRunApprovalCard
|
||||
definition={def()}
|
||||
reasons={['high_children', 'high_concurrency']}
|
||||
onApprove={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const reasons = screen.getByTestId('workflow-approval-reasons');
|
||||
expect(reasons.querySelectorAll('li')).toHaveLength(2);
|
||||
expect(screen.getByText('orchestration.approval.reason.children')).toBeInTheDocument();
|
||||
expect(screen.getByText('orchestration.approval.reason.concurrency')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the concrete cost facts (tier / concurrency / max children)', () => {
|
||||
render(
|
||||
<WorkflowRunApprovalCard
|
||||
definition={def({ defaultConcurrency: 3, maxChildren: 8 })}
|
||||
reasons={['high_children']}
|
||||
onApprove={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('orchestration.tier.readOnly')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onApprove when "Approve & start" is clicked', () => {
|
||||
const onApprove = vi.fn();
|
||||
render(
|
||||
<WorkflowRunApprovalCard
|
||||
definition={def()}
|
||||
reasons={['high_children']}
|
||||
onApprove={onApprove}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('workflow-approval-approve'));
|
||||
expect(onApprove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires onCancel when "Cancel" is clicked', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<WorkflowRunApprovalCard
|
||||
definition={def()}
|
||||
reasons={['high_children']}
|
||||
onApprove={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('workflow-approval-cancel'));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables both buttons and shows the starting label while a start is in flight', () => {
|
||||
render(
|
||||
<WorkflowRunApprovalCard
|
||||
definition={def()}
|
||||
reasons={['high_children']}
|
||||
starting
|
||||
onApprove={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('workflow-approval-approve')).toBeDisabled();
|
||||
expect(screen.getByTestId('workflow-approval-cancel')).toBeDisabled();
|
||||
expect(screen.getByText('orchestration.approval.starting')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* WorkflowRunApprovalCard (#3375)
|
||||
* --------------------------------
|
||||
*
|
||||
* Confirmation surface shown before starting a high-cost / high-concurrency
|
||||
* workflow run. The acceptance criterion "High-cost/high-concurrency runs
|
||||
* require explicit approval" is enforced on the client: the Orchestration tab
|
||||
* calls {@link assessWorkflowCost} and, when it returns `requiresApproval`,
|
||||
* renders this card instead of starting immediately. The user must press
|
||||
* "Approve & start" to proceed; "Cancel" aborts without touching the engine.
|
||||
*
|
||||
* Styling mirrors the chat ApprovalRequestCard (amber warning chrome) so the
|
||||
* approval affordance is visually consistent across the app.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import React from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
type WorkflowCostReason,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowSafetyTier,
|
||||
} from '../../services/api/workflowRunsApi';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
const log = debug('intelligence:workflow-approval');
|
||||
|
||||
/** i18n key for each cost reason code. */
|
||||
const REASON_KEY: Record<WorkflowCostReason, string> = {
|
||||
non_read_only_tier: 'orchestration.approval.reason.tier',
|
||||
high_concurrency: 'orchestration.approval.reason.concurrency',
|
||||
high_children: 'orchestration.approval.reason.children',
|
||||
};
|
||||
|
||||
/** i18n key for each safety tier label. */
|
||||
export const SAFETY_TIER_KEY: Record<WorkflowSafetyTier, string> = {
|
||||
read_only: 'orchestration.tier.readOnly',
|
||||
standard: 'orchestration.tier.standard',
|
||||
edit_capable: 'orchestration.tier.editCapable',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
definition: WorkflowDefinition;
|
||||
reasons: WorkflowCostReason[];
|
||||
/** Whether the start RPC is in flight (disables the buttons). */
|
||||
starting?: boolean;
|
||||
onApprove: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const WorkflowRunApprovalCard: React.FC<Props> = ({
|
||||
definition,
|
||||
reasons,
|
||||
starting = false,
|
||||
onApprove,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useT();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-label={t('orchestration.approval.title')}
|
||||
data-testid="workflow-approval-card"
|
||||
className="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm dark:border-amber-500/40 dark:bg-amber-500/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<span aria-hidden className="text-base leading-none">
|
||||
⚠️
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-200">
|
||||
{t('orchestration.approval.title')}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-amber-800/90 dark:text-amber-200/90">
|
||||
{t('orchestration.approval.body')}{' '}
|
||||
<span className="font-semibold">{definition.name}</span>
|
||||
</p>
|
||||
|
||||
{/* Why approval is required — one localized line per reason code. */}
|
||||
<ul
|
||||
data-testid="workflow-approval-reasons"
|
||||
className="mt-2 list-disc space-y-1 pl-5 text-xs text-amber-800/90 dark:text-amber-200/90">
|
||||
{reasons.map(reason => (
|
||||
<li key={reason}>{t(REASON_KEY[reason])}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Concrete cost facts so the user knows what they're approving. */}
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="rounded-md bg-amber-100/70 px-2 py-1.5 dark:bg-amber-500/15">
|
||||
<dt className="text-amber-700/80 dark:text-amber-300/80">
|
||||
{t('orchestration.approval.tier')}
|
||||
</dt>
|
||||
<dd className="font-medium text-amber-900 dark:text-amber-100">
|
||||
{t(SAFETY_TIER_KEY[definition.safetyTier])}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-100/70 px-2 py-1.5 dark:bg-amber-500/15">
|
||||
<dt className="text-amber-700/80 dark:text-amber-300/80">
|
||||
{t('orchestration.approval.concurrency')}
|
||||
</dt>
|
||||
<dd className="font-medium text-amber-900 dark:text-amber-100">
|
||||
{definition.defaultConcurrency}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-100/70 px-2 py-1.5 dark:bg-amber-500/15">
|
||||
<dt className="text-amber-700/80 dark:text-amber-300/80">
|
||||
{t('orchestration.approval.maxChildren')}
|
||||
</dt>
|
||||
<dd className="font-medium text-amber-900 dark:text-amber-100">
|
||||
{definition.maxChildren}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="workflow-approval-approve"
|
||||
disabled={starting}
|
||||
onClick={() => {
|
||||
log('approve definitionId=%s', definition.id);
|
||||
onApprove();
|
||||
}}>
|
||||
{starting
|
||||
? t('orchestration.approval.starting')
|
||||
: t('orchestration.approval.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-testid="workflow-approval-cancel"
|
||||
disabled={starting}
|
||||
onClick={() => {
|
||||
log('cancel definitionId=%s', definition.id);
|
||||
onCancel();
|
||||
}}>
|
||||
{t('orchestration.approval.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowRunApprovalCard;
|
||||
@@ -0,0 +1,200 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type WorkflowDefinition, type WorkflowRun } from '../../services/api/workflowRunsApi';
|
||||
import WorkflowRunDetail from './WorkflowRunDetail';
|
||||
|
||||
// i18n → echo the key so assertions can target stable strings.
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
function def(): WorkflowDefinition {
|
||||
return {
|
||||
id: 'parallel_research_cross_check',
|
||||
name: 'Parallel research',
|
||||
description: 'desc',
|
||||
phases: [
|
||||
{
|
||||
name: 'decompose',
|
||||
description: 'Break the question down.',
|
||||
agentIds: ['planner'],
|
||||
dependsOn: [],
|
||||
},
|
||||
{
|
||||
name: 'research',
|
||||
description: 'Research in parallel.',
|
||||
agentIds: ['researcher', 'researcher'],
|
||||
dependsOn: ['decompose'],
|
||||
},
|
||||
{
|
||||
name: 'synthesize',
|
||||
description: 'Synthesize.',
|
||||
agentIds: ['summarizer'],
|
||||
dependsOn: ['research'],
|
||||
},
|
||||
],
|
||||
defaultConcurrency: 2,
|
||||
maxChildren: 8,
|
||||
safetyTier: 'read_only',
|
||||
};
|
||||
}
|
||||
|
||||
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
|
||||
return {
|
||||
id: 'wfrun-1',
|
||||
definitionId: 'parallel_research_cross_check',
|
||||
parentThreadId: null,
|
||||
input: { question: 'q' },
|
||||
phaseStates: {
|
||||
decompose: {
|
||||
status: 'completed',
|
||||
outputs: [{ orchestrationId: 'orch-1', agentId: 'planner', output: 'three angles' }],
|
||||
},
|
||||
research: { status: 'running', outputs: [] },
|
||||
synthesize: { status: 'pending', outputs: [] },
|
||||
},
|
||||
childRunIds: ['orch-1'],
|
||||
status: 'running',
|
||||
summary: null,
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:01:00Z',
|
||||
completedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WorkflowRunDetail — progress rendering', () => {
|
||||
it('renders the run status and every declared phase in order', () => {
|
||||
render(
|
||||
<WorkflowRunDetail definition={def()} run={run()} onStop={vi.fn()} onResume={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('workflow-run-status')).toHaveTextContent(
|
||||
'orchestration.runStatus.running'
|
||||
);
|
||||
|
||||
const phases = screen.getByTestId('workflow-phase-list').querySelectorAll('li');
|
||||
expect(phases).toHaveLength(3);
|
||||
expect(screen.getByTestId('workflow-phase-decompose')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workflow-phase-research')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workflow-phase-synthesize')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects per-phase status from phaseStates', () => {
|
||||
render(
|
||||
<WorkflowRunDetail definition={def()} run={run()} onStop={vi.fn()} onResume={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByTestId('workflow-phase-status-decompose')).toHaveTextContent(
|
||||
'orchestration.phaseStatus.completed'
|
||||
);
|
||||
expect(screen.getByTestId('workflow-phase-status-research')).toHaveTextContent(
|
||||
'orchestration.phaseStatus.running'
|
||||
);
|
||||
expect(screen.getByTestId('workflow-phase-status-synthesize')).toHaveTextContent(
|
||||
'orchestration.phaseStatus.pending'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows child agent refs for a phase when expanded', () => {
|
||||
render(
|
||||
<WorkflowRunDetail definition={def()} run={run()} onStop={vi.fn()} onResume={vi.fn()} />
|
||||
);
|
||||
// Outputs are collapsed by default; expand the completed phase.
|
||||
fireEvent.click(screen.getByTestId('workflow-phase-decompose').querySelector('button')!);
|
||||
const outputs = screen.getByTestId('workflow-phase-outputs-decompose');
|
||||
expect(outputs).toHaveTextContent('planner');
|
||||
expect(outputs).toHaveTextContent('orch-1');
|
||||
expect(outputs).toHaveTextContent('three angles');
|
||||
});
|
||||
|
||||
it('renders the run-level child agent ref count', () => {
|
||||
render(
|
||||
<WorkflowRunDetail definition={def()} run={run()} onStop={vi.fn()} onResume={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByTestId('workflow-child-refs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the final synthesis only once the run is terminal with a summary', () => {
|
||||
const { rerender } = render(
|
||||
<WorkflowRunDetail definition={def()} run={run()} onStop={vi.fn()} onResume={vi.fn()} />
|
||||
);
|
||||
expect(screen.queryByTestId('workflow-run-summary')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<WorkflowRunDetail
|
||||
definition={def()}
|
||||
run={run({
|
||||
status: 'completed',
|
||||
summary: 'A cited report.',
|
||||
completedAt: '2026-01-01T00:05:00Z',
|
||||
})}
|
||||
onStop={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('workflow-run-summary')).toHaveTextContent('A cited report.');
|
||||
});
|
||||
|
||||
it('offers Stop while running and wires it to onStop', () => {
|
||||
const onStop = vi.fn();
|
||||
render(<WorkflowRunDetail definition={def()} run={run()} onStop={onStop} onResume={vi.fn()} />);
|
||||
expect(screen.queryByTestId('workflow-run-resume')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('workflow-run-stop'));
|
||||
expect(onStop).toHaveBeenCalledWith('wfrun-1');
|
||||
});
|
||||
|
||||
it('offers Resume when interrupted and wires it to onResume', () => {
|
||||
const onResume = vi.fn();
|
||||
render(
|
||||
<WorkflowRunDetail
|
||||
definition={def()}
|
||||
run={run({ status: 'interrupted' })}
|
||||
onStop={vi.fn()}
|
||||
onResume={onResume}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId('workflow-run-stop')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('workflow-run-resume'));
|
||||
expect(onResume).toHaveBeenCalledWith('wfrun-1');
|
||||
});
|
||||
|
||||
it('falls back to phaseStates keys + raw phase name when the definition is missing', () => {
|
||||
render(
|
||||
<WorkflowRunDetail
|
||||
definition={undefined}
|
||||
run={run({
|
||||
phaseStates: { decompose: { status: 'running', outputs: [] } },
|
||||
childRunIds: [],
|
||||
})}
|
||||
onStop={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// With no definition, the phase order comes from run.phaseStates and the
|
||||
// label falls back to the raw phase name (WorkflowRunDetail.tsx:173).
|
||||
const phases = screen.getByTestId('workflow-phase-list').querySelectorAll('li');
|
||||
expect(phases).toHaveLength(1);
|
||||
expect(screen.getByTestId('workflow-phase-decompose')).toHaveTextContent('decompose');
|
||||
});
|
||||
|
||||
it('renders the failure reason for a failed phase', () => {
|
||||
render(
|
||||
<WorkflowRunDetail
|
||||
definition={def()}
|
||||
run={run({
|
||||
status: 'failed',
|
||||
phaseStates: {
|
||||
decompose: { status: 'failed', outputs: [], reason: 'planner agent errored out' },
|
||||
research: { status: 'pending', outputs: [] },
|
||||
synthesize: { status: 'pending', outputs: [] },
|
||||
},
|
||||
})}
|
||||
onStop={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// The failed-phase reason banner (WorkflowRunDetail.tsx:197).
|
||||
expect(screen.getByTestId('workflow-phase-decompose')).toHaveTextContent(
|
||||
'planner agent errored out'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* WorkflowRunDetail (#3375)
|
||||
* -------------------------
|
||||
*
|
||||
* Drill-in view for a single workflow run. Given a definition (for phase
|
||||
* ordering + labels) and the latest run snapshot, it renders:
|
||||
* - the run status header,
|
||||
* - an ordered phase timeline driven by `run.phaseStates`,
|
||||
* - each phase's child agent refs (orchestration id + agent id + output),
|
||||
* - Stop / Resume controls wired to the engine,
|
||||
* - the final synthesized report once the run completes.
|
||||
*
|
||||
* The component is presentational + control-only: the parent (Orchestration
|
||||
* tab) owns the polling loop and passes a fresh `run` each tick. Stop / Resume
|
||||
* delegate to callbacks so the parent can refresh its run list too.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
type WorkflowDefinition,
|
||||
type WorkflowPhaseState,
|
||||
type WorkflowPhaseStatus,
|
||||
type WorkflowRun,
|
||||
type WorkflowRunStatus,
|
||||
} from '../../services/api/workflowRunsApi';
|
||||
|
||||
const log = debug('intelligence:workflow-detail');
|
||||
|
||||
/** Accent classes per run status (semantic palette from tailwind.config.js). */
|
||||
const RUN_STATUS_ACCENT: Record<WorkflowRunStatus, string> = {
|
||||
pending:
|
||||
'border-stone-200 bg-stone-50 text-stone-600 dark:border-neutral-700 dark:bg-neutral-800/60 dark:text-neutral-300',
|
||||
running:
|
||||
'border-ocean-200 bg-ocean-50 text-ocean-700 dark:border-ocean-500/30 dark:bg-ocean-500/10 dark:text-ocean-300',
|
||||
completed:
|
||||
'border-sage-200 bg-sage-50 text-sage-700 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-300',
|
||||
failed:
|
||||
'border-coral-200 bg-coral-50 text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300',
|
||||
cancelled:
|
||||
'border-stone-200 bg-stone-50 text-stone-600 dark:border-neutral-700 dark:bg-neutral-800/60 dark:text-neutral-300',
|
||||
interrupted:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300',
|
||||
};
|
||||
|
||||
const RUN_STATUS_KEY: Record<WorkflowRunStatus, string> = {
|
||||
pending: 'orchestration.runStatus.pending',
|
||||
running: 'orchestration.runStatus.running',
|
||||
completed: 'orchestration.runStatus.completed',
|
||||
failed: 'orchestration.runStatus.failed',
|
||||
cancelled: 'orchestration.runStatus.cancelled',
|
||||
interrupted: 'orchestration.runStatus.interrupted',
|
||||
};
|
||||
|
||||
const PHASE_STATUS_KEY: Record<WorkflowPhaseStatus, string> = {
|
||||
pending: 'orchestration.phaseStatus.pending',
|
||||
running: 'orchestration.phaseStatus.running',
|
||||
completed: 'orchestration.phaseStatus.completed',
|
||||
failed: 'orchestration.phaseStatus.failed',
|
||||
};
|
||||
|
||||
/** Glyph per phase status — color comes from the surrounding classes. */
|
||||
const PHASE_STATUS_DOT: Record<WorkflowPhaseStatus, string> = {
|
||||
pending: 'bg-stone-300 dark:bg-neutral-600',
|
||||
running: 'bg-ocean-500 animate-pulse',
|
||||
completed: 'bg-sage-500',
|
||||
failed: 'bg-coral-500',
|
||||
};
|
||||
|
||||
const TERMINAL_STATUSES: WorkflowRunStatus[] = ['completed', 'failed', 'cancelled', 'interrupted'];
|
||||
|
||||
interface Props {
|
||||
definition: WorkflowDefinition | undefined;
|
||||
run: WorkflowRun;
|
||||
/** True while a stop/resume RPC is in flight. */
|
||||
busy?: boolean;
|
||||
onStop: (id: string) => void;
|
||||
onResume: (id: string) => void;
|
||||
}
|
||||
|
||||
export const WorkflowRunDetail: React.FC<Props> = ({
|
||||
definition,
|
||||
run,
|
||||
busy = false,
|
||||
onStop,
|
||||
onResume,
|
||||
}) => {
|
||||
const { t } = useT();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Phase order: lead with the definition's declared order, then append any
|
||||
// runtime-only phases the run reports but the definition doesn't list (so a
|
||||
// run still renders its full progress during definition/version drift).
|
||||
const declaredPhaseNames = definition?.phases.map(p => p.name) ?? [];
|
||||
const declaredSet = new Set(declaredPhaseNames);
|
||||
const runtimeOnlyPhaseNames = Object.keys(run.phaseStates).filter(name => !declaredSet.has(name));
|
||||
const phaseNames = [...declaredPhaseNames, ...runtimeOnlyPhaseNames];
|
||||
|
||||
const isRunning = run.status === 'running' || run.status === 'pending';
|
||||
const canResume = run.status === 'interrupted';
|
||||
|
||||
const toggle = (name: string) => setExpanded(prev => ({ ...prev, [name]: !prev[name] }));
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="workflow-run-detail">
|
||||
{/* Header: status + controls */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
data-testid="workflow-run-status"
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${RUN_STATUS_ACCENT[run.status]}`}>
|
||||
{run.status === 'running' && (
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-ocean-500" />
|
||||
)}
|
||||
{t(RUN_STATUS_KEY[run.status])}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{run.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workflow-run-stop"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
log('stop id=%s', run.id);
|
||||
onStop(run.id);
|
||||
}}
|
||||
className="rounded-lg border border-coral-300 bg-white px-3 py-1.5 text-xs font-medium text-coral-700 hover:bg-coral-50 disabled:opacity-50 dark:border-coral-700 dark:bg-neutral-900 dark:text-coral-300 dark:hover:bg-coral-900/40">
|
||||
{t('orchestration.detail.stop')}
|
||||
</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workflow-run-resume"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
log('resume id=%s', run.id);
|
||||
onResume(run.id);
|
||||
}}
|
||||
className="rounded-lg border border-ocean-300 bg-white px-3 py-1.5 text-xs font-medium text-ocean-700 hover:bg-ocean-50 disabled:opacity-50 dark:border-ocean-700 dark:bg-neutral-900 dark:text-ocean-300 dark:hover:bg-ocean-900/40">
|
||||
{t('orchestration.detail.resume')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase timeline */}
|
||||
<ol className="space-y-2" data-testid="workflow-phase-list">
|
||||
{phaseNames.map(name => {
|
||||
const phaseDef = definition?.phases.find(p => p.name === name);
|
||||
const state: WorkflowPhaseState = run.phaseStates[name] ?? {
|
||||
status: 'pending',
|
||||
outputs: [],
|
||||
};
|
||||
const isOpen = expanded[name] ?? false;
|
||||
const hasOutputs = state.outputs.length > 0;
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
data-testid={`workflow-phase-${name}`}
|
||||
className="rounded-xl border border-stone-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(name)}
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 flex-none rounded-full ${PHASE_STATUS_DOT[state.status]}`}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium text-stone-800 dark:text-neutral-100">
|
||||
{phaseDef?.name ?? name}
|
||||
</span>
|
||||
<span
|
||||
data-testid={`workflow-phase-status-${name}`}
|
||||
className="rounded-md border border-stone-200 px-1.5 py-0.5 text-[10px] font-medium text-stone-500 dark:border-neutral-700 dark:text-neutral-400">
|
||||
{t(PHASE_STATUS_KEY[state.status])}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-none items-center gap-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{hasOutputs && (
|
||||
<span data-testid={`workflow-phase-count-${name}`}>
|
||||
{state.outputs.length} {t('orchestration.detail.agents')}
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden>{isOpen ? '▾' : '▸'}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{phaseDef?.description && (
|
||||
<p className="px-3 pb-1 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{phaseDef.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'failed' && state.reason && (
|
||||
<p className="mx-3 mb-2 rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{state.reason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Child agent refs for this phase */}
|
||||
{isOpen && hasOutputs && (
|
||||
<ul className="space-y-2 px-3 pb-3" data-testid={`workflow-phase-outputs-${name}`}>
|
||||
{state.outputs.map((out, idx) => (
|
||||
<li
|
||||
key={`${out.orchestrationId}-${idx}`}
|
||||
className="rounded-lg border border-stone-100 bg-stone-50 p-2 dark:border-neutral-800 dark:bg-neutral-800/40">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200">
|
||||
{out.agentId}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{out.orchestrationId}
|
||||
</span>
|
||||
</div>
|
||||
{out.output && (
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-xs leading-snug text-stone-600 dark:text-neutral-300">
|
||||
{out.output}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Child agent refs summary (full run-level list) */}
|
||||
{run.childRunIds.length > 0 && (
|
||||
<div
|
||||
className="text-[11px] text-stone-400 dark:text-neutral-500"
|
||||
data-testid="workflow-child-refs">
|
||||
{t('orchestration.detail.childRefs')}: {run.childRunIds.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final synthesis */}
|
||||
{run.summary && TERMINAL_STATUSES.includes(run.status) && (
|
||||
<div
|
||||
data-testid="workflow-run-summary"
|
||||
className="rounded-xl border border-sage-200 bg-sage-50 p-3 dark:border-sage-500/30 dark:bg-sage-500/10">
|
||||
<p className="mb-1 text-xs font-semibold text-sage-800 dark:text-sage-200">
|
||||
{t('orchestration.detail.synthesis')}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-snug text-stone-700 dark:text-neutral-200">
|
||||
{run.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowRunDetail;
|
||||
@@ -5360,6 +5360,55 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'أتحدث…',
|
||||
'notch.transcribing': 'أفسّر…',
|
||||
'notch.executing': 'أنفّذ…',
|
||||
'memory.tab.orchestration': 'التنسيق',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'شغّل سير عمل متعدد الوكلاء — وزّع سؤالاً على وكلاء متوازين، وتحقّق من نتائجهم بشكل متبادل، وراقب اكتمال كل مرحلة لتتجمّع في إجابة واحدة مُركَّبة.',
|
||||
'orchestration.subtitle':
|
||||
'ابدأ سير عمل متعدد الوكلاء، وراقب تقدّم مراحله، واقرأ النتيجة المُركَّبة.',
|
||||
'orchestration.loading': 'جارٍ تحميل سير العمل…',
|
||||
'orchestration.failedToLoad': 'تعذّر تحميل سير العمل',
|
||||
'orchestration.definitions': 'سير العمل المتاح',
|
||||
'orchestration.noDefinitions': 'لا توجد تعريفات سير عمل متاحة.',
|
||||
'orchestration.approvalRequired': 'الموافقة مطلوبة',
|
||||
'orchestration.start': 'بدء',
|
||||
'orchestration.confirmStart': 'بدء التشغيل',
|
||||
'orchestration.starting': 'جارٍ البدء…',
|
||||
'orchestration.questionLabel': 'سؤال البحث (اختياري)',
|
||||
'orchestration.questionPlaceholder': 'ما الذي يجب أن يبحث فيه الوكلاء؟',
|
||||
'orchestration.runProgress': 'تقدّم التشغيل',
|
||||
'orchestration.recentRuns': 'عمليات التشغيل الأخيرة',
|
||||
'orchestration.noRuns': 'لا توجد عمليات تشغيل لسير العمل بعد.',
|
||||
'orchestration.close': 'إغلاق',
|
||||
'orchestration.tier.readOnly': 'للقراءة فقط',
|
||||
'orchestration.tier.standard': 'قياسي',
|
||||
'orchestration.tier.editCapable': 'قادر على التحرير',
|
||||
'orchestration.approval.title': 'الموافقة على تشغيل سير العمل هذا',
|
||||
'orchestration.approval.body':
|
||||
'هذا تشغيل عالي التكلفة أو عالي التزامن ويتطلّب موافقتك الصريحة قبل أن يبدأ:',
|
||||
'orchestration.approval.reason.tier': 'يمكن لوكلائه تنفيذ إجراءات تتجاوز البحث للقراءة فقط.',
|
||||
'orchestration.approval.reason.concurrency': 'يشغّل العديد من الوكلاء في الوقت نفسه.',
|
||||
'orchestration.approval.reason.children': 'قد يُنشئ عدداً كبيراً جداً من الوكلاء إجمالاً.',
|
||||
'orchestration.approval.tier': 'مستوى الأمان',
|
||||
'orchestration.approval.concurrency': 'التزامن',
|
||||
'orchestration.approval.maxChildren': 'الحد الأقصى للوكلاء',
|
||||
'orchestration.approval.approve': 'الموافقة والبدء',
|
||||
'orchestration.approval.starting': 'جارٍ البدء…',
|
||||
'orchestration.approval.cancel': 'إلغاء',
|
||||
'orchestration.runStatus.pending': 'قيد الانتظار',
|
||||
'orchestration.runStatus.running': 'قيد التشغيل',
|
||||
'orchestration.runStatus.completed': 'مكتمل',
|
||||
'orchestration.runStatus.failed': 'فشل',
|
||||
'orchestration.runStatus.cancelled': 'مُلغى',
|
||||
'orchestration.runStatus.interrupted': 'مُقاطع',
|
||||
'orchestration.phaseStatus.pending': 'قيد الانتظار',
|
||||
'orchestration.phaseStatus.running': 'قيد التشغيل',
|
||||
'orchestration.phaseStatus.completed': 'مكتملة',
|
||||
'orchestration.phaseStatus.failed': 'فشلت',
|
||||
'orchestration.detail.stop': 'إيقاف',
|
||||
'orchestration.detail.resume': 'استئناف',
|
||||
'orchestration.detail.agents': 'وكلاء',
|
||||
'orchestration.detail.childRefs': 'الوكلاء الفرعيون',
|
||||
'orchestration.detail.synthesis': 'التركيب النهائي',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'ملفات الوكيل',
|
||||
'settings.profiles.subtitle': 'وكلاء بطابع خاص — لكل منهم روحه وذاكرته وموصّلاته ومهاراته.',
|
||||
|
||||
@@ -5470,6 +5470,55 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'বলছি…',
|
||||
'notch.transcribing': 'ট্রান্সক্রাইব করছি…',
|
||||
'notch.executing': 'চালাচ্ছি…',
|
||||
'memory.tab.orchestration': 'অর্কেস্ট্রেশন',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'বহু-এজেন্ট ওয়ার্কফ্লো চালান — একটি প্রশ্নকে সমান্তরাল এজেন্টদের মধ্যে ছড়িয়ে দিন, তাদের ফলাফল পরস্পর যাচাই করুন, এবং দেখুন প্রতিটি ধাপ কীভাবে একটি সমন্বিত উত্তরে সম্পূর্ণ হয়।',
|
||||
'orchestration.subtitle':
|
||||
'একটি বহু-এজেন্ট ওয়ার্কফ্লো শুরু করুন, এর ধাপগুলোর অগ্রগতি দেখুন, এবং সমন্বিত ফলাফল পড়ুন।',
|
||||
'orchestration.loading': 'ওয়ার্কফ্লো লোড হচ্ছে…',
|
||||
'orchestration.failedToLoad': 'ওয়ার্কফ্লো লোড করতে ব্যর্থ',
|
||||
'orchestration.definitions': 'উপলব্ধ ওয়ার্কফ্লো',
|
||||
'orchestration.noDefinitions': 'কোনো ওয়ার্কফ্লো সংজ্ঞা উপলব্ধ নেই।',
|
||||
'orchestration.approvalRequired': 'অনুমোদন প্রয়োজন',
|
||||
'orchestration.start': 'শুরু করুন',
|
||||
'orchestration.confirmStart': 'রান শুরু করুন',
|
||||
'orchestration.starting': 'শুরু হচ্ছে…',
|
||||
'orchestration.questionLabel': 'গবেষণা প্রশ্ন (ঐচ্ছিক)',
|
||||
'orchestration.questionPlaceholder': 'এজেন্টদের কী গবেষণা করা উচিত?',
|
||||
'orchestration.runProgress': 'রানের অগ্রগতি',
|
||||
'orchestration.recentRuns': 'সাম্প্রতিক রান',
|
||||
'orchestration.noRuns': 'এখনও কোনো ওয়ার্কফ্লো রান নেই।',
|
||||
'orchestration.close': 'বন্ধ করুন',
|
||||
'orchestration.tier.readOnly': 'শুধু-পড়া',
|
||||
'orchestration.tier.standard': 'মানক',
|
||||
'orchestration.tier.editCapable': 'সম্পাদনা-সক্ষম',
|
||||
'orchestration.approval.title': 'এই ওয়ার্কফ্লো রান অনুমোদন করুন',
|
||||
'orchestration.approval.body':
|
||||
'এটি একটি উচ্চ-ব্যয় বা উচ্চ-সমান্তরালতার রান এবং শুরুর আগে এর জন্য আপনার স্পষ্ট অনুমোদন প্রয়োজন:',
|
||||
'orchestration.approval.reason.tier': 'এর এজেন্টরা শুধু-পড়া গবেষণার বাইরেও পদক্ষেপ নিতে পারে।',
|
||||
'orchestration.approval.reason.concurrency': 'এটি একই সময়ে অনেক এজেন্ট চালায়।',
|
||||
'orchestration.approval.reason.children': 'এটি সর্বমোট বিপুল সংখ্যক এজেন্ট তৈরি করতে পারে।',
|
||||
'orchestration.approval.tier': 'নিরাপত্তা স্তর',
|
||||
'orchestration.approval.concurrency': 'সমান্তরালতা',
|
||||
'orchestration.approval.maxChildren': 'সর্বোচ্চ এজেন্ট',
|
||||
'orchestration.approval.approve': 'অনুমোদন ও শুরু করুন',
|
||||
'orchestration.approval.starting': 'শুরু হচ্ছে…',
|
||||
'orchestration.approval.cancel': 'বাতিল',
|
||||
'orchestration.runStatus.pending': 'অপেক্ষমাণ',
|
||||
'orchestration.runStatus.running': 'চলছে',
|
||||
'orchestration.runStatus.completed': 'সম্পন্ন',
|
||||
'orchestration.runStatus.failed': 'ব্যর্থ',
|
||||
'orchestration.runStatus.cancelled': 'বাতিল',
|
||||
'orchestration.runStatus.interrupted': 'বিঘ্নিত',
|
||||
'orchestration.phaseStatus.pending': 'অপেক্ষমাণ',
|
||||
'orchestration.phaseStatus.running': 'চলছে',
|
||||
'orchestration.phaseStatus.completed': 'সম্পন্ন',
|
||||
'orchestration.phaseStatus.failed': 'ব্যর্থ',
|
||||
'orchestration.detail.stop': 'থামান',
|
||||
'orchestration.detail.resume': 'পুনরায় শুরু করুন',
|
||||
'orchestration.detail.agents': 'এজেন্ট',
|
||||
'orchestration.detail.childRefs': 'চাইল্ড এজেন্ট',
|
||||
'orchestration.detail.synthesis': 'চূড়ান্ত সংশ্লেষণ',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'এজেন্ট প্রোফাইল',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5619,6 +5619,56 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'Spreche…',
|
||||
'notch.transcribing': 'Transkribiere…',
|
||||
'notch.executing': 'Führe aus…',
|
||||
'memory.tab.orchestration': 'Orchestrierung',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Führe Multi-Agenten-Workflows aus — verteile eine Frage auf parallele Agenten, gleiche ihre Ergebnisse ab und sieh zu, wie jede Phase zu einer zusammengeführten Antwort wird.',
|
||||
'orchestration.subtitle':
|
||||
'Starte einen Multi-Agenten-Workflow, verfolge seinen Phasenfortschritt und lies das zusammengeführte Ergebnis.',
|
||||
'orchestration.loading': 'Lade Workflows…',
|
||||
'orchestration.failedToLoad': 'Workflows konnten nicht geladen werden',
|
||||
'orchestration.definitions': 'Verfügbare Workflows',
|
||||
'orchestration.noDefinitions': 'Keine Workflow-Definitionen verfügbar.',
|
||||
'orchestration.approvalRequired': 'Genehmigung erforderlich',
|
||||
'orchestration.start': 'Starten',
|
||||
'orchestration.confirmStart': 'Lauf starten',
|
||||
'orchestration.starting': 'Wird gestartet…',
|
||||
'orchestration.questionLabel': 'Forschungsfrage (optional)',
|
||||
'orchestration.questionPlaceholder': 'Was sollen die Agenten erforschen?',
|
||||
'orchestration.runProgress': 'Lauffortschritt',
|
||||
'orchestration.recentRuns': 'Letzte Läufe',
|
||||
'orchestration.noRuns': 'Noch keine Workflow-Läufe.',
|
||||
'orchestration.close': 'Schließen',
|
||||
'orchestration.tier.readOnly': 'Nur lesen',
|
||||
'orchestration.tier.standard': 'Standard',
|
||||
'orchestration.tier.editCapable': 'Bearbeitungsfähig',
|
||||
'orchestration.approval.title': 'Diesen Workflow-Lauf genehmigen',
|
||||
'orchestration.approval.body':
|
||||
'Dies ist ein kosten- oder parallelitätsintensiver Lauf und benötigt vor dem Start deine ausdrückliche Genehmigung:',
|
||||
'orchestration.approval.reason.tier':
|
||||
'Seine Agenten können über reine Lese-Recherche hinaus Aktionen ausführen.',
|
||||
'orchestration.approval.reason.concurrency': 'Er führt viele Agenten gleichzeitig aus.',
|
||||
'orchestration.approval.reason.children': 'Er kann insgesamt sehr viele Agenten erzeugen.',
|
||||
'orchestration.approval.tier': 'Sicherheitsstufe',
|
||||
'orchestration.approval.concurrency': 'Parallelität',
|
||||
'orchestration.approval.maxChildren': 'Max. Agenten',
|
||||
'orchestration.approval.approve': 'Genehmigen & starten',
|
||||
'orchestration.approval.starting': 'Wird gestartet…',
|
||||
'orchestration.approval.cancel': 'Abbrechen',
|
||||
'orchestration.runStatus.pending': 'Ausstehend',
|
||||
'orchestration.runStatus.running': 'Läuft',
|
||||
'orchestration.runStatus.completed': 'Abgeschlossen',
|
||||
'orchestration.runStatus.failed': 'Fehlgeschlagen',
|
||||
'orchestration.runStatus.cancelled': 'Abgebrochen',
|
||||
'orchestration.runStatus.interrupted': 'Unterbrochen',
|
||||
'orchestration.phaseStatus.pending': 'Ausstehend',
|
||||
'orchestration.phaseStatus.running': 'Läuft',
|
||||
'orchestration.phaseStatus.completed': 'Abgeschlossen',
|
||||
'orchestration.phaseStatus.failed': 'Fehlgeschlagen',
|
||||
'orchestration.detail.stop': 'Stoppen',
|
||||
'orchestration.detail.resume': 'Fortsetzen',
|
||||
'orchestration.detail.agents': 'Agenten',
|
||||
'orchestration.detail.childRefs': 'Untergeordnete Agenten',
|
||||
'orchestration.detail.synthesis': 'Endgültige Zusammenführung',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'Agentenprofile',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -464,6 +464,9 @@ const en: TranslationMap = {
|
||||
'memory.tab.workflows': 'Workflows',
|
||||
'memory.tab.workflowsDescription':
|
||||
'Reusable, runnable procedures — a goal plus the steps to reach it. Create one, install from a URL, or open a workflow to run it.',
|
||||
'memory.tab.orchestration': 'Orchestration',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Run multi-agent workflows — fan a question out across parallel agents, cross-check their findings, and watch each phase finish into one synthesized answer.',
|
||||
'memory.tab.dreams': 'Dreams',
|
||||
'memory.tab.calls': 'Calls',
|
||||
'memory.tab.diagram': 'Diagram',
|
||||
@@ -3557,6 +3560,53 @@ const en: TranslationMap = {
|
||||
'intelligence.agentWork.action.continuePlaceholder': 'Reply to unblock this agent…',
|
||||
'intelligence.agentWork.action.followUpPlaceholder': 'Send a follow-up instruction…',
|
||||
'intelligence.agentWork.action.failed': 'Action failed',
|
||||
// Orchestration tab (#3375) — multi-agent workflow runs.
|
||||
'orchestration.subtitle':
|
||||
'Start a multi-agent workflow, watch its phases progress, and read the synthesized result.',
|
||||
'orchestration.loading': 'Loading workflows…',
|
||||
'orchestration.failedToLoad': 'Failed to load workflows',
|
||||
'orchestration.definitions': 'Available workflows',
|
||||
'orchestration.noDefinitions': 'No workflow definitions are available.',
|
||||
'orchestration.approvalRequired': 'Approval required',
|
||||
'orchestration.start': 'Start',
|
||||
'orchestration.confirmStart': 'Start run',
|
||||
'orchestration.starting': 'Starting…',
|
||||
'orchestration.questionLabel': 'Research question (optional)',
|
||||
'orchestration.questionPlaceholder': 'What should the agents research?',
|
||||
'orchestration.runProgress': 'Run progress',
|
||||
'orchestration.recentRuns': 'Recent runs',
|
||||
'orchestration.noRuns': 'No workflow runs yet.',
|
||||
'orchestration.close': 'Close',
|
||||
'orchestration.tier.readOnly': 'Read-only',
|
||||
'orchestration.tier.standard': 'Standard',
|
||||
'orchestration.tier.editCapable': 'Edit-capable',
|
||||
'orchestration.approval.title': 'Approve this workflow run',
|
||||
'orchestration.approval.body':
|
||||
'This is a high-cost or high-concurrency run and needs your explicit approval before it starts:',
|
||||
'orchestration.approval.reason.tier': 'Its agents can take actions beyond read-only research.',
|
||||
'orchestration.approval.reason.concurrency': 'It runs many agents at the same time.',
|
||||
'orchestration.approval.reason.children': 'It can spawn a large number of agents in total.',
|
||||
'orchestration.approval.tier': 'Safety tier',
|
||||
'orchestration.approval.concurrency': 'Concurrency',
|
||||
'orchestration.approval.maxChildren': 'Max agents',
|
||||
'orchestration.approval.approve': 'Approve & start',
|
||||
'orchestration.approval.starting': 'Starting…',
|
||||
'orchestration.approval.cancel': 'Cancel',
|
||||
'orchestration.runStatus.pending': 'Pending',
|
||||
'orchestration.runStatus.running': 'Running',
|
||||
'orchestration.runStatus.completed': 'Completed',
|
||||
'orchestration.runStatus.failed': 'Failed',
|
||||
'orchestration.runStatus.cancelled': 'Cancelled',
|
||||
'orchestration.runStatus.interrupted': 'Interrupted',
|
||||
'orchestration.phaseStatus.pending': 'Pending',
|
||||
'orchestration.phaseStatus.running': 'Running',
|
||||
'orchestration.phaseStatus.completed': 'Completed',
|
||||
'orchestration.phaseStatus.failed': 'Failed',
|
||||
'orchestration.detail.stop': 'Stop',
|
||||
'orchestration.detail.resume': 'Resume',
|
||||
'orchestration.detail.agents': 'agents',
|
||||
'orchestration.detail.childRefs': 'Child agents',
|
||||
'orchestration.detail.synthesis': 'Final synthesis',
|
||||
'intelligence.teams.subtitle': 'Coordinated agent teams and the tasks they share.',
|
||||
'intelligence.teams.loading': 'Loading teams…',
|
||||
'intelligence.teams.failedToLoad': 'Failed to load teams',
|
||||
|
||||
@@ -5584,6 +5584,56 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'Hablando…',
|
||||
'notch.transcribing': 'Transcribiendo…',
|
||||
'notch.executing': 'Ejecutando…',
|
||||
'memory.tab.orchestration': 'Orquestación',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Ejecuta flujos de trabajo con múltiples agentes: distribuye una pregunta entre agentes paralelos, contrasta sus hallazgos y observa cómo cada fase culmina en una única respuesta sintetizada.',
|
||||
'orchestration.subtitle':
|
||||
'Inicia un flujo de trabajo con múltiples agentes, observa el avance de sus fases y lee el resultado sintetizado.',
|
||||
'orchestration.loading': 'Cargando flujos de trabajo…',
|
||||
'orchestration.failedToLoad': 'No se pudieron cargar los flujos de trabajo',
|
||||
'orchestration.definitions': 'Flujos de trabajo disponibles',
|
||||
'orchestration.noDefinitions': 'No hay definiciones de flujos de trabajo disponibles.',
|
||||
'orchestration.approvalRequired': 'Aprobación requerida',
|
||||
'orchestration.start': 'Iniciar',
|
||||
'orchestration.confirmStart': 'Iniciar ejecución',
|
||||
'orchestration.starting': 'Iniciando…',
|
||||
'orchestration.questionLabel': 'Pregunta de investigación (opcional)',
|
||||
'orchestration.questionPlaceholder': '¿Qué deben investigar los agentes?',
|
||||
'orchestration.runProgress': 'Progreso de la ejecución',
|
||||
'orchestration.recentRuns': 'Ejecuciones recientes',
|
||||
'orchestration.noRuns': 'Aún no hay ejecuciones de flujos de trabajo.',
|
||||
'orchestration.close': 'Cerrar',
|
||||
'orchestration.tier.readOnly': 'Solo lectura',
|
||||
'orchestration.tier.standard': 'Estándar',
|
||||
'orchestration.tier.editCapable': 'Con capacidad de edición',
|
||||
'orchestration.approval.title': 'Aprobar esta ejecución del flujo de trabajo',
|
||||
'orchestration.approval.body':
|
||||
'Esta es una ejecución de alto costo o alta concurrencia y necesita tu aprobación explícita antes de iniciar:',
|
||||
'orchestration.approval.reason.tier':
|
||||
'Sus agentes pueden realizar acciones más allá de la investigación de solo lectura.',
|
||||
'orchestration.approval.reason.concurrency': 'Ejecuta muchos agentes al mismo tiempo.',
|
||||
'orchestration.approval.reason.children': 'Puede generar una gran cantidad de agentes en total.',
|
||||
'orchestration.approval.tier': 'Nivel de seguridad',
|
||||
'orchestration.approval.concurrency': 'Concurrencia',
|
||||
'orchestration.approval.maxChildren': 'Máx. de agentes',
|
||||
'orchestration.approval.approve': 'Aprobar e iniciar',
|
||||
'orchestration.approval.starting': 'Iniciando…',
|
||||
'orchestration.approval.cancel': 'Cancelar',
|
||||
'orchestration.runStatus.pending': 'Pendiente',
|
||||
'orchestration.runStatus.running': 'En ejecución',
|
||||
'orchestration.runStatus.completed': 'Completada',
|
||||
'orchestration.runStatus.failed': 'Fallida',
|
||||
'orchestration.runStatus.cancelled': 'Cancelada',
|
||||
'orchestration.runStatus.interrupted': 'Interrumpida',
|
||||
'orchestration.phaseStatus.pending': 'Pendiente',
|
||||
'orchestration.phaseStatus.running': 'En ejecución',
|
||||
'orchestration.phaseStatus.completed': 'Completada',
|
||||
'orchestration.phaseStatus.failed': 'Fallida',
|
||||
'orchestration.detail.stop': 'Detener',
|
||||
'orchestration.detail.resume': 'Reanudar',
|
||||
'orchestration.detail.agents': 'agentes',
|
||||
'orchestration.detail.childRefs': 'Agentes secundarios',
|
||||
'orchestration.detail.synthesis': 'Síntesis final',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'Perfiles de agente',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5604,6 +5604,57 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'Je parle…',
|
||||
'notch.transcribing': 'Transcription…',
|
||||
'notch.executing': "J'exécute…",
|
||||
'memory.tab.orchestration': 'Orchestration',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Exécutez des workflows multi-agents — répartissez une question entre des agents parallèles, recoupez leurs résultats et regardez chaque phase aboutir à une réponse synthétisée unique.',
|
||||
'orchestration.subtitle':
|
||||
'Démarrez un workflow multi-agents, suivez la progression de ses phases et lisez le résultat synthétisé.',
|
||||
'orchestration.loading': 'Chargement des workflows…',
|
||||
'orchestration.failedToLoad': 'Échec du chargement des workflows',
|
||||
'orchestration.definitions': 'Workflows disponibles',
|
||||
'orchestration.noDefinitions': 'Aucune définition de workflow disponible.',
|
||||
'orchestration.approvalRequired': 'Approbation requise',
|
||||
'orchestration.start': 'Démarrer',
|
||||
'orchestration.confirmStart': "Démarrer l'exécution",
|
||||
'orchestration.starting': 'Démarrage…',
|
||||
'orchestration.questionLabel': 'Question de recherche (facultatif)',
|
||||
'orchestration.questionPlaceholder': 'Que doivent rechercher les agents ?',
|
||||
'orchestration.runProgress': "Progression de l'exécution",
|
||||
'orchestration.recentRuns': 'Exécutions récentes',
|
||||
'orchestration.noRuns': 'Aucune exécution de workflow pour le moment.',
|
||||
'orchestration.close': 'Fermer',
|
||||
'orchestration.tier.readOnly': 'Lecture seule',
|
||||
'orchestration.tier.standard': 'Standard',
|
||||
'orchestration.tier.editCapable': "Capable d'édition",
|
||||
'orchestration.approval.title': 'Approuver cette exécution de workflow',
|
||||
'orchestration.approval.body':
|
||||
"Il s'agit d'une exécution coûteuse ou très concurrente qui nécessite votre approbation explicite avant de démarrer :",
|
||||
'orchestration.approval.reason.tier':
|
||||
'Ses agents peuvent effectuer des actions au-delà de la recherche en lecture seule.',
|
||||
'orchestration.approval.reason.concurrency': 'Il exécute de nombreux agents en même temps.',
|
||||
'orchestration.approval.reason.children':
|
||||
"Il peut générer un très grand nombre d'agents au total.",
|
||||
'orchestration.approval.tier': 'Niveau de sécurité',
|
||||
'orchestration.approval.concurrency': 'Concurrence',
|
||||
'orchestration.approval.maxChildren': "Nombre max d'agents",
|
||||
'orchestration.approval.approve': 'Approuver et démarrer',
|
||||
'orchestration.approval.starting': 'Démarrage…',
|
||||
'orchestration.approval.cancel': 'Annuler',
|
||||
'orchestration.runStatus.pending': 'En attente',
|
||||
'orchestration.runStatus.running': 'En cours',
|
||||
'orchestration.runStatus.completed': 'Terminée',
|
||||
'orchestration.runStatus.failed': 'Échouée',
|
||||
'orchestration.runStatus.cancelled': 'Annulée',
|
||||
'orchestration.runStatus.interrupted': 'Interrompue',
|
||||
'orchestration.phaseStatus.pending': 'En attente',
|
||||
'orchestration.phaseStatus.running': 'En cours',
|
||||
'orchestration.phaseStatus.completed': 'Terminée',
|
||||
'orchestration.phaseStatus.failed': 'Échouée',
|
||||
'orchestration.detail.stop': 'Arrêter',
|
||||
'orchestration.detail.resume': 'Reprendre',
|
||||
'orchestration.detail.agents': 'agents',
|
||||
'orchestration.detail.childRefs': 'Agents enfants',
|
||||
'orchestration.detail.synthesis': 'Synthèse finale',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': "Profils d'agent",
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5471,6 +5471,56 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'बोल रहा हूं…',
|
||||
'notch.transcribing': 'ट्रांसक्राइब कर रहा हूं…',
|
||||
'notch.executing': 'चला रहा हूं…',
|
||||
'memory.tab.orchestration': 'ऑर्केस्ट्रेशन',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'बहु-एजेंट वर्कफ़्लो चलाएं — एक प्रश्न को समानांतर एजेंटों में बाँटें, उनके निष्कर्षों की परस्पर जाँच करें, और देखें कि हर चरण एक संयोजित उत्तर में पूरा होता है।',
|
||||
'orchestration.subtitle':
|
||||
'बहु-एजेंट वर्कफ़्लो शुरू करें, उसके चरणों की प्रगति देखें, और संयोजित परिणाम पढ़ें।',
|
||||
'orchestration.loading': 'वर्कफ़्लो लोड हो रहे हैं…',
|
||||
'orchestration.failedToLoad': 'वर्कफ़्लो लोड करने में विफल',
|
||||
'orchestration.definitions': 'उपलब्ध वर्कफ़्लो',
|
||||
'orchestration.noDefinitions': 'कोई वर्कफ़्लो परिभाषा उपलब्ध नहीं है।',
|
||||
'orchestration.approvalRequired': 'अनुमोदन आवश्यक',
|
||||
'orchestration.start': 'शुरू करें',
|
||||
'orchestration.confirmStart': 'रन शुरू करें',
|
||||
'orchestration.starting': 'शुरू हो रहा है…',
|
||||
'orchestration.questionLabel': 'शोध प्रश्न (वैकल्पिक)',
|
||||
'orchestration.questionPlaceholder': 'एजेंटों को क्या शोध करना चाहिए?',
|
||||
'orchestration.runProgress': 'रन प्रगति',
|
||||
'orchestration.recentRuns': 'हाल के रन',
|
||||
'orchestration.noRuns': 'अभी तक कोई वर्कफ़्लो रन नहीं।',
|
||||
'orchestration.close': 'बंद करें',
|
||||
'orchestration.tier.readOnly': 'केवल-पढ़ने योग्य',
|
||||
'orchestration.tier.standard': 'मानक',
|
||||
'orchestration.tier.editCapable': 'संपादन-सक्षम',
|
||||
'orchestration.approval.title': 'इस वर्कफ़्लो रन को अनुमोदित करें',
|
||||
'orchestration.approval.body':
|
||||
'यह एक उच्च-लागत या उच्च-समवर्ती रन है और शुरू होने से पहले इसे आपके स्पष्ट अनुमोदन की आवश्यकता है:',
|
||||
'orchestration.approval.reason.tier': 'इसके एजेंट केवल-पढ़ने योग्य शोध से परे कार्य कर सकते हैं।',
|
||||
'orchestration.approval.reason.concurrency': 'यह एक साथ कई एजेंट चलाता है।',
|
||||
'orchestration.approval.reason.children':
|
||||
'यह कुल मिलाकर बहुत बड़ी संख्या में एजेंट उत्पन्न कर सकता है।',
|
||||
'orchestration.approval.tier': 'सुरक्षा स्तर',
|
||||
'orchestration.approval.concurrency': 'समवर्तीता',
|
||||
'orchestration.approval.maxChildren': 'अधिकतम एजेंट',
|
||||
'orchestration.approval.approve': 'अनुमोदित करें और शुरू करें',
|
||||
'orchestration.approval.starting': 'शुरू हो रहा है…',
|
||||
'orchestration.approval.cancel': 'रद्द करें',
|
||||
'orchestration.runStatus.pending': 'लंबित',
|
||||
'orchestration.runStatus.running': 'चल रहा है',
|
||||
'orchestration.runStatus.completed': 'पूर्ण',
|
||||
'orchestration.runStatus.failed': 'विफल',
|
||||
'orchestration.runStatus.cancelled': 'रद्द',
|
||||
'orchestration.runStatus.interrupted': 'बाधित',
|
||||
'orchestration.phaseStatus.pending': 'लंबित',
|
||||
'orchestration.phaseStatus.running': 'चल रहा है',
|
||||
'orchestration.phaseStatus.completed': 'पूर्ण',
|
||||
'orchestration.phaseStatus.failed': 'विफल',
|
||||
'orchestration.detail.stop': 'रोकें',
|
||||
'orchestration.detail.resume': 'पुनः आरंभ करें',
|
||||
'orchestration.detail.agents': 'एजेंट',
|
||||
'orchestration.detail.childRefs': 'चाइल्ड एजेंट',
|
||||
'orchestration.detail.synthesis': 'अंतिम संश्लेषण',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'एजेंट प्रोफ़ाइल',
|
||||
'settings.profiles.subtitle': 'विशिष्ट एजेंट — हर एक की अपनी आत्मा, स्मृति, कनेक्टर और कौशल।',
|
||||
|
||||
@@ -5488,6 +5488,57 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'Berbicara…',
|
||||
'notch.transcribing': 'Mentranskrip…',
|
||||
'notch.executing': 'Mengeksekusi…',
|
||||
'memory.tab.orchestration': 'Orkestrasi',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Jalankan alur kerja multi-agen — sebarkan satu pertanyaan ke agen-agen paralel, periksa silang temuan mereka, dan saksikan setiap fase berakhir menjadi satu jawaban tersintesis.',
|
||||
'orchestration.subtitle':
|
||||
'Mulai alur kerja multi-agen, pantau kemajuan fasenya, dan baca hasil tersintesisnya.',
|
||||
'orchestration.loading': 'Memuat alur kerja…',
|
||||
'orchestration.failedToLoad': 'Gagal memuat alur kerja',
|
||||
'orchestration.definitions': 'Alur kerja tersedia',
|
||||
'orchestration.noDefinitions': 'Tidak ada definisi alur kerja yang tersedia.',
|
||||
'orchestration.approvalRequired': 'Persetujuan diperlukan',
|
||||
'orchestration.start': 'Mulai',
|
||||
'orchestration.confirmStart': 'Mulai jalankan',
|
||||
'orchestration.starting': 'Memulai…',
|
||||
'orchestration.questionLabel': 'Pertanyaan riset (opsional)',
|
||||
'orchestration.questionPlaceholder': 'Apa yang harus diteliti para agen?',
|
||||
'orchestration.runProgress': 'Kemajuan eksekusi',
|
||||
'orchestration.recentRuns': 'Eksekusi terbaru',
|
||||
'orchestration.noRuns': 'Belum ada eksekusi alur kerja.',
|
||||
'orchestration.close': 'Tutup',
|
||||
'orchestration.tier.readOnly': 'Hanya-baca',
|
||||
'orchestration.tier.standard': 'Standar',
|
||||
'orchestration.tier.editCapable': 'Mampu menyunting',
|
||||
'orchestration.approval.title': 'Setujui eksekusi alur kerja ini',
|
||||
'orchestration.approval.body':
|
||||
'Ini adalah eksekusi berbiaya tinggi atau berkonkurensi tinggi dan memerlukan persetujuan eksplisit Anda sebelum dimulai:',
|
||||
'orchestration.approval.reason.tier':
|
||||
'Agen-agennya dapat melakukan tindakan di luar riset hanya-baca.',
|
||||
'orchestration.approval.reason.concurrency': 'Ini menjalankan banyak agen pada saat yang sama.',
|
||||
'orchestration.approval.reason.children':
|
||||
'Ini dapat memunculkan agen dalam jumlah sangat besar secara total.',
|
||||
'orchestration.approval.tier': 'Tingkat keamanan',
|
||||
'orchestration.approval.concurrency': 'Konkurensi',
|
||||
'orchestration.approval.maxChildren': 'Maks. agen',
|
||||
'orchestration.approval.approve': 'Setujui & mulai',
|
||||
'orchestration.approval.starting': 'Memulai…',
|
||||
'orchestration.approval.cancel': 'Batal',
|
||||
'orchestration.runStatus.pending': 'Menunggu',
|
||||
'orchestration.runStatus.running': 'Berjalan',
|
||||
'orchestration.runStatus.completed': 'Selesai',
|
||||
'orchestration.runStatus.failed': 'Gagal',
|
||||
'orchestration.runStatus.cancelled': 'Dibatalkan',
|
||||
'orchestration.runStatus.interrupted': 'Terputus',
|
||||
'orchestration.phaseStatus.pending': 'Menunggu',
|
||||
'orchestration.phaseStatus.running': 'Berjalan',
|
||||
'orchestration.phaseStatus.completed': 'Selesai',
|
||||
'orchestration.phaseStatus.failed': 'Gagal',
|
||||
'orchestration.detail.stop': 'Hentikan',
|
||||
'orchestration.detail.resume': 'Lanjutkan',
|
||||
'orchestration.detail.agents': 'agen',
|
||||
'orchestration.detail.childRefs': 'Agen anak',
|
||||
'orchestration.detail.synthesis': 'Sintesis akhir',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'Profil Agen',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5574,6 +5574,56 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'Parlo…',
|
||||
'notch.transcribing': 'Trascrizione…',
|
||||
'notch.executing': 'Eseguendo…',
|
||||
'memory.tab.orchestration': 'Orchestrazione',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Esegui flussi di lavoro multi-agente — distribuisci una domanda tra agenti paralleli, confronta i loro risultati e osserva ogni fase concludersi in un’unica risposta sintetizzata.',
|
||||
'orchestration.subtitle':
|
||||
'Avvia un flusso di lavoro multi-agente, osserva l’avanzamento delle sue fasi e leggi il risultato sintetizzato.',
|
||||
'orchestration.loading': 'Caricamento dei flussi di lavoro…',
|
||||
'orchestration.failedToLoad': 'Impossibile caricare i flussi di lavoro',
|
||||
'orchestration.definitions': 'Flussi di lavoro disponibili',
|
||||
'orchestration.noDefinitions': 'Nessuna definizione di flusso di lavoro disponibile.',
|
||||
'orchestration.approvalRequired': 'Approvazione richiesta',
|
||||
'orchestration.start': 'Avvia',
|
||||
'orchestration.confirmStart': 'Avvia esecuzione',
|
||||
'orchestration.starting': 'Avvio in corso…',
|
||||
'orchestration.questionLabel': 'Domanda di ricerca (facoltativa)',
|
||||
'orchestration.questionPlaceholder': 'Cosa devono ricercare gli agenti?',
|
||||
'orchestration.runProgress': 'Avanzamento esecuzione',
|
||||
'orchestration.recentRuns': 'Esecuzioni recenti',
|
||||
'orchestration.noRuns': 'Ancora nessuna esecuzione di flussi di lavoro.',
|
||||
'orchestration.close': 'Chiudi',
|
||||
'orchestration.tier.readOnly': 'Sola lettura',
|
||||
'orchestration.tier.standard': 'Standard',
|
||||
'orchestration.tier.editCapable': 'Con capacità di modifica',
|
||||
'orchestration.approval.title': 'Approva questa esecuzione del flusso di lavoro',
|
||||
'orchestration.approval.body':
|
||||
'Questa è un’esecuzione ad alto costo o ad alta concorrenza e richiede la tua approvazione esplicita prima di iniziare:',
|
||||
'orchestration.approval.reason.tier':
|
||||
'I suoi agenti possono compiere azioni oltre la ricerca in sola lettura.',
|
||||
'orchestration.approval.reason.concurrency': 'Esegue molti agenti contemporaneamente.',
|
||||
'orchestration.approval.reason.children': 'Può generare un gran numero di agenti in totale.',
|
||||
'orchestration.approval.tier': 'Livello di sicurezza',
|
||||
'orchestration.approval.concurrency': 'Concorrenza',
|
||||
'orchestration.approval.maxChildren': 'Numero massimo di agenti',
|
||||
'orchestration.approval.approve': 'Approva e avvia',
|
||||
'orchestration.approval.starting': 'Avvio in corso…',
|
||||
'orchestration.approval.cancel': 'Annulla',
|
||||
'orchestration.runStatus.pending': 'In attesa',
|
||||
'orchestration.runStatus.running': 'In esecuzione',
|
||||
'orchestration.runStatus.completed': 'Completata',
|
||||
'orchestration.runStatus.failed': 'Non riuscita',
|
||||
'orchestration.runStatus.cancelled': 'Annullata',
|
||||
'orchestration.runStatus.interrupted': 'Interrotta',
|
||||
'orchestration.phaseStatus.pending': 'In attesa',
|
||||
'orchestration.phaseStatus.running': 'In esecuzione',
|
||||
'orchestration.phaseStatus.completed': 'Completata',
|
||||
'orchestration.phaseStatus.failed': 'Non riuscita',
|
||||
'orchestration.detail.stop': 'Arresta',
|
||||
'orchestration.detail.resume': 'Riprendi',
|
||||
'orchestration.detail.agents': 'agenti',
|
||||
'orchestration.detail.childRefs': 'Agenti figli',
|
||||
'orchestration.detail.synthesis': 'Sintesi finale',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'Profili agente',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5416,6 +5416,57 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': '말하는 중…',
|
||||
'notch.transcribing': '변환 중…',
|
||||
'notch.executing': '실행 중…',
|
||||
'memory.tab.orchestration': '오케스트레이션',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'다중 에이전트 워크플로를 실행하세요. 하나의 질문을 병렬 에이전트들에 분산하고, 그 결과를 교차 검증하며, 각 단계가 하나의 종합된 답변으로 완성되는 과정을 지켜보세요.',
|
||||
'orchestration.subtitle':
|
||||
'다중 에이전트 워크플로를 시작하고, 단계 진행 상황을 지켜보며, 종합된 결과를 확인하세요.',
|
||||
'orchestration.loading': '워크플로 불러오는 중…',
|
||||
'orchestration.failedToLoad': '워크플로를 불러오지 못했습니다',
|
||||
'orchestration.definitions': '사용 가능한 워크플로',
|
||||
'orchestration.noDefinitions': '사용 가능한 워크플로 정의가 없습니다.',
|
||||
'orchestration.approvalRequired': '승인 필요',
|
||||
'orchestration.start': '시작',
|
||||
'orchestration.confirmStart': '실행 시작',
|
||||
'orchestration.starting': '시작 중…',
|
||||
'orchestration.questionLabel': '연구 질문 (선택 사항)',
|
||||
'orchestration.questionPlaceholder': '에이전트가 무엇을 조사해야 하나요?',
|
||||
'orchestration.runProgress': '실행 진행 상황',
|
||||
'orchestration.recentRuns': '최근 실행',
|
||||
'orchestration.noRuns': '아직 워크플로 실행이 없습니다.',
|
||||
'orchestration.close': '닫기',
|
||||
'orchestration.tier.readOnly': '읽기 전용',
|
||||
'orchestration.tier.standard': '표준',
|
||||
'orchestration.tier.editCapable': '편집 가능',
|
||||
'orchestration.approval.title': '이 워크플로 실행 승인',
|
||||
'orchestration.approval.body':
|
||||
'이것은 고비용 또는 고동시성 실행으로, 시작하기 전에 명시적인 승인이 필요합니다:',
|
||||
'orchestration.approval.reason.tier':
|
||||
'해당 에이전트는 읽기 전용 조사를 넘어서는 작업을 수행할 수 있습니다.',
|
||||
'orchestration.approval.reason.concurrency': '동시에 많은 에이전트를 실행합니다.',
|
||||
'orchestration.approval.reason.children':
|
||||
'전체적으로 매우 많은 수의 에이전트를 생성할 수 있습니다.',
|
||||
'orchestration.approval.tier': '보안 등급',
|
||||
'orchestration.approval.concurrency': '동시성',
|
||||
'orchestration.approval.maxChildren': '최대 에이전트 수',
|
||||
'orchestration.approval.approve': '승인 및 시작',
|
||||
'orchestration.approval.starting': '시작 중…',
|
||||
'orchestration.approval.cancel': '취소',
|
||||
'orchestration.runStatus.pending': '대기 중',
|
||||
'orchestration.runStatus.running': '실행 중',
|
||||
'orchestration.runStatus.completed': '완료됨',
|
||||
'orchestration.runStatus.failed': '실패함',
|
||||
'orchestration.runStatus.cancelled': '취소됨',
|
||||
'orchestration.runStatus.interrupted': '중단됨',
|
||||
'orchestration.phaseStatus.pending': '대기 중',
|
||||
'orchestration.phaseStatus.running': '실행 중',
|
||||
'orchestration.phaseStatus.completed': '완료됨',
|
||||
'orchestration.phaseStatus.failed': '실패함',
|
||||
'orchestration.detail.stop': '중지',
|
||||
'orchestration.detail.resume': '재개',
|
||||
'orchestration.detail.agents': '에이전트',
|
||||
'orchestration.detail.childRefs': '하위 에이전트',
|
||||
'orchestration.detail.synthesis': '최종 종합',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': '에이전트 프로필',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5557,6 +5557,56 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'Mówię…',
|
||||
'notch.transcribing': 'Transkrybuję…',
|
||||
'notch.executing': 'Wykonuję…',
|
||||
'memory.tab.orchestration': 'Orkiestracja',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Uruchamiaj przepływy pracy z wieloma agentami — rozdziel pytanie między równoległych agentów, porównaj ich ustalenia i obserwuj, jak każda faza kończy się jedną zsyntetyzowaną odpowiedzią.',
|
||||
'orchestration.subtitle':
|
||||
'Uruchom przepływ pracy z wieloma agentami, obserwuj postęp jego faz i przeczytaj zsyntetyzowany wynik.',
|
||||
'orchestration.loading': 'Ładowanie przepływów pracy…',
|
||||
'orchestration.failedToLoad': 'Nie udało się załadować przepływów pracy',
|
||||
'orchestration.definitions': 'Dostępne przepływy pracy',
|
||||
'orchestration.noDefinitions': 'Brak dostępnych definicji przepływów pracy.',
|
||||
'orchestration.approvalRequired': 'Wymagane zatwierdzenie',
|
||||
'orchestration.start': 'Uruchom',
|
||||
'orchestration.confirmStart': 'Uruchom wykonanie',
|
||||
'orchestration.starting': 'Uruchamianie…',
|
||||
'orchestration.questionLabel': 'Pytanie badawcze (opcjonalne)',
|
||||
'orchestration.questionPlaceholder': 'Co agenci mają zbadać?',
|
||||
'orchestration.runProgress': 'Postęp wykonania',
|
||||
'orchestration.recentRuns': 'Ostatnie wykonania',
|
||||
'orchestration.noRuns': 'Brak wykonań przepływów pracy.',
|
||||
'orchestration.close': 'Zamknij',
|
||||
'orchestration.tier.readOnly': 'Tylko do odczytu',
|
||||
'orchestration.tier.standard': 'Standardowy',
|
||||
'orchestration.tier.editCapable': 'Z możliwością edycji',
|
||||
'orchestration.approval.title': 'Zatwierdź to wykonanie przepływu pracy',
|
||||
'orchestration.approval.body':
|
||||
'To kosztowne lub silnie współbieżne wykonanie wymaga Twojego wyraźnego zatwierdzenia przed rozpoczęciem:',
|
||||
'orchestration.approval.reason.tier':
|
||||
'Jego agenci mogą wykonywać działania wykraczające poza badania tylko do odczytu.',
|
||||
'orchestration.approval.reason.concurrency': 'Uruchamia wielu agentów jednocześnie.',
|
||||
'orchestration.approval.reason.children': 'Może łącznie utworzyć bardzo dużą liczbę agentów.',
|
||||
'orchestration.approval.tier': 'Poziom bezpieczeństwa',
|
||||
'orchestration.approval.concurrency': 'Współbieżność',
|
||||
'orchestration.approval.maxChildren': 'Maks. agentów',
|
||||
'orchestration.approval.approve': 'Zatwierdź i uruchom',
|
||||
'orchestration.approval.starting': 'Uruchamianie…',
|
||||
'orchestration.approval.cancel': 'Anuluj',
|
||||
'orchestration.runStatus.pending': 'Oczekujące',
|
||||
'orchestration.runStatus.running': 'W trakcie',
|
||||
'orchestration.runStatus.completed': 'Zakończone',
|
||||
'orchestration.runStatus.failed': 'Nieudane',
|
||||
'orchestration.runStatus.cancelled': 'Anulowane',
|
||||
'orchestration.runStatus.interrupted': 'Przerwane',
|
||||
'orchestration.phaseStatus.pending': 'Oczekująca',
|
||||
'orchestration.phaseStatus.running': 'W trakcie',
|
||||
'orchestration.phaseStatus.completed': 'Zakończona',
|
||||
'orchestration.phaseStatus.failed': 'Nieudana',
|
||||
'orchestration.detail.stop': 'Zatrzymaj',
|
||||
'orchestration.detail.resume': 'Wznów',
|
||||
'orchestration.detail.agents': 'agenci',
|
||||
'orchestration.detail.childRefs': 'Agenci podrzędni',
|
||||
'orchestration.detail.synthesis': 'Synteza końcowa',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'Profile agenta',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5567,6 +5567,56 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'Falando…',
|
||||
'notch.transcribing': 'Transcrevendo…',
|
||||
'notch.executing': 'Executando…',
|
||||
'memory.tab.orchestration': 'Orquestração',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Execute fluxos de trabalho com múltiplos agentes — distribua uma pergunta entre agentes paralelos, confronte seus resultados e veja cada fase culminar em uma única resposta sintetizada.',
|
||||
'orchestration.subtitle':
|
||||
'Inicie um fluxo de trabalho com múltiplos agentes, acompanhe o progresso de suas fases e leia o resultado sintetizado.',
|
||||
'orchestration.loading': 'Carregando fluxos de trabalho…',
|
||||
'orchestration.failedToLoad': 'Falha ao carregar os fluxos de trabalho',
|
||||
'orchestration.definitions': 'Fluxos de trabalho disponíveis',
|
||||
'orchestration.noDefinitions': 'Nenhuma definição de fluxo de trabalho disponível.',
|
||||
'orchestration.approvalRequired': 'Aprovação necessária',
|
||||
'orchestration.start': 'Iniciar',
|
||||
'orchestration.confirmStart': 'Iniciar execução',
|
||||
'orchestration.starting': 'Iniciando…',
|
||||
'orchestration.questionLabel': 'Pergunta de pesquisa (opcional)',
|
||||
'orchestration.questionPlaceholder': 'O que os agentes devem pesquisar?',
|
||||
'orchestration.runProgress': 'Progresso da execução',
|
||||
'orchestration.recentRuns': 'Execuções recentes',
|
||||
'orchestration.noRuns': 'Ainda não há execuções de fluxos de trabalho.',
|
||||
'orchestration.close': 'Fechar',
|
||||
'orchestration.tier.readOnly': 'Somente leitura',
|
||||
'orchestration.tier.standard': 'Padrão',
|
||||
'orchestration.tier.editCapable': 'Com capacidade de edição',
|
||||
'orchestration.approval.title': 'Aprovar esta execução do fluxo de trabalho',
|
||||
'orchestration.approval.body':
|
||||
'Esta é uma execução de alto custo ou alta concorrência e precisa da sua aprovação explícita antes de iniciar:',
|
||||
'orchestration.approval.reason.tier':
|
||||
'Seus agentes podem realizar ações além da pesquisa somente leitura.',
|
||||
'orchestration.approval.reason.concurrency': 'Ela executa muitos agentes ao mesmo tempo.',
|
||||
'orchestration.approval.reason.children': 'Ela pode gerar um grande número de agentes no total.',
|
||||
'orchestration.approval.tier': 'Nível de segurança',
|
||||
'orchestration.approval.concurrency': 'Concorrência',
|
||||
'orchestration.approval.maxChildren': 'Máx. de agentes',
|
||||
'orchestration.approval.approve': 'Aprovar e iniciar',
|
||||
'orchestration.approval.starting': 'Iniciando…',
|
||||
'orchestration.approval.cancel': 'Cancelar',
|
||||
'orchestration.runStatus.pending': 'Pendente',
|
||||
'orchestration.runStatus.running': 'Em execução',
|
||||
'orchestration.runStatus.completed': 'Concluída',
|
||||
'orchestration.runStatus.failed': 'Falhou',
|
||||
'orchestration.runStatus.cancelled': 'Cancelada',
|
||||
'orchestration.runStatus.interrupted': 'Interrompida',
|
||||
'orchestration.phaseStatus.pending': 'Pendente',
|
||||
'orchestration.phaseStatus.running': 'Em execução',
|
||||
'orchestration.phaseStatus.completed': 'Concluída',
|
||||
'orchestration.phaseStatus.failed': 'Falhou',
|
||||
'orchestration.detail.stop': 'Parar',
|
||||
'orchestration.detail.resume': 'Retomar',
|
||||
'orchestration.detail.agents': 'agentes',
|
||||
'orchestration.detail.childRefs': 'Agentes filhos',
|
||||
'orchestration.detail.synthesis': 'Síntese final',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'Perfis de agente',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5527,6 +5527,57 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': 'Говорю…',
|
||||
'notch.transcribing': 'Транскрибирую…',
|
||||
'notch.executing': 'Выполняю…',
|
||||
'memory.tab.orchestration': 'Оркестрация',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'Запускайте мультиагентные рабочие процессы — распределяйте вопрос между параллельными агентами, перепроверяйте их выводы и наблюдайте, как каждая фаза сходится в единый синтезированный ответ.',
|
||||
'orchestration.subtitle':
|
||||
'Запустите мультиагентный рабочий процесс, следите за ходом его фаз и читайте синтезированный результат.',
|
||||
'orchestration.loading': 'Загрузка рабочих процессов…',
|
||||
'orchestration.failedToLoad': 'Не удалось загрузить рабочие процессы',
|
||||
'orchestration.definitions': 'Доступные рабочие процессы',
|
||||
'orchestration.noDefinitions': 'Нет доступных определений рабочих процессов.',
|
||||
'orchestration.approvalRequired': 'Требуется подтверждение',
|
||||
'orchestration.start': 'Запустить',
|
||||
'orchestration.confirmStart': 'Запустить выполнение',
|
||||
'orchestration.starting': 'Запуск…',
|
||||
'orchestration.questionLabel': 'Исследовательский вопрос (необязательно)',
|
||||
'orchestration.questionPlaceholder': 'Что должны исследовать агенты?',
|
||||
'orchestration.runProgress': 'Ход выполнения',
|
||||
'orchestration.recentRuns': 'Недавние выполнения',
|
||||
'orchestration.noRuns': 'Пока нет выполнений рабочих процессов.',
|
||||
'orchestration.close': 'Закрыть',
|
||||
'orchestration.tier.readOnly': 'Только чтение',
|
||||
'orchestration.tier.standard': 'Стандартный',
|
||||
'orchestration.tier.editCapable': 'С правом изменения',
|
||||
'orchestration.approval.title': 'Подтвердить это выполнение рабочего процесса',
|
||||
'orchestration.approval.body':
|
||||
'Это дорогостоящее или высокопараллельное выполнение, требующее вашего явного подтверждения перед запуском:',
|
||||
'orchestration.approval.reason.tier':
|
||||
'Его агенты могут выполнять действия за пределами исследования только для чтения.',
|
||||
'orchestration.approval.reason.concurrency': 'Он одновременно запускает множество агентов.',
|
||||
'orchestration.approval.reason.children':
|
||||
'Он может в совокупности порождать очень большое число агентов.',
|
||||
'orchestration.approval.tier': 'Уровень безопасности',
|
||||
'orchestration.approval.concurrency': 'Параллелизм',
|
||||
'orchestration.approval.maxChildren': 'Макс. агентов',
|
||||
'orchestration.approval.approve': 'Подтвердить и запустить',
|
||||
'orchestration.approval.starting': 'Запуск…',
|
||||
'orchestration.approval.cancel': 'Отмена',
|
||||
'orchestration.runStatus.pending': 'В ожидании',
|
||||
'orchestration.runStatus.running': 'Выполняется',
|
||||
'orchestration.runStatus.completed': 'Завершено',
|
||||
'orchestration.runStatus.failed': 'Не удалось',
|
||||
'orchestration.runStatus.cancelled': 'Отменено',
|
||||
'orchestration.runStatus.interrupted': 'Прервано',
|
||||
'orchestration.phaseStatus.pending': 'В ожидании',
|
||||
'orchestration.phaseStatus.running': 'Выполняется',
|
||||
'orchestration.phaseStatus.completed': 'Завершена',
|
||||
'orchestration.phaseStatus.failed': 'Не удалась',
|
||||
'orchestration.detail.stop': 'Остановить',
|
||||
'orchestration.detail.resume': 'Возобновить',
|
||||
'orchestration.detail.agents': 'агенты',
|
||||
'orchestration.detail.childRefs': 'Дочерние агенты',
|
||||
'orchestration.detail.synthesis': 'Итоговый синтез',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': 'Профили агента',
|
||||
'settings.profiles.subtitle':
|
||||
|
||||
@@ -5194,6 +5194,53 @@ const messages: TranslationMap = {
|
||||
'notch.speaking': '说话中…',
|
||||
'notch.transcribing': '转录中…',
|
||||
'notch.executing': '执行中…',
|
||||
'memory.tab.orchestration': '编排',
|
||||
'memory.tab.orchestrationDescription':
|
||||
'运行多智能体工作流——将一个问题分发给多个并行智能体,交叉核对它们的发现,并观察每个阶段汇聚成一个综合答案。',
|
||||
'orchestration.subtitle': '启动多智能体工作流,观察其各阶段进展,并阅读综合结果。',
|
||||
'orchestration.loading': '正在加载工作流…',
|
||||
'orchestration.failedToLoad': '加载工作流失败',
|
||||
'orchestration.definitions': '可用工作流',
|
||||
'orchestration.noDefinitions': '没有可用的工作流定义。',
|
||||
'orchestration.approvalRequired': '需要批准',
|
||||
'orchestration.start': '启动',
|
||||
'orchestration.confirmStart': '启动运行',
|
||||
'orchestration.starting': '正在启动…',
|
||||
'orchestration.questionLabel': '研究问题(可选)',
|
||||
'orchestration.questionPlaceholder': '智能体应研究什么?',
|
||||
'orchestration.runProgress': '运行进度',
|
||||
'orchestration.recentRuns': '近期运行',
|
||||
'orchestration.noRuns': '尚无工作流运行。',
|
||||
'orchestration.close': '关闭',
|
||||
'orchestration.tier.readOnly': '只读',
|
||||
'orchestration.tier.standard': '标准',
|
||||
'orchestration.tier.editCapable': '可编辑',
|
||||
'orchestration.approval.title': '批准此工作流运行',
|
||||
'orchestration.approval.body': '这是一次高成本或高并发的运行,启动前需要您明确批准:',
|
||||
'orchestration.approval.reason.tier': '其智能体可执行超出只读研究范围的操作。',
|
||||
'orchestration.approval.reason.concurrency': '它会同时运行许多智能体。',
|
||||
'orchestration.approval.reason.children': '它总共可能生成大量智能体。',
|
||||
'orchestration.approval.tier': '安全级别',
|
||||
'orchestration.approval.concurrency': '并发数',
|
||||
'orchestration.approval.maxChildren': '最大智能体数',
|
||||
'orchestration.approval.approve': '批准并启动',
|
||||
'orchestration.approval.starting': '正在启动…',
|
||||
'orchestration.approval.cancel': '取消',
|
||||
'orchestration.runStatus.pending': '等待中',
|
||||
'orchestration.runStatus.running': '运行中',
|
||||
'orchestration.runStatus.completed': '已完成',
|
||||
'orchestration.runStatus.failed': '已失败',
|
||||
'orchestration.runStatus.cancelled': '已取消',
|
||||
'orchestration.runStatus.interrupted': '已中断',
|
||||
'orchestration.phaseStatus.pending': '等待中',
|
||||
'orchestration.phaseStatus.running': '运行中',
|
||||
'orchestration.phaseStatus.completed': '已完成',
|
||||
'orchestration.phaseStatus.failed': '已失败',
|
||||
'orchestration.detail.stop': '停止',
|
||||
'orchestration.detail.resume': '继续',
|
||||
'orchestration.detail.agents': '个智能体',
|
||||
'orchestration.detail.childRefs': '子智能体',
|
||||
'orchestration.detail.synthesis': '最终综合',
|
||||
// ── Agent Profiles ───────────────────────────────────────────────────────
|
||||
'settings.profiles.title': '智能体配置',
|
||||
'settings.profiles.subtitle': '风格化智能体——每个都有自己的灵魂、记忆、连接器和技能。',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import IntelligenceAgentsTab from '../components/intelligence/IntelligenceAgentsTab';
|
||||
import IntelligenceAgentWorkTab from '../components/intelligence/IntelligenceAgentWorkTab';
|
||||
import IntelligenceOrchestrationTab from '../components/intelligence/IntelligenceOrchestrationTab';
|
||||
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
|
||||
import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab';
|
||||
import IntelligenceTeamsTab from '../components/intelligence/IntelligenceTeamsTab';
|
||||
@@ -37,6 +38,7 @@ type IntelligenceTab =
|
||||
| 'teams'
|
||||
| 'agents'
|
||||
| 'workflows'
|
||||
| 'orchestration'
|
||||
| 'council';
|
||||
|
||||
const INTELLIGENCE_TABS: IntelligenceTab[] = [
|
||||
@@ -47,6 +49,7 @@ const INTELLIGENCE_TABS: IntelligenceTab[] = [
|
||||
'teams',
|
||||
'agents',
|
||||
'workflows',
|
||||
'orchestration',
|
||||
'council',
|
||||
];
|
||||
|
||||
@@ -178,6 +181,11 @@ export default function Intelligence({ tabParamKey = 'tab' }: IntelligenceProps
|
||||
label: t('memory.tab.workflows'),
|
||||
description: t('memory.tab.workflowsDescription'),
|
||||
},
|
||||
{
|
||||
id: 'orchestration',
|
||||
label: t('memory.tab.orchestration'),
|
||||
description: t('memory.tab.orchestrationDescription'),
|
||||
},
|
||||
{ id: 'council', label: t('memory.tab.council'), devOnly: true },
|
||||
{ id: 'agents', label: t('memory.tab.agents'), description: t('memory.tab.agentsDescription') },
|
||||
];
|
||||
@@ -273,6 +281,8 @@ export default function Intelligence({ tabParamKey = 'tab' }: IntelligenceProps
|
||||
|
||||
{activeTab === 'workflows' && <WorkflowsTab />}
|
||||
|
||||
{activeTab === 'orchestration' && <IntelligenceOrchestrationTab />}
|
||||
|
||||
{activeTab === 'council' && <ModelCouncilTab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
import {
|
||||
assessWorkflowCost,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowRun,
|
||||
workflowRunsApi,
|
||||
} from './workflowRunsApi';
|
||||
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
const mockRpc = vi.mocked(callCoreRpc);
|
||||
|
||||
function def(overrides: Partial<WorkflowDefinition> = {}): WorkflowDefinition {
|
||||
return {
|
||||
id: 'parallel_research_cross_check',
|
||||
name: 'Parallel research with cross-checking',
|
||||
description: 'Decompose, research in parallel, cross-check, synthesize.',
|
||||
phases: [
|
||||
{ name: 'decompose', description: '', agentIds: ['planner'], dependsOn: [] },
|
||||
{
|
||||
name: 'research',
|
||||
description: '',
|
||||
agentIds: ['researcher', 'researcher'],
|
||||
dependsOn: ['decompose'],
|
||||
},
|
||||
],
|
||||
defaultConcurrency: 2,
|
||||
maxChildren: 8,
|
||||
safetyTier: 'read_only',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
|
||||
return {
|
||||
id: 'wfrun-1',
|
||||
definitionId: 'parallel_research_cross_check',
|
||||
parentThreadId: null,
|
||||
input: { question: 'why is the sky blue?' },
|
||||
phaseStates: {},
|
||||
childRunIds: [],
|
||||
status: 'running',
|
||||
summary: null,
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
completedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('assessWorkflowCost', () => {
|
||||
it('requires approval for the builtin (maxChildren >= 8)', () => {
|
||||
const result = assessWorkflowCost(def({ maxChildren: 8, defaultConcurrency: 2 }));
|
||||
expect(result.requiresApproval).toBe(true);
|
||||
expect(result.reasons).toContain('high_children');
|
||||
});
|
||||
|
||||
it('does not require approval for a small read-only fan-out', () => {
|
||||
const result = assessWorkflowCost(
|
||||
def({ maxChildren: 4, defaultConcurrency: 2, safetyTier: 'read_only' })
|
||||
);
|
||||
expect(result.requiresApproval).toBe(false);
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags non-read-only tiers', () => {
|
||||
const result = assessWorkflowCost(
|
||||
def({ maxChildren: 2, defaultConcurrency: 1, safetyTier: 'edit_capable' })
|
||||
);
|
||||
expect(result.requiresApproval).toBe(true);
|
||||
expect(result.reasons).toContain('non_read_only_tier');
|
||||
});
|
||||
|
||||
it('flags high concurrency', () => {
|
||||
const result = assessWorkflowCost(
|
||||
def({ maxChildren: 2, defaultConcurrency: 4, safetyTier: 'read_only' })
|
||||
);
|
||||
expect(result.requiresApproval).toBe(true);
|
||||
expect(result.reasons).toContain('high_concurrency');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowRunsApi', () => {
|
||||
beforeEach(() => mockRpc.mockReset());
|
||||
|
||||
it('listDefinitions calls the RPC and returns the array', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ definitions: [def()], count: 1 });
|
||||
const defs = await workflowRunsApi.listDefinitions();
|
||||
expect(mockRpc).toHaveBeenCalledWith({ method: 'openhuman.workflow_run_list_definitions' });
|
||||
expect(defs).toHaveLength(1);
|
||||
expect(defs[0].id).toBe('parallel_research_cross_check');
|
||||
});
|
||||
|
||||
it('listDefinitions tolerates a missing array', async () => {
|
||||
mockRpc.mockResolvedValueOnce({});
|
||||
expect(await workflowRunsApi.listDefinitions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('listRuns forwards filters and returns the runs array', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ runs: [run()], count: 1 });
|
||||
const runs = await workflowRunsApi.listRuns({ limit: 50, status: 'running' });
|
||||
expect(mockRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflow_run_list',
|
||||
params: { limit: 50, status: 'running' },
|
||||
});
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].id).toBe('wfrun-1');
|
||||
});
|
||||
|
||||
it('listRuns defaults params and tolerates a missing array', async () => {
|
||||
mockRpc.mockResolvedValueOnce({});
|
||||
const runs = await workflowRunsApi.listRuns();
|
||||
expect(mockRpc).toHaveBeenCalledWith({ method: 'openhuman.workflow_run_list', params: {} });
|
||||
expect(runs).toEqual([]);
|
||||
});
|
||||
|
||||
it('getRun returns the run when present', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ workflowRun: run() });
|
||||
const fetched = await workflowRunsApi.getRun('wfrun-1');
|
||||
expect(mockRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflow_run_get',
|
||||
params: { id: 'wfrun-1' },
|
||||
});
|
||||
expect(fetched?.id).toBe('wfrun-1');
|
||||
});
|
||||
|
||||
it('startRun forwards definitionId + input and returns the run', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ workflowRun: run() });
|
||||
const started = await workflowRunsApi.startRun({
|
||||
definitionId: 'parallel_research_cross_check',
|
||||
input: { question: 'q' },
|
||||
});
|
||||
expect(mockRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflow_run_start',
|
||||
params: { definitionId: 'parallel_research_cross_check', input: { question: 'q' } },
|
||||
});
|
||||
expect(started.id).toBe('wfrun-1');
|
||||
});
|
||||
|
||||
it('getRun returns null when the run is absent', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ workflowRun: null });
|
||||
expect(await workflowRunsApi.getRun('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('stopRun returns the updated run', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ workflowRun: run({ status: 'interrupted' }) });
|
||||
const stopped = await workflowRunsApi.stopRun('wfrun-1');
|
||||
expect(mockRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflow_run_stop',
|
||||
params: { id: 'wfrun-1' },
|
||||
});
|
||||
expect(stopped?.status).toBe('interrupted');
|
||||
});
|
||||
|
||||
it('resumeRun returns the resumed run', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ workflowRun: run({ status: 'running' }) });
|
||||
const resumed = await workflowRunsApi.resumeRun('wfrun-1');
|
||||
expect(mockRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflow_run_resume',
|
||||
params: { id: 'wfrun-1' },
|
||||
});
|
||||
expect(resumed.status).toBe('running');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Frontend client for the declarative workflow-runs engine (#3375).
|
||||
*
|
||||
* Wraps the six `openhuman.workflow_run_*` JSON-RPC controllers exposed by the
|
||||
* Rust `workflow_runs` domain:
|
||||
* - `workflow_run_list_definitions` — catalog of runnable workflow definitions
|
||||
* - `workflow_run_list` — durable runs (with filters / paging)
|
||||
* - `workflow_run_get` — single run (poll target for progress)
|
||||
* - `workflow_run_start` — start a run from a definition id
|
||||
* - `workflow_run_stop` — interrupt a running workflow
|
||||
* - `workflow_run_resume` — resume an interrupted workflow
|
||||
*
|
||||
* The Rust controllers serialize structs with `rename_all = "camelCase"` and
|
||||
* the status / safety-tier enums with `rename_all = "snake_case"`, so the wire
|
||||
* payload already matches these TypeScript shapes — no snake/camel transform is
|
||||
* needed. Progress is poll-based in this engine (no socket events yet): start a
|
||||
* run, then poll `getRun(id)` until `status` is terminal.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('workflowRunsApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types — mirror `src/openhuman/workflow_runs/types.rs`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** What a workflow's child agents are permitted to do. (`snake_case` on wire.) */
|
||||
export type WorkflowSafetyTier = 'read_only' | 'standard' | 'edit_capable';
|
||||
|
||||
/** Lifecycle status of a durable run. (`snake_case` on wire.) */
|
||||
export type WorkflowRunStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'interrupted';
|
||||
|
||||
/** Per-phase progress status inside `WorkflowRun.phaseStates`. */
|
||||
export type WorkflowPhaseStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
/** One ordered phase of a workflow definition. */
|
||||
export interface WorkflowPhase {
|
||||
/** Unique within the definition (e.g. "decompose", "research"). */
|
||||
name: string;
|
||||
/** Human-readable purpose. */
|
||||
description: string;
|
||||
/** Agent definition ids fanned out (in parallel) for this phase. */
|
||||
agentIds: string[];
|
||||
/** Phase names that must complete before this one runs. */
|
||||
dependsOn: string[];
|
||||
}
|
||||
|
||||
/** A runnable workflow definition (builtin or, later, user-authored). */
|
||||
export interface WorkflowDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
phases: WorkflowPhase[];
|
||||
/** Max agents run concurrently within a single phase. */
|
||||
defaultConcurrency: number;
|
||||
/** Hard cap on child agents spawned across the whole run. */
|
||||
maxChildren: number;
|
||||
safetyTier: WorkflowSafetyTier;
|
||||
}
|
||||
|
||||
/** One child agent's result within a phase. */
|
||||
export interface WorkflowPhaseOutput {
|
||||
/** Orchestration id of the spawned child agent. */
|
||||
orchestrationId: string;
|
||||
/** Agent definition id (e.g. "researcher"). */
|
||||
agentId: string;
|
||||
/** The child's result summary. */
|
||||
output: string;
|
||||
}
|
||||
|
||||
/** Progress record for a single phase, keyed by phase name in `phaseStates`. */
|
||||
export interface WorkflowPhaseState {
|
||||
status: WorkflowPhaseStatus;
|
||||
outputs: WorkflowPhaseOutput[];
|
||||
/** Present only when `status === 'failed'`. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** A durable workflow run. Mirrors the Rust `WorkflowRun`. */
|
||||
export interface WorkflowRun {
|
||||
id: string;
|
||||
definitionId: string;
|
||||
parentThreadId: string | null;
|
||||
/** Run input object (e.g. `{ question: "..." }`). */
|
||||
input: unknown;
|
||||
/** Per-phase progress, keyed by phase name. */
|
||||
phaseStates: Record<string, WorkflowPhaseState>;
|
||||
/** Orchestration ids of every spawned child agent. */
|
||||
childRunIds: string[];
|
||||
status: WorkflowRunStatus;
|
||||
/** Final synthesized output — present once `status === 'completed'`. */
|
||||
summary: string | null;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
interface ListDefinitionsResult {
|
||||
definitions: WorkflowDefinition[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ListRunsResult {
|
||||
runs: WorkflowRun[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
workflowRun: WorkflowRun;
|
||||
}
|
||||
|
||||
interface MaybeRunResult {
|
||||
workflowRun: WorkflowRun | null;
|
||||
}
|
||||
|
||||
/** Filters accepted by `workflow_run_list`. */
|
||||
export interface ListRunsParams {
|
||||
definitionId?: string;
|
||||
status?: WorkflowRunStatus;
|
||||
parentThreadId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** Parameters accepted by `workflow_run_start`. */
|
||||
export interface StartRunParams {
|
||||
definitionId: string;
|
||||
/** Run input, e.g. `{ question: "..." }` or `{ modelOverride: "..." }`. */
|
||||
input?: Record<string, unknown>;
|
||||
/** Originating thread id, for lineage. */
|
||||
parentThreadId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cost / concurrency safety gate (#3375 AC: high-cost runs require approval).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Why a run is considered high-cost / high-concurrency and therefore gated
|
||||
* behind explicit approval. Stable codes — the UI maps each to a localized
|
||||
* sentence so the user understands what they're approving.
|
||||
*/
|
||||
export type WorkflowCostReason = 'non_read_only_tier' | 'high_concurrency' | 'high_children';
|
||||
|
||||
export interface WorkflowCostAssessment {
|
||||
/** True when the definition must be explicitly approved before starting. */
|
||||
requiresApproval: boolean;
|
||||
/** Machine-readable reasons (empty when no approval required). */
|
||||
reasons: WorkflowCostReason[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-phase concurrency at or above this is treated as high-concurrency.
|
||||
* The builtin parallel-research workflow runs at 2, so routine read-only
|
||||
* fan-outs stay frictionless; only unusually wide phases trip the gate.
|
||||
*/
|
||||
export const HIGH_CONCURRENCY_THRESHOLD = 4;
|
||||
|
||||
/**
|
||||
* Total child-agent budget at or above this is treated as high-cost — every
|
||||
* child is a full agent turn (tokens + wall-clock), so a large cap is the
|
||||
* clearest proxy for spend in the absence of an explicit cost field.
|
||||
*/
|
||||
export const HIGH_CHILDREN_THRESHOLD = 8;
|
||||
|
||||
/**
|
||||
* Decide whether starting `def` needs explicit user approval.
|
||||
*
|
||||
* The Rust safety tiers exist on the definition but the engine does not yet
|
||||
* gate on them, so the approval decision lives here on the client: any
|
||||
* non-`read_only` tier (child agents may take actions / edit files), or a
|
||||
* wide/expensive fan-out, requires the user to confirm before we call
|
||||
* `startRun`. Read-only, low-fan-out definitions start immediately.
|
||||
*/
|
||||
export function assessWorkflowCost(def: WorkflowDefinition): WorkflowCostAssessment {
|
||||
const reasons: WorkflowCostReason[] = [];
|
||||
if (def.safetyTier !== 'read_only') reasons.push('non_read_only_tier');
|
||||
if (def.defaultConcurrency >= HIGH_CONCURRENCY_THRESHOLD) reasons.push('high_concurrency');
|
||||
if (def.maxChildren >= HIGH_CHILDREN_THRESHOLD) reasons.push('high_children');
|
||||
return { requiresApproval: reasons.length > 0, reasons };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RPC client.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const workflowRunsApi = {
|
||||
/** List available declarative workflow definitions (builtins, currently). */
|
||||
listDefinitions: async (): Promise<WorkflowDefinition[]> => {
|
||||
log('listDefinitions: request');
|
||||
const result = await callCoreRpc<ListDefinitionsResult>({
|
||||
method: 'openhuman.workflow_run_list_definitions',
|
||||
});
|
||||
const definitions = result?.definitions ?? [];
|
||||
log('listDefinitions: count=%d', definitions.length);
|
||||
return definitions;
|
||||
},
|
||||
|
||||
/** List durable workflow runs, newest first, with optional filters. */
|
||||
listRuns: async (params: ListRunsParams = {}): Promise<WorkflowRun[]> => {
|
||||
log('listRuns: request %o', params);
|
||||
const result = await callCoreRpc<ListRunsResult>({
|
||||
method: 'openhuman.workflow_run_list',
|
||||
params,
|
||||
});
|
||||
const runs = result?.runs ?? [];
|
||||
log('listRuns: count=%d', runs.length);
|
||||
return runs;
|
||||
},
|
||||
|
||||
/** Fetch a single run by id. Returns null when no such run exists. */
|
||||
getRun: async (id: string): Promise<WorkflowRun | null> => {
|
||||
log('getRun: request id=%s', id);
|
||||
const result = await callCoreRpc<MaybeRunResult>({
|
||||
method: 'openhuman.workflow_run_get',
|
||||
params: { id },
|
||||
});
|
||||
const run = result?.workflowRun ?? null;
|
||||
log('getRun: status=%s', run?.status ?? 'null');
|
||||
return run;
|
||||
},
|
||||
|
||||
/** Start a run from a definition id; returns the created Running run. */
|
||||
startRun: async (params: StartRunParams): Promise<WorkflowRun> => {
|
||||
log('startRun: request definitionId=%s', params.definitionId);
|
||||
const result = await callCoreRpc<RunResult>({ method: 'openhuman.workflow_run_start', params });
|
||||
log('startRun: id=%s status=%s', result.workflowRun.id, result.workflowRun.status);
|
||||
return result.workflowRun;
|
||||
},
|
||||
|
||||
/** Stop a running workflow after its current phase. Null if id unknown. */
|
||||
stopRun: async (id: string): Promise<WorkflowRun | null> => {
|
||||
log('stopRun: request id=%s', id);
|
||||
const result = await callCoreRpc<MaybeRunResult>({
|
||||
method: 'openhuman.workflow_run_stop',
|
||||
params: { id },
|
||||
});
|
||||
log('stopRun: status=%s', result?.workflowRun?.status ?? 'null');
|
||||
return result?.workflowRun ?? null;
|
||||
},
|
||||
|
||||
/** Resume an interrupted workflow from the first incomplete phase. */
|
||||
resumeRun: async (id: string): Promise<WorkflowRun> => {
|
||||
log('resumeRun: request id=%s', id);
|
||||
const result = await callCoreRpc<RunResult>({
|
||||
method: 'openhuman.workflow_run_resume',
|
||||
params: { id },
|
||||
});
|
||||
log('resumeRun: status=%s', result.workflowRun.status);
|
||||
return result.workflowRun;
|
||||
},
|
||||
};
|
||||
@@ -503,6 +503,16 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: DERIVED_TO_BACKEND,
|
||||
},
|
||||
Capability {
|
||||
id: "intelligence.workflow_orchestration",
|
||||
name: "Workflow Orchestration",
|
||||
domain: "workflow_runs",
|
||||
category: CapabilityCategory::Intelligence,
|
||||
description: "Run declarative multi-agent workflows such as parallel research with cross-checking: a question is decomposed into angles, researched in parallel, adversarially cross-checked, and synthesized into one cited report. Watch each phase progress with its child agent results, stop or resume a run, and read the final synthesis. High-cost / high-concurrency runs require explicit approval before starting.",
|
||||
how_to: "Intelligence > Orchestration > pick a workflow and Start",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: DERIVED_TO_BACKEND,
|
||||
},
|
||||
Capability {
|
||||
id: "intelligence.agent_library",
|
||||
name: "Agents Library",
|
||||
|
||||
Reference in New Issue
Block a user