feat(workflows): Phase 2a+2c — extended node types + trigger UI
All checks were successful
Stuffle/agos-client/pipeline/head This commit was not built

Phase 2a (frontend node types):
- Extend WfNodeType from 9 to 17 types: add switch, loop, merge, delay,
  http_request, code, human_approval, notification
- Extend WfNodeFamily from 4 to 7: add logic, tool, gate
- Full inspector for each new type: Switch (cases editor), Loop (concurrency,
  iteration config), Merge (wait mode), Delay (duration), HTTP Request
  (method, URL, headers, body, auth), Code (language, editor, packages),
  Approval Gate (title, timeout, notify channels), Notification (template,
  channels)
- Node palette: 6 categories (Input, AI/Engine, Control Flow, Integration,
  Governance, Output)
- Graph validation rules for all new types
- Diamond shape for logic nodes, new colors/icons

Phase 2c (trigger UI):
- Trigger selector: 5 types (Manual, Webhook, Schedule, Event, Chat)
- Schedule trigger: cron expression + timezone + agent ID
- Event trigger: multi-line event types
- Chat trigger: /run command input

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-21 02:07:16 +05:30
parent 6f86b2a24e
commit d02a6c5bca
7 changed files with 1000 additions and 40 deletions

View File

@@ -22,10 +22,12 @@ export function WorkflowNode({ id, data }: NodeProps) {
const Icon = NODE_ICON[d.type] ?? Zap
const isTerminal = d.type === 'start' || d.type === 'end'
const shape = NODE_SHAPE_BY_FAMILY[family]
const borderRadius = shape === 'pill' ? 999 : (shape === 'hex' ? 22 : 14)
const borderRadius = shape === 'pill' ? 999 : (shape === 'hex' || shape === 'diamond' ? 22 : 14)
const clipPath = shape === 'hex'
? 'polygon(8% 0%, 92% 0%, 100% 50%, 92% 100%, 8% 100%, 0% 50%)'
: undefined
: shape === 'diamond'
? 'polygon(50% 0%, 96% 50%, 50% 100%, 4% 50%)'
: undefined
return (
<div

View File

@@ -148,8 +148,55 @@ export function validateGraph(definition: WfDefinition): ValidationResult {
}
if (node.type === 'condition') {
if (!node.description?.trim()) {
errors.push({ nodeId: node.id, severity: 'error', message: 'Condition node requires a condition rule' })
const mode = node.config?.condition_mode ?? 'expression'
if (mode === 'expression' && !node.config?.expression?.trim()) {
errors.push({ nodeId: node.id, severity: 'error', message: 'Condition node requires an expression' })
}
if (mode === 'llm_judge' && !node.config?.judge_prompt?.trim()) {
errors.push({ nodeId: node.id, severity: 'error', message: 'Condition node (LLM judge) requires a judge prompt' })
}
const outgoing = outgoingCount.get(node.id) ?? 0
if (outgoing > 0 && outgoing !== 2) {
errors.push({ nodeId: node.id, severity: 'warning', message: 'Condition node should have exactly 2 outgoing edges (true/false)' })
}
}
if (node.type === 'switch') {
if (!node.config?.expression?.trim()) {
errors.push({ nodeId: node.id, severity: 'error', message: 'Switch node requires an expression' })
}
if (!node.config?.cases?.length) {
errors.push({ nodeId: node.id, severity: 'error', message: 'Switch node requires at least one case' })
}
}
if (node.type === 'loop') {
if (!node.config?.source_expression?.trim()) {
errors.push({ nodeId: node.id, severity: 'error', message: 'Loop node requires a source expression (array)' })
}
}
if (node.type === 'merge') {
if ((incomingCount.get(node.id) ?? 0) < 2) {
errors.push({ nodeId: node.id, severity: 'warning', message: 'Merge node should have 2+ incoming edges' })
}
}
if (node.type === 'http_request') {
if (!node.config?.http_url?.trim()) {
errors.push({ nodeId: node.id, severity: 'error', message: 'HTTP Request node requires a URL' })
}
}
if (node.type === 'code') {
if (!node.config?.code_source?.trim()) {
errors.push({ nodeId: node.id, severity: 'error', message: 'Code node requires source code' })
}
}
if (node.type === 'human_approval') {
if (!node.config?.approval_title?.trim()) {
errors.push({ nodeId: node.id, severity: 'error', message: 'Approval gate requires a title' })
}
}

View File

@@ -16,6 +16,7 @@ import type {
WfRuntimeParam,
WfOutputFormat,
WfOutputRouteDestination,
WfNotifyChannel,
} from '../../types'
import {
INPUT_NODE_TYPES,
@@ -53,6 +54,70 @@ function serializeRuntimeParamOptions(param: WfRuntimeParam) {
return (param.options ?? []).map(option => `${option.value}:${option.label}`).join('\n')
}
/* ── Notify channels sub-editor ───────────────────────────────────────── */
function NotifyChannelsEditor({
channels,
onChange,
}: {
channels: WfNotifyChannel[]
onChange: (channels: WfNotifyChannel[]) => void
}) {
return (
<div>
<div style={SECTION_TITLE_STYLE}>Notification channels</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
{channels.map((ch, i) => (
<div key={`ch_${i}`} style={{ display: 'grid', gridTemplateColumns: '100px 1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<label style={FIELD_LABEL_STYLE}>Type</label>
<SimpleSelect
size="sm"
value={ch.type}
onChange={v => {
const updated = [...channels]
updated[i] = { ...ch, type: v as WfNotifyChannel['type'] }
onChange(updated)
}}
options={[
{ value: 'email', label: 'Email' },
{ value: 'slack', label: 'Slack' },
{ value: 'agos_notification', label: 'In-app' },
]}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Target</label>
<input
className="nbl-input"
value={ch.target}
placeholder={ch.type === 'email' ? 'user@example.com' : ch.type === 'slack' ? '#channel' : 'user-id'}
onChange={e => {
const updated = [...channels]
updated[i] = { ...ch, target: e.target.value }
onChange(updated)
}}
/>
</div>
<button
onClick={() => onChange(channels.filter((_, j) => j !== i))}
style={{ height: 34, borderRadius: 10, border: '1px solid color-mix(in srgb, var(--nbl-red) 28%, transparent)', background: 'color-mix(in srgb, var(--nbl-red) 10%, transparent)', color: 'var(--nbl-red)', cursor: 'pointer', padding: '0 10px', fontSize: 11 }}
>
Del
</button>
</div>
))}
<button
onClick={() => onChange([...channels, { type: 'agos_notification', target: '' }])}
style={{ padding: '7px 10px', borderRadius: 10, cursor: 'pointer', border: '1px solid var(--nbl-border)', background: 'var(--nbl-bg)', color: 'var(--nbl-text)', fontSize: 11, fontWeight: 700 }}
>
Add channel
</button>
</div>
</div>
)
}
interface NodeInspectorProps {
node: WfNode
onUpdateNode: (patch: Partial<WfNode>) => void
@@ -277,17 +342,514 @@ export function NodeInspector({
{/* Condition config */}
{node.type === 'condition' && (
<div>
<label style={FIELD_LABEL_STYLE}>Condition rule</label>
<textarea
className="nbl-textarea"
rows={4}
style={{ resize: 'vertical' }}
value={node.description ?? ''}
placeholder="Describe the branch condition"
onChange={e => onUpdateNode({ description: e.target.value })}
<>
<div>
<label style={FIELD_LABEL_STYLE}>Mode</label>
<SimpleSelect
size="sm"
value={node.config?.condition_mode ?? 'expression'}
onChange={v => updateConfig({ condition_mode: v as 'expression' | 'llm_judge' })}
options={[
{ value: 'expression', label: 'Expression' },
{ value: 'llm_judge', label: 'LLM Judge' },
]}
/>
</div>
{(node.config?.condition_mode ?? 'expression') === 'expression' ? (
<div>
<label style={FIELD_LABEL_STYLE}>Expression</label>
<input
className="nbl-input"
value={node.config?.expression ?? ''}
placeholder={'{{reason_1.output.score}} > 0.8'}
onChange={e => updateConfig({ expression: e.target.value })}
/>
</div>
) : (
<div>
<label style={FIELD_LABEL_STYLE}>Judge prompt</label>
<textarea
className="nbl-textarea"
rows={4}
style={{ resize: 'vertical' }}
value={node.config?.judge_prompt ?? ''}
placeholder="Is this content appropriate? Answer yes/no."
onChange={e => updateConfig({ judge_prompt: e.target.value })}
/>
</div>
)}
</>
)}
{/* Switch config */}
{node.type === 'switch' && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Expression</label>
<input
className="nbl-input"
value={node.config?.expression ?? ''}
placeholder={'{{reason_1.output.category}}'}
onChange={e => updateConfig({ expression: e.target.value })}
/>
</div>
<div style={{ ...SECTION_TITLE_STYLE, marginTop: 4 }}>Cases</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(node.config?.cases ?? []).map((c, i) => (
<div key={`case_${i}`} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<label style={FIELD_LABEL_STYLE}>Label</label>
<input className="nbl-input" value={c.label} onChange={e => {
const cases = [...(node.config?.cases ?? [])]
cases[i] = { ...c, label: e.target.value }
updateConfig({ cases })
}} />
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Value</label>
<input className="nbl-input" value={c.value} onChange={e => {
const cases = [...(node.config?.cases ?? [])]
cases[i] = { ...c, value: e.target.value }
updateConfig({ cases })
}} />
</div>
<button
onClick={() => updateConfig({ cases: (node.config?.cases ?? []).filter((_, j) => j !== i) })}
style={{ height: 34, borderRadius: 10, border: '1px solid color-mix(in srgb, var(--nbl-red) 28%, transparent)', background: 'color-mix(in srgb, var(--nbl-red) 10%, transparent)', color: 'var(--nbl-red)', cursor: 'pointer', padding: '0 10px', fontSize: 11 }}
>
Del
</button>
</div>
))}
<button
onClick={() => {
const cases = [...(node.config?.cases ?? [])]
const idx = cases.length + 1
cases.push({ label: `Case ${idx}`, value: '', handle: `case_${idx}` })
updateConfig({ cases })
}}
style={{ padding: '7px 10px', borderRadius: 10, cursor: 'pointer', border: '1px solid var(--nbl-border)', background: 'var(--nbl-bg)', color: 'var(--nbl-text)', fontSize: 11, fontWeight: 700 }}
>
Add case
</button>
</div>
</>
)}
{/* Loop config */}
{node.type === 'loop' && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Source expression (array)</label>
<input
className="nbl-input"
value={node.config?.source_expression ?? ''}
placeholder={'{{input.items}}'}
onChange={e => updateConfig({ source_expression: e.target.value })}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Item variable</label>
<input
className="nbl-input"
value={node.config?.item_variable ?? 'item'}
onChange={e => updateConfig({ item_variable: e.target.value })}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Index variable</label>
<input
className="nbl-input"
value={node.config?.index_variable ?? 'index'}
onChange={e => updateConfig({ index_variable: e.target.value })}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Concurrency</label>
<input
className="nbl-input"
type="number"
min={1}
max={20}
value={node.config?.concurrency ?? 1}
onChange={e => updateConfig({ concurrency: Number(e.target.value) || 1 })}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Max iterations</label>
<input
className="nbl-input"
type="number"
min={1}
max={10000}
value={node.config?.max_iterations ?? 1000}
onChange={e => updateConfig({ max_iterations: Number(e.target.value) || 1000 })}
/>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--nbl-text-muted)' }}>
<input
type="checkbox"
checked={node.config?.collect_results !== false}
onChange={e => updateConfig({ collect_results: e.target.checked })}
/>
Collect results as array
</label>
</>
)}
{/* Merge config */}
{node.type === 'merge' && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Wait mode</label>
<SimpleSelect
size="sm"
value={node.config?.merge_mode ?? 'all'}
onChange={v => updateConfig({ merge_mode: v as 'all' | 'any' | 'first_n' })}
options={[
{ value: 'all', label: 'All branches' },
{ value: 'any', label: 'Any one branch' },
{ value: 'first_n', label: 'First N branches' },
]}
/>
</div>
{node.config?.merge_mode === 'first_n' && (
<div>
<label style={FIELD_LABEL_STYLE}>N (branches to wait for)</label>
<input
className="nbl-input"
type="number"
min={1}
value={node.config?.merge_n ?? 1}
onChange={e => updateConfig({ merge_n: Number(e.target.value) || 1 })}
/>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
<input
className="nbl-input"
type="number"
min={0}
value={node.config?.merge_timeout_seconds ?? 300}
onChange={e => updateConfig({ merge_timeout_seconds: Number(e.target.value) || 300 })}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>On timeout</label>
<SimpleSelect
size="sm"
value={node.config?.merge_on_timeout ?? 'fail'}
onChange={v => updateConfig({ merge_on_timeout: v as 'fail' | 'proceed_with_available' })}
options={[
{ value: 'fail', label: 'Fail' },
{ value: 'proceed_with_available', label: 'Proceed' },
]}
/>
</div>
</div>
</>
)}
{/* Delay config */}
{node.type === 'delay' && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Delay (seconds)</label>
<input
className="nbl-input"
type="number"
min={0}
value={node.config?.delay_seconds ?? 5}
onChange={e => updateConfig({ delay_seconds: Number(e.target.value) || 0 })}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Until expression (optional)</label>
<input
className="nbl-input"
value={node.config?.delay_until_expression ?? ''}
placeholder="Wait until this evaluates to true"
onChange={e => updateConfig({ delay_until_expression: e.target.value })}
/>
</div>
</>
)}
{/* HTTP Request config */}
{node.type === 'http_request' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Method</label>
<SimpleSelect
size="sm"
value={node.config?.http_method ?? 'GET'}
onChange={v => updateConfig({ http_method: v as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' })}
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'PATCH', label: 'PATCH' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'HEAD', label: 'HEAD' },
]}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>URL</label>
<input
className="nbl-input"
value={node.config?.http_url ?? ''}
placeholder="https://api.example.com/{{input.path}}"
onChange={e => updateConfig({ http_url: e.target.value })}
/>
</div>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Headers (JSON)</label>
<textarea
className="nbl-textarea"
rows={3}
style={{ resize: 'vertical' }}
value={node.config?.http_headers ? JSON.stringify(node.config.http_headers, null, 2) : ''}
placeholder={'{\n "Authorization": "Bearer {{params.token}}"\n}'}
onChange={e => {
try { updateConfig({ http_headers: JSON.parse(e.target.value) }) } catch { /* skip invalid JSON */ }
}}
/>
</div>
{(node.config?.http_method !== 'GET' && node.config?.http_method !== 'HEAD') && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Content type</label>
<SimpleSelect
size="sm"
value={node.config?.http_body_content_type ?? 'json'}
onChange={v => updateConfig({ http_body_content_type: v as 'json' | 'form' | 'text' | 'xml' })}
options={[
{ value: 'json', label: 'JSON' },
{ value: 'form', label: 'Form' },
{ value: 'text', label: 'Text' },
{ value: 'xml', label: 'XML' },
]}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Body template</label>
<textarea
className="nbl-textarea"
rows={5}
style={{ resize: 'vertical' }}
value={node.config?.http_body_template ?? ''}
placeholder={'{\n "message": "{{reason_1.output}}"\n}'}
onChange={e => updateConfig({ http_body_template: e.target.value })}
/>
</div>
</>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Auth</label>
<SimpleSelect
size="sm"
value={node.config?.http_auth_type ?? 'none'}
onChange={v => updateConfig({ http_auth_type: v as 'none' | 'bearer' | 'basic' | 'api_key' | 'credential_ref' })}
options={[
{ value: 'none', label: 'None' },
{ value: 'bearer', label: 'Bearer' },
{ value: 'basic', label: 'Basic' },
{ value: 'api_key', label: 'API Key' },
{ value: 'credential_ref', label: 'Credential' },
]}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Timeout (ms)</label>
<input
className="nbl-input"
type="number"
min={1000}
max={120000}
value={node.config?.http_timeout_ms ?? 30000}
onChange={e => updateConfig({ http_timeout_ms: Number(e.target.value) || 30000 })}
/>
</div>
</div>
{node.config?.http_auth_type === 'credential_ref' && (
<div>
<label style={FIELD_LABEL_STYLE}>Credential ID</label>
<input
className="nbl-input"
value={node.config?.http_credential_id ?? ''}
placeholder="credential-id from AGOS vault"
onChange={e => updateConfig({ http_credential_id: e.target.value })}
/>
</div>
)}
<div>
<label style={FIELD_LABEL_STYLE}>Response extract path (JSONPath)</label>
<input
className="nbl-input"
value={node.config?.http_response_extract_path ?? ''}
placeholder="$.data.results"
onChange={e => updateConfig({ http_response_extract_path: e.target.value })}
/>
</div>
</>
)}
{/* Code node config */}
{node.type === 'code' && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Language</label>
<SimpleSelect
size="sm"
value={node.config?.code_language ?? 'python'}
onChange={v => updateConfig({ code_language: v as 'javascript' | 'python' })}
options={[
{ value: 'python', label: 'Python' },
{ value: 'javascript', label: 'JavaScript' },
]}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Source code</label>
<textarea
className="nbl-textarea"
rows={10}
style={{ resize: 'vertical', fontFamily: 'var(--nbl-mono)', fontSize: 11 }}
value={node.config?.code_source ?? ''}
placeholder={node.config?.code_language === 'javascript'
? '// Write your code here\nconst result = { output: "hello" };'
: '# Write your code here\nresult = {"output": "hello"}'}
onChange={e => updateConfig({ code_source: e.target.value })}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
<input
className="nbl-input"
type="number"
min={1}
max={300}
value={node.config?.code_timeout_seconds ?? 60}
onChange={e => updateConfig({ code_timeout_seconds: Number(e.target.value) || 60 })}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--nbl-text-muted)', alignSelf: 'end', paddingBottom: 8 }}>
<input
type="checkbox"
checked={node.config?.code_network_allowed === true}
onChange={e => updateConfig({ code_network_allowed: e.target.checked })}
/>
Network access
</label>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Allowed packages (one per line)</label>
<textarea
className="nbl-textarea"
rows={3}
style={{ resize: 'vertical' }}
value={(node.config?.code_packages ?? []).join('\n')}
placeholder="requests\nnumpy"
onChange={e => updateConfig({ code_packages: e.target.value.split('\n').map(s => s.trim()).filter(Boolean) })}
/>
</div>
</>
)}
{/* Human Approval config */}
{node.type === 'human_approval' && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Approval title</label>
<input
className="nbl-input"
value={node.config?.approval_title ?? ''}
placeholder="Review and approve this step"
onChange={e => updateConfig({ approval_title: e.target.value })}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Description</label>
<textarea
className="nbl-textarea"
rows={3}
style={{ resize: 'vertical' }}
value={node.config?.approval_description ?? ''}
placeholder="Context shown to the approver"
onChange={e => updateConfig({ approval_description: e.target.value })}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
<input
className="nbl-input"
type="number"
min={60}
max={604800}
value={node.config?.approval_timeout_seconds ?? 86400}
onChange={e => updateConfig({ approval_timeout_seconds: Number(e.target.value) || 86400 })}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>On timeout</label>
<SimpleSelect
size="sm"
value={node.config?.approval_timeout_action ?? 'fail'}
onChange={v => updateConfig({ approval_timeout_action: v as 'fail' | 'skip' | 'auto_approve' })}
options={[
{ value: 'fail', label: 'Fail' },
{ value: 'skip', label: 'Skip' },
{ value: 'auto_approve', label: 'Auto-approve' },
]}
/>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--nbl-text-muted)' }}>
<input
type="checkbox"
checked={node.config?.approval_allow_modification === true}
onChange={e => updateConfig({ approval_allow_modification: e.target.checked })}
/>
Allow approver to modify data
</label>
<NotifyChannelsEditor
channels={node.config?.approval_notify_channels ?? []}
onChange={channels => updateConfig({ approval_notify_channels: channels })}
/>
</div>
</>
)}
{/* Notification config */}
{node.type === 'notification' && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Message template</label>
<textarea
className="nbl-textarea"
rows={4}
style={{ resize: 'vertical' }}
value={node.config?.notify_template ?? ''}
placeholder={'Workflow {{meta.workflow_id}} completed with result: {{output_1.result}}'}
onChange={e => updateConfig({ notify_template: e.target.value })}
/>
</div>
<NotifyChannelsEditor
channels={node.config?.notify_channels ?? []}
onChange={channels => updateConfig({ notify_channels: channels })}
/>
</>
)}
{/* Output config */}

