feat(G): G-RUN/G-CTX/G-WAIT/G-HTTP/G-MAIL/G-CAL/G-DMS/G-SEARCH/G-AGENT/G-CRM/G-EVT/G-WEB — Durable WorkflowRun, 10 step handlers, resume/wait/lock/retry, SSRF protection, frontend step editor

This commit is contained in:
Agent Zero
2026-08-18 00:29:58 +02:00
parent 4ec2ac9eb5
commit db41e60042
8 changed files with 1783 additions and 154 deletions
@@ -12,8 +12,8 @@ import {
useCreateWorkflow,
useUpdateWorkflow,
} from '@/api/workflows';
import type { Workflow, WorkflowStep, WorkflowCreateInput, WorkflowUpdateInput } from '@/api/workflows';
import { ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react';
import type { Workflow, WorkflowStep, WorkflowStepType, WorkflowCreateInput, WorkflowUpdateInput } from '@/api/workflows';
import { ArrowUp, ArrowDown, Plus, Trash2, Code2, FormInput, LayoutTemplate } from 'lucide-react';
const triggerEventOptions = [
{ value: '', label: '— Kein Trigger —' },
@@ -51,6 +51,101 @@ const emptyForm: EditorFormState = {
steps: [],
};
interface WorkflowTemplate {
id: string;
name: string;
description: string;
form: EditorFormState;
}
const templates: WorkflowTemplate[] = [
{
id: 'welcome-email',
name: 'Welcome Email',
description: 'Sendet eine Willkommens-Mail an neue Kontakte.',
form: {
name: 'Welcome Email',
description: 'Sendet eine Willkommens-Mail an neue Kontakte.',
trigger_event: 'contact.created',
is_active: true,
steps: [
{
name: 'Willkommens-Mail senden',
type: 'mail',
config: {
to: '{{contact.email}}',
subject: 'Willkommen bei uns!',
body: 'Hallo {{contact.first_name}}, willkommen bei unserem Unternehmen!',
},
description: 'Sendet die Willkommens-Mail an den neuen Kontakt.',
},
],
},
},
{
id: 'contact-follow-up',
name: 'Contact Follow-Up',
description: 'Wartet 3 Tage und sendet dann ein Follow-Up.',
form: {
name: 'Contact Follow-Up',
description: 'Wartet 3 Tage und sendet dann ein Follow-Up.',
trigger_event: 'contact.created',
is_active: true,
steps: [
{
name: '3 Tage warten',
type: 'wait',
config: { duration_seconds: 259200 },
description: 'Wartet 3 Tage nach Kontakterstellung.',
},
{
name: 'Follow-Up senden',
type: 'mail',
config: {
to: '{{contact.email}}',
subject: 'Wie können wir helfen?',
body: 'Hallo {{contact.first_name}}, wir wollten nachfragen, ob wir helfen können.',
},
description: 'Sendet das Follow-Up an den Kontakt.',
},
],
},
},
{
id: 'approval-chain',
name: 'Approval Chain',
description: 'Deal-Genehmigung mit zweistufiger Freigabe.',
form: {
name: 'Approval Chain',
description: 'Deal-Genehmigung mit zweistufiger Freigabe.',
trigger_event: 'deal.stage_changed',
is_active: true,
steps: [
{
name: 'Vertriebsleiter-Genehmigung',
type: 'approval',
config: { approver_role: 'sales_manager', timeout_hours: 24 },
description: 'Erste Freigabe durch den Vertriebsleiter.',
},
{
name: 'Geschäftsführer-Genehmigung',
type: 'approval',
config: { approver_role: 'ceo', timeout_hours: 48 },
description: 'Zweite Freigabe durch die Geschäftsführung.',
},
{
name: 'Bestätigungs-Mail',
type: 'notification',
config: { channel: 'email', template: 'deal_approved', recipients: '{{deal.owner_email}}' },
description: 'Benachrichtigt den Deal-Owner über die Freigabe.',
},
],
},
},
];
type EditorMode = 'form' | 'json';
export interface WorkflowEditorProps {
open: boolean;
workflow?: Workflow | null;
@@ -65,6 +160,10 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
const isEdit = !!workflow;
const [form, setForm] = useState<EditorFormState>(emptyForm);
const [mode, setMode] = useState<EditorMode>('form');
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | undefined>(undefined);
const [showTemplates, setShowTemplates] = useState(false);
useEffect(() => {
if (open) {
@@ -86,9 +185,17 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
} else {
setForm({ ...emptyForm });
}
setMode('form');
setShowTemplates(false);
setJsonError(undefined);
}
}, [open, workflow]);
useEffect(() => {
setJsonText(JSON.stringify(form, null, 2));
setJsonError(undefined);
}, [form]);
const updateField = <K extends keyof EditorFormState>(
key: K,
value: EditorFormState[K]
@@ -125,6 +232,65 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
});
};
const applyTemplate = (template: WorkflowTemplate) => {
setForm({
name: template.form.name,
description: template.form.description,
trigger_event: template.form.trigger_event,
is_active: template.form.is_active,
steps: template.form.steps.map((s) => ({ ...s, config: { ...s.config } })),
});
setShowTemplates(false);
};
const handleJsonChange = (value: string) => {
setJsonText(value);
try {
const parsed = JSON.parse(value) as EditorFormState;
if (!Array.isArray(parsed.steps)) {
setJsonError('steps muss ein Array sein');
return;
}
setJsonError(undefined);
setForm(parsed);
} catch {
setJsonError('Ungültiges JSON');
}
};
const requiredConfigFields: Partial<Record<WorkflowStepType, string[]>> = {
wait: ['duration_seconds'],
http: ['url'],
mail: ['to', 'subject'],
calendar: ['action'],
dms: ['action'],
search: ['query'],
agent: ['agent_id'],
crm: ['action'],
};
const validateSteps = (steps: WorkflowStep[]): string | null => {
if (steps.length === 0) {
return 'Mindestens ein Schritt ist erforderlich';
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
if (!s.name.trim()) {
return `Schritt ${i + 1}: Name ist erforderlich`;
}
const required = requiredConfigFields[s.type];
if (required) {
for (const field of required) {
const v = s.config[field];
if (v === undefined || v === null || v === '') {
return `Schritt ${i + 1} (${s.type}): Feld "${field}" ist erforderlich`;
}
}
}
}
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -133,14 +299,9 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
return;
}
if (form.steps.length === 0) {
toast.error('Mindestens ein Schritt ist erforderlich');
return;
}
const hasEmptyStepName = form.steps.some((s) => !s.name.trim());
if (hasEmptyStepName) {
toast.error('Alle Schritte muessen einen Namen haben');
const stepError = validateSteps(form.steps);
if (stepError) {
toast.error(stepError);
return;
}
@@ -192,108 +353,197 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
size="xl"
>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4">
<Input
label="Name"
required
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="z.B. Deal-Genehmigungsprozess"
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Beschreibung
</label>
<textarea
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Optionale Beschreibung"
/>
{/* Mode toggle + template gallery */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{!isEdit && (
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => setShowTemplates((v) => !v)}
icon={<LayoutTemplate className="h-4 w-4" />}
>
Vorlagen
</Button>
)}
</div>
<div className="flex items-center gap-1 rounded-md border border-secondary-200 bg-white p-0.5">
<button
type="button"
onClick={() => setMode('form')}
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium ${
mode === 'form'
? 'bg-primary-600 text-white'
: 'text-secondary-600 hover:bg-secondary-100'
}`}
aria-pressed={mode === 'form'}
>
<FormInput className="h-3.5 w-3.5" /> Formular
</button>
<button
type="button"
onClick={() => setMode('json')}
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium ${
mode === 'json'
? 'bg-primary-600 text-white'
: 'text-secondary-600 hover:bg-secondary-100'
}`}
aria-pressed={mode === 'json'}
>
<Code2 className="h-3.5 w-3.5" /> JSON Expert
</button>
</div>
<Select
label="Trigger-Event"
options={triggerEventOptions}
value={form.trigger_event}
onChange={(e) => updateField('trigger_event', e.target.value)}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => updateField('is_active', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
Aktiv
</label>
</div>
{/* Steps section */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium text-secondary-700">
Schritte
</label>
<Button
size="sm"
variant="ghost"
onClick={addStep}
type="button"
icon={<Plus className="h-4 w-4" />}
>
Schritt hinzufuegen
</Button>
</div>
{form.steps.length === 0 && (
<p className="text-sm text-secondary-400 italic">
Keine Schritte definiert. Klicke auf Schritt hinzufuegen.
</p>
)}
<div className="space-y-4">
{form.steps.map((step, i) => (
<div key={i} className="relative">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-secondary-500">
Schritt {i + 1}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveStep(i, 'up')}
disabled={i === 0}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach oben"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => moveStep(i, 'down')}
disabled={i === form.steps.length - 1}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach unten"
>
<ArrowDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeStep(i)}
className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Schritt entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<StepConfigPanel
step={step}
onChange={(updated) => updateStep(i, updated)}
/>
</div>
{/* Template gallery */}
{showTemplates && !isEdit && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{templates.map((template) => (
<button
key={template.id}
type="button"
onClick={() => applyTemplate(template)}
className="rounded-lg border border-secondary-200 bg-secondary-50 p-4 text-left hover:border-primary-500 hover:bg-primary-50 transition-colors"
>
<span className="block text-sm font-medium text-secondary-800">
{template.name}
</span>
<span className="mt-1 block text-xs text-secondary-500">
{template.description}
</span>
<span className="mt-2 block text-xs text-primary-600 font-medium">
{template.form.steps.length} Schritte
</span>
</button>
))}
</div>
</div>
)}
{mode === 'form' ? (
<>
<div className="grid grid-cols-1 gap-4">
<Input
label="Name"
required
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="z.B. Deal-Genehmigungsprozess"
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Beschreibung
</label>
<textarea
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Optionale Beschreibung"
/>
</div>
<Select
label="Trigger-Event"
options={triggerEventOptions}
value={form.trigger_event}
onChange={(e) => updateField('trigger_event', e.target.value)}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => updateField('is_active', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
Aktiv
</label>
</div>
{/* Steps section */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium text-secondary-700">
Schritte
</label>
<Button
size="sm"
variant="ghost"
onClick={addStep}
type="button"
icon={<Plus className="h-4 w-4" />}
>
Schritt hinzufuegen
</Button>
</div>
{form.steps.length === 0 && (
<p className="text-sm text-secondary-400 italic">
Keine Schritte definiert. Klicke auf Schritt hinzufuegen.
</p>
)}
<div className="space-y-4">
{form.steps.map((step, i) => (
<div key={i} className="relative">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-secondary-500">
Schritt {i + 1}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveStep(i, 'up')}
disabled={i === 0}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach oben"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => moveStep(i, 'down')}
disabled={i === form.steps.length - 1}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach unten"
>
<ArrowDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeStep(i)}
className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Schritt entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<StepConfigPanel
step={step}
onChange={(updated) => updateStep(i, updated)}
/>
</div>
))}
</div>
</div>
</>
) : (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Workflow (JSON)
</label>
<textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
rows={18}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder='{"name": "...", "steps": [...]}'
/>
{jsonError && (
<p className="mt-1 text-sm text-danger-600" role="alert">
{jsonError}
</p>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
<Button variant="secondary" onClick={onClose} type="button">