minimal-workflow 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-workflow",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Visual editor for minimal-agent workflow YAML files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,8 @@ import { AssertNode } from './nodes/AssertNode.tsx';
21
21
  import { PauseNode } from './nodes/PauseNode.tsx';
22
22
  import { BranchNode } from './nodes/BranchNode.tsx';
23
23
  import { LoopNode } from './nodes/LoopNode.tsx';
24
+ import { ParallelNode } from './nodes/ParallelNode.tsx';
25
+ import { VoteNode } from './nodes/VoteNode.tsx';
24
26
  import { SequentialEdge } from './edges/SequentialEdge.tsx';
25
27
  import type { StepDef, StepKind } from '../types.ts';
26
28
 
@@ -32,6 +34,8 @@ const nodeTypes = {
32
34
  pause: PauseNode,
33
35
  branch: BranchNode,
34
36
  loop: LoopNode,
37
+ parallel: ParallelNode,
38
+ vote: VoteNode,
35
39
  };
36
40
 
37
41
  const edgeTypes = {
@@ -175,5 +179,15 @@ function makeStepTemplate(
175
179
  return { id, type: 'branch', condition: 'false', then: [], else: [] };
176
180
  case 'loop':
177
181
  return { id, type: 'loop', over: '[]', as: 'item', do: [] };
182
+ case 'parallel':
183
+ return { id, type: 'parallel', fork: [] };
184
+ case 'vote':
185
+ return {
186
+ id,
187
+ type: 'vote',
188
+ voters_count: 3,
189
+ rules: '',
190
+ input: '${all_findings}',
191
+ };
178
192
  }
179
193
  }