View File

@@ -4,7 +4,7 @@
* Shown when no node is selected.
*/
import type { WfDefinition, WfProviderType, WfTrigger, WfProvider } from '../../types'
import type { WfDefinition, WfProviderType, WfTrigger, WfProvider, WfTriggerType } from '../../types'
const FIELD_LABEL_STYLE = {
fontSize: 10,
@@ -20,7 +20,7 @@ const SECTION_TITLE_STYLE = {
letterSpacing: '0.08em',
} as const
type TriggerMode = 'manual' | 'webhook'
type TriggerMode = WfTriggerType
interface WorkflowInspectorProps {
definition: WfDefinition
@@ -48,26 +48,31 @@ export function WorkflowInspector({
{/* Trigger */}
<div>
<div style={SECTION_TITLE_STYLE}>Trigger</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginTop: 8 }}>
{(['manual', 'webhook'] as TriggerMode[]).map(mode => {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6, marginTop: 8 }}>
{([
{ mode: 'manual' as TriggerMode, label: 'Manual' },
{ mode: 'webhook' as TriggerMode, label: 'Webhook' },
{ mode: 'schedule' as TriggerMode, label: 'Schedule' },
{ mode: 'event' as TriggerMode, label: 'Event' },
{ mode: 'chat' as TriggerMode, label: 'Chat' },
]).map(({ mode, label }) => {
const active = trigger.type === mode
return (
<button
key={mode}
onClick={() => onUpdateTrigger({ type: mode, enabled: true })}
style={{
padding: '9px 10px',
padding: '8px 6px',
borderRadius: 10,
cursor: 'pointer',
border: active ? '1px solid var(--nbl-agos)' : '1px solid var(--nbl-border)',
background: active ? 'color-mix(in srgb, var(--nbl-agos) 12%, var(--nbl-bg))' : 'var(--nbl-bg)',
color: active ? 'var(--nbl-agos)' : 'var(--nbl-text-muted)',
fontSize: 11,
fontSize: 10,
fontWeight: 700,
textTransform: 'capitalize',
}}
>
{mode}
{label}
</button>
)
})}
@@ -105,7 +110,7 @@ export function WorkflowInspector({
</div>
</div>
{/* Webhook config (conditional) */}
{/* Webhook config */}
{trigger.type === 'webhook' && (
<>
<div>
@@ -114,7 +119,7 @@ export function WorkflowInspector({
className="nbl-input"
value={trigger.config?.slug ?? ''}
placeholder="auto-generated if empty"
onChange={e => onUpdateTrigger({ config: { slug: e.target.value } })}
onChange={e => onUpdateTrigger({ config: { ...trigger.config, slug: e.target.value } })}
/>
</div>
<div>
@@ -123,12 +128,75 @@ export function WorkflowInspector({
className="nbl-input"
value={trigger.config?.agent_id ?? ''}
placeholder="running agent id"
onChange={e => onUpdateTrigger({ config: { agent_id: e.target.value } })}
onChange={e => onUpdateTrigger({ config: { ...trigger.config, agent_id: e.target.value } })}
/>
</div>
</>
)}
{/* Schedule config */}
{trigger.type === 'schedule' && (
<>
<div>
<label style={FIELD_LABEL_STYLE}>Cron expression</label>
<input
className="nbl-input"
value={trigger.config?.cron ?? ''}
placeholder="0 9 * * 1 (Mon 9am UTC)"
onChange={e => onUpdateTrigger({ config: { ...trigger.config, cron: e.target.value } })}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Timezone</label>
<input
className="nbl-input"
value={trigger.config?.timezone ?? 'UTC'}
placeholder="UTC"
onChange={e => onUpdateTrigger({ config: { ...trigger.config, timezone: e.target.value } })}
/>
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Agent ID</label>
<input
className="nbl-input"
value={trigger.config?.agent_id ?? ''}
placeholder="Agent to run the workflow"
onChange={e => onUpdateTrigger({ config: { ...trigger.config, agent_id: e.target.value } })}
/>
</div>
</>
)}
{/* Event config */}
{trigger.type === 'event' && (
<div>
<label style={FIELD_LABEL_STYLE}>Event types (one per line)</label>
<textarea
className="nbl-textarea"
rows={3}
style={{ resize: 'vertical' }}
value={(trigger.config?.event_types ?? []).join('\n')}
placeholder={'agent.task.completed\nworkflow.execution.failed'}
onChange={e => onUpdateTrigger({
config: { ...trigger.config, event_types: e.target.value.split('\n').map(s => s.trim()).filter(Boolean) },
})}
/>
</div>
)}
{/* Chat trigger config */}
{trigger.type === 'chat' && (
<div>
<label style={FIELD_LABEL_STYLE}>Chat command</label>
<input
className="nbl-input"
value={trigger.config?.command ?? ''}
placeholder="/run my-workflow"
onChange={e => onUpdateTrigger({ config: { ...trigger.config, command: e.target.value } })}
/>
</div>
)}
{/* Template reference */}
<div>
<label style={FIELD_LABEL_STYLE}>Template reference</label>

View File

@@ -42,12 +42,23 @@ const NODE_TYPE_ENTRIES: NodeTypeEntry[] = [
{ type: 'text_input', family: 'input', label: 'Text Input', description: 'Collect text for downstream reasoning or tools', icon: NODE_ICON.text_input, color: NODE_COLOR.text_input, idPrefix: 'text' },
{ type: 'file_input', family: 'input', label: 'File Input', description: 'Provide a file path or artifact reference', icon: NODE_ICON.file_input, color: NODE_COLOR.file_input, idPrefix: 'file' },
{ type: 'image_input', family: 'input', label: 'Image Input', description: 'Provide an image path for downstream nodes', icon: NODE_ICON.image_input, color: NODE_COLOR.image_input, idPrefix: 'image' },
// Engine
// Engine (AI)
{ type: 'llm_reason', family: 'engine', label: 'LLM Reasoning', description: 'Send a prompt to an LLM for reasoning', icon: NODE_ICON.llm_reason, color: NODE_COLOR.llm_reason, idPrefix: 'reason' },
{ type: 'tool_call', family: 'engine', label: 'Tool Call', description: 'Execute a registered tool with structured input', icon: NODE_ICON.tool_call, color: NODE_COLOR.tool_call, idPrefix: 'tool' },
{ type: 'condition', family: 'engine', label: 'Condition', description: 'Branch execution based on a condition', icon: NODE_ICON.condition, color: NODE_COLOR.condition, idPrefix: 'condition' },
// Logic / Control Flow
{ type: 'condition', family: 'logic', label: 'Condition', description: 'Binary branch (if/else) based on expression or LLM judge', icon: NODE_ICON.condition, color: NODE_COLOR.condition, idPrefix: 'cond' },
{ type: 'switch', family: 'logic', label: 'Switch', description: 'Multi-way branch with named cases', icon: NODE_ICON.switch, color: NODE_COLOR.switch, idPrefix: 'switch' },
{ type: 'loop', family: 'logic', label: 'Loop', description: 'Iterate over a list with configurable concurrency', icon: NODE_ICON.loop, color: NODE_COLOR.loop, idPrefix: 'loop' },
{ type: 'merge', family: 'logic', label: 'Merge', description: 'Wait for multiple branches to complete', icon: NODE_ICON.merge, color: NODE_COLOR.merge, idPrefix: 'merge' },
{ type: 'delay', family: 'logic', label: 'Delay', description: 'Wait for a duration or condition before continuing', icon: NODE_ICON.delay, color: NODE_COLOR.delay, idPrefix: 'delay' },
// Integration / Tool
{ type: 'http_request', family: 'tool', label: 'HTTP Request', description: 'Make an HTTP request to an external API', icon: NODE_ICON.http_request, color: NODE_COLOR.http_request, idPrefix: 'http' },
{ type: 'code', family: 'tool', label: 'Code', description: 'Execute sandboxed JavaScript or Python code', icon: NODE_ICON.code, color: NODE_COLOR.code, idPrefix: 'code' },
// Gate
{ type: 'human_approval', family: 'gate', label: 'Approval Gate', description: 'Pause for human review and approval', icon: NODE_ICON.human_approval, color: NODE_COLOR.human_approval, idPrefix: 'gate' },
// Output
{ type: 'output', family: 'output', label: 'Output', description: 'Create a first-class workflow output', icon: NODE_ICON.output, color: NODE_COLOR.output, idPrefix: 'output' },
{ type: 'notification', family: 'output', label: 'Notification', description: 'Send notification via email, Slack, or in-app', icon: NODE_ICON.notification, color: NODE_COLOR.notification, idPrefix: 'notify' },
]
const NODE_TYPE_MAP = new Map(NODE_TYPE_ENTRIES.map(e => [e.type, e]))
@@ -106,7 +117,10 @@ export function getPaletteGroups(filter?: string): PaletteGroup[] {
const groups: PaletteGroup[] = []
const familyOrder: { family: WfNodeFamily; label: string }[] = [
{ family: 'input', label: 'Input' },
{ family: 'engine', label: 'Engine' },
{ family: 'engine', label: 'AI / Engine' },
{ family: 'logic', label: 'Control Flow' },
{ family: 'tool', label: 'Integration' },
{ family: 'gate', label: 'Governance' },
{ family: 'output', label: 'Output' },
]
for (const { family, label } of familyOrder) {
@@ -122,7 +136,19 @@ export function getPaletteGroups(filter?: string): PaletteGroup[] {
export const INPUT_NODE_TYPES: WfNodeType[] = ['text_input', 'file_input', 'image_input']
/** Node types within the Engine family (for inspector type morph) */
export const ENGINE_NODE_TYPES: WfNodeType[] = ['llm_reason', 'tool_call', 'condition']
export const ENGINE_NODE_TYPES: WfNodeType[] = ['llm_reason', 'tool_call']
/** Node types within the Logic family */
export const LOGIC_NODE_TYPES: WfNodeType[] = ['condition', 'switch', 'loop', 'merge', 'delay']
/** Node types within the Tool/Integration family */
export const TOOL_NODE_TYPES: WfNodeType[] = ['http_request', 'code']
/** Node types within the Gate family */
export const GATE_NODE_TYPES: WfNodeType[] = ['human_approval']
/** Node types within the Output family */
export const OUTPUT_NODE_TYPES: WfNodeType[] = ['output', 'notification']
/** Output format options */
export const OUTPUT_FORMAT_OPTIONS: { value: WfOutputFormat; label: string }[] = [
@@ -201,7 +227,115 @@ export function buildDefaultNode(def: WfDefinition, type: WfNodeType, label: str
}
}
if (type === 'condition') {
return { id, type, family, label, description: '', runtime_params: [] }
return {
id, type, family, label,
description: 'Branch based on expression or LLM judge',
config: { condition_mode: 'expression', expression: '' },
runtime_params: [],
}
}
if (type === 'switch') {
return {
id, type, family, label,
description: 'Multi-way branch with named cases',
config: {
condition_mode: 'expression',
expression: '',
cases: [
{ label: 'Case A', value: 'a', handle: 'case_1' },
{ label: 'Case B', value: 'b', handle: 'case_2' },
],
default_handle: 'default',
},
runtime_params: [],
}
}
if (type === 'loop') {
return {
id, type, family, label,
description: 'Iterate over a collection',
config: {
source_expression: '',
item_variable: 'item',
index_variable: 'index',
concurrency: 1,
max_iterations: 1000,
collect_results: true,
},
runtime_params: [],
}
}
if (type === 'merge') {
return {
id, type, family, label,
description: 'Wait for branches to complete',
config: {
merge_mode: 'all',
merge_timeout_seconds: 300,
merge_on_timeout: 'fail',
},
runtime_params: [],
}
}
if (type === 'delay') {
return {
id, type, family, label,
description: 'Wait before continuing',
config: { delay_seconds: 5 },
runtime_params: [],
}
}
if (type === 'http_request') {
return {
id, type, family, label,
description: 'Make an HTTP request',
config: {
http_method: 'GET',
http_url: '',
http_body_content_type: 'json',
http_auth_type: 'none',
http_timeout_ms: 30000,
},
runtime_params: [],
}
}
if (type === 'code') {
return {
id, type, family, label,
description: 'Execute sandboxed code',
config: {
code_language: 'python',
code_source: '# Write your code here\nresult = {"output": "hello"}',
code_timeout_seconds: 60,
code_network_allowed: false,
},
runtime_params: [],
}
}
if (type === 'human_approval') {
return {
id, type, family, label,
description: 'Pause for human review',
config: {
approval_title: 'Review required',
approval_timeout_seconds: 86400,
approval_timeout_action: 'fail',
approval_allow_modification: false,
approval_notify_channels: [],
},
runtime_params: [],
}
}
if (type === 'notification') {
return {
id, type, family, label,
description: 'Send a notification',
config: {
notify_channels: [{ type: 'agos_notification', target: '' }],
notify_template: '',
},
runtime_params: [],
}
}
if (type === 'output') {
return {

View File

@@ -1,5 +1,6 @@
import {
GitBranch, Play, CheckCircle2, Cpu, Zap, Type, FileText, Image as ImageIcon, ArrowDownToLine,
ArrowLeftRight, Repeat, Merge, Timer, Globe, Code2, ShieldCheck, Bell,
} from 'lucide-react'
import type { ElementType } from 'react'
import type { WfNodeType, WfNode, WfNodeFamily } from '../types'
@@ -12,6 +13,9 @@ export const NODE_FAMILY_COLOR: Record<WfNodeFamily, string> = {
system: 'var(--nbl-text-muted)',
input: 'var(--nbl-green)',
engine: 'var(--nbl-agos)',
logic: 'var(--nbl-amber)',
tool: 'var(--nbl-cyan)',
gate: 'var(--nbl-pink, #f472b6)',
output: 'var(--nbl-cyan)',
}
@@ -24,7 +28,15 @@ export const NODE_COLOR: Record<WfNodeType, string> = {
llm_reason: 'var(--nbl-agos)',
tool_call: 'var(--nbl-cyan)',
condition: 'var(--nbl-amber)',
switch: 'var(--nbl-amber)',
loop: 'var(--nbl-amber)',
merge: 'var(--nbl-amber)',
delay: 'var(--nbl-text-muted)',
http_request: 'var(--nbl-cyan)',
code: 'var(--nbl-agos)',
human_approval: 'var(--nbl-pink, #f472b6)',
output: 'var(--nbl-pink, #f472b6)',
notification: 'var(--nbl-pink, #f472b6)',
}
export const NODE_ICON: Record<WfNodeType, ElementType> = {
@@ -36,7 +48,15 @@ export const NODE_ICON: Record<WfNodeType, ElementType> = {
llm_reason: Cpu,
tool_call: Zap,
condition: GitBranch,
switch: ArrowLeftRight,
loop: Repeat,
merge: Merge,
delay: Timer,
http_request: Globe,
code: Code2,
human_approval: ShieldCheck,
output: ArrowDownToLine,
notification: Bell,
}
export const NODE_TYPE_LABEL: Record<WfNodeType, string> = {
@@ -48,13 +68,24 @@ export const NODE_TYPE_LABEL: Record<WfNodeType, string> = {
llm_reason: 'LLM Reasoning',
tool_call: 'Tool Call',
condition: 'Condition',
switch: 'Switch',
loop: 'Loop',
merge: 'Merge',
delay: 'Delay',
http_request: 'HTTP Request',
code: 'Code',
human_approval: 'Approval Gate',
output: 'Output',
notification: 'Notification',
}
export const NODE_SHAPE_BY_FAMILY: Record<WfNodeFamily, 'pill' | 'rect' | 'hex'> = {
export const NODE_SHAPE_BY_FAMILY: Record<WfNodeFamily, 'pill' | 'rect' | 'hex' | 'diamond'> = {
system: 'pill',
input: 'pill',
engine: 'rect',
logic: 'diamond',
tool: 'rect',
gate: 'hex',
output: 'hex',
}

View File

@@ -1,6 +1,20 @@
export type WfNodeType = 'llm_reason' | 'tool_call' | 'condition' | 'text_input' | 'file_input' | 'image_input' | 'output' | 'start' | 'end'
export type WfNodeType =
// System
| 'start' | 'end'
// Input
| 'text_input' | 'file_input' | 'image_input'
// Engine (AI)
| 'llm_reason' | 'tool_call'
// Logic / Control Flow
| 'condition' | 'switch' | 'loop' | 'merge' | 'delay'
// Integration
| 'http_request' | 'code'
// Gate
| 'human_approval'
// Output
| 'output' | 'notification'
export type WfNodeFamily = 'system' | 'input' | 'engine' | 'output'
export type WfNodeFamily = 'system' | 'input' | 'engine' | 'logic' | 'tool' | 'gate' | 'output'
export type WfRuntimeParamType =
| 'text'
@@ -38,7 +52,7 @@ export type WfOutputRoute = {
export type WfOutputFormat = 'json' | 'md' | 'csv' | 'raw' | 'custom'
export type WfTriggerType = 'manual' | 'webhook'
export type WfTriggerType = 'manual' | 'webhook' | 'schedule' | 'event' | 'chat'
export type WfProviderType = 'agos-native' | 'n8n'
@@ -57,6 +71,13 @@ export type WfTriggerConfig = {
slug?: string
secret?: string
agent_id?: string
// Schedule
cron?: string
timezone?: string
// Event
event_types?: string[]
// Chat
command?: string
}
export type WfTrigger = {
@@ -65,18 +86,88 @@ export type WfTrigger = {
config: WfTriggerConfig
}
export type WfSwitchCase = {
label: string
value: string
handle: string
}
export type WfNotifyChannel = {
type: 'email' | 'slack' | 'agos_notification'
target: string
}
export type WfNodeConfig = {
// Input nodes
source_key?: string
input_kind?: 'text' | 'file' | 'image'
required?: boolean
default_value?: string
placeholder?: string
accept?: string
// Output nodes
output_format?: WfOutputFormat
value_template?: string
output_schema?: string
output_routes?: WfOutputRoute[]
// Tool call
tool_input?: string
// Condition / Switch
condition_mode?: 'expression' | 'llm_judge'
expression?: string
judge_prompt?: string
cases?: WfSwitchCase[]
default_handle?: string
// Loop
source_expression?: string
item_variable?: string
index_variable?: string
concurrency?: number
max_iterations?: number
collect_results?: boolean
// Merge
merge_mode?: 'all' | 'any' | 'first_n'
merge_n?: number
merge_timeout_seconds?: number
merge_on_timeout?: 'fail' | 'proceed_with_available'
// Delay
delay_seconds?: number
delay_until_expression?: string
// HTTP Request
http_method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
http_url?: string
http_headers?: Record<string, string>
http_body_template?: string
http_body_content_type?: 'json' | 'form' | 'text' | 'xml'
http_auth_type?: 'none' | 'bearer' | 'basic' | 'api_key' | 'credential_ref'
http_credential_id?: string
http_timeout_ms?: number
http_response_extract_path?: string
// Code
code_language?: 'javascript' | 'python'
code_source?: string
code_packages?: string[]
code_timeout_seconds?: number
code_network_allowed?: boolean
// Human approval
approval_title?: string
approval_description?: string
approval_timeout_seconds?: number
approval_timeout_action?: 'fail' | 'skip' | 'auto_approve'
approval_allow_modification?: boolean
approval_notify_channels?: WfNotifyChannel[]
// Notification
notify_channels?: WfNotifyChannel[]
notify_template?: string
}
export type WfNodeExecution = {
timeout_seconds?: number
retry_max?: number
retry_backoff_type?: 'fixed' | 'exponential'
retry_backoff_seconds?: number
on_error?: 'fail' | 'skip' | 'default_value'
default_value?: unknown
}
export type WfNode = {
@@ -87,15 +178,24 @@ export type WfNode = {
description?: string
tool?: string
prompt?: string
model?: string
temperature?: number
max_tokens?: number
structured_output_schema?: string
config?: WfNodeConfig
execution?: WfNodeExecution
runtime_params?: WfRuntimeParam[]
position?: { x: number; y: number }
}
export type WfEdge = {
id: string
source: string
target: string
source_handle?: string
target_handle?: string
label?: string
condition?: string
}
export type WfDefinition = {
@@ -110,7 +210,10 @@ export type WfDefinition = {
export function getNodeFamily(type: WfNodeType): WfNodeFamily {
if (type === 'start' || type === 'end') return 'system'
if (type === 'text_input' || type === 'file_input' || type === 'image_input') return 'input'
if (type === 'output') return 'output'
if (type === 'condition' || type === 'switch' || type === 'loop' || type === 'merge' || type === 'delay') return 'logic'
if (type === 'http_request' || type === 'code') return 'tool'
if (type === 'human_approval') return 'gate'
if (type === 'output' || type === 'notification') return 'output'
return 'engine'
}
@@ -244,15 +347,21 @@ function parseNodeConfig(
return undefined
}
/** All known node types for direct metadata matching */
const KNOWN_NODE_TYPES = new Set<string>([
'start', 'end', 'text_input', 'file_input', 'image_input',
'llm_reason', 'tool_call', 'condition', 'switch', 'loop', 'merge', 'delay',
'http_request', 'code', 'human_approval', 'output', 'notification',
])
function inferNodeType(
tool: string,
metadataType: string,
input: Record<string, unknown> | undefined,
): WfNodeType {
if (metadataType === 'condition') return 'condition'
if (metadataType === 'text_input' || metadataType === 'file_input' || metadataType === 'image_input' || metadataType === 'output') {
return metadataType
}
// Direct match from metadata type
if (KNOWN_NODE_TYPES.has(metadataType)) return metadataType as WfNodeType
if (tool === 'workflow_input') {
const inputKind = String(input?.input_kind ?? '').trim()
if (inputKind === 'file') return 'file_input'
@@ -297,10 +406,13 @@ function nodeSpecToWfNode(spec: Record<string, unknown>): WfNode {
}
}
const VALID_TRIGGER_TYPES = new Set<string>(['manual', 'webhook', 'schedule', 'event', 'chat'])
function parseTrigger(raw: Record<string, unknown>): WfTrigger {
const trigger = raw.trigger as Record<string, unknown> | undefined
const config = trigger?.config as Record<string, unknown> | undefined
const type = trigger?.type === 'webhook' ? 'webhook' : 'manual'
const rawType = String(trigger?.type ?? 'manual')
const type: WfTriggerType = VALID_TRIGGER_TYPES.has(rawType) ? rawType as WfTriggerType : 'manual'
return {
type,
enabled: trigger?.enabled !== false,
@@ -308,6 +420,10 @@ function parseTrigger(raw: Record<string, unknown>): WfTrigger {
slug: typeof config?.slug === 'string' ? config.slug : undefined,
secret: typeof config?.secret === 'string' ? config.secret : undefined,
agent_id: typeof config?.agent_id === 'string' ? config.agent_id : undefined,
cron: typeof config?.cron === 'string' ? config.cron : undefined,
timezone: typeof config?.timezone === 'string' ? config.timezone : undefined,
event_types: Array.isArray(config?.event_types) ? config.event_types as string[] : undefined,
command: typeof config?.command === 'string' ? config.command : undefined,
},
}
}