@@ -0,0 +1,23 @@
1
+ import type { NodeProps } from '@xyflow/react';
2
+ import { BaseNode } from './BaseNode.tsx';
3
+ import type { CanvasNodeData } from '../../types.ts';
4
+
5
+ /**
6
+ * ADR-05 parallel 节点:fan-out 多分支 step 并行执行。
7
+ * 子步骤(fork[])不暴露为画布 sourceHandle,在 inspector 列表里编辑。
8
+ * 节点只有默认的 single Bottom source handle(后继 step)+ Top target handle。
9
+ */
10
+ export function ParallelNode(props: NodeProps) {
11
+ const data = props.data as CanvasNodeData;
12
+ const step = data.step;
13
+ const forkCount = step.fork?.length ?? 0;
14
+ return (
15
+ <BaseNode
16
+ {...props}
17
+ data={data}
18
+ variant="parallel"
19
+ title="⫶ parallel"
20
+ body={`fork × ${forkCount}(子步骤在 inspector 编辑)`}
21
+ />
22
+ );
23
+ }
@@ -0,0 +1,23 @@
1
+ import type { NodeProps } from '@xyflow/react';
2
+ import { BaseNode } from './BaseNode.tsx';
3
+ import type { CanvasNodeData } from '../../types.ts';
4
+
5
+ /**
6
+ * ADR-05 vote 节点:多 voter LLM 评估同一输入,按 threshold 决断保留。
7
+ * 节点只有默认的 single Bottom source / Top target handle。
8
+ */
9
+ export function VoteNode(props: NodeProps) {
10
+ const data = props.data as CanvasNodeData;
11
+ const step = data.step;
12
+ const voters = step.voters_count ?? 3;
13
+ const threshold = step.threshold ?? Math.ceil(voters / 2);
14
+ return (
15
+ <BaseNode
16
+ {...props}
17
+ data={data}
18
+ variant="vote"
19
+ title="⚖ vote"
20
+ body={`${voters} voters, threshold=${threshold}`}
21
+ />
22
+ );
23
+ }
package/src/index.css CHANGED
@@ -288,6 +288,14 @@ select {
288
288
  .canvas-node.loop {
289
289
  border-color: #06b6d4;
290
290
  }
291
+ .canvas-node.parallel {
292
+ border-color: #d946ef;
293
+ background: #fdf4ff;
294
+ }
295
+ .canvas-node.vote {
296
+ border-color: #ea580c;
297
+ background: #fff7ed;
298
+ }
291
299
 
292
300
  /* 文件列表 / 工作流列表(叠在 palette 上方) */
293
301
  .file-list {
@@ -1,8 +1,9 @@
1
+ import { useState } from 'react';
1
2
  import { useWorkflowStore } from '../store/workflowStore.ts';
2
3
  import { ToolNodeForm } from './ToolNodeForm.tsx';
3
4
  import { LlmNodeForm } from './LlmNodeForm.tsx';
4
5
  import { SkillNodeForm } from './SkillNodeForm.tsx';
5
- import type { StepDef } from '../types.ts';
6
+ import type { StepDef, StepKind } from '../types.ts';
6
7
 
7
8
  export function Inspector() {
8
9
  const selectedId = useWorkflowStore((s) => s.selectedNodeId);
@@ -38,6 +39,8 @@ export function Inspector() {
38
39
  {kind === 'pause' && <PauseForm step={step} onPatch={onPatch} />}
39
40
  {kind === 'branch' && <BranchForm step={step} onPatch={onPatch} />}
40
41
  {kind === 'loop' && <LoopForm step={step} onPatch={onPatch} />}
42
+ {kind === 'parallel' && <ParallelForm step={step} onPatch={onPatch} />}
43
+ {kind === 'vote' && <VoteForm step={step} onPatch={onPatch} />}
41
44
 
42
45
  <CaptureField step={step} onPatch={onPatch} />
43
46
 
@@ -222,3 +225,239 @@ function LoopForm({ step, onPatch }: FieldProps) {
222
225
  </>
223
226
  );
224
227
  }
228
+
229
+ /* ─────────────────────── ADR-05: parallel / vote ─────────────────────── */
230
+
231
+ /** parallel 节点支持的 fork 子 step 类型(不允许 branch/loop/pause/parallel/vote 嵌套)。 */
232
+ const FORK_CHILD_KINDS: StepKind[] = ['tool', 'llm', 'skill', 'assert'];
233
+
234
+ function detectChildKind(child: StepDef): StepKind {
235
+ if (child.type) return child.type;
236
+ if (child.tool) return 'tool';
237
+ if (child.skill) return 'skill';
238
+ if (child.llm) return 'llm';
239
+ return 'assert';
240
+ }
241
+
242
+ function ParallelForm({ step, onPatch }: FieldProps) {
243
+ const fork = step.fork ?? [];
244
+ const [expanded, setExpanded] = useState<Record<number, boolean>>({});
245
+
246
+ const updateForkAt = (i: number, patch: Partial<StepDef>) => {
247
+ const next = fork.map((s, idx) => (idx === i ? { ...s, ...patch } : s));
248
+ onPatch({ fork: next });
249
+ };
250
+
251
+ const removeForkAt = (i: number) => {
252
+ const next = fork.filter((_, idx) => idx !== i);
253
+ onPatch({ fork: next });
254
+ };
255
+
256
+ const addFork = (kind: StepKind) => {
257
+ const baseId = nextForkId(fork, kind);
258
+ let child: StepDef;
259
+ switch (kind) {
260
+ case 'tool':
261
+ child = { id: baseId, tool: 'Read', args: {} };
262
+ break;
263
+ case 'llm':
264
+ child = { id: baseId, llm: '' };
265
+ break;
266
+ case 'skill':
267
+ child = { id: baseId, skill: 'commit', input: '' };
268
+ break;
269
+ case 'assert':
270
+ child = { id: baseId, type: 'assert', condition: 'true', onFail: '' };
271
+ break;
272
+ default:
273
+ return;
274
+ }
275
+ onPatch({ fork: [...fork, child] });
276
+ setExpanded((m) => ({ ...m, [fork.length]: true }));
277
+ };
278
+
279
+ return (
280
+ <>
281
+ <div className="field">
282
+ <label>fork(并行子步骤,{fork.length} 个)</label>
283
+ <small style={{ color: '#6b7280', fontSize: 11 }}>
284
+ 所有子步骤并发执行;结果聚合后写回当前 step 的 result。子步骤不参与画布连线。
285
+ </small>
286
+ </div>
287
+
288
+ {fork.map((child, i) => {
289
+ const childKind = detectChildKind(child);
290
+ const open = expanded[i] ?? false;
291
+ return (
292
+ <div
293
+ key={i}
294
+ className="field"
295
+ style={{
296
+ border: '1px solid #e5e7eb',
297
+ borderRadius: 4,
298
+ padding: 8,
299
+ marginBottom: 6,
300
+ }}
301
+ >
302
+ <div
303
+ style={{
304
+ display: 'flex',
305
+ gap: 6,
306
+ alignItems: 'center',
307
+ justifyContent: 'space-between',
308
+ }}
309
+ >
310
+ <span
311
+ style={{ cursor: 'pointer', userSelect: 'none', flex: 1 }}
312
+ onClick={() => setExpanded((m) => ({ ...m, [i]: !open }))}
313
+ >
314
+ {open ? '▼' : '▶'}{' '}
315
+ <code style={{ fontSize: 12 }}>{child.id}</code>
316
+ <span style={{ color: '#9ca3af', fontSize: 11 }}>
317
+ {' '}
318
+ · {childKind}
319
+ </span>
320
+ </span>
321
+ <button
322
+ onClick={() => removeForkAt(i)}
323
+ style={{
324
+ fontSize: 11,
325
+ padding: '2px 6px',
326
+ background: '#fef2f2',
327
+ color: '#dc2626',
328
+ border: '1px solid #fca5a5',
329
+ borderRadius: 3,
330
+ }}
331
+ >
332
+ ×
333
+ </button>
334
+ </div>
335
+ {open && (
336
+ <div style={{ marginTop: 6 }}>
337
+ <div className="field">
338
+ <label>id</label>
339
+ <input
340
+ value={child.id}
341
+ onChange={(e) => updateForkAt(i, { id: e.target.value })}
342
+ />
343
+ </div>
344
+ {childKind === 'tool' && (
345
+ <ToolNodeForm
346
+ step={child}
347
+ onPatch={(p) => updateForkAt(i, p)}
348
+ />
349
+ )}
350
+ {childKind === 'llm' && (
351
+ <LlmNodeForm
352
+ step={child}
353
+ onPatch={(p) => updateForkAt(i, p)}
354
+ />
355
+ )}
356
+ {childKind === 'skill' && (
357
+ <SkillNodeForm
358
+ step={child}
359
+ onPatch={(p) => updateForkAt(i, p)}
360
+ />
361
+ )}
362
+ {childKind === 'assert' && (
363
+ <AssertForm
364
+ step={child}
365
+ onPatch={(p) => updateForkAt(i, p)}
366
+ />
367
+ )}
368
+ </div>
369
+ )}
370
+ </div>
371
+ );
372
+ })}
373
+
374
+ <div className="field">
375
+ <label>添加 fork</label>
376
+ <div style={{ display: 'flex', gap: 4 }}>
377
+ {FORK_CHILD_KINDS.map((k) => (
378
+ <button
379
+ key={k}
380
+ onClick={() => addFork(k)}
381
+ style={{ fontSize: 11, padding: '2px 8px' }}
382
+ >
383
+ + {k}
384
+ </button>
385
+ ))}
386
+ </div>
387
+ </div>
388
+ </>
389
+ );
390
+ }
391
+
392
+ function nextForkId(fork: StepDef[], kind: StepKind): string {
393
+ const taken = new Set(fork.map((s) => s.id));
394
+ let i = 1;
395
+ while (taken.has(`${kind}_${i}`)) i++;
396
+ return `${kind}_${i}`;
397
+ }
398
+
399
+ function VoteForm({ step, onPatch }: FieldProps) {
400
+ const voters = step.voters_count ?? 3;
401
+ const defaultThreshold = Math.ceil(voters / 2);
402
+ return (
403
+ <>
404
+ <div className="field">
405
+ <label>voters_count(投票 LLM 数量)</label>
406
+ <input
407
+ type="number"
408
+ min={2}
409
+ max={10}
410
+ value={voters}
411
+ onChange={(e) => {
412
+ const v = Number(e.target.value);
413
+ if (Number.isFinite(v) && v >= 2 && v <= 10) {
414
+ onPatch({ voters_count: v });
415
+ }
416
+ }}
417
+ />
418
+ </div>
419
+ <div className="field">
420
+ <label>threshold(通过票数,留空 = 默认)</label>
421
+ <input
422
+ type="number"
423
+ min={1}
424
+ value={step.threshold ?? ''}
425
+ placeholder={`默认 Math.ceil(voters_count/2) = ${defaultThreshold}`}
426
+ onChange={(e) => {
427
+ const v = e.target.value;
428
+ if (v === '') {
429
+ onPatch({ threshold: undefined });
430
+ } else {
431
+ const n = Number(v);
432
+ if (Number.isFinite(n) && n >= 1) onPatch({ threshold: n });
433
+ }
434
+ }}
435
+ />
436
+ </div>
437
+ <div className="field">
438
+ <label>rules(评估规则 prompt)</label>
439
+ <textarea
440
+ value={step.rules ?? ''}
441
+ rows={5}
442
+ spellCheck={false}
443
+ placeholder="评估这个 finding 是否值得保留..."
444
+ onChange={(e) => onPatch({ rules: e.target.value })}
445
+ style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
446
+ />
447
+ </div>
448
+ <div className="field">
449
+ <label>input(要被投票评估的输入)</label>
450
+ <input
451
+ value={step.input ?? ''}
452
+ placeholder="${all_findings}"
453
+ onChange={(e) =>
454
+ onPatch({ input: e.target.value === '' ? undefined : e.target.value })
455
+ }
456
+ />
457
+ <small style={{ color: '#6b7280', fontSize: 11 }}>
458
+ 支持 ${'{var}'} 模板;通常引用上游 parallel 的聚合 result
459
+ </small>
460
+ </div>
461
+ </>
462
+ );
463
+ }
@@ -13,6 +13,18 @@ const ITEMS: FlowItem[] = [
13
13
  { kind: 'pause', icon: '⏸', label: 'pause', desc: 'HITL 暂停点(非交互模式跳过)' },
14
14
  { kind: 'branch', icon: '🔀', label: 'branch', desc: '二选一分支(then / else)' },
15
15
  { kind: 'loop', icon: '🔁', label: 'loop', desc: '遍历数组,对每项执行子步' },
16
+ {
17
+ kind: 'parallel',
18
+ icon: '⫶',
19
+ label: 'parallel',
20
+ desc: 'fan-out 并行执行多个子步骤(fork 列表在 inspector 编辑)',
21
+ },
22
+ {
23
+ kind: 'vote',
24
+ icon: '⚖',
25
+ label: 'vote',
26
+ desc: '多 voter LLM 评估同一输入,按 threshold 决断',
27
+ },
16
28
  ];
17
29
 
18
30
  export function ControlFlowSection() {
@@ -115,6 +115,13 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
115
115
 
116
116
  const flatten = (steps: StepDef[]): void => {
117
117
  for (const raw of steps) {
118
+ // parallel / vote:fork[] / 内部子步骤**不展平**,整个 step 原样作为
119
+ // 单个 canvas node(子步骤在 inspector 列表内编辑)。
120
+ if (raw.type === 'parallel' || raw.type === 'vote') {
121
+ flat.push({ step: raw, childEdges: [] });
122
+ continue;
123
+ }
124
+
118
125
  const { then: thenChildren, else: elseChildren, do: doChildren, ...rest } =
119
126
  raw;
120
127
  const cleaned: StepDef = { ...rest };
@@ -517,6 +524,9 @@ function withChildren(
517
524
  step: StepDef,
518
525
  children: { then?: StepDef[]; else?: StepDef[]; do?: StepDef[] } | undefined,
519
526
  ): StepDef {
527
+ // ADR-05:parallel / vote 不挂 then/else/do 子步骤,原样返回
528
+ // (fork / voters_count 等字段已在 step 上)
529
+ if (step.type === 'parallel' || step.type === 'vote') return step;
520
530
  if (!children) return step;
521
531
  const out: StepDef = { ...step };
522
532
  if (children.then) out.then = children.then;
package/src/types.ts CHANGED
@@ -10,7 +10,9 @@ export type StepKind =
10
10
  | 'assert'
11
11
  | 'branch'
12
12
  | 'loop'
13
- | 'pause';
13
+ | 'pause'
14
+ | 'parallel'
15
+ | 'vote';
14
16
 
15
17
  export interface WorkflowInput {
16
18
  name: string;
@@ -39,6 +41,15 @@ export interface StepDef {
39
41
  onError?: 'halt' | 'continue';
40
42
  onFail?: string;
41
43
  maxTurns?: number;
44
+
45
+ // ADR-05 字段(parallel / vote / 通用 LLM 增强)
46
+ output_schema?: Record<string, unknown>;
47
+ fork?: StepDef[];
48
+ voters_count?: number;
49
+ rules?: string;
50
+ threshold?: number;
51
+ context_files?: Array<{ path: string; hint?: string }>;
52
+ allowed_tools?: string[];
42
53
  }
43
54
 
44
55
  export interface WorkflowEdgeMeta {